Add application read models and thin TUI command adapter
This commit is contained in:
@@ -135,7 +135,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo
|
|||||||
| 23 | Readable saved-state Commit and restore scenarios | 22 | accepted |
|
| 23 | Readable saved-state Commit and restore scenarios | 22 | accepted |
|
||||||
| 24 | Session-owned first delivery and password reissue scenarios | 23 | accepted |
|
| 24 | Session-owned first delivery and password reissue scenarios | 23 | accepted |
|
||||||
| 25 | Explicit restart verification scenario | 24 | accepted |
|
| 25 | Explicit restart verification scenario | 24 | accepted |
|
||||||
| 26 | Application read models and thin TUI command adapter | 25 | pending |
|
| 26 | Application read models and thin TUI command adapter | 25 | accepted |
|
||||||
| 27 | Integrated refactoring acceptance and architecture handoff | 26 | pending |
|
| 27 | Integrated refactoring acceptance and architecture handoff | 26 | pending |
|
||||||
|
|
||||||
### Milestone 1 — Rust foundation, settings, and domain states
|
### Milestone 1 — Rust foundation, settings, and domain states
|
||||||
@@ -4099,7 +4099,7 @@ Focused commands `cargo test --locked app::` (67), `runtime::` (25), `service::`
|
|||||||
|
|
||||||
### Milestone 26 — Application read models and thin TUI command adapter
|
### Milestone 26 — Application read models and thin TUI command adapter
|
||||||
|
|
||||||
Status: pending. Prerequisite: M25 accepted.
|
Status: accepted (2026-10-30T23:01:09+02:00; independent R1–R4 re-review, evidence verification and integrated Rust gates passed). Prerequisite: M25 accepted. M1–8 and M10–25 are accepted; M9 is retired and not a prerequisite. This session's coordinator GPT-6-Sol/medium was user-confirmed; `.codex/config.toml` and explicit worker dispatch use GPT-6-Luna/high. Initial tree clean at `9a143e9`; pinned Rust/Cargo 1.97.1 verified. TUI previously reparsed saved config for peer count; app deletion already supports some exact-host-route peers outside manageable `ClientStates`.
|
||||||
|
|
||||||
#### Outcome and scope
|
#### Outcome and scope
|
||||||
|
|
||||||
@@ -4107,10 +4107,10 @@ TUI consumes public application facts and action eligibility without parsing sav
|
|||||||
|
|
||||||
| Task | Owner | State | Bounded deliverable |
|
| Task | Owner | State | Bounded deliverable |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| M26.1 | implementer | pending | Secret-free application view and per-action eligibility |
|
| M26.1 | implementer `/root/m26_read_model` | complete | `ApplicationView` and per-action eligibility built from authoritative peer and session facts; app/TUI/workflow focused checks, fmt and Clippy passed |
|
||||||
| M26.2 | implementer | pending | Migrate TUI projection and command execution adapter |
|
| M26.2 | implementer `/root/m26_tui_projection` | complete | TUI view consumes app read model; selection-bound guards/hints use per-action eligibility; TUI/workflow/client-delivery focused checks, fmt and Clippy passed |
|
||||||
| M26.3 | implementer | pending | Split command/result/navigation responsibilities where justified |
|
| M26.3 | implementer `/root/m26_command_adapter` | complete | Application command/prepared-context adapter extracted under events; TUI/workflow/client-delivery focused checks, fmt and Clippy passed; broad terminal/render split deferred as no cohesive gain |
|
||||||
| M26.4 | reviewer; repairer; coordinator | pending | UI parity/authorization/lifecycle review, repair and RUST acceptance |
|
| M26.4 | reviewer `/root/m26_review`; repairer `/root/m26_repair`; coordinator | complete | M26-R1 removed unprojectable saved peer, R2 unrelated issue overblock, R3 false Removed row and R4 capability-issue reissue mismatch closed on re-review; evidence and integrated RUST verified |
|
||||||
|
|
||||||
1. Move ClientDisplay/SessionDisplay projection responsibilities to an application-owned read model. Include saved peer count, draft/saved public client differences, issue classification, reservation/apply facts and vault-initialized state. Keep UI-only selection, scroll, dialog focus, rendered strings/styles and worker generation in TUI. Raw ConfigDocument/SavedBytes/vault/password/payload must not be fields of the public view.
|
1. Move ClientDisplay/SessionDisplay projection responsibilities to an application-owned read model. Include saved peer count, draft/saved public client differences, issue classification, reservation/apply facts and vault-initialized state. Keep UI-only selection, scroll, dialog focus, rendered strings/styles and worker generation in TUI. Raw ConfigDocument/SavedBytes/vault/password/payload must not be fields of the public view.
|
||||||
2. Represent action eligibility explicitly per action with typed reasons where useful. Audit current guards first: rename, delete, first delivery and restart do not necessarily have identical eligibility. In particular inspection-only management projection must not accidentally prohibit an already-supported deletion by public key. Never silently enable actions for malformed/ambiguous peers. Eligibility is guidance for presentation; application execution revalidates its actual preconditions.
|
2. Represent action eligibility explicitly per action with typed reasons where useful. Audit current guards first: rename, delete, first delivery and restart do not necessarily have identical eligibility. In particular inspection-only management projection must not accidentally prohibit an already-supported deletion by public key. Never silently enable actions for malformed/ambiguous peers. Eligibility is guidance for presentation; application execution revalidates its actual preconditions.
|
||||||
@@ -4121,12 +4121,12 @@ TUI consumes public application facts and action eligibility without parsing sav
|
|||||||
|
|
||||||
#### Acceptance and validation
|
#### Acceptance and validation
|
||||||
|
|
||||||
- [ ] M26-A1: TUI no longer parses saved server bytes to obtain application facts; read model is secret-free and includes all authoritative records.
|
- [x] M26-A1: TUI no longer parses saved server bytes to obtain application facts; read model is secret-free and includes all authoritative records.
|
||||||
- [ ] M26-A2: per-action availability matches existing handlers, with authoritative revalidation; selection/cancel/draft/QR behavior preserved.
|
- [x] M26-A2: per-action availability matches existing handlers, with authoritative revalidation; selection/cancel/draft/QR behavior preserved.
|
||||||
- [ ] M26-A3: worker generation/continuation/shutdown tests, responsive help/list/footer buffer tests and synthetic PTY workflows pass without weakened assertions.
|
- [x] M26-A3: worker generation/continuation/shutdown tests, responsive help/list/footer buffer tests and synthetic PTY workflows pass without weakened assertions.
|
||||||
- [ ] M26-A4: reviewer checks UI parity and boundary direction; RUST passes; actual module layout documented.
|
- [x] M26-A4: reviewer checks UI parity and boundary direction; RUST passes; actual module layout documented.
|
||||||
|
|
||||||
Focused commands: `cargo test --locked tui::`, `cargo test --locked --test tui_workflow`, `cargo test --locked --test client_delivery`. Evidence: docs/evidence/milestone-26.md. Outcome: not accepted. Handoff: M27 after acceptance.
|
Focused commands `cargo test --locked tui::` (88), `--test tui_workflow` (9), `--test client_delivery` (5), and affected `app::` (74) exited 0 on the integrated tree. Final coordinator `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (library 352 passed, 2 ignored; integration/doc tests passed), `cargo build --locked --release`, and `git diff --check` exited 0. Independent reviewer closed M26-R1 (removed unprojectable peer omission), R2 (unrelated issue reissue overblock), R3 (false Removed row from repair) and R4 (capability-issue reissue mismatch); all 11 evidence hashes and final gate results were rechecked. [M26 evidence](docs/evidence/milestone-26.md) identifies inputs, fixes and host/synthetic limits. No fresh Alpine or VM/systemd run is claimed. Outcome: accepted for M26 host/synthetic scope. Stop after M26; M27 starts only on a separate request.
|
||||||
|
|
||||||
### Milestone 27 — Integrated refactoring acceptance and architecture handoff
|
### Milestone 27 — Integrated refactoring acceptance and architecture handoff
|
||||||
|
|
||||||
@@ -4159,7 +4159,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af
|
|||||||
|
|
||||||
## Current progress and resume point
|
## Current progress and resume point
|
||||||
|
|
||||||
- M25 is accepted for host/fixture scope: bound preview/preparation and ordered restart verification moved into one scenario; independent review and required Rust gates passed. M25-R1 evidence gap was closed on re-review. [M25 evidence](docs/evidence/milestone-25.md) records final inputs and limits. M26–M27 remain pending; M9 remains retired/unaccepted and its real-system limitations remain unchanged.
|
- M26 is accepted for host/synthetic scope: app-owned secret-free facts and per-action eligibility drive TUI presentation, while the command adapter owns prepared contexts and the worker keeps generation/lifecycle control. Independent R1–R4 closure and required Rust gates passed. [M26 evidence](docs/evidence/milestone-26.md) records final inputs and limits. M27 remains 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.
|
- 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 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.
|
||||||
- M18–M25 handoffs remain complete. Stop after M25; begin M26.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–M26 handoffs remain complete. Stop after M26; begin M27.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
|
## Plan maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ src/
|
|||||||
draft.rs # Coordinated in-memory document, client-state, and vault drafts
|
draft.rs # Coordinated in-memory document, client-state, and vault drafts
|
||||||
first_delivery.rs # Session-local first-issue authorization and retry state
|
first_delivery.rs # Session-local first-issue authorization and retry state
|
||||||
first_file.rs # Password-free first-file and optional QR scenario
|
first_file.rs # Password-free first-file and optional QR scenario
|
||||||
|
read_model.rs # Secret-free application facts and per-action eligibility
|
||||||
reissue.rs # Per-action vault authentication and saved-client delivery
|
reissue.rs # Per-action vault authentication and saved-client delivery
|
||||||
commit.rs # Prepare, backup, install, and publish saved-state Commit
|
commit.rs # Prepare, backup, install, and publish saved-state Commit
|
||||||
restore.rs # Bound preview and confirmed saved-state replacement
|
restore.rs # Bound preview and confirmed saved-state replacement
|
||||||
@@ -51,7 +52,9 @@ src/
|
|||||||
tui.rs # Terminal UI entry point
|
tui.rs # Terminal UI entry point
|
||||||
tui/
|
tui/
|
||||||
state.rs # Screen state and navigation
|
state.rs # Screen state and navigation
|
||||||
events.rs # Keyboard and background events
|
events.rs # Commands, worker lifecycle, and background events
|
||||||
|
events/
|
||||||
|
executor.rs # Application command adaptation and prepared contexts
|
||||||
forms.rs # Short-lived forms and secret owners
|
forms.rs # Short-lived forms and secret owners
|
||||||
render.rs # Rendering
|
render.rs # Rendering
|
||||||
terminal.rs # Terminal setup and cleanup
|
terminal.rs # Terminal setup and cleanup
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Milestone 26 evidence — application read model and TUI command adapter
|
||||||
|
|
||||||
|
Run date: 2026-10-30T22:59:07+02:00. Baseline commit: `9a143e9`; the working tree contains uncommitted M26 source, test and layout changes below, plus coordinator-owned `PLANS.md`. Rust/Cargo: 1.97.1, native host target. Tests used synthetic fixtures, buffer rendering and PTY workflows only. No production configuration, keys, VPN, host service, Docker environment or VM was accessed.
|
||||||
|
|
||||||
|
## Integrated inputs
|
||||||
|
|
||||||
|
| Input | SHA-256 |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/app.rs` | `5aeab061f45a75f3b4f4ec41b078cbecce3cbdd5a49ec2c47c0a98f062747023` |
|
||||||
|
| `src/app/read_model.rs` | `92a1945ca0ad0d4f6dc7aad255643d3d515712a1750c466738d29655f69d282d` |
|
||||||
|
| `src/app/first_delivery.rs` | `b9e80c7802b083f0ffa6e1e99898e9be9459e9eb9f3ea415b00e5592020a6ef3` |
|
||||||
|
| `src/tui/state.rs` | `da429e61bf918bf8524b7d131bc553460eb260f76f39d3763824e399764dcfac` |
|
||||||
|
| `src/tui/terminal.rs` | `9131ef9ae1e4d6f8a84715cdcdfcdd528262930dc95834991916484ff5ce4e21` |
|
||||||
|
| `src/tui/render.rs` | `3fa3b6496b792f3d7d3db16405a847c91736f2e975e5dba892d6921ca367df19` |
|
||||||
|
| `src/tui/events.rs` | `114c9505e69a1e9ec0782b149daf2ff9dcdc1a29af99abdb28df91fee70b758c` |
|
||||||
|
| `src/tui/events/executor.rs` | `4a46a8b03b1f708ab89d2e50ceceb60674c30e3bc1bcbb4c87af93b57815cd57` |
|
||||||
|
| `tests/tui_workflow.rs` | `8050c2e7887366ca6145c62fa8ded25b9dcd36f01062afa85ce85518ce64733f` |
|
||||||
|
| `docs/PROJECT_LAYOUT.md` | `06b83f3c0a26de4a7c3f8ccbaf1d37a697afb17cf67c297b69326018d144262c` |
|
||||||
|
| `target/release/wireguard-admin` | `70b45b51e2e345e1d914b1196355fde9f5688f123bed47332690d56752ca5cbe` |
|
||||||
|
|
||||||
|
No dependency, manifest or lockfile changed. The release artifact hash identifies the binary built from this integrated source; no deployment occurred.
|
||||||
|
|
||||||
|
## Checks on integrated tree
|
||||||
|
|
||||||
|
| Command | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `cargo test --locked app::` | Exit 0; 74 app tests passed. |
|
||||||
|
| `cargo test --locked tui::` | Exit 0; 88 TUI tests passed. |
|
||||||
|
| `cargo test --locked --test tui_workflow` | Exit 0; 9 tests passed. |
|
||||||
|
| `cargo test --locked --test client_delivery` | Exit 0; 5 tests passed. |
|
||||||
|
| `cargo fmt --all -- --check` | Exit 0. |
|
||||||
|
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
|
||||||
|
| `cargo test --locked` | Exit 0; library 352 passed, 2 ignored; integration and doc tests passed. Opt-in Alpine scenarios stayed ignored. |
|
||||||
|
| `cargo build --locked --release` | Exit 0. |
|
||||||
|
| `git diff --check` | Exit 0. |
|
||||||
|
|
||||||
|
## Acceptance mapping and review
|
||||||
|
|
||||||
|
- **M26-A1:** `Application::read_model` owns the secret-free peer rows, saved peer count, draft/saved public client changes, issue classes, reservations, applied knowledge, vault/draft flags and per-action eligibility. `SessionView::from_application` consumes those facts and maps them into compatibility UI fields without accessing saved server bytes or `ClientStates`. Malformed, duplicate and no-key draft peer rows remain available for inspection, and removed saved peers outside manageable `ClientStates` remain visible.
|
||||||
|
- **M26-A2:** Selection binds action presentation to public identity and peer index. App eligibility separates rename, deletion, first delivery, reissue and restart; execution still checks its own preconditions. Exact-host-route deletion remains available even for a peer outside the manageable client projection, and the removed row is inspection-only. First-delivery session availability is queried without secret disclosure. Unrelated saved-document issues do not suppress a valid selected reissue; selected-peer issues and vault conflicts do.
|
||||||
|
- **M26-A3:** TUI, workflow and delivery suites cover worker generations, stale continuations and statistics, shutdown, QR, selection, list/help/footer buffers and synthetic PTY behavior. Existing dimensioned tests include 34x8, 40x9, 60x16, 80x24, 100x30 and 180x50 cases. No assertions were weakened to pass the refactor.
|
||||||
|
- **M26-A4:** `src/tui/events/executor.rs` owns command adaptation and prepared destination/restore contexts; `events.rs` keeps the single-owner worker queue and generation lifecycle. `docs/PROJECT_LAYOUT.md` matches the integrated modules. Broader terminal/render splitting was deferred because it would broaden visibility across shared lifecycle and layout helpers without a cohesive ownership gain in M26.
|
||||||
|
|
||||||
|
Fresh independent reviewer `/root/m26_review` reviewed the integrated diff. M26-R1 (removed unprojectable peer omitted) and M26-R2 (unrelated issue overblocked reissue) were fixed by repairer `/root/m26_repair`. Coordinator found M26-R3 (false Removed row for unchanged saved client) in the repair; it was fixed with a one-row regression. Reviewer found M26-R4 (reissue shown for selected capability-issue peer); the repair aligned eligibility with delivery validation and added a failing-before/passing-after regression. The reviewer independently re-reviewed and closed R1–R4 with no remaining source finding, then verified all 11 evidence hashes, reran the full test suite, app tests and release build, and found no evidence issue.
|
||||||
|
|
||||||
|
This is host/synthetic evidence only. No fresh Alpine Testcontainers scenario or disposable systemd VM test was run; prior Alpine results do not identify this M26 source, and Alpine does not prove systemd behavior. Real systemd/D-Bus and live IPv6 behavior remain unverified. The retired M9 real-system limitation remains outside M26.
|
||||||
+472
-81
@@ -9,10 +9,16 @@ mod commit;
|
|||||||
mod draft;
|
mod draft;
|
||||||
mod first_delivery;
|
mod first_delivery;
|
||||||
mod first_file;
|
mod first_file;
|
||||||
|
mod read_model;
|
||||||
mod reissue;
|
mod reissue;
|
||||||
mod restart;
|
mod restart;
|
||||||
mod restore;
|
mod restore;
|
||||||
|
|
||||||
|
pub use read_model::{
|
||||||
|
ActionEligibility, ActionUnavailableReason, ApplicationView, ClassifiedPeerIssue,
|
||||||
|
ClientActions, ClientDisplay, ClientStateChange, FirstDeliveryEligibility, PeerIssueClass,
|
||||||
|
PeerRecord, ReservationFacts, SessionDisplay,
|
||||||
|
};
|
||||||
pub use restart::{
|
pub use restart::{
|
||||||
RestartChoice, RestartDiagnostic, RestartFailureKind, RestartPreview, RestartReport,
|
RestartChoice, RestartDiagnostic, RestartFailureKind, RestartPreview, RestartReport,
|
||||||
RestartStage,
|
RestartStage,
|
||||||
@@ -47,46 +53,6 @@ use crate::vault::{VaultBundle, VaultError, WireGuardPrivateKey};
|
|||||||
use crate::wireguard::SectionKind;
|
use crate::wireguard::SectionKind;
|
||||||
use crate::wireguard::{ConfigDocument, DocumentError, EditError};
|
use crate::wireguard::{ConfigDocument, DocumentError, EditError};
|
||||||
|
|
||||||
/// Secret-free row for displaying every authoritative peer section.
|
|
||||||
/// `mutable_identity` is present only when the peer has an unambiguous,
|
|
||||||
/// supported projection into the client domain model.
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct ClientDisplay {
|
|
||||||
pub peer_index: usize,
|
|
||||||
pub line: usize,
|
|
||||||
pub identity: Option<PublicKey>,
|
|
||||||
pub label: String,
|
|
||||||
pub allowed_ips: Vec<String>,
|
|
||||||
pub has_subnet_routes: bool,
|
|
||||||
pub issues: Vec<crate::wireguard::PeerIssue>,
|
|
||||||
pub mutable_identity: Option<PublicKey>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClientDisplay {
|
|
||||||
/// Whether a retained parser issue describes a valid address shape the
|
|
||||||
/// client model cannot manage, rather than a malformed configuration.
|
|
||||||
pub fn is_capability_limitation_issue(&self, issue: &crate::wireguard::PeerIssue) -> bool {
|
|
||||||
matches!(
|
|
||||||
issue,
|
|
||||||
crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { .. }
|
|
||||||
| crate::wireguard::PeerIssue::MissingIpv4HostRoute
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn presented_issues(&self) -> impl Iterator<Item = &crate::wireguard::PeerIssue> {
|
|
||||||
self.issues
|
|
||||||
.iter()
|
|
||||||
.filter(|issue| !self.is_capability_limitation_issue(issue))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Secret-free display projection of the current document and client state.
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct SessionDisplay {
|
|
||||||
pub clients: Vec<ClientDisplay>,
|
|
||||||
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An open, in-memory management session. The configuration and state drafts
|
/// An open, in-memory management session. The configuration and state drafts
|
||||||
/// change independently of installed bytes until [`Self::commit`] succeeds.
|
/// change independently of installed bytes until [`Self::commit`] succeeds.
|
||||||
pub struct Application {
|
pub struct Application {
|
||||||
@@ -446,46 +412,13 @@ impl Application {
|
|||||||
/// Return a secret-free projection that includes unsupported and
|
/// Return a secret-free projection that includes unsupported and
|
||||||
/// ambiguous peers instead of silently omitting them from the client list.
|
/// ambiguous peers instead of silently omitting them from the client list.
|
||||||
pub fn display_projection(&self) -> SessionDisplay {
|
pub fn display_projection(&self) -> SessionDisplay {
|
||||||
let clients = self
|
read_model::legacy_session_display(self)
|
||||||
.draft
|
}
|
||||||
.document
|
|
||||||
.peers()
|
/// Return secret-free application facts for presentation and per-action
|
||||||
.iter()
|
/// availability. Operations still revalidate their preconditions when run.
|
||||||
.map(|peer| {
|
pub fn read_model(&self) -> ApplicationView {
|
||||||
let projected = peer
|
read_model::session_display(self)
|
||||||
.identity
|
|
||||||
.and_then(|identity| self.draft.document.project_client(&identity).ok());
|
|
||||||
let metadata_label = match &peer.metadata {
|
|
||||||
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
|
|
||||||
Some(metadata.alias.clone())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let identity = peer.identity;
|
|
||||||
let label = metadata_label.unwrap_or_else(|| {
|
|
||||||
identity.map_or_else(
|
|
||||||
|| format!("Peer at line {}", peer.section_line),
|
|
||||||
|key| format!("{}…", key.to_string().chars().take(8).collect::<String>()),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
ClientDisplay {
|
|
||||||
peer_index: peer.index,
|
|
||||||
line: peer.section_line,
|
|
||||||
identity,
|
|
||||||
label,
|
|
||||||
allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(),
|
|
||||||
has_subnet_routes: peer.has_subnet_routes,
|
|
||||||
issues: peer.issues.clone(),
|
|
||||||
mutable_identity: projected
|
|
||||||
.filter(|client| self.draft.states.draft().get(&client.identity).is_some())
|
|
||||||
.map(|client| client.identity),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
SessionDisplay {
|
|
||||||
clients,
|
|
||||||
document_issues: self.draft.document.issues().to_vec(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add one prepared public-key client to the byte-preserving server draft
|
/// Add one prepared public-key client to the byte-preserving server draft
|
||||||
@@ -1271,7 +1204,8 @@ mod tests {
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
AppError, Application, FileOutputError, FirstIssueAttempt, FirstIssueDeliveryFailure,
|
ActionUnavailableReason, AppError, Application, ClientStateChange, FileOutputError,
|
||||||
|
FirstDeliveryEligibility, FirstIssueAttempt, FirstIssueDeliveryFailure,
|
||||||
FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartFailureKind,
|
FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartFailureKind,
|
||||||
RestartStage, clone_saved, snapshot_from_document,
|
RestartStage, clone_saved, snapshot_from_document,
|
||||||
};
|
};
|
||||||
@@ -2678,6 +2612,93 @@ restart_timeout_seconds = 30
|
|||||||
assert!(!String::from_utf8_lossy(&saved.server_config).contains(&unsupported.to_string()));
|
assert!(!String::from_utf8_lossy(&saved.server_config).contains(&unsupported.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_model_keeps_removed_unprojectable_peer_and_reserved_routes_visible() {
|
||||||
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
|
let (store, states, _, _) = setup(root.path(), false);
|
||||||
|
let unsupported = PublicKey::from_bytes([10; 32]);
|
||||||
|
let mut server =
|
||||||
|
fs::read_to_string(root.path().join("server.conf")).expect("server fixture reads");
|
||||||
|
server.push_str(&format!(
|
||||||
|
"\n[Peer]\nPublicKey = {unsupported}\nAllowedIPs = 10.20.0.10/32, fd20::10/128\n"
|
||||||
|
));
|
||||||
|
fs::write(root.path().join("server.conf"), server).expect("server fixture updated");
|
||||||
|
let mut app = Application::open(store, states).expect("application opens");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!app.delete_client(unsupported)
|
||||||
|
.expect("unprojectable exact-host peer is deleted")
|
||||||
|
);
|
||||||
|
let view = app.read_model();
|
||||||
|
let removed = view
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.identity == Some(unsupported))
|
||||||
|
.expect("removed authoritative peer remains in the read model");
|
||||||
|
assert_eq!(removed.state_change, ClientStateChange::Removed);
|
||||||
|
assert_eq!(removed.saved_client, None);
|
||||||
|
assert_eq!(removed.peer_index, None);
|
||||||
|
assert_eq!(removed.saved_peer_index, Some(1));
|
||||||
|
assert_eq!(
|
||||||
|
removed.label,
|
||||||
|
format!("{}…", unsupported.to_string()[..8].to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(removed.allowed_ips, ["10.20.0.10/32", "fd20::10/128"]);
|
||||||
|
assert!(!removed.actions.rename.available);
|
||||||
|
assert!(!removed.actions.delete.available);
|
||||||
|
assert!(
|
||||||
|
view.reservations
|
||||||
|
.pending_host_routes
|
||||||
|
.contains(&"10.20.0.10".parse().unwrap())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
view.reservations
|
||||||
|
.pending_host_routes
|
||||||
|
.contains(&"fd20::10".parse().unwrap())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unchanged_saved_client_has_one_read_model_and_tui_row() {
|
||||||
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
|
let (store, states, identity, _) = setup(root.path(), false);
|
||||||
|
let app = Application::open(store, states).expect("application opens");
|
||||||
|
|
||||||
|
let application_view = app.read_model();
|
||||||
|
let matching_rows = application_view
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.filter(|peer| peer.identity == Some(identity))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(matching_rows.len(), 1);
|
||||||
|
assert_eq!(matching_rows[0].state_change, ClientStateChange::Unchanged);
|
||||||
|
|
||||||
|
let tui_view = crate::tui::state::SessionView::from_application(&app, 0);
|
||||||
|
assert_eq!(
|
||||||
|
tui_view
|
||||||
|
.visible_records()
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.identity() == Some(identity))
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"the unchanged saved client must not be appended as a removed row"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tui_view
|
||||||
|
.application
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.filter(|peer| {
|
||||||
|
peer.identity == Some(identity)
|
||||||
|
&& peer.state_change == ClientStateChange::Removed
|
||||||
|
})
|
||||||
|
.count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change() {
|
fn deletion_of_unprojectable_peer_with_broad_route_fails_without_draft_change() {
|
||||||
let root = tempfile::tempdir().expect("temp directory");
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
@@ -4241,4 +4262,374 @@ restart_timeout_seconds = 30
|
|||||||
assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown);
|
assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown);
|
||||||
assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown);
|
assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn application_read_model_keeps_malformed_and_duplicate_authoritative_peer_rows() {
|
||||||
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
|
let (store, states, key, _) = setup(root.path(), false);
|
||||||
|
let duplicate = WireGuardPrivateKey::generate()
|
||||||
|
.expect("synthetic duplicate identity")
|
||||||
|
.public_key();
|
||||||
|
let additional = format!(
|
||||||
|
"\n[Peer]\nAllowedIPs = 10.20.0.9/32\n\n[Peer]\nPublicKey = malformed\nAllowedIPs = 10.20.0.10/32\n\n[Peer]\nPublicKey = {duplicate}\nAllowedIPs = 10.20.0.11/32\n\n[Peer]\nPublicKey = {duplicate}\nAllowedIPs = 10.20.0.12/32\n"
|
||||||
|
);
|
||||||
|
let path = root.path().join("server.conf");
|
||||||
|
let mut server = fs::read(&path).expect("synthetic server fixture");
|
||||||
|
server.extend_from_slice(additional.as_bytes());
|
||||||
|
fs::write(&path, server).expect("extend synthetic saved peers");
|
||||||
|
let mut app = Application::open(store, states).expect("application opens");
|
||||||
|
app.prepare_initial_vault(
|
||||||
|
VaultBundle::create("synthetic read model password").expect("synthetic vault"),
|
||||||
|
)
|
||||||
|
.expect("vault stages");
|
||||||
|
app.commit().expect("vault is committed");
|
||||||
|
let created = app
|
||||||
|
.prepare_new_client("draft-only", None, None)
|
||||||
|
.expect("new client is staged");
|
||||||
|
app.delete_client(key)
|
||||||
|
.expect("saved client is removed from draft");
|
||||||
|
|
||||||
|
let view = app.read_model();
|
||||||
|
assert_eq!(view.saved_peer_count, 5);
|
||||||
|
assert_eq!(
|
||||||
|
view.clients.len(),
|
||||||
|
6,
|
||||||
|
"draft peers and the removed saved row remain visible"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
view.clients
|
||||||
|
.iter()
|
||||||
|
.filter(|peer| peer.draft_peer_index.is_some())
|
||||||
|
.count(),
|
||||||
|
5,
|
||||||
|
"all current authoritative peer sections remain visible"
|
||||||
|
);
|
||||||
|
assert!(view.clients.iter().any(|peer| {
|
||||||
|
peer.draft_peer_index.is_some()
|
||||||
|
&& peer.identity.is_none()
|
||||||
|
&& peer
|
||||||
|
.issues
|
||||||
|
.iter()
|
||||||
|
.any(|issue| matches!(issue, crate::wireguard::PeerIssue::MissingPublicKey))
|
||||||
|
}));
|
||||||
|
assert!(view.clients.iter().any(|peer| {
|
||||||
|
peer.identity.is_none()
|
||||||
|
&& peer.issues.iter().any(|issue| {
|
||||||
|
matches!(
|
||||||
|
issue,
|
||||||
|
crate::wireguard::PeerIssue::MalformedPublicKey { .. }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
assert_eq!(
|
||||||
|
view.clients
|
||||||
|
.iter()
|
||||||
|
.filter(|peer| peer.issues.iter().any(|issue| matches!(
|
||||||
|
issue,
|
||||||
|
crate::wireguard::PeerIssue::DuplicatePeerIdentity { .. }
|
||||||
|
)))
|
||||||
|
.count(),
|
||||||
|
2,
|
||||||
|
"both sides of a duplicate identity stay visible"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
view.clients
|
||||||
|
.iter()
|
||||||
|
.filter(|peer| peer.issues.iter().any(|issue| matches!(
|
||||||
|
issue,
|
||||||
|
crate::wireguard::PeerIssue::DuplicatePeerIdentity { .. }
|
||||||
|
)))
|
||||||
|
.all(|peer| {
|
||||||
|
!peer.actions.rename.available
|
||||||
|
&& !peer.actions.delete.available
|
||||||
|
&& !peer.actions.reissue.available
|
||||||
|
&& peer.mutable_identity.is_none()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let created_row = view
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.identity == Some(created))
|
||||||
|
.expect("created record represented");
|
||||||
|
assert_eq!(created_row.state_change, ClientStateChange::Added);
|
||||||
|
let removed_row = view
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.identity == Some(key))
|
||||||
|
.expect("removed saved client is represented");
|
||||||
|
assert_eq!(removed_row.state_change, ClientStateChange::Removed);
|
||||||
|
assert_eq!(removed_row.draft_peer_index, None);
|
||||||
|
assert_eq!(view.draft_client_count, 1);
|
||||||
|
assert_eq!(view.saved_client_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn application_read_model_reports_distinct_delivery_and_exact_route_actions() {
|
||||||
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
|
let (store, states, manageable, _) = setup(root.path(), false);
|
||||||
|
let exact_key = WireGuardPrivateKey::generate()
|
||||||
|
.expect("synthetic exact-route peer")
|
||||||
|
.public_key();
|
||||||
|
let subnet_key = WireGuardPrivateKey::generate()
|
||||||
|
.expect("synthetic subnet peer")
|
||||||
|
.public_key();
|
||||||
|
let path = root.path().join("server.conf");
|
||||||
|
let mut server = fs::read(&path).expect("synthetic server fixture");
|
||||||
|
server.extend_from_slice(
|
||||||
|
format!(
|
||||||
|
"\n[Peer]\nPublicKey = {exact_key}\nAllowedIPs = 10.20.0.20/32\n\n[Peer]\nPublicKey = {subnet_key}\nAllowedIPs = 10.20.1.0/24\n"
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
fs::write(&path, server).expect("add synthetic unsupported peers");
|
||||||
|
let mut app = Application::open(store, states).expect("application opens");
|
||||||
|
app.prepare_initial_vault(
|
||||||
|
VaultBundle::create("synthetic read model password").expect("synthetic vault"),
|
||||||
|
)
|
||||||
|
.expect("vault stages");
|
||||||
|
app.commit().expect("vault is committed");
|
||||||
|
let created = app
|
||||||
|
.prepare_new_client("first-file", None, None)
|
||||||
|
.expect("new client is staged");
|
||||||
|
|
||||||
|
let pending = app.read_model();
|
||||||
|
assert_eq!(
|
||||||
|
pending
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.identity == Some(created))
|
||||||
|
.unwrap()
|
||||||
|
.actions
|
||||||
|
.first_delivery,
|
||||||
|
FirstDeliveryEligibility::Unavailable,
|
||||||
|
"draft-only first delivery is not yet eligible"
|
||||||
|
);
|
||||||
|
assert!(pending.vault_initialized);
|
||||||
|
assert!(pending.has_draft);
|
||||||
|
let exact = pending
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.identity == Some(exact_key))
|
||||||
|
.expect("exact-host peer remains represented");
|
||||||
|
assert!(exact.actions.delete.available);
|
||||||
|
assert_eq!(exact.mutable_identity, None);
|
||||||
|
let subnet = pending
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.identity == Some(subnet_key))
|
||||||
|
.expect("subnet peer remains represented");
|
||||||
|
assert_eq!(
|
||||||
|
subnet.actions.delete.reason,
|
||||||
|
Some(ActionUnavailableReason::ExactHostRoutesRequired)
|
||||||
|
);
|
||||||
|
let manageable_row = pending
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.identity == Some(manageable))
|
||||||
|
.expect("managed saved peer represented");
|
||||||
|
assert_eq!(
|
||||||
|
manageable_row.actions.reissue.reason,
|
||||||
|
Some(ActionUnavailableReason::VaultRecordMissing)
|
||||||
|
);
|
||||||
|
|
||||||
|
app.commit().expect("created client is saved");
|
||||||
|
let committed = app.read_model();
|
||||||
|
let created_row = committed
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.identity == Some(created))
|
||||||
|
.expect("committed client represented");
|
||||||
|
assert_eq!(
|
||||||
|
created_row.actions.first_delivery,
|
||||||
|
FirstDeliveryEligibility::InitialFile
|
||||||
|
);
|
||||||
|
assert!(created_row.actions.reissue.available);
|
||||||
|
assert!(created_row.actions.rename.available);
|
||||||
|
assert!(created_row.actions.delete.available);
|
||||||
|
|
||||||
|
let failed = app
|
||||||
|
.deliver_first_issue(created, FirstIssueAttempt::Initial, |_| {
|
||||||
|
Err::<(), _>((
|
||||||
|
"cleanup required",
|
||||||
|
FirstIssueDeliveryFailure::CleanupRequired,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.expect("synthetic publication failure is represented");
|
||||||
|
assert!(matches!(
|
||||||
|
failed,
|
||||||
|
FirstIssueDeliveryOutcome::Failed {
|
||||||
|
retry: FirstIssueRetry::ManualCleanupRequired,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
let retry = app.read_model();
|
||||||
|
assert_eq!(
|
||||||
|
retry
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.identity == Some(created))
|
||||||
|
.unwrap()
|
||||||
|
.actions
|
||||||
|
.first_delivery,
|
||||||
|
FirstDeliveryEligibility::RetryNeedsManualCleanup
|
||||||
|
);
|
||||||
|
app.deliver_first_issue(
|
||||||
|
created,
|
||||||
|
FirstIssueAttempt::Retry {
|
||||||
|
confirm_possible_overwrite: false,
|
||||||
|
confirm_manual_cleanup: true,
|
||||||
|
},
|
||||||
|
|_| Ok::<_, ((), FirstIssueDeliveryFailure)>(()),
|
||||||
|
)
|
||||||
|
.expect("acknowledged first-file retry succeeds in synthetic adapter");
|
||||||
|
assert_eq!(
|
||||||
|
app.read_model()
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.identity == Some(created))
|
||||||
|
.unwrap()
|
||||||
|
.actions
|
||||||
|
.first_delivery,
|
||||||
|
FirstDeliveryEligibility::Unavailable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reissue_eligibility_ignores_unrelated_document_issue_but_rejects_ambiguous_peer() {
|
||||||
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
|
let (store, states, identity, private_key) = setup(root.path(), false);
|
||||||
|
let mut server =
|
||||||
|
fs::read_to_string(root.path().join("server.conf")).expect("server fixture reads");
|
||||||
|
server.push_str("\nmalformed unrelated directive\n");
|
||||||
|
fs::write(root.path().join("server.conf"), server).expect("fixture issue installed");
|
||||||
|
let mut app = Application::open(store, states).expect("application opens");
|
||||||
|
let mut vault = VaultBundle::create("synthetic reissue password").expect("synthetic vault");
|
||||||
|
vault
|
||||||
|
.encrypt_record(identity, &private_key)
|
||||||
|
.expect("saved peer record is encrypted");
|
||||||
|
app.prepare_initial_vault(vault).expect("vault staged");
|
||||||
|
app.commit().expect("vault committed");
|
||||||
|
|
||||||
|
let valid = app.read_model();
|
||||||
|
assert!(!valid.document_issues.is_empty());
|
||||||
|
assert!(
|
||||||
|
valid
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.identity == Some(identity))
|
||||||
|
.expect("valid selected peer represented")
|
||||||
|
.actions
|
||||||
|
.reissue
|
||||||
|
.available,
|
||||||
|
"an unrelated document issue must not block the selected safe peer"
|
||||||
|
);
|
||||||
|
|
||||||
|
let saved_vault = app.saved.vault.clone();
|
||||||
|
let mut conflicted_vault =
|
||||||
|
VaultBundle::create("synthetic conflict password").expect("conflict vault");
|
||||||
|
conflicted_vault
|
||||||
|
.encrypt_record_with_status(identity, &private_key, true)
|
||||||
|
.expect("conflicted saved record is encrypted");
|
||||||
|
app.saved.vault = Some(
|
||||||
|
conflicted_vault
|
||||||
|
.to_toml()
|
||||||
|
.expect("conflict vault serializes")
|
||||||
|
.into_bytes(),
|
||||||
|
);
|
||||||
|
let conflicted = app.read_model();
|
||||||
|
assert_eq!(
|
||||||
|
conflicted
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.identity == Some(identity))
|
||||||
|
.expect("conflicted peer represented")
|
||||||
|
.actions
|
||||||
|
.reissue
|
||||||
|
.reason,
|
||||||
|
Some(ActionUnavailableReason::VaultRecordConflict),
|
||||||
|
"a selected server/client parameter mismatch remains blocked"
|
||||||
|
);
|
||||||
|
app.saved.vault = saved_vault;
|
||||||
|
|
||||||
|
app.saved.server_config.extend_from_slice(
|
||||||
|
format!("\n[Peer]\nPublicKey = {identity}\nAllowedIPs = 10.20.0.99/32\n").as_bytes(),
|
||||||
|
);
|
||||||
|
let ambiguous = app.read_model();
|
||||||
|
let selected = ambiguous
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.identity == Some(identity))
|
||||||
|
.expect("selected peer remains represented");
|
||||||
|
assert_eq!(
|
||||||
|
selected.actions.reissue.reason,
|
||||||
|
Some(ActionUnavailableReason::UnsafePeerOrDocument),
|
||||||
|
"duplicate selected identity remains blocked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reissue_eligibility_blocks_selected_peer_capability_issues() {
|
||||||
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
|
let (store, states, identity, private_key) = setup(root.path(), false);
|
||||||
|
let mut app = Application::open(store.clone(), states.clone()).expect("application opens");
|
||||||
|
let mut vault =
|
||||||
|
VaultBundle::create("synthetic capability password").expect("synthetic vault");
|
||||||
|
vault
|
||||||
|
.encrypt_record(identity, &private_key)
|
||||||
|
.expect("saved peer record is encrypted");
|
||||||
|
app.prepare_initial_vault(vault).expect("vault staged");
|
||||||
|
app.commit().expect("vault committed");
|
||||||
|
|
||||||
|
let mut server = fs::read_to_string(root.path().join("server.conf"))
|
||||||
|
.expect("saved server configuration reads");
|
||||||
|
server = server.replace(
|
||||||
|
"AllowedIPs = 10.20.0.2/32, fd20::2/128",
|
||||||
|
"AllowedIPs = 10.20.0.2/32, 10.20.0.0/24",
|
||||||
|
);
|
||||||
|
fs::write(root.path().join("server.conf"), server)
|
||||||
|
.expect("install synthetic selected-peer capability issue");
|
||||||
|
let reopened = Application::open(store, app.states().clone())
|
||||||
|
.expect("application opens with synthetic route issue");
|
||||||
|
|
||||||
|
let selected = reopened
|
||||||
|
.read_model()
|
||||||
|
.clients
|
||||||
|
.into_iter()
|
||||||
|
.find(|peer| peer.identity == Some(identity))
|
||||||
|
.expect("selected saved peer remains represented");
|
||||||
|
assert!(selected.issues.iter().any(|issue| matches!(
|
||||||
|
issue,
|
||||||
|
crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { .. }
|
||||||
|
)));
|
||||||
|
assert!(!selected.actions.reissue.available);
|
||||||
|
assert_eq!(
|
||||||
|
selected.actions.reissue.reason,
|
||||||
|
Some(ActionUnavailableReason::UnsafePeerOrDocument)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn application_read_model_debug_does_not_expose_saved_config_or_vault_password() {
|
||||||
|
let root = tempfile::tempdir().expect("temp directory");
|
||||||
|
let mut app = setup_with_vault(root.path());
|
||||||
|
app.prepare_new_client("redaction-check", None, None)
|
||||||
|
.expect("client is staged");
|
||||||
|
let view = app.read_model();
|
||||||
|
let debug = format!("{view:?}");
|
||||||
|
let saved_text = String::from_utf8(app.saved_bytes().server_config.clone())
|
||||||
|
.expect("synthetic server configuration is UTF-8");
|
||||||
|
assert!(saved_text.contains("PrivateKey = "));
|
||||||
|
assert!(!debug.contains("PrivateKey = "));
|
||||||
|
assert!(!debug.contains("synthetic vault password"));
|
||||||
|
assert!(!debug.contains("vault_secret"));
|
||||||
|
let saved_vault = String::from_utf8(
|
||||||
|
app.saved_bytes()
|
||||||
|
.vault
|
||||||
|
.as_ref()
|
||||||
|
.expect("synthetic vault is saved")
|
||||||
|
.clone(),
|
||||||
|
)
|
||||||
|
.expect("synthetic vault is UTF-8");
|
||||||
|
assert!(!debug.contains(&saved_vault));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ enum IssueStage {
|
|||||||
RetryNeedsManualCleanup,
|
RetryNeedsManualCleanup,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read-only first-file availability with no access to the held private key,
|
||||||
|
/// issue context, or payload.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(super) enum SessionFirstDeliveryAvailability {
|
||||||
|
InitialFile,
|
||||||
|
Retry,
|
||||||
|
RetryNeedsOverwriteConfirmation,
|
||||||
|
RetryNeedsManualCleanup,
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) struct FirstIssueAuthorization {
|
pub(super) struct FirstIssueAuthorization {
|
||||||
identity: PublicKey,
|
identity: PublicKey,
|
||||||
context: IssueContext,
|
context: IssueContext,
|
||||||
@@ -64,6 +75,36 @@ impl FirstDeliverySession {
|
|||||||
self.issues.len()
|
self.issues.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn read_model_availability(
|
||||||
|
&self,
|
||||||
|
identity: PublicKey,
|
||||||
|
document: &ConfigDocument,
|
||||||
|
vault: Option<&VaultBundle>,
|
||||||
|
) -> SessionFirstDeliveryAvailability {
|
||||||
|
let Some(issue) = self.issues.get(&identity) else {
|
||||||
|
return SessionFirstDeliveryAvailability::Unavailable;
|
||||||
|
};
|
||||||
|
let matching = vault
|
||||||
|
.and_then(|vault| context_for(document, vault, identity).ok())
|
||||||
|
.is_some_and(|actual| actual == issue.context);
|
||||||
|
if !matching {
|
||||||
|
return SessionFirstDeliveryAvailability::Unavailable;
|
||||||
|
}
|
||||||
|
match issue.stage {
|
||||||
|
IssueStage::AwaitingFirstFile => SessionFirstDeliveryAvailability::InitialFile,
|
||||||
|
IssueStage::RetryAvailable => SessionFirstDeliveryAvailability::Retry,
|
||||||
|
IssueStage::RetryNeedsOverwriteConfirmation => {
|
||||||
|
SessionFirstDeliveryAvailability::RetryNeedsOverwriteConfirmation
|
||||||
|
}
|
||||||
|
IssueStage::RetryNeedsManualCleanup => {
|
||||||
|
SessionFirstDeliveryAvailability::RetryNeedsManualCleanup
|
||||||
|
}
|
||||||
|
IssueStage::PendingDraft | IssueStage::Authorizing(_) => {
|
||||||
|
SessionFirstDeliveryAvailability::Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(super) fn is_empty(&self) -> bool {
|
pub(super) fn is_empty(&self) -> bool {
|
||||||
self.issues.is_empty()
|
self.issues.is_empty()
|
||||||
|
|||||||
@@ -0,0 +1,585 @@
|
|||||||
|
//! Secret-free application facts prepared for the terminal and other readers.
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use crate::domain::{ApplyStatus, Client, PublicKey};
|
||||||
|
use crate::wireguard::{ConfigDocument, PeerInspection, PeerIssue};
|
||||||
|
|
||||||
|
use super::Application;
|
||||||
|
use super::first_delivery::SessionFirstDeliveryAvailability;
|
||||||
|
|
||||||
|
/// A presentation action is available only as far as the current application
|
||||||
|
/// facts can establish. The operation itself remains the final authority.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ActionEligibility {
|
||||||
|
pub available: bool,
|
||||||
|
pub reason: Option<ActionUnavailableReason>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionEligibility {
|
||||||
|
fn available() -> Self {
|
||||||
|
Self {
|
||||||
|
available: true,
|
||||||
|
reason: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unavailable(reason: ActionUnavailableReason) -> Self {
|
||||||
|
Self {
|
||||||
|
available: false,
|
||||||
|
reason: Some(reason),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ActionUnavailableReason {
|
||||||
|
NoUniquePeer,
|
||||||
|
PeerNotInDraft,
|
||||||
|
PeerNotInSavedState,
|
||||||
|
UnsafePeerOrDocument,
|
||||||
|
ExactHostRoutesRequired,
|
||||||
|
VaultNotInitialized,
|
||||||
|
VaultRecordMissing,
|
||||||
|
VaultRecordConflict,
|
||||||
|
FirstDeliveryNotAvailable,
|
||||||
|
RestartPreviewRejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum FirstDeliveryEligibility {
|
||||||
|
InitialFile,
|
||||||
|
Retry,
|
||||||
|
RetryNeedsOverwriteConfirmation,
|
||||||
|
RetryNeedsManualCleanup,
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ClientActions {
|
||||||
|
pub rename: ActionEligibility,
|
||||||
|
pub delete: ActionEligibility,
|
||||||
|
pub first_delivery: FirstDeliveryEligibility,
|
||||||
|
pub reissue: ActionEligibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PeerIssueClass {
|
||||||
|
MalformedOrAmbiguous,
|
||||||
|
UnsupportedCapability,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ClassifiedPeerIssue {
|
||||||
|
pub issue: PeerIssue,
|
||||||
|
pub class: PeerIssueClass,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ClientStateChange {
|
||||||
|
Added,
|
||||||
|
Removed,
|
||||||
|
Changed,
|
||||||
|
Unchanged,
|
||||||
|
UnsupportedOrUnmanaged,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compatibility row for the original `display_projection` API.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ClientDisplay {
|
||||||
|
pub peer_index: usize,
|
||||||
|
pub line: usize,
|
||||||
|
pub identity: Option<PublicKey>,
|
||||||
|
pub label: String,
|
||||||
|
pub allowed_ips: Vec<String>,
|
||||||
|
pub has_subnet_routes: bool,
|
||||||
|
pub issues: Vec<PeerIssue>,
|
||||||
|
pub mutable_identity: Option<PublicKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientDisplay {
|
||||||
|
/// Whether an issue is a valid configuration shape outside the current
|
||||||
|
/// client model's capabilities.
|
||||||
|
pub fn is_capability_limitation_issue(&self, issue: &PeerIssue) -> bool {
|
||||||
|
classify_issue(issue) == PeerIssueClass::UnsupportedCapability
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn presented_issues(&self) -> impl Iterator<Item = &PeerIssue> {
|
||||||
|
self.issues
|
||||||
|
.iter()
|
||||||
|
.filter(|issue| !self.is_capability_limitation_issue(issue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secret-free authoritative peer record used by the application read model.
|
||||||
|
/// Saved-only rows have no draft peer index and retain the former saved index.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PeerRecord {
|
||||||
|
pub peer_index: Option<usize>,
|
||||||
|
pub draft_peer_index: Option<usize>,
|
||||||
|
pub saved_peer_index: Option<usize>,
|
||||||
|
pub line: usize,
|
||||||
|
pub identity: Option<PublicKey>,
|
||||||
|
pub label: String,
|
||||||
|
pub allowed_ips: Vec<String>,
|
||||||
|
pub has_subnet_routes: bool,
|
||||||
|
pub issues: Vec<PeerIssue>,
|
||||||
|
pub classified_issues: Vec<ClassifiedPeerIssue>,
|
||||||
|
pub mutable_identity: Option<PublicKey>,
|
||||||
|
pub draft_client: Option<Client>,
|
||||||
|
pub saved_client: Option<Client>,
|
||||||
|
pub state_change: ClientStateChange,
|
||||||
|
pub actions: ClientActions,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct ReservationFacts {
|
||||||
|
pub committed: Vec<IpAddr>,
|
||||||
|
pub pending_host_routes: Vec<IpAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compatibility projection for existing TUI callers. New consumers should
|
||||||
|
/// use [`ApplicationView`] for saved-state and action facts.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SessionDisplay {
|
||||||
|
pub clients: Vec<ClientDisplay>,
|
||||||
|
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public projection of application facts. It intentionally has no document,
|
||||||
|
/// saved-byte, vault, password, or client-payload owner.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ApplicationView {
|
||||||
|
pub clients: Vec<PeerRecord>,
|
||||||
|
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
|
||||||
|
pub saved_peer_count: usize,
|
||||||
|
pub draft_client_count: usize,
|
||||||
|
pub saved_client_count: usize,
|
||||||
|
pub reservations: ReservationFacts,
|
||||||
|
pub applied: ApplyStatus,
|
||||||
|
pub vault_initialized: bool,
|
||||||
|
pub has_draft: bool,
|
||||||
|
pub restart: ActionEligibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn legacy_session_display(application: &Application) -> SessionDisplay {
|
||||||
|
SessionDisplay {
|
||||||
|
clients: application
|
||||||
|
.draft
|
||||||
|
.document
|
||||||
|
.peers()
|
||||||
|
.iter()
|
||||||
|
.map(|peer| {
|
||||||
|
let projected = peer
|
||||||
|
.identity
|
||||||
|
.and_then(|identity| application.draft.document.project_client(&identity).ok());
|
||||||
|
let label = match &peer.metadata {
|
||||||
|
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
|
||||||
|
Some(metadata.alias.clone())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
peer.identity.map_or_else(
|
||||||
|
|| format!("Peer at line {}", peer.section_line),
|
||||||
|
|key| format!("{}…", key.to_string().chars().take(8).collect::<String>()),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
ClientDisplay {
|
||||||
|
peer_index: peer.index,
|
||||||
|
line: peer.section_line,
|
||||||
|
identity: peer.identity,
|
||||||
|
label,
|
||||||
|
allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(),
|
||||||
|
has_subnet_routes: peer.has_subnet_routes,
|
||||||
|
issues: peer.issues.clone(),
|
||||||
|
mutable_identity: projected
|
||||||
|
.filter(|client| {
|
||||||
|
application
|
||||||
|
.draft
|
||||||
|
.states
|
||||||
|
.draft()
|
||||||
|
.get(&client.identity)
|
||||||
|
.is_some()
|
||||||
|
})
|
||||||
|
.map(|client| client.identity),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
document_issues: application.draft.document.issues().to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn session_display(application: &Application) -> ApplicationView {
|
||||||
|
let draft_document = &application.draft.document;
|
||||||
|
// The saved document was parsed when the session opened and every saved
|
||||||
|
// replacement is parsed before publication. Keep this projection total if
|
||||||
|
// that internal invariant is ever violated: no saved records are inferred.
|
||||||
|
let saved_document = ConfigDocument::parse(&application.saved.server_config).ok();
|
||||||
|
let saved_peer_count = saved_document.as_ref().map_or(0, |doc| doc.peers().len());
|
||||||
|
let mut clients = draft_document
|
||||||
|
.peers()
|
||||||
|
.iter()
|
||||||
|
.map(|peer| display_peer(application, peer, saved_document.as_ref()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let draft_peer_identities = draft_document
|
||||||
|
.peers()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|peer| peer.public_key_candidates.iter().copied())
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
let mut saved_only_identities = std::collections::BTreeSet::new();
|
||||||
|
if let Some(document) = saved_document.as_ref() {
|
||||||
|
for saved_peer in document.peers() {
|
||||||
|
let Some(identity) = saved_peer.identity else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if draft_peer_identities.contains(&identity)
|
||||||
|
|| !saved_only_identities.insert(identity)
|
||||||
|
|| document
|
||||||
|
.peers()
|
||||||
|
.iter()
|
||||||
|
.filter(|peer| peer.identity == Some(identity))
|
||||||
|
.count()
|
||||||
|
!= 1
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let saved_client = application.draft.states.saved().get(&identity).cloned();
|
||||||
|
let label = saved_client.as_ref().map_or_else(
|
||||||
|
|| match &saved_peer.metadata {
|
||||||
|
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
|
||||||
|
metadata.alias.clone()
|
||||||
|
}
|
||||||
|
_ => format!(
|
||||||
|
"{}…",
|
||||||
|
identity.to_string().chars().take(8).collect::<String>()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|client| client.alias.clone(),
|
||||||
|
);
|
||||||
|
clients.push(PeerRecord {
|
||||||
|
peer_index: None,
|
||||||
|
draft_peer_index: None,
|
||||||
|
saved_peer_index: Some(saved_peer.index),
|
||||||
|
line: saved_peer.section_line,
|
||||||
|
identity: Some(identity),
|
||||||
|
label,
|
||||||
|
allowed_ips: saved_peer
|
||||||
|
.allowed_ips
|
||||||
|
.iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect(),
|
||||||
|
has_subnet_routes: saved_peer.has_subnet_routes,
|
||||||
|
issues: saved_peer.issues.clone(),
|
||||||
|
classified_issues: classify_issues(&saved_peer.issues),
|
||||||
|
mutable_identity: None,
|
||||||
|
draft_client: None,
|
||||||
|
saved_client,
|
||||||
|
state_change: ClientStateChange::Removed,
|
||||||
|
actions: actions_for(application, Some(identity), false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A saved client can be absent from a malformed or unsupported saved
|
||||||
|
// document projection. Retain the state-backed row only when no
|
||||||
|
// authoritative peer row represented that identity above.
|
||||||
|
for client in application.draft.states.saved().clients() {
|
||||||
|
if draft_peer_identities.contains(&client.identity)
|
||||||
|
|| clients.iter().any(|peer| {
|
||||||
|
peer.state_change == ClientStateChange::Removed
|
||||||
|
&& peer.identity == Some(client.identity)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
clients.push(PeerRecord {
|
||||||
|
peer_index: None,
|
||||||
|
draft_peer_index: None,
|
||||||
|
saved_peer_index: None,
|
||||||
|
line: 0,
|
||||||
|
identity: Some(client.identity),
|
||||||
|
label: client.alias.clone(),
|
||||||
|
allowed_ips: Vec::new(),
|
||||||
|
has_subnet_routes: false,
|
||||||
|
issues: Vec::new(),
|
||||||
|
classified_issues: Vec::new(),
|
||||||
|
mutable_identity: None,
|
||||||
|
draft_client: None,
|
||||||
|
saved_client: Some(client.clone()),
|
||||||
|
state_change: ClientStateChange::Removed,
|
||||||
|
actions: actions_for(application, Some(client.identity), false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let reservations = application.draft.states.address_reservations();
|
||||||
|
let restart = if restart_is_eligible(application) {
|
||||||
|
ActionEligibility::available()
|
||||||
|
} else {
|
||||||
|
ActionEligibility::unavailable(ActionUnavailableReason::RestartPreviewRejected)
|
||||||
|
};
|
||||||
|
ApplicationView {
|
||||||
|
clients,
|
||||||
|
document_issues: draft_document.issues().to_vec(),
|
||||||
|
saved_peer_count,
|
||||||
|
draft_client_count: application.draft.states.draft().clients().count(),
|
||||||
|
saved_client_count: application.draft.states.saved().clients().count(),
|
||||||
|
reservations: ReservationFacts {
|
||||||
|
committed: reservations.committed().iter().copied().collect(),
|
||||||
|
pending_host_routes: reservations.pending_host_routes().collect(),
|
||||||
|
},
|
||||||
|
applied: *application.draft.states.applied(),
|
||||||
|
vault_initialized: application.draft.vault.is_some(),
|
||||||
|
has_draft: application.has_draft(),
|
||||||
|
restart,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_peer(
|
||||||
|
application: &Application,
|
||||||
|
peer: &PeerInspection,
|
||||||
|
saved_document: Option<&ConfigDocument>,
|
||||||
|
) -> PeerRecord {
|
||||||
|
let identity = peer.identity;
|
||||||
|
let draft_client = identity.and_then(|key| application.draft.states.draft().get(&key).cloned());
|
||||||
|
let saved_client = identity.and_then(|key| application.draft.states.saved().get(&key).cloned());
|
||||||
|
let projected = identity.and_then(|key| application.draft.document.project_client(&key).ok());
|
||||||
|
let metadata_label = match &peer.metadata {
|
||||||
|
crate::wireguard::MetadataAttachment::Valid { metadata, .. } => {
|
||||||
|
Some(metadata.alias.clone())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let label = metadata_label.unwrap_or_else(|| {
|
||||||
|
identity.map_or_else(
|
||||||
|
|| format!("Peer at line {}", peer.section_line),
|
||||||
|
|key| format!("{}…", key.to_string().chars().take(8).collect::<String>()),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let saved_peer_index = identity.and_then(|key| {
|
||||||
|
saved_document.and_then(|document| {
|
||||||
|
document
|
||||||
|
.peers()
|
||||||
|
.iter()
|
||||||
|
.find(|saved_peer| saved_peer.identity == Some(key))
|
||||||
|
.map(|saved_peer| saved_peer.index)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let mutable_identity = projected
|
||||||
|
.filter(|client| {
|
||||||
|
draft_client
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|draft| draft.identity == client.identity)
|
||||||
|
})
|
||||||
|
.map(|client| client.identity);
|
||||||
|
let state_change = match (&draft_client, &saved_client) {
|
||||||
|
(Some(_), None) => ClientStateChange::Added,
|
||||||
|
(None, Some(_)) => ClientStateChange::UnsupportedOrUnmanaged,
|
||||||
|
(Some(draft), Some(saved)) if draft == saved => ClientStateChange::Unchanged,
|
||||||
|
(Some(_), Some(_)) => ClientStateChange::Changed,
|
||||||
|
(None, None) => ClientStateChange::UnsupportedOrUnmanaged,
|
||||||
|
};
|
||||||
|
PeerRecord {
|
||||||
|
peer_index: Some(peer.index),
|
||||||
|
draft_peer_index: Some(peer.index),
|
||||||
|
saved_peer_index,
|
||||||
|
line: peer.section_line,
|
||||||
|
identity,
|
||||||
|
label,
|
||||||
|
allowed_ips: peer.allowed_ips.iter().map(ToString::to_string).collect(),
|
||||||
|
has_subnet_routes: peer.has_subnet_routes,
|
||||||
|
issues: peer.issues.clone(),
|
||||||
|
classified_issues: classify_issues(&peer.issues),
|
||||||
|
mutable_identity,
|
||||||
|
draft_client,
|
||||||
|
saved_client,
|
||||||
|
state_change,
|
||||||
|
actions: actions_for(application, identity, true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actions_for(
|
||||||
|
application: &Application,
|
||||||
|
identity: Option<PublicKey>,
|
||||||
|
peer_is_in_draft: bool,
|
||||||
|
) -> ClientActions {
|
||||||
|
let (rename, delete) = match identity {
|
||||||
|
None => {
|
||||||
|
let reason = ActionUnavailableReason::NoUniquePeer;
|
||||||
|
(
|
||||||
|
ActionEligibility::unavailable(reason),
|
||||||
|
ActionEligibility::unavailable(reason),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Some(_identity) if !peer_is_in_draft => {
|
||||||
|
let reason = ActionUnavailableReason::PeerNotInDraft;
|
||||||
|
(
|
||||||
|
ActionEligibility::unavailable(reason),
|
||||||
|
ActionEligibility::unavailable(reason),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Some(identity) => (
|
||||||
|
if application
|
||||||
|
.draft
|
||||||
|
.document
|
||||||
|
.rename_client(&identity, "eligibility probe")
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
ActionEligibility::available()
|
||||||
|
} else {
|
||||||
|
ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument)
|
||||||
|
},
|
||||||
|
delete_eligibility(application, identity),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let first_delivery =
|
||||||
|
identity.map_or(
|
||||||
|
FirstDeliveryEligibility::Unavailable,
|
||||||
|
|identity| match application.first_delivery.read_model_availability(
|
||||||
|
identity,
|
||||||
|
&application.draft.document,
|
||||||
|
application.draft.vault.as_ref(),
|
||||||
|
) {
|
||||||
|
SessionFirstDeliveryAvailability::InitialFile => {
|
||||||
|
FirstDeliveryEligibility::InitialFile
|
||||||
|
}
|
||||||
|
SessionFirstDeliveryAvailability::Retry => FirstDeliveryEligibility::Retry,
|
||||||
|
SessionFirstDeliveryAvailability::RetryNeedsOverwriteConfirmation => {
|
||||||
|
FirstDeliveryEligibility::RetryNeedsOverwriteConfirmation
|
||||||
|
}
|
||||||
|
SessionFirstDeliveryAvailability::RetryNeedsManualCleanup => {
|
||||||
|
FirstDeliveryEligibility::RetryNeedsManualCleanup
|
||||||
|
}
|
||||||
|
SessionFirstDeliveryAvailability::Unavailable => {
|
||||||
|
FirstDeliveryEligibility::Unavailable
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let reissue = match identity {
|
||||||
|
None => ActionEligibility::unavailable(ActionUnavailableReason::NoUniquePeer),
|
||||||
|
Some(identity) => reissue_eligibility(application, identity),
|
||||||
|
};
|
||||||
|
ClientActions {
|
||||||
|
rename,
|
||||||
|
delete,
|
||||||
|
first_delivery,
|
||||||
|
reissue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_eligibility(application: &Application, identity: PublicKey) -> ActionEligibility {
|
||||||
|
if application.draft.document.delete_client(&identity).is_err() {
|
||||||
|
return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument);
|
||||||
|
}
|
||||||
|
let saved_peer = ConfigDocument::parse(&application.saved.server_config)
|
||||||
|
.ok()
|
||||||
|
.and_then(|document| {
|
||||||
|
document
|
||||||
|
.peers()
|
||||||
|
.iter()
|
||||||
|
.find(|peer| peer.identity == Some(identity))
|
||||||
|
.cloned()
|
||||||
|
});
|
||||||
|
if application.draft.states.saved().get(&identity).is_some() || saved_peer.is_none() {
|
||||||
|
return ActionEligibility::available();
|
||||||
|
}
|
||||||
|
let Some(peer) = saved_peer else {
|
||||||
|
return ActionEligibility::available();
|
||||||
|
};
|
||||||
|
let exact_host_routes = !peer.allowed_ips.is_empty()
|
||||||
|
&& peer.allowed_ips.iter().all(|route| match route {
|
||||||
|
ipnet::IpNet::V4(net) => net.prefix_len() == 32,
|
||||||
|
ipnet::IpNet::V6(net) => net.prefix_len() == 128,
|
||||||
|
});
|
||||||
|
if exact_host_routes {
|
||||||
|
ActionEligibility::available()
|
||||||
|
} else {
|
||||||
|
ActionEligibility::unavailable(ActionUnavailableReason::ExactHostRoutesRequired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reissue_eligibility(application: &Application, identity: PublicKey) -> ActionEligibility {
|
||||||
|
let saved_document = match ConfigDocument::parse(&application.saved.server_config) {
|
||||||
|
Ok(document) => document,
|
||||||
|
Err(_) => {
|
||||||
|
return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let matching_peers = saved_document
|
||||||
|
.peers()
|
||||||
|
.iter()
|
||||||
|
.filter(|peer| peer.identity == Some(identity))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let Some(peer) = matching_peers.first() else {
|
||||||
|
return ActionEligibility::unavailable(ActionUnavailableReason::PeerNotInSavedState);
|
||||||
|
};
|
||||||
|
if matching_peers.len() != 1
|
||||||
|
|| peer.identity != peer.public_key_candidates.first().copied()
|
||||||
|
|| peer.public_key_candidates.len() != 1
|
||||||
|
|| !peer.issues.is_empty()
|
||||||
|
|| peer.allowed_ips.is_empty()
|
||||||
|
{
|
||||||
|
return ActionEligibility::unavailable(ActionUnavailableReason::UnsafePeerOrDocument);
|
||||||
|
}
|
||||||
|
let Some(bytes) = application.saved.vault.as_deref() else {
|
||||||
|
return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized);
|
||||||
|
};
|
||||||
|
let vault_text = match std::str::from_utf8(bytes) {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(_) => {
|
||||||
|
return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let vault = match crate::vault::VaultBundle::from_toml(vault_text) {
|
||||||
|
Ok(vault) => vault,
|
||||||
|
Err(_) => {
|
||||||
|
return ActionEligibility::unavailable(ActionUnavailableReason::VaultNotInitialized);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if vault.contains_record(identity) {
|
||||||
|
if vault.reissue_blocked(identity).unwrap_or(true) {
|
||||||
|
ActionEligibility::unavailable(ActionUnavailableReason::VaultRecordConflict)
|
||||||
|
} else {
|
||||||
|
ActionEligibility::available()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ActionEligibility::unavailable(ActionUnavailableReason::VaultRecordMissing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restart_is_eligible(application: &Application) -> bool {
|
||||||
|
let Ok(document) = ConfigDocument::parse(&application.saved.server_config) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
document
|
||||||
|
.sections()
|
||||||
|
.iter()
|
||||||
|
.filter(|section| section.kind == crate::wireguard::SectionKind::Interface)
|
||||||
|
.count()
|
||||||
|
== 1
|
||||||
|
&& !document.has_unsafe_save_config()
|
||||||
|
&& super::restart::restart_projection_from_document(&document).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_issues(issues: &[PeerIssue]) -> Vec<ClassifiedPeerIssue> {
|
||||||
|
issues
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|issue| ClassifiedPeerIssue {
|
||||||
|
class: classify_issue(&issue),
|
||||||
|
issue,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_issue(issue: &PeerIssue) -> PeerIssueClass {
|
||||||
|
match issue {
|
||||||
|
PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute => {
|
||||||
|
PeerIssueClass::UnsupportedCapability
|
||||||
|
}
|
||||||
|
_ => PeerIssueClass::MalformedOrAmbiguous,
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-395
@@ -10,13 +10,10 @@ use std::thread::{self, JoinHandle};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::app::Application;
|
use crate::app::Application;
|
||||||
use crate::app::{
|
use crate::app::{FirstIssueAttempt, FirstIssueRetry, RestartChoice, RestartReport};
|
||||||
FirstIssueAttempt, FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartReport,
|
|
||||||
RestorePreview,
|
|
||||||
};
|
|
||||||
use crate::backup::{ArchiveSummary, BackupReason, HistoricalApplyStatus};
|
use crate::backup::{ArchiveSummary, BackupReason, HistoricalApplyStatus};
|
||||||
use crate::config::SettingsFile;
|
use crate::config::SettingsFile;
|
||||||
use crate::delivery::files::{FileOutputError, PreparedDestination};
|
use crate::delivery::files::FileOutputError;
|
||||||
use crate::delivery::{QrCodeOutput, QrError};
|
use crate::delivery::{QrCodeOutput, QrError};
|
||||||
use crate::domain::{ClientAddresses, PublicKey};
|
use crate::domain::{ClientAddresses, PublicKey};
|
||||||
use crate::runtime::RuntimeObservation;
|
use crate::runtime::RuntimeObservation;
|
||||||
@@ -287,396 +284,10 @@ pub trait OperationExecutor: Send + 'static {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DeliveryMode {
|
mod executor;
|
||||||
FirstIssue,
|
pub use executor::ApplicationExecutor;
|
||||||
Reissue,
|
#[cfg(test)]
|
||||||
}
|
use executor::{destination_generation_matches, restore_preview_generation_matches};
|
||||||
|
|
||||||
struct PreparedDestinationContext {
|
|
||||||
identity: PublicKey,
|
|
||||||
include_qr: bool,
|
|
||||||
mode: DeliveryMode,
|
|
||||||
attempt: FirstIssueAttempt,
|
|
||||||
generation: u64,
|
|
||||||
destination: PreparedDestination,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RestorePreviewContext {
|
|
||||||
_archive_name: String,
|
|
||||||
generation: u64,
|
|
||||||
preview: RestorePreview,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct ApplicationExecutor {
|
|
||||||
prepared_destination: Option<PreparedDestinationContext>,
|
|
||||||
restore_preview: Option<RestorePreviewContext>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OperationExecutor for ApplicationExecutor {
|
|
||||||
fn execute(
|
|
||||||
&mut self,
|
|
||||||
app: &mut Application,
|
|
||||||
command: UiCommand,
|
|
||||||
) -> Result<CommandResult, OperationError> {
|
|
||||||
self.execute_at(app, command, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn execute_at(
|
|
||||||
&mut self,
|
|
||||||
app: &mut Application,
|
|
||||||
command: UiCommand,
|
|
||||||
generation: u64,
|
|
||||||
) -> Result<CommandResult, OperationError> {
|
|
||||||
let delivery_continuation = matches!(command, UiCommand::DeliverFirstIssue { .. });
|
|
||||||
let restore_continuation = matches!(
|
|
||||||
command,
|
|
||||||
UiCommand::ConfirmRestore { .. } | UiCommand::CancelRestorePreview
|
|
||||||
);
|
|
||||||
if !delivery_continuation && !matches!(&command, UiCommand::CancelPreparedDelivery) {
|
|
||||||
self.prepared_destination = None;
|
|
||||||
}
|
|
||||||
if !restore_continuation && !matches!(&command, UiCommand::PreviewRestore { .. }) {
|
|
||||||
self.restore_preview = None;
|
|
||||||
}
|
|
||||||
let result = match command {
|
|
||||||
UiCommand::CreateClient {
|
|
||||||
alias,
|
|
||||||
manual_addresses,
|
|
||||||
created_utc,
|
|
||||||
} => app
|
|
||||||
.prepare_new_client(&alias, manual_addresses, created_utc.as_deref())
|
|
||||||
.map(CommandResult::ClientCreated),
|
|
||||||
UiCommand::RenameClient { identity, alias } => app
|
|
||||||
.rename_client(identity, &alias)
|
|
||||||
.map(|()| CommandResult::ClientRenamed),
|
|
||||||
UiCommand::DeleteClient { identity } => app
|
|
||||||
.delete_client(identity)
|
|
||||||
.map(|record_removed| CommandResult::ClientDeleted { record_removed }),
|
|
||||||
UiCommand::DiscardDraft => app.discard_draft().map(|()| CommandResult::DraftDiscarded),
|
|
||||||
UiCommand::Commit => app.commit().map(CommandResult::Committed),
|
|
||||||
UiCommand::CreateBackup => app.create_manual_backup().map(CommandResult::BackupCreated),
|
|
||||||
UiCommand::ListBackups => app.list_backups().map(CommandResult::Backups),
|
|
||||||
UiCommand::ImportSelectedFile { file_name } => app
|
|
||||||
.import_selected_file(&file_name)
|
|
||||||
.map(CommandResult::SelectedFileImported),
|
|
||||||
UiCommand::PreviewRestart => app.preview_restart().map(|preview| {
|
|
||||||
CommandResult::RestartPreview(RestartPreviewSummary {
|
|
||||||
has_draft: preview.has_draft(),
|
|
||||||
interface: preview.interface().to_owned(),
|
|
||||||
systemd_unit: preview.systemd_unit().to_owned(),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
UiCommand::Restart(choice) => {
|
|
||||||
let result = app
|
|
||||||
.preview_restart()
|
|
||||||
.map(|preview| app.restart(preview, choice));
|
|
||||||
result.map(CommandResult::Restarted)
|
|
||||||
}
|
|
||||||
UiCommand::PrepareDelivery {
|
|
||||||
identity,
|
|
||||||
path,
|
|
||||||
include_qr,
|
|
||||||
reissue,
|
|
||||||
attempt,
|
|
||||||
} => app
|
|
||||||
.prepare_first_issue_destination(path)
|
|
||||||
.map_err(crate::app::AppError::FileOutput)
|
|
||||||
.map(|destination| {
|
|
||||||
let summary = DeliveryDestinationSummary {
|
|
||||||
identity,
|
|
||||||
path: destination.path().to_path_buf(),
|
|
||||||
overwrite_required: destination.overwrite_confirmation_required(),
|
|
||||||
include_qr,
|
|
||||||
reissue,
|
|
||||||
attempt,
|
|
||||||
prepared_generation: generation,
|
|
||||||
};
|
|
||||||
self.prepared_destination = Some(PreparedDestinationContext {
|
|
||||||
identity,
|
|
||||||
include_qr,
|
|
||||||
mode: if reissue {
|
|
||||||
DeliveryMode::Reissue
|
|
||||||
} else {
|
|
||||||
DeliveryMode::FirstIssue
|
|
||||||
},
|
|
||||||
attempt,
|
|
||||||
generation,
|
|
||||||
destination,
|
|
||||||
});
|
|
||||||
CommandResult::DeliveryDestination(summary)
|
|
||||||
}),
|
|
||||||
UiCommand::DeliverFirstIssue {
|
|
||||||
attempt,
|
|
||||||
overwrite_confirmed,
|
|
||||||
prepared_generation,
|
|
||||||
} => {
|
|
||||||
let prepared = self
|
|
||||||
.prepared_destination
|
|
||||||
.take()
|
|
||||||
.ok_or_else(|| OperationError {
|
|
||||||
message: "No prepared output destination is available.".to_owned(),
|
|
||||||
})?;
|
|
||||||
if !matches!(prepared.mode, DeliveryMode::FirstIssue)
|
|
||||||
|| prepared.attempt != attempt
|
|
||||||
|| !destination_generation_matches(
|
|
||||||
prepared.generation,
|
|
||||||
prepared_generation,
|
|
||||||
generation,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return Err(OperationError {
|
|
||||||
message: "Prepared output destination is stale; select it again."
|
|
||||||
.to_owned(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let identity = prepared.identity;
|
|
||||||
let include_qr = prepared.include_qr;
|
|
||||||
let mut destination = prepared.destination;
|
|
||||||
if destination.overwrite_confirmation_required() {
|
|
||||||
if !overwrite_confirmed {
|
|
||||||
return Err(OperationError {
|
|
||||||
message: "Overwrite confirmation was not supplied.".to_owned(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
destination = destination.confirm_overwrite().map_err(operation_error)?;
|
|
||||||
}
|
|
||||||
if include_qr {
|
|
||||||
app.deliver_first_issue_file_and_qr(identity, attempt, destination)
|
|
||||||
.map(|outcome| match outcome {
|
|
||||||
FirstIssueDeliveryOutcome::Delivered(delivery) => {
|
|
||||||
let (qr, qr_output) = qr_result(delivery.qr);
|
|
||||||
CommandResult::FirstIssue(FirstIssueSummary {
|
|
||||||
identity,
|
|
||||||
status: FirstIssueStatus::Delivered,
|
|
||||||
retry: None,
|
|
||||||
activation_warning: delivery.activation_warning,
|
|
||||||
qr,
|
|
||||||
qr_output,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
|
|
||||||
value,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let (qr, qr_output) = qr_result(value.qr);
|
|
||||||
CommandResult::FirstIssue(FirstIssueSummary {
|
|
||||||
identity,
|
|
||||||
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
|
|
||||||
retry: None,
|
|
||||||
activation_warning: value.activation_warning,
|
|
||||||
qr,
|
|
||||||
qr_output,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
FirstIssueDeliveryOutcome::Failed {
|
|
||||||
error,
|
|
||||||
retry,
|
|
||||||
activation_warning,
|
|
||||||
} => CommandResult::FirstIssue(FirstIssueSummary {
|
|
||||||
identity,
|
|
||||||
status: FirstIssueStatus::Failed(error),
|
|
||||||
retry: Some(retry),
|
|
||||||
activation_warning,
|
|
||||||
qr: QrStatus::NotRequested,
|
|
||||||
qr_output: None,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
app.deliver_first_issue_file(identity, attempt, destination)
|
|
||||||
.map(|outcome| match outcome {
|
|
||||||
FirstIssueDeliveryOutcome::Delivered(delivery) => {
|
|
||||||
CommandResult::FirstIssue(FirstIssueSummary {
|
|
||||||
identity,
|
|
||||||
status: FirstIssueStatus::Delivered,
|
|
||||||
retry: None,
|
|
||||||
activation_warning: delivery.activation_warning,
|
|
||||||
qr: QrStatus::NotRequested,
|
|
||||||
qr_output: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
|
|
||||||
value,
|
|
||||||
..
|
|
||||||
} => CommandResult::FirstIssue(FirstIssueSummary {
|
|
||||||
identity,
|
|
||||||
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
|
|
||||||
retry: None,
|
|
||||||
activation_warning: value.activation_warning,
|
|
||||||
qr: QrStatus::NotRequested,
|
|
||||||
qr_output: None,
|
|
||||||
}),
|
|
||||||
FirstIssueDeliveryOutcome::Failed {
|
|
||||||
error,
|
|
||||||
retry,
|
|
||||||
activation_warning,
|
|
||||||
} => CommandResult::FirstIssue(FirstIssueSummary {
|
|
||||||
identity,
|
|
||||||
status: FirstIssueStatus::Failed(error),
|
|
||||||
retry: Some(retry),
|
|
||||||
activation_warning,
|
|
||||||
qr: QrStatus::NotRequested,
|
|
||||||
qr_output: None,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UiCommand::PreviewRestore { file_name } => {
|
|
||||||
app.preview_restore(&file_name).map(|preview| {
|
|
||||||
let changes = preview.change_summary(app.saved_bytes());
|
|
||||||
let summary = RestorePreviewSummary {
|
|
||||||
file_name: file_name.clone(),
|
|
||||||
created_at_utc: preview.created_at_utc().to_owned(),
|
|
||||||
reason: preview.reason(),
|
|
||||||
historical_apply: preview.historical_apply().clone(),
|
|
||||||
changes,
|
|
||||||
preview_generation: generation,
|
|
||||||
};
|
|
||||||
self.restore_preview = Some(RestorePreviewContext {
|
|
||||||
_archive_name: file_name,
|
|
||||||
generation,
|
|
||||||
preview,
|
|
||||||
});
|
|
||||||
CommandResult::RestorePreview(summary)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
UiCommand::CancelPreparedDelivery => {
|
|
||||||
self.prepared_destination = None;
|
|
||||||
Ok(CommandResult::Cancelled)
|
|
||||||
}
|
|
||||||
UiCommand::CancelRestorePreview => {
|
|
||||||
self.restore_preview = None;
|
|
||||||
Ok(CommandResult::Cancelled)
|
|
||||||
}
|
|
||||||
UiCommand::ConfirmRestore { preview_generation } => {
|
|
||||||
let context = self.restore_preview.take().ok_or_else(|| OperationError {
|
|
||||||
message: "Restore preview is no longer available; select the archive again."
|
|
||||||
.to_owned(),
|
|
||||||
})?;
|
|
||||||
if !restore_preview_generation_matches(
|
|
||||||
context.generation,
|
|
||||||
preview_generation,
|
|
||||||
generation,
|
|
||||||
) {
|
|
||||||
return Err(OperationError {
|
|
||||||
message: "Restore preview is stale; select the archive again.".to_owned(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
app.confirm_restore(context.preview)
|
|
||||||
.map(CommandResult::Restored)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
result.map_err(|error| OperationError {
|
|
||||||
message: error.to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn execute_sensitive(
|
|
||||||
&mut self,
|
|
||||||
app: &mut Application,
|
|
||||||
command: SensitiveCommand,
|
|
||||||
) -> Result<CommandResult, OperationError> {
|
|
||||||
match command {
|
|
||||||
SensitiveCommand::PrepareInitialSetup(password) => app
|
|
||||||
.prepare_initial_setup_from_sources(password.expose())
|
|
||||||
.map(CommandResult::InitialSetupPrepared)
|
|
||||||
.map_err(|error| OperationError {
|
|
||||||
message: error.to_string(),
|
|
||||||
}),
|
|
||||||
SensitiveCommand::ReissueClient {
|
|
||||||
identity,
|
|
||||||
password,
|
|
||||||
overwrite_confirmed,
|
|
||||||
prepared_generation,
|
|
||||||
} => {
|
|
||||||
let prepared = self
|
|
||||||
.prepared_destination
|
|
||||||
.take()
|
|
||||||
.ok_or_else(|| OperationError {
|
|
||||||
message: "No prepared output destination is available.".to_owned(),
|
|
||||||
})?;
|
|
||||||
if prepared.identity != identity
|
|
||||||
|| !matches!(prepared.mode, DeliveryMode::Reissue)
|
|
||||||
|| prepared.generation != prepared_generation
|
|
||||||
{
|
|
||||||
return Err(OperationError {
|
|
||||||
message: "Prepared output destination is stale; select it again."
|
|
||||||
.to_owned(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let include_qr = prepared.include_qr;
|
|
||||||
let mut destination = prepared.destination;
|
|
||||||
if destination.overwrite_confirmation_required() {
|
|
||||||
if !overwrite_confirmed {
|
|
||||||
return Err(OperationError {
|
|
||||||
message: "Overwrite confirmation was not supplied.".to_owned(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
destination = destination.confirm_overwrite().map_err(operation_error)?;
|
|
||||||
}
|
|
||||||
let delivery = app
|
|
||||||
.reissue_client_file(identity, password.expose(), destination, include_qr)
|
|
||||||
.map_err(|error| OperationError {
|
|
||||||
message: error.to_string(),
|
|
||||||
})?;
|
|
||||||
let (qr, qr_output) = match delivery.qr {
|
|
||||||
None => (QrStatus::NotRequested, None),
|
|
||||||
Some(result) => qr_result(result),
|
|
||||||
};
|
|
||||||
Ok(CommandResult::Reissued(ReissueSummary {
|
|
||||||
activation_warning: delivery.activation_warning,
|
|
||||||
qr,
|
|
||||||
qr_output,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn execute_sensitive_at(
|
|
||||||
&mut self,
|
|
||||||
app: &mut Application,
|
|
||||||
command: SensitiveCommand,
|
|
||||||
generation: u64,
|
|
||||||
) -> Result<CommandResult, OperationError> {
|
|
||||||
match &command {
|
|
||||||
SensitiveCommand::ReissueClient {
|
|
||||||
prepared_generation,
|
|
||||||
..
|
|
||||||
} if *prepared_generation != generation => {
|
|
||||||
return Err(OperationError {
|
|
||||||
message: "Prepared output destination is stale; select it again.".to_owned(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
if matches!(command, SensitiveCommand::PrepareInitialSetup(_)) {
|
|
||||||
self.prepared_destination = None;
|
|
||||||
self.restore_preview = None;
|
|
||||||
}
|
|
||||||
self.execute_sensitive(app, command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn operation_error(error: impl std::fmt::Display) -> OperationError {
|
|
||||||
OperationError {
|
|
||||||
message: error.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn destination_generation_matches(stored: u64, requested: u64, current: u64) -> bool {
|
|
||||||
stored == requested && stored == current
|
|
||||||
}
|
|
||||||
|
|
||||||
fn restore_preview_generation_matches(stored: u64, requested: u64, current: u64) -> bool {
|
|
||||||
stored == requested && current == requested.saturating_add(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn qr_result(result: Result<QrCodeOutput, QrError>) -> (QrStatus, Option<QrCodeOutput>) {
|
|
||||||
match result {
|
|
||||||
Ok(output) => (QrStatus::Ready, Some(output)),
|
|
||||||
Err(error) => (QrStatus::Failed(error), None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait StatisticsReader: Send + 'static {
|
pub trait StatisticsReader: Send + 'static {
|
||||||
/// Implementations must return within `timeout`; unavailable runtime data
|
/// Implementations must return within `timeout`; unavailable runtime data
|
||||||
@@ -1151,6 +762,47 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_delivery_confirmation_consumes_prepared_destination_without_delivery() {
|
||||||
|
let (root, mut app) = application();
|
||||||
|
let identity = PublicKey::from_bytes([45; 32]);
|
||||||
|
let path = root.path().join("prepared.conf");
|
||||||
|
let mut executor = ApplicationExecutor::default();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
executor.execute_at(
|
||||||
|
&mut app,
|
||||||
|
UiCommand::PrepareDelivery {
|
||||||
|
identity,
|
||||||
|
path,
|
||||||
|
include_qr: false,
|
||||||
|
reissue: false,
|
||||||
|
attempt: FirstIssueAttempt::Initial,
|
||||||
|
},
|
||||||
|
7,
|
||||||
|
),
|
||||||
|
Ok(CommandResult::DeliveryDestination(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
let stale = UiCommand::DeliverFirstIssue {
|
||||||
|
attempt: FirstIssueAttempt::Initial,
|
||||||
|
overwrite_confirmed: false,
|
||||||
|
prepared_generation: 7,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
executor
|
||||||
|
.execute_at(&mut app, stale.clone(), 8)
|
||||||
|
.unwrap_err()
|
||||||
|
.message,
|
||||||
|
"Prepared output destination is stale; select it again."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
executor.execute_at(&mut app, stale, 7).unwrap_err().message,
|
||||||
|
"No prepared output destination is available."
|
||||||
|
);
|
||||||
|
assert!(!root.path().join("prepared.conf").exists());
|
||||||
|
}
|
||||||
|
|
||||||
struct FakeStatistics {
|
struct FakeStatistics {
|
||||||
started: Option<mpsc::SyncSender<()>>,
|
started: Option<mpsc::SyncSender<()>>,
|
||||||
release: Option<Receiver<()>>,
|
release: Option<Receiver<()>>,
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
use super::{
|
||||||
|
CommandResult, DeliveryDestinationSummary, FirstIssueAttempt, FirstIssueStatus,
|
||||||
|
FirstIssueSummary, OperationError, OperationExecutor, QrStatus, ReissueSummary,
|
||||||
|
RestartPreviewSummary, RestorePreviewSummary, SensitiveCommand, UiCommand,
|
||||||
|
};
|
||||||
|
use crate::app::{Application, FirstIssueDeliveryOutcome, RestorePreview};
|
||||||
|
use crate::delivery::files::PreparedDestination;
|
||||||
|
use crate::delivery::{QrCodeOutput, QrError};
|
||||||
|
use crate::domain::PublicKey;
|
||||||
|
|
||||||
|
enum DeliveryMode {
|
||||||
|
FirstIssue,
|
||||||
|
Reissue,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PreparedDestinationContext {
|
||||||
|
identity: PublicKey,
|
||||||
|
include_qr: bool,
|
||||||
|
mode: DeliveryMode,
|
||||||
|
attempt: FirstIssueAttempt,
|
||||||
|
generation: u64,
|
||||||
|
destination: PreparedDestination,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RestorePreviewContext {
|
||||||
|
_archive_name: String,
|
||||||
|
generation: u64,
|
||||||
|
preview: RestorePreview,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ApplicationExecutor {
|
||||||
|
prepared_destination: Option<PreparedDestinationContext>,
|
||||||
|
restore_preview: Option<RestorePreviewContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OperationExecutor for ApplicationExecutor {
|
||||||
|
fn execute(
|
||||||
|
&mut self,
|
||||||
|
app: &mut Application,
|
||||||
|
command: UiCommand,
|
||||||
|
) -> Result<CommandResult, OperationError> {
|
||||||
|
self.execute_at(app, command, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_at(
|
||||||
|
&mut self,
|
||||||
|
app: &mut Application,
|
||||||
|
command: UiCommand,
|
||||||
|
generation: u64,
|
||||||
|
) -> Result<CommandResult, OperationError> {
|
||||||
|
let delivery_continuation = matches!(command, UiCommand::DeliverFirstIssue { .. });
|
||||||
|
let restore_continuation = matches!(
|
||||||
|
command,
|
||||||
|
UiCommand::ConfirmRestore { .. } | UiCommand::CancelRestorePreview
|
||||||
|
);
|
||||||
|
if !delivery_continuation && !matches!(&command, UiCommand::CancelPreparedDelivery) {
|
||||||
|
self.prepared_destination = None;
|
||||||
|
}
|
||||||
|
if !restore_continuation && !matches!(&command, UiCommand::PreviewRestore { .. }) {
|
||||||
|
self.restore_preview = None;
|
||||||
|
}
|
||||||
|
let result = match command {
|
||||||
|
UiCommand::CreateClient {
|
||||||
|
alias,
|
||||||
|
manual_addresses,
|
||||||
|
created_utc,
|
||||||
|
} => app
|
||||||
|
.prepare_new_client(&alias, manual_addresses, created_utc.as_deref())
|
||||||
|
.map(CommandResult::ClientCreated),
|
||||||
|
UiCommand::RenameClient { identity, alias } => app
|
||||||
|
.rename_client(identity, &alias)
|
||||||
|
.map(|()| CommandResult::ClientRenamed),
|
||||||
|
UiCommand::DeleteClient { identity } => app
|
||||||
|
.delete_client(identity)
|
||||||
|
.map(|record_removed| CommandResult::ClientDeleted { record_removed }),
|
||||||
|
UiCommand::DiscardDraft => app.discard_draft().map(|()| CommandResult::DraftDiscarded),
|
||||||
|
UiCommand::Commit => app.commit().map(CommandResult::Committed),
|
||||||
|
UiCommand::CreateBackup => app.create_manual_backup().map(CommandResult::BackupCreated),
|
||||||
|
UiCommand::ListBackups => app.list_backups().map(CommandResult::Backups),
|
||||||
|
UiCommand::ImportSelectedFile { file_name } => app
|
||||||
|
.import_selected_file(&file_name)
|
||||||
|
.map(CommandResult::SelectedFileImported),
|
||||||
|
UiCommand::PreviewRestart => app.preview_restart().map(|preview| {
|
||||||
|
CommandResult::RestartPreview(RestartPreviewSummary {
|
||||||
|
has_draft: preview.has_draft(),
|
||||||
|
interface: preview.interface().to_owned(),
|
||||||
|
systemd_unit: preview.systemd_unit().to_owned(),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
UiCommand::Restart(choice) => {
|
||||||
|
let result = app
|
||||||
|
.preview_restart()
|
||||||
|
.map(|preview| app.restart(preview, choice));
|
||||||
|
result.map(CommandResult::Restarted)
|
||||||
|
}
|
||||||
|
UiCommand::PrepareDelivery {
|
||||||
|
identity,
|
||||||
|
path,
|
||||||
|
include_qr,
|
||||||
|
reissue,
|
||||||
|
attempt,
|
||||||
|
} => app
|
||||||
|
.prepare_first_issue_destination(path)
|
||||||
|
.map_err(crate::app::AppError::FileOutput)
|
||||||
|
.map(|destination| {
|
||||||
|
let summary = DeliveryDestinationSummary {
|
||||||
|
identity,
|
||||||
|
path: destination.path().to_path_buf(),
|
||||||
|
overwrite_required: destination.overwrite_confirmation_required(),
|
||||||
|
include_qr,
|
||||||
|
reissue,
|
||||||
|
attempt,
|
||||||
|
prepared_generation: generation,
|
||||||
|
};
|
||||||
|
self.prepared_destination = Some(PreparedDestinationContext {
|
||||||
|
identity,
|
||||||
|
include_qr,
|
||||||
|
mode: if reissue {
|
||||||
|
DeliveryMode::Reissue
|
||||||
|
} else {
|
||||||
|
DeliveryMode::FirstIssue
|
||||||
|
},
|
||||||
|
attempt,
|
||||||
|
generation,
|
||||||
|
destination,
|
||||||
|
});
|
||||||
|
CommandResult::DeliveryDestination(summary)
|
||||||
|
}),
|
||||||
|
UiCommand::DeliverFirstIssue {
|
||||||
|
attempt,
|
||||||
|
overwrite_confirmed,
|
||||||
|
prepared_generation,
|
||||||
|
} => {
|
||||||
|
let prepared = self
|
||||||
|
.prepared_destination
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| OperationError {
|
||||||
|
message: "No prepared output destination is available.".to_owned(),
|
||||||
|
})?;
|
||||||
|
if !matches!(prepared.mode, DeliveryMode::FirstIssue)
|
||||||
|
|| prepared.attempt != attempt
|
||||||
|
|| !destination_generation_matches(
|
||||||
|
prepared.generation,
|
||||||
|
prepared_generation,
|
||||||
|
generation,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Err(OperationError {
|
||||||
|
message: "Prepared output destination is stale; select it again."
|
||||||
|
.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let identity = prepared.identity;
|
||||||
|
let include_qr = prepared.include_qr;
|
||||||
|
let mut destination = prepared.destination;
|
||||||
|
if destination.overwrite_confirmation_required() {
|
||||||
|
if !overwrite_confirmed {
|
||||||
|
return Err(OperationError {
|
||||||
|
message: "Overwrite confirmation was not supplied.".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
destination = destination.confirm_overwrite().map_err(operation_error)?;
|
||||||
|
}
|
||||||
|
if include_qr {
|
||||||
|
app.deliver_first_issue_file_and_qr(identity, attempt, destination)
|
||||||
|
.map(|outcome| match outcome {
|
||||||
|
FirstIssueDeliveryOutcome::Delivered(delivery) => {
|
||||||
|
let (qr, qr_output) = qr_result(delivery.qr);
|
||||||
|
CommandResult::FirstIssue(FirstIssueSummary {
|
||||||
|
identity,
|
||||||
|
status: FirstIssueStatus::Delivered,
|
||||||
|
retry: None,
|
||||||
|
activation_warning: delivery.activation_warning,
|
||||||
|
qr,
|
||||||
|
qr_output,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
|
||||||
|
value,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let (qr, qr_output) = qr_result(value.qr);
|
||||||
|
CommandResult::FirstIssue(FirstIssueSummary {
|
||||||
|
identity,
|
||||||
|
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
|
||||||
|
retry: None,
|
||||||
|
activation_warning: value.activation_warning,
|
||||||
|
qr,
|
||||||
|
qr_output,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
FirstIssueDeliveryOutcome::Failed {
|
||||||
|
error,
|
||||||
|
retry,
|
||||||
|
activation_warning,
|
||||||
|
} => CommandResult::FirstIssue(FirstIssueSummary {
|
||||||
|
identity,
|
||||||
|
status: FirstIssueStatus::Failed(error),
|
||||||
|
retry: Some(retry),
|
||||||
|
activation_warning,
|
||||||
|
qr: QrStatus::NotRequested,
|
||||||
|
qr_output: None,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
app.deliver_first_issue_file(identity, attempt, destination)
|
||||||
|
.map(|outcome| match outcome {
|
||||||
|
FirstIssueDeliveryOutcome::Delivered(delivery) => {
|
||||||
|
CommandResult::FirstIssue(FirstIssueSummary {
|
||||||
|
identity,
|
||||||
|
status: FirstIssueStatus::Delivered,
|
||||||
|
retry: None,
|
||||||
|
activation_warning: delivery.activation_warning,
|
||||||
|
qr: QrStatus::NotRequested,
|
||||||
|
qr_output: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
|
||||||
|
value,
|
||||||
|
..
|
||||||
|
} => CommandResult::FirstIssue(FirstIssueSummary {
|
||||||
|
identity,
|
||||||
|
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
|
||||||
|
retry: None,
|
||||||
|
activation_warning: value.activation_warning,
|
||||||
|
qr: QrStatus::NotRequested,
|
||||||
|
qr_output: None,
|
||||||
|
}),
|
||||||
|
FirstIssueDeliveryOutcome::Failed {
|
||||||
|
error,
|
||||||
|
retry,
|
||||||
|
activation_warning,
|
||||||
|
} => CommandResult::FirstIssue(FirstIssueSummary {
|
||||||
|
identity,
|
||||||
|
status: FirstIssueStatus::Failed(error),
|
||||||
|
retry: Some(retry),
|
||||||
|
activation_warning,
|
||||||
|
qr: QrStatus::NotRequested,
|
||||||
|
qr_output: None,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UiCommand::PreviewRestore { file_name } => {
|
||||||
|
app.preview_restore(&file_name).map(|preview| {
|
||||||
|
let changes = preview.change_summary(app.saved_bytes());
|
||||||
|
let summary = RestorePreviewSummary {
|
||||||
|
file_name: file_name.clone(),
|
||||||
|
created_at_utc: preview.created_at_utc().to_owned(),
|
||||||
|
reason: preview.reason(),
|
||||||
|
historical_apply: preview.historical_apply().clone(),
|
||||||
|
changes,
|
||||||
|
preview_generation: generation,
|
||||||
|
};
|
||||||
|
self.restore_preview = Some(RestorePreviewContext {
|
||||||
|
_archive_name: file_name,
|
||||||
|
generation,
|
||||||
|
preview,
|
||||||
|
});
|
||||||
|
CommandResult::RestorePreview(summary)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
UiCommand::CancelPreparedDelivery => {
|
||||||
|
self.prepared_destination = None;
|
||||||
|
Ok(CommandResult::Cancelled)
|
||||||
|
}
|
||||||
|
UiCommand::CancelRestorePreview => {
|
||||||
|
self.restore_preview = None;
|
||||||
|
Ok(CommandResult::Cancelled)
|
||||||
|
}
|
||||||
|
UiCommand::ConfirmRestore { preview_generation } => {
|
||||||
|
let context = self.restore_preview.take().ok_or_else(|| OperationError {
|
||||||
|
message: "Restore preview is no longer available; select the archive again."
|
||||||
|
.to_owned(),
|
||||||
|
})?;
|
||||||
|
if !restore_preview_generation_matches(
|
||||||
|
context.generation,
|
||||||
|
preview_generation,
|
||||||
|
generation,
|
||||||
|
) {
|
||||||
|
return Err(OperationError {
|
||||||
|
message: "Restore preview is stale; select the archive again.".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
app.confirm_restore(context.preview)
|
||||||
|
.map(CommandResult::Restored)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
result.map_err(|error| OperationError {
|
||||||
|
message: error.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_sensitive(
|
||||||
|
&mut self,
|
||||||
|
app: &mut Application,
|
||||||
|
command: SensitiveCommand,
|
||||||
|
) -> Result<CommandResult, OperationError> {
|
||||||
|
match command {
|
||||||
|
SensitiveCommand::PrepareInitialSetup(password) => app
|
||||||
|
.prepare_initial_setup_from_sources(password.expose())
|
||||||
|
.map(CommandResult::InitialSetupPrepared)
|
||||||
|
.map_err(|error| OperationError {
|
||||||
|
message: error.to_string(),
|
||||||
|
}),
|
||||||
|
SensitiveCommand::ReissueClient {
|
||||||
|
identity,
|
||||||
|
password,
|
||||||
|
overwrite_confirmed,
|
||||||
|
prepared_generation,
|
||||||
|
} => {
|
||||||
|
let prepared = self
|
||||||
|
.prepared_destination
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| OperationError {
|
||||||
|
message: "No prepared output destination is available.".to_owned(),
|
||||||
|
})?;
|
||||||
|
if prepared.identity != identity
|
||||||
|
|| !matches!(prepared.mode, DeliveryMode::Reissue)
|
||||||
|
|| prepared.generation != prepared_generation
|
||||||
|
{
|
||||||
|
return Err(OperationError {
|
||||||
|
message: "Prepared output destination is stale; select it again."
|
||||||
|
.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let include_qr = prepared.include_qr;
|
||||||
|
let mut destination = prepared.destination;
|
||||||
|
if destination.overwrite_confirmation_required() {
|
||||||
|
if !overwrite_confirmed {
|
||||||
|
return Err(OperationError {
|
||||||
|
message: "Overwrite confirmation was not supplied.".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
destination = destination.confirm_overwrite().map_err(operation_error)?;
|
||||||
|
}
|
||||||
|
let delivery = app
|
||||||
|
.reissue_client_file(identity, password.expose(), destination, include_qr)
|
||||||
|
.map_err(|error| OperationError {
|
||||||
|
message: error.to_string(),
|
||||||
|
})?;
|
||||||
|
let (qr, qr_output) = match delivery.qr {
|
||||||
|
None => (QrStatus::NotRequested, None),
|
||||||
|
Some(result) => qr_result(result),
|
||||||
|
};
|
||||||
|
Ok(CommandResult::Reissued(ReissueSummary {
|
||||||
|
activation_warning: delivery.activation_warning,
|
||||||
|
qr,
|
||||||
|
qr_output,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_sensitive_at(
|
||||||
|
&mut self,
|
||||||
|
app: &mut Application,
|
||||||
|
command: SensitiveCommand,
|
||||||
|
generation: u64,
|
||||||
|
) -> Result<CommandResult, OperationError> {
|
||||||
|
match &command {
|
||||||
|
SensitiveCommand::ReissueClient {
|
||||||
|
prepared_generation,
|
||||||
|
..
|
||||||
|
} if *prepared_generation != generation => {
|
||||||
|
return Err(OperationError {
|
||||||
|
message: "Prepared output destination is stale; select it again.".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if matches!(command, SensitiveCommand::PrepareInitialSetup(_)) {
|
||||||
|
self.prepared_destination = None;
|
||||||
|
self.restore_preview = None;
|
||||||
|
}
|
||||||
|
self.execute_sensitive(app, command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operation_error(error: impl std::fmt::Display) -> OperationError {
|
||||||
|
OperationError {
|
||||||
|
message: error.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn destination_generation_matches(stored: u64, requested: u64, current: u64) -> bool {
|
||||||
|
stored == requested && stored == current
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn restore_preview_generation_matches(
|
||||||
|
stored: u64,
|
||||||
|
requested: u64,
|
||||||
|
current: u64,
|
||||||
|
) -> bool {
|
||||||
|
stored == requested && current == requested.saturating_add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn qr_result(result: Result<QrCodeOutput, QrError>) -> (QrStatus, Option<QrCodeOutput>) {
|
||||||
|
match result {
|
||||||
|
Ok(output) => (QrStatus::Ready, Some(output)),
|
||||||
|
Err(error) => (QrStatus::Failed(error), None),
|
||||||
|
}
|
||||||
|
}
|
||||||
+504
-148
@@ -208,7 +208,9 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
|
|||||||
add("p", "preview", HintKind::Action);
|
add("p", "preview", HintKind::Action);
|
||||||
add("c", "Commit", HintKind::Action);
|
add("c", "Commit", HintKind::Action);
|
||||||
add("x", "discard", HintKind::Destructive);
|
add("x", "discard", HintKind::Destructive);
|
||||||
add("R", "restart", HintKind::Destructive);
|
if restart_available(state) {
|
||||||
|
add("R", "restart", HintKind::Destructive);
|
||||||
|
}
|
||||||
add("b", "backups / restore", HintKind::Action);
|
add("b", "backups / restore", HintKind::Action);
|
||||||
add("B", "backup", HintKind::Action);
|
add("B", "backup", HintKind::Action);
|
||||||
add("!", "issues", HintKind::Action);
|
add("!", "issues", HintKind::Action);
|
||||||
@@ -219,22 +221,21 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
|
|||||||
{
|
{
|
||||||
add("s", "setup", HintKind::Action);
|
add("s", "setup", HintKind::Action);
|
||||||
}
|
}
|
||||||
if state.selected_client_mutable() {
|
if state.selected_rename_available() {
|
||||||
add("r", "rename", HintKind::Action);
|
add("r", "rename", HintKind::Action);
|
||||||
|
}
|
||||||
|
if state.selected_delete_available() {
|
||||||
add("d", "delete", HintKind::Destructive);
|
add("d", "delete", HintKind::Destructive);
|
||||||
let saved = state
|
}
|
||||||
.selected_identity
|
if state.selected_first_delivery_available() {
|
||||||
.and_then(|id| state.view.as_ref()?.client_states.get(&id))
|
add("o", "first file", HintKind::Action);
|
||||||
.is_some_and(|row| row.saved.is_some());
|
}
|
||||||
if saved {
|
if state.selected_reissue_available() {
|
||||||
add("o", "first file", HintKind::Action);
|
add("O", "password reissue", HintKind::Action);
|
||||||
add("O", "password reissue", HintKind::Action);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Screen::ClientList => {
|
Screen::ClientList => {
|
||||||
let has_view = state.view.is_some();
|
let has_view = state.view.is_some();
|
||||||
let selected = state.selected_client_mutable();
|
|
||||||
add("↑/↓", "select", HintKind::Navigation);
|
add("↑/↓", "select", HintKind::Navigation);
|
||||||
if state
|
if state
|
||||||
.view
|
.view
|
||||||
@@ -246,23 +247,22 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
|
|||||||
add("?", "Help", HintKind::Navigation);
|
add("?", "Help", HintKind::Navigation);
|
||||||
if has_view {
|
if has_view {
|
||||||
add("n", "new", HintKind::Action);
|
add("n", "new", HintKind::Action);
|
||||||
if selected {
|
if state.selected_rename_available() {
|
||||||
add("r", "rename", HintKind::Action);
|
add("r", "rename", HintKind::Action);
|
||||||
|
}
|
||||||
|
if state.selected_delete_available() {
|
||||||
add("d", "delete", HintKind::Destructive);
|
add("d", "delete", HintKind::Destructive);
|
||||||
}
|
}
|
||||||
add("p", "preview", HintKind::Action);
|
add("p", "preview", HintKind::Action);
|
||||||
add("c", "Commit", HintKind::Action);
|
add("c", "Commit", HintKind::Action);
|
||||||
add("x", "discard", HintKind::Destructive);
|
add("x", "discard", HintKind::Destructive);
|
||||||
add("R", "restart", HintKind::Destructive);
|
if restart_available(state) {
|
||||||
if let Some(id) = state.selected_identity
|
add("R", "restart", HintKind::Destructive);
|
||||||
&& state
|
}
|
||||||
.view
|
if state.selected_first_delivery_available() {
|
||||||
.as_ref()
|
|
||||||
.and_then(|view| view.client_states.get(&id))
|
|
||||||
.is_some_and(|row| row.saved.is_some())
|
|
||||||
&& selected
|
|
||||||
{
|
|
||||||
add("o", "first file", HintKind::Action);
|
add("o", "first file", HintKind::Action);
|
||||||
|
}
|
||||||
|
if state.selected_reissue_available() {
|
||||||
add("O", "password reissue", HintKind::Action);
|
add("O", "password reissue", HintKind::Action);
|
||||||
}
|
}
|
||||||
add("b", "backups / restore", HintKind::Action);
|
add("b", "backups / restore", HintKind::Action);
|
||||||
@@ -286,6 +286,14 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
|
|||||||
hints
|
hints
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn restart_available(state: &UiState) -> bool {
|
||||||
|
state
|
||||||
|
.view
|
||||||
|
.as_ref()
|
||||||
|
.and_then(SessionView::restart_eligibility)
|
||||||
|
.is_none_or(|eligibility| eligibility.available)
|
||||||
|
}
|
||||||
|
|
||||||
fn pack_hints(hints: &[Hint], width: usize) -> Vec<Vec<Hint>> {
|
fn pack_hints(hints: &[Hint], width: usize) -> Vec<Vec<Hint>> {
|
||||||
let mut rows = Vec::<Vec<Hint>>::new();
|
let mut rows = Vec::<Vec<Hint>>::new();
|
||||||
let mut used = 0;
|
let mut used = 0;
|
||||||
@@ -649,10 +657,7 @@ fn qr_state_summary(state: &UiState, width: usize) -> Option<String> {
|
|||||||
},
|
},
|
||||||
view.saved_peer_count
|
view.saved_peer_count
|
||||||
.map_or_else(|| "unknown".to_owned(), |count| count.to_string()),
|
.map_or_else(|| "unknown".to_owned(), |count| count.to_string()),
|
||||||
view.client_states
|
view.saved_managed_client_count(),
|
||||||
.values()
|
|
||||||
.filter(|row| row.saved.is_some())
|
|
||||||
.count(),
|
|
||||||
match view.applied {
|
match view.applied {
|
||||||
crate::domain::ApplyStatus::Verified => "verified",
|
crate::domain::ApplyStatus::Verified => "verified",
|
||||||
crate::domain::ApplyStatus::Unknown => "not verified",
|
crate::domain::ApplyStatus::Unknown => "not verified",
|
||||||
@@ -827,37 +832,83 @@ fn draft_review_lines(state: &UiState) -> Vec<String> {
|
|||||||
if view.has_draft { "yes" } else { "no" }
|
if view.has_draft { "yes" } else { "no" }
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
for (identity, row) in &view.client_states {
|
if let Some(application) = &view.application {
|
||||||
match (&row.saved, &row.draft) {
|
for peer in &application.clients {
|
||||||
(None, Some(draft)) => lines.push(format!(
|
let identity = peer.identity;
|
||||||
"Added {identity}: {} (IPv4 {}, IPv6 {})",
|
match (
|
||||||
safe_text(&draft.alias),
|
peer.state_change,
|
||||||
draft.addresses.ipv4,
|
peer.saved_client.as_ref(),
|
||||||
draft
|
peer.draft_client.as_ref(),
|
||||||
.addresses
|
) {
|
||||||
.ipv6
|
(crate::app::ClientStateChange::Added, _, Some(draft)) => lines.push(format!(
|
||||||
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
|
"Added {}: {} (IPv4 {}, IPv6 {})",
|
||||||
)),
|
identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()),
|
||||||
(Some(saved), None) => lines.push(format!(
|
safe_text(&draft.alias),
|
||||||
"Removed {identity}: {} (saved removal remains unapplied until restart)",
|
draft.addresses.ipv4,
|
||||||
safe_text(&saved.alias)
|
draft
|
||||||
)),
|
.addresses
|
||||||
(Some(saved), Some(draft)) if saved != draft => lines.push(format!(
|
.ipv6
|
||||||
"Changed {identity}: alias {} → {}; addresses {} / {} → {} / {}",
|
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
|
||||||
safe_text(&saved.alias),
|
)),
|
||||||
safe_text(&draft.alias),
|
(crate::app::ClientStateChange::Removed, _, _) => lines.push(format!(
|
||||||
saved.addresses.ipv4,
|
"Removed {}: {} (saved removal remains unapplied until restart)",
|
||||||
saved
|
identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()),
|
||||||
.addresses
|
safe_text(&peer.label)
|
||||||
.ipv6
|
)),
|
||||||
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()),
|
(crate::app::ClientStateChange::Changed, Some(saved), Some(draft)) => {
|
||||||
draft.addresses.ipv4,
|
lines.push(format!(
|
||||||
draft
|
"Changed {}: alias {} → {}; addresses {} / {} → {} / {}",
|
||||||
.addresses
|
identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()),
|
||||||
.ipv6
|
safe_text(&saved.alias),
|
||||||
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
|
safe_text(&draft.alias),
|
||||||
)),
|
saved.addresses.ipv4,
|
||||||
_ => {}
|
saved
|
||||||
|
.addresses
|
||||||
|
.ipv6
|
||||||
|
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()),
|
||||||
|
draft.addresses.ipv4,
|
||||||
|
draft
|
||||||
|
.addresses
|
||||||
|
.ipv6
|
||||||
|
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (identity, row) in &view.client_states {
|
||||||
|
match (&row.saved, &row.draft) {
|
||||||
|
(None, Some(draft)) => lines.push(format!(
|
||||||
|
"Added {identity}: {} (IPv4 {}, IPv6 {})",
|
||||||
|
safe_text(&draft.alias),
|
||||||
|
draft.addresses.ipv4,
|
||||||
|
draft
|
||||||
|
.addresses
|
||||||
|
.ipv6
|
||||||
|
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
|
||||||
|
)),
|
||||||
|
(Some(saved), None) => lines.push(format!(
|
||||||
|
"Removed {identity}: {} (saved removal remains unapplied until restart)",
|
||||||
|
safe_text(&saved.alias)
|
||||||
|
)),
|
||||||
|
(Some(saved), Some(draft)) if saved != draft => lines.push(format!(
|
||||||
|
"Changed {identity}: alias {} → {}; addresses {} / {} → {} / {}",
|
||||||
|
safe_text(&saved.alias),
|
||||||
|
safe_text(&draft.alias),
|
||||||
|
saved.addresses.ipv4,
|
||||||
|
saved
|
||||||
|
.addresses
|
||||||
|
.ipv6
|
||||||
|
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()),
|
||||||
|
draft.addresses.ipv4,
|
||||||
|
draft
|
||||||
|
.addresses
|
||||||
|
.ipv6
|
||||||
|
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
|
||||||
|
)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if lines.len() == 2 && view.has_draft {
|
if lines.len() == 2 && view.has_draft {
|
||||||
@@ -884,25 +935,38 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
|
|||||||
.clients
|
.clients
|
||||||
.iter()
|
.iter()
|
||||||
.map(|client| {
|
.map(|client| {
|
||||||
let visible_issues: Vec<_> = client.presented_issues().collect();
|
let visible_issues = view.presented_issues(client);
|
||||||
let identity = client
|
let identity = client
|
||||||
.identity
|
.identity
|
||||||
.map(|key| short_key(&key))
|
.map(|key| short_key(&key))
|
||||||
.unwrap_or_else(|| format!("peer at line {}", client.line));
|
.unwrap_or_else(|| format!("peer at line {}", client.line));
|
||||||
let (ipv4, ipv6, route_marker) = client_address_cells(client);
|
let (ipv4, ipv6, route_marker) = client_address_cells(client);
|
||||||
let row_state = client
|
let row_state = view.client_state_change(client).map_or_else(
|
||||||
.identity
|
|| {
|
||||||
.and_then(|identity| view.client_states.get(&identity))
|
client
|
||||||
.map_or_else(
|
.identity
|
||||||
|| "Saved ?".to_owned(),
|
.and_then(|identity| view.client_states.get(&identity))
|
||||||
|row| match (&row.saved, &row.draft) {
|
.map_or_else(
|
||||||
(None, Some(_)) => "Draft added".to_owned(),
|
|| "Saved ?".to_owned(),
|
||||||
(Some(saved), Some(draft)) if saved != draft => "Draft changed".to_owned(),
|
|row| match (&row.saved, &row.draft) {
|
||||||
(Some(_), None) => "Draft removed".to_owned(),
|
(None, Some(_)) => "Draft added".to_owned(),
|
||||||
(Some(_), Some(_)) => "Saved".to_owned(),
|
(Some(saved), Some(draft)) if saved != draft => {
|
||||||
(None, None) => "State unavailable".to_owned(),
|
"Draft changed".to_owned()
|
||||||
},
|
}
|
||||||
);
|
(Some(_), None) => "Draft removed".to_owned(),
|
||||||
|
(Some(_), Some(_)) => "Saved".to_owned(),
|
||||||
|
(None, None) => "State unavailable".to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|change| match change {
|
||||||
|
crate::app::ClientStateChange::Added => "Draft added".to_owned(),
|
||||||
|
crate::app::ClientStateChange::Removed => "Draft removed".to_owned(),
|
||||||
|
crate::app::ClientStateChange::Changed => "Draft changed".to_owned(),
|
||||||
|
crate::app::ClientStateChange::Unchanged => "Saved".to_owned(),
|
||||||
|
crate::app::ClientStateChange::UnsupportedOrUnmanaged => "Saved ?".to_owned(),
|
||||||
|
},
|
||||||
|
);
|
||||||
let row_state = if layout.compact {
|
let row_state = if layout.compact {
|
||||||
compact_state_label(&row_state).to_owned()
|
compact_state_label(&row_state).to_owned()
|
||||||
} else {
|
} else {
|
||||||
@@ -935,46 +999,94 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
rows.extend(view.client_states.iter().filter_map(|(identity, row)| {
|
let removed_clients = if let Some(application) = &view.application {
|
||||||
let saved = row.saved.as_ref()?;
|
application
|
||||||
if row.draft.is_some()
|
.clients
|
||||||
|| view
|
.iter()
|
||||||
.clients
|
.filter_map(|peer| {
|
||||||
.iter()
|
if peer.state_change != crate::app::ClientStateChange::Removed {
|
||||||
.any(|peer| peer.identity == Some(*identity))
|
return None;
|
||||||
{
|
}
|
||||||
return None;
|
let identity = peer.identity?;
|
||||||
}
|
let (ipv4, ipv6) = peer.saved_client.as_ref().map_or_else(
|
||||||
let ipv6 = saved
|
|| removed_peer_addresses(&peer.allowed_ips),
|
||||||
.addresses
|
|saved| {
|
||||||
.ipv6
|
(
|
||||||
.map_or_else(|| "none".to_owned(), |address| address.to_string());
|
saved.addresses.ipv4.to_string(),
|
||||||
Some(client_table_row(
|
saved
|
||||||
&layout.columns,
|
.addresses
|
||||||
[
|
.ipv6
|
||||||
if layout.compact {
|
.map_or_else(String::new, |ip| ip.to_string()),
|
||||||
"Removed".to_owned()
|
)
|
||||||
} else {
|
},
|
||||||
"Draft removed".to_owned()
|
);
|
||||||
},
|
Some((
|
||||||
safe_text(&saved.alias),
|
identity,
|
||||||
identity.to_string(),
|
peer.label.clone(),
|
||||||
saved.addresses.ipv4.to_string(),
|
ipv4,
|
||||||
ipv6,
|
ipv6,
|
||||||
String::new(),
|
if peer.saved_client.is_some() {
|
||||||
"Managed".to_owned(),
|
"Managed"
|
||||||
address_summary(
|
} else {
|
||||||
&saved.addresses.ipv4.to_string(),
|
"Inspect only"
|
||||||
&saved
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
} else {
|
||||||
|
view.client_states
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(identity, row)| {
|
||||||
|
let saved = row.saved.as_ref()?;
|
||||||
|
if row.draft.is_some()
|
||||||
|
|| view
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.any(|peer| peer.identity == Some(*identity))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((
|
||||||
|
*identity,
|
||||||
|
saved.alias.clone(),
|
||||||
|
saved.addresses.ipv4.to_string(),
|
||||||
|
saved
|
||||||
.addresses
|
.addresses
|
||||||
.ipv6
|
.ipv6
|
||||||
.map_or_else(String::new, |address| address.to_string()),
|
.map_or_else(String::new, |ip| ip.to_string()),
|
||||||
"",
|
"Managed",
|
||||||
),
|
))
|
||||||
],
|
})
|
||||||
false,
|
.collect::<Vec<_>>()
|
||||||
))
|
};
|
||||||
}));
|
rows.extend(
|
||||||
|
removed_clients
|
||||||
|
.iter()
|
||||||
|
.map(|(identity, label, ipv4, ipv6, access)| {
|
||||||
|
client_table_row(
|
||||||
|
&layout.columns,
|
||||||
|
[
|
||||||
|
if layout.compact {
|
||||||
|
"Removed".to_owned()
|
||||||
|
} else {
|
||||||
|
"Draft removed".to_owned()
|
||||||
|
},
|
||||||
|
safe_text(label),
|
||||||
|
identity.to_string(),
|
||||||
|
ipv4.clone(),
|
||||||
|
if ipv6.is_empty() {
|
||||||
|
"none".to_owned()
|
||||||
|
} else {
|
||||||
|
ipv6.clone()
|
||||||
|
},
|
||||||
|
String::new(),
|
||||||
|
(*access).to_owned(),
|
||||||
|
address_summary(ipv4, ipv6, ""),
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
);
|
||||||
rows.extend(view.document_issues.iter().map(|issue| {
|
rows.extend(view.document_issues.iter().map(|issue| {
|
||||||
client_table_row(
|
client_table_row(
|
||||||
&layout.columns,
|
&layout.columns,
|
||||||
@@ -1110,23 +1222,60 @@ fn render_client_screen(frame: &mut Frame<'_>, area: Rect, state: &UiState) {
|
|||||||
fn selected_display(state: &UiState) -> Option<ClientDisplay> {
|
fn selected_display(state: &UiState) -> Option<ClientDisplay> {
|
||||||
match state.selected_record()? {
|
match state.selected_record()? {
|
||||||
VisibleRecord::Peer(client) => Some(client.clone()),
|
VisibleRecord::Peer(client) => Some(client.clone()),
|
||||||
VisibleRecord::DraftRemoved { identity, client } => {
|
VisibleRecord::DraftRemoved {
|
||||||
let mut allowed_ips = vec![format!("{}/32", client.addresses.ipv4)];
|
identity,
|
||||||
if let Some(ipv6) = client.addresses.ipv6 {
|
peer: Some(peer),
|
||||||
allowed_ips.push(format!("{ipv6}/128"));
|
..
|
||||||
}
|
} => Some(ClientDisplay {
|
||||||
Some(ClientDisplay {
|
peer_index: usize::MAX,
|
||||||
peer_index: usize::MAX,
|
line: peer.line,
|
||||||
line: 0,
|
identity: Some(identity),
|
||||||
identity: Some(identity),
|
label: peer.label.clone(),
|
||||||
label: client.alias.clone(),
|
allowed_ips: peer.allowed_ips.clone(),
|
||||||
allowed_ips,
|
has_subnet_routes: peer.has_subnet_routes,
|
||||||
has_subnet_routes: false,
|
issues: peer.issues.clone(),
|
||||||
issues: Vec::new(),
|
mutable_identity: None,
|
||||||
mutable_identity: None,
|
}),
|
||||||
})
|
VisibleRecord::DraftRemoved {
|
||||||
|
identity,
|
||||||
|
client: Some(client),
|
||||||
|
..
|
||||||
|
} => Some(ClientDisplay {
|
||||||
|
peer_index: usize::MAX,
|
||||||
|
line: 0,
|
||||||
|
identity: Some(identity),
|
||||||
|
label: client.alias.clone(),
|
||||||
|
allowed_ips: client_routes(client),
|
||||||
|
has_subnet_routes: false,
|
||||||
|
issues: Vec::new(),
|
||||||
|
mutable_identity: None,
|
||||||
|
}),
|
||||||
|
VisibleRecord::DraftRemoved { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_routes(client: &crate::domain::Client) -> Vec<String> {
|
||||||
|
let mut routes = vec![format!("{}/32", client.addresses.ipv4)];
|
||||||
|
if let Some(ipv6) = client.addresses.ipv6 {
|
||||||
|
routes.push(format!("{ipv6}/128"));
|
||||||
|
}
|
||||||
|
routes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn removed_peer_addresses(allowed_ips: &[String]) -> (String, String) {
|
||||||
|
let mut ipv4 = String::new();
|
||||||
|
let mut ipv6 = String::new();
|
||||||
|
for route in allowed_ips {
|
||||||
|
let Some((address, _)) = route.split_once('/') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match address.parse::<std::net::IpAddr>() {
|
||||||
|
Ok(std::net::IpAddr::V4(address)) if ipv4.is_empty() => ipv4 = address.to_string(),
|
||||||
|
Ok(std::net::IpAddr::V6(address)) if ipv6.is_empty() => ipv6 = address.to_string(),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
(ipv4, ipv6)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persistent_detail_lines(
|
fn persistent_detail_lines(
|
||||||
@@ -1204,11 +1353,7 @@ fn persistent_state_line(view: &SessionView) -> String {
|
|||||||
let saved_peers = view
|
let saved_peers = view
|
||||||
.saved_peer_count
|
.saved_peer_count
|
||||||
.map_or_else(|| "unknown".to_owned(), |count| count.to_string());
|
.map_or_else(|| "unknown".to_owned(), |count| count.to_string());
|
||||||
let saved_managed = view
|
let saved_managed = view.saved_managed_client_count();
|
||||||
.client_states
|
|
||||||
.values()
|
|
||||||
.filter(|row| row.saved.is_some())
|
|
||||||
.count();
|
|
||||||
let applied = match view.applied {
|
let applied = match view.applied {
|
||||||
crate::domain::ApplyStatus::Verified => "verified",
|
crate::domain::ApplyStatus::Verified => "verified",
|
||||||
crate::domain::ApplyStatus::Unknown => "not verified",
|
crate::domain::ApplyStatus::Unknown => "not verified",
|
||||||
@@ -1254,7 +1399,7 @@ fn inspection_summary(client: &ClientDisplay, view: &SessionView) -> String {
|
|||||||
} else if client
|
} else if client
|
||||||
.issues
|
.issues
|
||||||
.iter()
|
.iter()
|
||||||
.any(|issue| client.is_capability_limitation_issue(issue))
|
.any(|issue| view.is_capability_limitation_issue(client, issue))
|
||||||
{
|
{
|
||||||
"Inspect: address shape; Enter for details".to_owned()
|
"Inspect: address shape; Enter for details".to_owned()
|
||||||
} else if !client.issues.is_empty() {
|
} else if !client.issues.is_empty() {
|
||||||
@@ -1629,7 +1774,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
|
|||||||
let has_capability_limitation = client
|
let has_capability_limitation = client
|
||||||
.issues
|
.issues
|
||||||
.iter()
|
.iter()
|
||||||
.any(|issue| client.is_capability_limitation_issue(issue));
|
.any(|issue| view.is_capability_limitation_issue(client, issue));
|
||||||
if client.has_subnet_routes {
|
if client.has_subnet_routes {
|
||||||
lines.push(Line::from(
|
lines.push(Line::from(
|
||||||
"This peer contains subnet routes. Managing this configuration is not supported yet.",
|
"This peer contains subnet routes. Managing this configuration is not supported yet.",
|
||||||
@@ -1640,18 +1785,26 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(identity) = client.identity {
|
if let Some(identity) = client.identity {
|
||||||
if let Some(row) = view.client_states.get(&identity) {
|
let managed_state = view
|
||||||
|
.application_record_for_client(client)
|
||||||
|
.map(|peer| (&peer.draft_client, &peer.saved_client))
|
||||||
|
.or_else(|| {
|
||||||
|
view.client_states
|
||||||
|
.get(&identity)
|
||||||
|
.map(|row| (&row.draft, &row.saved))
|
||||||
|
});
|
||||||
|
if let Some((draft, saved)) = managed_state {
|
||||||
lines.push(Line::from(format!(
|
lines.push(Line::from(format!(
|
||||||
"Draft managed record: {}",
|
"Draft managed record: {}",
|
||||||
safe_text(row.draft.as_ref().map_or("absent", |c| c.alias.as_str()))
|
safe_text(draft.as_ref().map_or("absent", |c| c.alias.as_str()))
|
||||||
)));
|
)));
|
||||||
lines.push(Line::from(format!(
|
lines.push(Line::from(format!(
|
||||||
"Saved managed record: {}",
|
"Saved managed record: {}",
|
||||||
safe_text(row.saved.as_ref().map_or("absent", |c| c.alias.as_str()))
|
safe_text(saved.as_ref().map_or("absent", |c| c.alias.as_str()))
|
||||||
)));
|
)));
|
||||||
if row.draft != row.saved {
|
if draft != saved {
|
||||||
lines.push(Line::from("Draft differs from saved state."));
|
lines.push(Line::from("Draft differs from saved state."));
|
||||||
if let Some(client) = &row.draft {
|
if let Some(client) = draft {
|
||||||
lines.push(Line::from(format!(
|
lines.push(Line::from(format!(
|
||||||
"Draft addresses: IPv4 {}, IPv6 {}",
|
"Draft addresses: IPv4 {}, IPv6 {}",
|
||||||
client.addresses.ipv4,
|
client.addresses.ipv4,
|
||||||
@@ -1661,7 +1814,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
|
|||||||
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
|
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if let Some(client) = &row.saved {
|
if let Some(client) = saved {
|
||||||
lines.push(Line::from(format!(
|
lines.push(Line::from(format!(
|
||||||
"Saved addresses: IPv4 {}, IPv6 {}",
|
"Saved addresses: IPv4 {}, IPv6 {}",
|
||||||
client.addresses.ipv4,
|
client.addresses.ipv4,
|
||||||
@@ -1703,7 +1856,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
|
|||||||
Line::from(issue_summary(view)),
|
Line::from(issue_summary(view)),
|
||||||
Line::from("Handshake and counters are observations, not apply verification."),
|
Line::from("Handshake and counters are observations, not apply verification."),
|
||||||
]);
|
]);
|
||||||
for issue in client.presented_issues() {
|
for issue in view.presented_issues(client) {
|
||||||
lines.push(Line::from(format!(
|
lines.push(Line::from(format!(
|
||||||
"Peer issue: {}",
|
"Peer issue: {}",
|
||||||
safe_text(&format!("{issue:?}"))
|
safe_text(&format!("{issue:?}"))
|
||||||
@@ -1767,10 +1920,7 @@ fn state_summary(view: &SessionView) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn managed_change_count(view: &SessionView) -> usize {
|
fn managed_change_count(view: &SessionView) -> usize {
|
||||||
view.client_states
|
view.managed_change_count()
|
||||||
.values()
|
|
||||||
.filter(|row| row.draft != row.saved)
|
|
||||||
.count()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draft_status(view: &SessionView) -> String {
|
fn draft_status(view: &SessionView) -> String {
|
||||||
@@ -1784,11 +1934,7 @@ fn draft_status(view: &SessionView) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn saved_status(view: &SessionView) -> String {
|
fn saved_status(view: &SessionView) -> String {
|
||||||
let managed = view
|
let managed = view.saved_managed_client_count();
|
||||||
.client_states
|
|
||||||
.values()
|
|
||||||
.filter(|row| row.saved.is_some())
|
|
||||||
.count();
|
|
||||||
match view.saved_peer_count {
|
match view.saved_peer_count {
|
||||||
Some(peers) => format!("Saved: {peers} peer sections; {managed} managed clients"),
|
Some(peers) => format!("Saved: {peers} peer sections; {managed} managed clients"),
|
||||||
None => format!("Saved: peer count unavailable; {managed} managed clients"),
|
None => format!("Saved: peer count unavailable; {managed} managed clients"),
|
||||||
@@ -1814,7 +1960,7 @@ fn issue_summary(view: &SessionView) -> String {
|
|||||||
let peer_problems = view
|
let peer_problems = view
|
||||||
.clients
|
.clients
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|peer| peer.presented_issues().next().is_some())
|
.filter(|peer| !view.presented_issues(peer).is_empty())
|
||||||
.count();
|
.count();
|
||||||
let capability_limits = view
|
let capability_limits = view
|
||||||
.clients
|
.clients
|
||||||
@@ -1824,7 +1970,7 @@ fn issue_summary(view: &SessionView) -> String {
|
|||||||
|| peer
|
|| peer
|
||||||
.issues
|
.issues
|
||||||
.iter()
|
.iter()
|
||||||
.any(|issue| peer.is_capability_limitation_issue(issue))
|
.any(|issue| view.is_capability_limitation_issue(peer, issue))
|
||||||
})
|
})
|
||||||
.count();
|
.count();
|
||||||
format!(
|
format!(
|
||||||
@@ -2334,9 +2480,145 @@ mod tests {
|
|||||||
statistics: RuntimeObservation::unknown(),
|
statistics: RuntimeObservation::unknown(),
|
||||||
has_draft: true,
|
has_draft: true,
|
||||||
vault_initialized: false,
|
vault_initialized: false,
|
||||||
|
application: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draft_review_shows_removed_unmanaged_authoritative_peer() {
|
||||||
|
let identity = PublicKey::from_bytes([45; 32]);
|
||||||
|
let mut view = empty_view();
|
||||||
|
view.application = Some(crate::app::ApplicationView {
|
||||||
|
clients: vec![crate::app::PeerRecord {
|
||||||
|
peer_index: None,
|
||||||
|
draft_peer_index: None,
|
||||||
|
saved_peer_index: Some(2),
|
||||||
|
line: 8,
|
||||||
|
identity: Some(identity),
|
||||||
|
label: "legacy route device".to_owned(),
|
||||||
|
allowed_ips: vec!["10.0.0.45/32".to_owned(), "fd00::45/128".to_owned()],
|
||||||
|
has_subnet_routes: false,
|
||||||
|
issues: Vec::new(),
|
||||||
|
classified_issues: Vec::new(),
|
||||||
|
mutable_identity: None,
|
||||||
|
draft_client: None,
|
||||||
|
saved_client: None,
|
||||||
|
state_change: crate::app::ClientStateChange::Removed,
|
||||||
|
actions: crate::app::ClientActions {
|
||||||
|
rename: crate::app::ActionEligibility {
|
||||||
|
available: false,
|
||||||
|
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
|
||||||
|
},
|
||||||
|
delete: crate::app::ActionEligibility {
|
||||||
|
available: false,
|
||||||
|
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
|
||||||
|
},
|
||||||
|
first_delivery: crate::app::FirstDeliveryEligibility::Unavailable,
|
||||||
|
reissue: crate::app::ActionEligibility {
|
||||||
|
available: false,
|
||||||
|
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
document_issues: Vec::new(),
|
||||||
|
saved_peer_count: 1,
|
||||||
|
draft_client_count: 0,
|
||||||
|
saved_client_count: 0,
|
||||||
|
reservations: crate::app::ReservationFacts::default(),
|
||||||
|
applied: crate::domain::ApplyStatus::Unknown,
|
||||||
|
vault_initialized: false,
|
||||||
|
has_draft: true,
|
||||||
|
restart: crate::app::ActionEligibility {
|
||||||
|
available: true,
|
||||||
|
reason: None,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let state = UiState {
|
||||||
|
view: Some(view),
|
||||||
|
..UiState::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let lines = draft_review_lines(&state);
|
||||||
|
let removed = lines
|
||||||
|
.iter()
|
||||||
|
.find(|line| line.starts_with("Removed "))
|
||||||
|
.expect("removed peer appears in draft review");
|
||||||
|
assert!(removed.contains(&identity.to_string()));
|
||||||
|
assert!(removed.contains("legacy route device"));
|
||||||
|
assert!(removed.contains("unapplied until restart"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_shows_removed_unprojectable_peer_as_inspection_only() {
|
||||||
|
let identity = PublicKey::from_bytes([46; 32]);
|
||||||
|
let mut view = empty_view();
|
||||||
|
view.application = Some(crate::app::ApplicationView {
|
||||||
|
clients: vec![crate::app::PeerRecord {
|
||||||
|
peer_index: None,
|
||||||
|
draft_peer_index: None,
|
||||||
|
saved_peer_index: Some(3),
|
||||||
|
line: 10,
|
||||||
|
identity: Some(identity),
|
||||||
|
label: "legacy route device".to_owned(),
|
||||||
|
allowed_ips: vec!["10.0.0.46/32".to_owned(), "fd00::46/128".to_owned()],
|
||||||
|
has_subnet_routes: false,
|
||||||
|
issues: Vec::new(),
|
||||||
|
classified_issues: Vec::new(),
|
||||||
|
mutable_identity: None,
|
||||||
|
draft_client: None,
|
||||||
|
saved_client: None,
|
||||||
|
state_change: crate::app::ClientStateChange::Removed,
|
||||||
|
actions: crate::app::ClientActions {
|
||||||
|
rename: crate::app::ActionEligibility {
|
||||||
|
available: false,
|
||||||
|
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
|
||||||
|
},
|
||||||
|
delete: crate::app::ActionEligibility {
|
||||||
|
available: false,
|
||||||
|
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
|
||||||
|
},
|
||||||
|
first_delivery: crate::app::FirstDeliveryEligibility::Unavailable,
|
||||||
|
reissue: crate::app::ActionEligibility {
|
||||||
|
available: false,
|
||||||
|
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
document_issues: Vec::new(),
|
||||||
|
saved_peer_count: 1,
|
||||||
|
draft_client_count: 0,
|
||||||
|
saved_client_count: 0,
|
||||||
|
reservations: crate::app::ReservationFacts::default(),
|
||||||
|
applied: crate::domain::ApplyStatus::Unknown,
|
||||||
|
vault_initialized: false,
|
||||||
|
has_draft: true,
|
||||||
|
restart: crate::app::ActionEligibility {
|
||||||
|
available: true,
|
||||||
|
reason: None,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let state = UiState {
|
||||||
|
view: Some(view),
|
||||||
|
..UiState::default()
|
||||||
|
};
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| draw(frame, &state, &FormController::default()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let text = rendered_text(&terminal);
|
||||||
|
for expected in [
|
||||||
|
"Draft removed",
|
||||||
|
"legacy route device",
|
||||||
|
"10.0.0.46",
|
||||||
|
"fd00::46",
|
||||||
|
"Inspect only",
|
||||||
|
] {
|
||||||
|
assert!(text.contains(expected), "missing {expected:?}:\n{text}");
|
||||||
|
}
|
||||||
|
assert!(text.contains(&identity.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_document_covers_nine_sections_and_distinguishes_case_sensitive_actions() {
|
fn help_document_covers_nine_sections_and_distinguishes_case_sensitive_actions() {
|
||||||
let rows = help_document(178);
|
let rows = help_document(178);
|
||||||
@@ -2550,6 +2832,7 @@ mod tests {
|
|||||||
statistics: RuntimeObservation::unknown(),
|
statistics: RuntimeObservation::unknown(),
|
||||||
has_draft: false,
|
has_draft: false,
|
||||||
vault_initialized: true,
|
vault_initialized: true,
|
||||||
|
application: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2838,6 +3121,77 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inspection_row_hints_follow_each_application_action_eligibility() {
|
||||||
|
let identity = PublicKey::from_bytes([44; 32]);
|
||||||
|
let mut view = empty_view();
|
||||||
|
let unavailable = crate::app::ActionEligibility {
|
||||||
|
available: false,
|
||||||
|
reason: Some(crate::app::ActionUnavailableReason::UnsafePeerOrDocument),
|
||||||
|
};
|
||||||
|
view.clients.push(ClientDisplay {
|
||||||
|
peer_index: 3,
|
||||||
|
line: 4,
|
||||||
|
identity: Some(identity),
|
||||||
|
label: "legacy peer".to_owned(),
|
||||||
|
allowed_ips: vec!["10.0.0.2/32".to_owned()],
|
||||||
|
has_subnet_routes: false,
|
||||||
|
issues: vec![crate::wireguard::PeerIssue::MissingIpv4HostRoute],
|
||||||
|
mutable_identity: None,
|
||||||
|
});
|
||||||
|
view.application = Some(crate::app::ApplicationView {
|
||||||
|
clients: vec![crate::app::PeerRecord {
|
||||||
|
peer_index: Some(3),
|
||||||
|
draft_peer_index: Some(3),
|
||||||
|
saved_peer_index: Some(3),
|
||||||
|
line: 4,
|
||||||
|
identity: Some(identity),
|
||||||
|
label: "legacy peer".to_owned(),
|
||||||
|
allowed_ips: vec!["10.0.0.2/32".to_owned()],
|
||||||
|
has_subnet_routes: false,
|
||||||
|
issues: vec![crate::wireguard::PeerIssue::MissingIpv4HostRoute],
|
||||||
|
classified_issues: vec![crate::app::ClassifiedPeerIssue {
|
||||||
|
issue: crate::wireguard::PeerIssue::MissingIpv4HostRoute,
|
||||||
|
class: crate::app::PeerIssueClass::UnsupportedCapability,
|
||||||
|
}],
|
||||||
|
mutable_identity: None,
|
||||||
|
draft_client: None,
|
||||||
|
saved_client: None,
|
||||||
|
state_change: crate::app::ClientStateChange::UnsupportedOrUnmanaged,
|
||||||
|
actions: crate::app::ClientActions {
|
||||||
|
rename: unavailable.clone(),
|
||||||
|
delete: crate::app::ActionEligibility {
|
||||||
|
available: true,
|
||||||
|
reason: None,
|
||||||
|
},
|
||||||
|
first_delivery: crate::app::FirstDeliveryEligibility::Unavailable,
|
||||||
|
reissue: unavailable.clone(),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
document_issues: Vec::new(),
|
||||||
|
saved_peer_count: 1,
|
||||||
|
draft_client_count: 0,
|
||||||
|
saved_client_count: 0,
|
||||||
|
reservations: Default::default(),
|
||||||
|
applied: crate::domain::ApplyStatus::Unknown,
|
||||||
|
vault_initialized: true,
|
||||||
|
has_draft: false,
|
||||||
|
restart: unavailable,
|
||||||
|
});
|
||||||
|
let state = UiState {
|
||||||
|
selected_identity: Some(identity),
|
||||||
|
selected_peer_index: Some(3),
|
||||||
|
view: Some(view),
|
||||||
|
..UiState::default()
|
||||||
|
};
|
||||||
|
let hints = keyboard_hints(&state, &FormController::default());
|
||||||
|
let available_keys = hints.iter().map(|hint| hint.key).collect::<Vec<_>>();
|
||||||
|
assert!(available_keys.contains(&"d"));
|
||||||
|
for unavailable_key in ["r", "o", "O", "R"] {
|
||||||
|
assert!(!available_keys.contains(&unavailable_key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn standard_footer_is_four_rows_and_list_height_is_stable_across_states() {
|
fn standard_footer_is_four_rows_and_list_height_is_stable_across_states() {
|
||||||
let identity = PublicKey::from_bytes([4; 32]);
|
let identity = PublicKey::from_bytes([4; 32]);
|
||||||
@@ -3937,6 +4291,7 @@ mod tests {
|
|||||||
statistics: RuntimeObservation::unknown(),
|
statistics: RuntimeObservation::unknown(),
|
||||||
has_draft: false,
|
has_draft: false,
|
||||||
vault_initialized: true,
|
vault_initialized: true,
|
||||||
|
application: None,
|
||||||
};
|
};
|
||||||
let state = UiState {
|
let state = UiState {
|
||||||
screen: Screen::ClientDetails,
|
screen: Screen::ClientDetails,
|
||||||
@@ -4060,6 +4415,7 @@ mod tests {
|
|||||||
statistics: RuntimeObservation::unknown(),
|
statistics: RuntimeObservation::unknown(),
|
||||||
has_draft: true,
|
has_draft: true,
|
||||||
vault_initialized: true,
|
vault_initialized: true,
|
||||||
|
application: None,
|
||||||
};
|
};
|
||||||
let mut state = UiState {
|
let mut state = UiState {
|
||||||
view: Some(view),
|
view: Some(view),
|
||||||
|
|||||||
+493
-54
@@ -3,7 +3,10 @@
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
|
||||||
use crate::app::{Application, ClientDisplay};
|
use crate::app::{
|
||||||
|
ActionEligibility, Application, ApplicationView, ClientDisplay, FirstDeliveryEligibility,
|
||||||
|
PeerRecord,
|
||||||
|
};
|
||||||
use crate::domain::{ApplyStatus, Client, PublicKey};
|
use crate::domain::{ApplyStatus, Client, PublicKey};
|
||||||
use crate::runtime::RuntimeObservation;
|
use crate::runtime::RuntimeObservation;
|
||||||
|
|
||||||
@@ -56,45 +59,191 @@ pub struct SessionView {
|
|||||||
pub statistics: RuntimeObservation,
|
pub statistics: RuntimeObservation,
|
||||||
pub has_draft: bool,
|
pub has_draft: bool,
|
||||||
pub vault_initialized: bool,
|
pub vault_initialized: bool,
|
||||||
|
/// Authoritative application facts used to present action availability.
|
||||||
|
/// The parallel fields above are the navigation/rendering projection.
|
||||||
|
pub application: Option<ApplicationView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionView {
|
impl SessionView {
|
||||||
pub fn from_application(app: &Application, generation: u64) -> Self {
|
pub fn from_application(app: &Application, generation: u64) -> Self {
|
||||||
let states = app.states();
|
let application = app.read_model();
|
||||||
let mut identities = BTreeSet::new();
|
let mut client_states = std::collections::BTreeMap::new();
|
||||||
identities.extend(states.saved().clients().map(|client| client.identity));
|
for row in &application.clients {
|
||||||
identities.extend(states.draft().clients().map(|client| client.identity));
|
if let Some(identity) = row.identity {
|
||||||
let client_states = identities
|
if row.saved_client.is_none() && row.draft_client.is_none() {
|
||||||
.into_iter()
|
continue;
|
||||||
.map(|identity| {
|
}
|
||||||
(
|
let state = client_states
|
||||||
identity,
|
.entry(identity)
|
||||||
ClientStateRow {
|
.or_insert_with(|| ClientStateRow {
|
||||||
saved: states.saved().get(&identity).cloned(),
|
saved: None,
|
||||||
draft: states.draft().get(&identity).cloned(),
|
draft: None,
|
||||||
},
|
});
|
||||||
)
|
if state.saved.is_none() {
|
||||||
})
|
state.saved.clone_from(&row.saved_client);
|
||||||
|
}
|
||||||
|
if state.draft.is_none() {
|
||||||
|
state.draft.clone_from(&row.draft_client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let clients = application
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.filter_map(navigation_peer)
|
||||||
.collect();
|
.collect();
|
||||||
let projection = app.display_projection();
|
|
||||||
let saved_peer_count =
|
|
||||||
crate::wireguard::ConfigDocument::parse(&app.saved_bytes().server_config)
|
|
||||||
.ok()
|
|
||||||
.map(|document| document.peers().len());
|
|
||||||
Self {
|
Self {
|
||||||
generation,
|
generation,
|
||||||
clients: projection.clients,
|
clients,
|
||||||
client_states,
|
client_states,
|
||||||
saved_peer_count,
|
saved_peer_count: Some(application.saved_peer_count),
|
||||||
document_issues: projection.document_issues,
|
document_issues: application.document_issues.clone(),
|
||||||
applied: *states.applied(),
|
applied: application.applied,
|
||||||
reserved_addresses: states.reserved_addresses().clone(),
|
reserved_addresses: application.reservations.committed.iter().copied().collect(),
|
||||||
statistics: RuntimeObservation::unknown(),
|
statistics: RuntimeObservation::unknown(),
|
||||||
has_draft: app.has_draft_changes(),
|
has_draft: application.has_draft,
|
||||||
vault_initialized: app.vault_initialized(),
|
vault_initialized: application.vault_initialized,
|
||||||
|
application: Some(application),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn application_peer(
|
||||||
|
&self,
|
||||||
|
identity: PublicKey,
|
||||||
|
peer_index: Option<usize>,
|
||||||
|
) -> Option<&PeerRecord> {
|
||||||
|
let application = self.application.as_ref()?;
|
||||||
|
let mut matching = application
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.filter(|peer| peer.identity == Some(identity));
|
||||||
|
let peer = matching.next()?;
|
||||||
|
if matching.next().is_some() || peer.peer_index != peer_index {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn application_record_for_client(&self, client: &ClientDisplay) -> Option<&PeerRecord> {
|
||||||
|
let identity = client.identity?;
|
||||||
|
self.application_peer(identity, Some(client.peer_index))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_rename_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool {
|
||||||
|
self.application_peer(identity, Some(peer_index))
|
||||||
|
.is_some_and(|peer| peer.actions.rename.available)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_delete_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool {
|
||||||
|
self.application_peer(identity, Some(peer_index))
|
||||||
|
.is_some_and(|peer| peer.actions.delete.available)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_first_delivery_eligibility(
|
||||||
|
&self,
|
||||||
|
identity: PublicKey,
|
||||||
|
peer_index: usize,
|
||||||
|
) -> bool {
|
||||||
|
self.application_peer(identity, Some(peer_index))
|
||||||
|
.is_some_and(|peer| {
|
||||||
|
peer.actions.first_delivery != FirstDeliveryEligibility::Unavailable
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_reissue_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool {
|
||||||
|
self.application_peer(identity, Some(peer_index))
|
||||||
|
.is_some_and(|peer| peer.actions.reissue.available)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restart_eligibility(&self) -> Option<&ActionEligibility> {
|
||||||
|
self.application.as_ref().map(|view| &view.restart)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_capability_limitation_issue(
|
||||||
|
&self,
|
||||||
|
client: &ClientDisplay,
|
||||||
|
issue: &crate::wireguard::PeerIssue,
|
||||||
|
) -> bool {
|
||||||
|
self.application
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|application| {
|
||||||
|
application.clients.iter().find(|peer| {
|
||||||
|
peer.peer_index == Some(client.peer_index) && peer.identity == client.identity
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.and_then(|peer| {
|
||||||
|
peer.classified_issues
|
||||||
|
.iter()
|
||||||
|
.find(|classified| &classified.issue == issue)
|
||||||
|
})
|
||||||
|
.map_or_else(
|
||||||
|
|| client.is_capability_limitation_issue(issue),
|
||||||
|
|classified| classified.class == crate::app::PeerIssueClass::UnsupportedCapability,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn presented_issues<'a>(
|
||||||
|
&'a self,
|
||||||
|
client: &'a ClientDisplay,
|
||||||
|
) -> Vec<&'a crate::wireguard::PeerIssue> {
|
||||||
|
client
|
||||||
|
.issues
|
||||||
|
.iter()
|
||||||
|
.filter(|issue| !self.is_capability_limitation_issue(client, issue))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client_state_change(
|
||||||
|
&self,
|
||||||
|
client: &ClientDisplay,
|
||||||
|
) -> Option<crate::app::ClientStateChange> {
|
||||||
|
self.application
|
||||||
|
.as_ref()?
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|peer| {
|
||||||
|
peer.peer_index == Some(client.peer_index) && peer.identity == client.identity
|
||||||
|
})
|
||||||
|
.map(|peer| peer.state_change)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn saved_managed_client_count(&self) -> usize {
|
||||||
|
self.application.as_ref().map_or_else(
|
||||||
|
|| {
|
||||||
|
self.client_states
|
||||||
|
.values()
|
||||||
|
.filter(|row| row.saved.is_some())
|
||||||
|
.count()
|
||||||
|
},
|
||||||
|
|application| application.saved_client_count,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn managed_change_count(&self) -> usize {
|
||||||
|
self.application.as_ref().map_or_else(
|
||||||
|
|| {
|
||||||
|
self.client_states
|
||||||
|
.values()
|
||||||
|
.filter(|row| row.draft != row.saved)
|
||||||
|
.count()
|
||||||
|
},
|
||||||
|
|application| {
|
||||||
|
application
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.filter(|peer| {
|
||||||
|
matches!(
|
||||||
|
peer.state_change,
|
||||||
|
crate::app::ClientStateChange::Added
|
||||||
|
| crate::app::ClientStateChange::Removed
|
||||||
|
| crate::app::ClientStateChange::Changed
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn contains_identity(&self, identity: &PublicKey) -> bool {
|
pub fn contains_identity(&self, identity: &PublicKey) -> bool {
|
||||||
self.clients
|
self.clients
|
||||||
.iter()
|
.iter()
|
||||||
@@ -109,31 +258,60 @@ impl SessionView {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(VisibleRecord::Peer)
|
.map(VisibleRecord::Peer)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
records.extend(self.client_states.iter().filter_map(|(identity, row)| {
|
if let Some(application) = &self.application {
|
||||||
let client = row.saved.as_ref()?;
|
records.extend(application.clients.iter().filter_map(|peer| {
|
||||||
if row.draft.is_some()
|
if peer.state_change != crate::app::ClientStateChange::Removed {
|
||||||
|| self
|
return None;
|
||||||
.clients
|
}
|
||||||
.iter()
|
Some(VisibleRecord::DraftRemoved {
|
||||||
.any(|peer| peer.identity == Some(*identity))
|
identity: peer.identity?,
|
||||||
{
|
peer: Some(peer),
|
||||||
return None;
|
client: peer.saved_client.as_ref(),
|
||||||
}
|
})
|
||||||
Some(VisibleRecord::DraftRemoved {
|
}));
|
||||||
identity: *identity,
|
} else {
|
||||||
client,
|
records.extend(self.client_states.iter().filter_map(|(identity, row)| {
|
||||||
})
|
let client = row.saved.as_ref()?;
|
||||||
}));
|
if row.draft.is_some()
|
||||||
|
|| self
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.any(|peer| peer.identity == Some(*identity))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(VisibleRecord::DraftRemoved {
|
||||||
|
identity: *identity,
|
||||||
|
peer: None,
|
||||||
|
client: Some(client),
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
}
|
||||||
records
|
records
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn navigation_peer(peer: &PeerRecord) -> Option<ClientDisplay> {
|
||||||
|
let peer_index = peer.peer_index?;
|
||||||
|
Some(ClientDisplay {
|
||||||
|
peer_index,
|
||||||
|
line: peer.line,
|
||||||
|
identity: peer.identity,
|
||||||
|
label: peer.label.clone(),
|
||||||
|
allowed_ips: peer.allowed_ips.clone(),
|
||||||
|
has_subnet_routes: peer.has_subnet_routes,
|
||||||
|
issues: peer.issues.clone(),
|
||||||
|
mutable_identity: peer.mutable_identity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub enum VisibleRecord<'a> {
|
pub enum VisibleRecord<'a> {
|
||||||
Peer(&'a ClientDisplay),
|
Peer(&'a ClientDisplay),
|
||||||
DraftRemoved {
|
DraftRemoved {
|
||||||
identity: PublicKey,
|
identity: PublicKey,
|
||||||
client: &'a Client,
|
peer: Option<&'a PeerRecord>,
|
||||||
|
client: Option<&'a Client>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,13 +426,8 @@ impl UiState {
|
|||||||
self.select_first_record(view);
|
self.select_first_record(view);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let removed = view.client_states.get(&identity).is_some_and(|row| {
|
let removed = view.visible_records().iter().any(|record| {
|
||||||
row.saved.is_some()
|
matches!(record, VisibleRecord::DraftRemoved { identity: removed, .. } if *removed == identity)
|
||||||
&& row.draft.is_none()
|
|
||||||
&& !view
|
|
||||||
.clients
|
|
||||||
.iter()
|
|
||||||
.any(|client| client.identity == Some(identity))
|
|
||||||
});
|
});
|
||||||
if removed {
|
if removed {
|
||||||
self.selected_peer_index = None;
|
self.selected_peer_index = None;
|
||||||
@@ -277,9 +450,9 @@ impl UiState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.selected_identity.is_some_and(|identity| {
|
if self.selected_identity.is_some_and(|identity| {
|
||||||
view.client_states
|
view.visible_records().iter().any(|record| {
|
||||||
.get(&identity)
|
matches!(record, VisibleRecord::DraftRemoved { identity: removed, .. } if *removed == identity)
|
||||||
.is_some_and(|row| row.saved.is_some() && row.draft.is_none())
|
})
|
||||||
}) {
|
}) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -477,11 +650,75 @@ impl UiState {
|
|||||||
&& client.mutable_identity == Some(identity)
|
&& client.mutable_identity == Some(identity)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn selected_rename_available(&self) -> bool {
|
||||||
|
self.view.as_ref().is_some_and(|view| {
|
||||||
|
view.application.as_ref().map_or_else(
|
||||||
|
|| self.selected_client_mutable(),
|
||||||
|
|_| self.selected_peer_action(|peer| peer.actions.rename.available),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_delete_available(&self) -> bool {
|
||||||
|
self.view.as_ref().is_some_and(|view| {
|
||||||
|
view.application.as_ref().map_or_else(
|
||||||
|
|| self.selected_client_mutable(),
|
||||||
|
|_| self.selected_peer_action(|peer| peer.actions.delete.available),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_first_delivery_available(&self) -> bool {
|
||||||
|
self.view.as_ref().is_some_and(|view| {
|
||||||
|
view.application.as_ref().map_or_else(
|
||||||
|
|| self.selected_client_is_saved(),
|
||||||
|
|_| {
|
||||||
|
self.selected_peer_action(|peer| {
|
||||||
|
peer.actions.first_delivery != FirstDeliveryEligibility::Unavailable
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_reissue_available(&self) -> bool {
|
||||||
|
self.view.as_ref().is_some_and(|view| {
|
||||||
|
view.application.as_ref().map_or_else(
|
||||||
|
|| self.selected_client_is_saved(),
|
||||||
|
|_| self.selected_peer_action(|peer| peer.actions.reissue.available),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_client_is_saved(&self) -> bool {
|
||||||
|
self.selected_identity.is_some_and(|identity| {
|
||||||
|
self.view
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|view| view.client_states.get(&identity))
|
||||||
|
.is_some_and(|row| row.saved.is_some())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_peer_action(&self, action: impl FnOnce(&PeerRecord) -> bool) -> bool {
|
||||||
|
let (Some(identity), Some(peer_index)) = (self.selected_identity, self.selected_peer_index)
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.view
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|view| view.application_peer(identity, Some(peer_index)))
|
||||||
|
.is_some_and(action)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::app::{
|
||||||
|
ActionEligibility, ActionUnavailableReason, ClientActions, ClientStateChange,
|
||||||
|
FirstDeliveryEligibility, PeerIssueClass, ReservationFacts,
|
||||||
|
};
|
||||||
use crate::domain::PublicKey;
|
use crate::domain::PublicKey;
|
||||||
use crate::wireguard::PeerIssue;
|
use crate::wireguard::PeerIssue;
|
||||||
|
|
||||||
@@ -514,6 +751,72 @@ mod tests {
|
|||||||
statistics: RuntimeObservation::unknown(),
|
statistics: RuntimeObservation::unknown(),
|
||||||
has_draft: false,
|
has_draft: false,
|
||||||
vault_initialized: true,
|
vault_initialized: true,
|
||||||
|
application: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn application_peer(
|
||||||
|
identity: PublicKey,
|
||||||
|
peer_index: usize,
|
||||||
|
actions: ClientActions,
|
||||||
|
) -> PeerRecord {
|
||||||
|
PeerRecord {
|
||||||
|
peer_index: Some(peer_index),
|
||||||
|
draft_peer_index: Some(peer_index),
|
||||||
|
saved_peer_index: Some(peer_index),
|
||||||
|
line: peer_index + 1,
|
||||||
|
identity: Some(identity),
|
||||||
|
label: "inspect-only peer".to_owned(),
|
||||||
|
allowed_ips: vec!["10.0.0.2/32".to_owned()],
|
||||||
|
has_subnet_routes: false,
|
||||||
|
issues: vec![PeerIssue::MissingIpv4HostRoute],
|
||||||
|
classified_issues: vec![crate::app::ClassifiedPeerIssue {
|
||||||
|
issue: PeerIssue::MissingIpv4HostRoute,
|
||||||
|
class: PeerIssueClass::MalformedOrAmbiguous,
|
||||||
|
}],
|
||||||
|
mutable_identity: None,
|
||||||
|
draft_client: None,
|
||||||
|
saved_client: None,
|
||||||
|
state_change: ClientStateChange::UnsupportedOrUnmanaged,
|
||||||
|
actions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn available_actions(delete: bool) -> ClientActions {
|
||||||
|
let unavailable = ActionEligibility {
|
||||||
|
available: false,
|
||||||
|
reason: Some(ActionUnavailableReason::UnsafePeerOrDocument),
|
||||||
|
};
|
||||||
|
ClientActions {
|
||||||
|
rename: unavailable.clone(),
|
||||||
|
delete: if delete {
|
||||||
|
ActionEligibility {
|
||||||
|
available: true,
|
||||||
|
reason: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unavailable.clone()
|
||||||
|
},
|
||||||
|
first_delivery: FirstDeliveryEligibility::Unavailable,
|
||||||
|
reissue: unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn application_view(records: Vec<PeerRecord>) -> ApplicationView {
|
||||||
|
ApplicationView {
|
||||||
|
clients: records,
|
||||||
|
document_issues: Vec::new(),
|
||||||
|
saved_peer_count: 1,
|
||||||
|
draft_client_count: 0,
|
||||||
|
saved_client_count: 0,
|
||||||
|
reservations: ReservationFacts::default(),
|
||||||
|
applied: ApplyStatus::Unknown,
|
||||||
|
vault_initialized: false,
|
||||||
|
has_draft: false,
|
||||||
|
restart: ActionEligibility {
|
||||||
|
available: true,
|
||||||
|
reason: None,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,6 +901,72 @@ mod tests {
|
|||||||
assert!(!state.selected_client_mutable());
|
assert!(!state.selected_client_mutable());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn action_eligibility_is_per_action_and_bound_to_the_selected_peer_position() {
|
||||||
|
let identity = PublicKey::from_bytes([40; 32]);
|
||||||
|
let mut peer = application_peer(identity, 6, available_actions(true));
|
||||||
|
let mut session = view(
|
||||||
|
0,
|
||||||
|
vec![ClientDisplay {
|
||||||
|
peer_index: 6,
|
||||||
|
line: 7,
|
||||||
|
identity: Some(identity),
|
||||||
|
label: peer.label.clone(),
|
||||||
|
allowed_ips: peer.allowed_ips.clone(),
|
||||||
|
has_subnet_routes: false,
|
||||||
|
issues: peer.issues.clone(),
|
||||||
|
mutable_identity: None,
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
session.application = Some(application_view(vec![peer.clone()]));
|
||||||
|
let mut state = UiState {
|
||||||
|
view: Some(session),
|
||||||
|
..UiState::default()
|
||||||
|
};
|
||||||
|
assert!(state.select_peer_index(6));
|
||||||
|
assert!(!state.selected_client_mutable());
|
||||||
|
assert!(!state.selected_rename_available());
|
||||||
|
assert!(state.selected_delete_available());
|
||||||
|
assert!(!state.selected_first_delivery_available());
|
||||||
|
assert!(!state.selected_reissue_available());
|
||||||
|
|
||||||
|
state.selected_peer_index = Some(7);
|
||||||
|
assert!(!state.selected_delete_available());
|
||||||
|
|
||||||
|
peer.peer_index = Some(7);
|
||||||
|
if let Some(application) = state
|
||||||
|
.view
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|view| view.application.as_mut())
|
||||||
|
{
|
||||||
|
application.clients.push(peer);
|
||||||
|
}
|
||||||
|
state.selected_peer_index = Some(6);
|
||||||
|
assert!(!state.selected_delete_available());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_peer_inspection_never_inherits_keyed_action_eligibility() {
|
||||||
|
let identity = PublicKey::from_bytes([41; 32]);
|
||||||
|
let keyed = application_peer(identity, 2, available_actions(true));
|
||||||
|
let mut malformed = keyed.clone();
|
||||||
|
malformed.peer_index = Some(9);
|
||||||
|
malformed.draft_peer_index = Some(9);
|
||||||
|
malformed.identity = None;
|
||||||
|
let mut inspection = row(identity, "peer", false);
|
||||||
|
inspection.peer_index = 9;
|
||||||
|
inspection.identity = None;
|
||||||
|
let mut state_view = view(0, vec![inspection]);
|
||||||
|
state_view.application = Some(application_view(vec![keyed.clone(), malformed]));
|
||||||
|
let mut state = UiState {
|
||||||
|
view: Some(state_view),
|
||||||
|
..UiState::default()
|
||||||
|
};
|
||||||
|
assert!(state.select_peer_index(9));
|
||||||
|
assert_eq!(state.selected_identity, None);
|
||||||
|
assert!(!state.selected_delete_available());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn removed_saved_client_is_visible_and_never_mutable() {
|
fn removed_saved_client_is_visible_and_never_mutable() {
|
||||||
let key = PublicKey::from_bytes([13; 32]);
|
let key = PublicKey::from_bytes([13; 32]);
|
||||||
@@ -635,6 +1004,76 @@ mod tests {
|
|||||||
assert_eq!(state.view.as_ref().unwrap().visible_records().len(), 1);
|
assert_eq!(state.view.as_ref().unwrap().visible_records().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_model_removed_rows_remain_visible_without_state_map_reconstruction() {
|
||||||
|
let identity = PublicKey::from_bytes([42; 32]);
|
||||||
|
let removed = Client::new(
|
||||||
|
identity,
|
||||||
|
"removed by draft",
|
||||||
|
crate::domain::ClientAddresses {
|
||||||
|
ipv4: "10.0.0.42".parse().unwrap(),
|
||||||
|
ipv6: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut peer = application_peer(identity, 0, available_actions(false));
|
||||||
|
peer.peer_index = None;
|
||||||
|
peer.draft_peer_index = None;
|
||||||
|
peer.saved_client = Some(removed.clone());
|
||||||
|
peer.state_change = ClientStateChange::Removed;
|
||||||
|
let mut session = view(0, Vec::new());
|
||||||
|
session.application = Some(application_view(vec![peer]));
|
||||||
|
|
||||||
|
let mut state = UiState::default();
|
||||||
|
assert!(state.apply_view(session));
|
||||||
|
assert_eq!(state.selected_identity, Some(identity));
|
||||||
|
assert!(matches!(
|
||||||
|
state.selected_record(),
|
||||||
|
Some(VisibleRecord::DraftRemoved {
|
||||||
|
identity: selected,
|
||||||
|
client: Some(client),
|
||||||
|
..
|
||||||
|
}) if selected == identity && client == &removed
|
||||||
|
));
|
||||||
|
assert!(state.view.as_ref().unwrap().client_states.is_empty());
|
||||||
|
assert!(!state.selected_delete_available());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removed_unprojectable_authoritative_peer_stays_selectable_without_actions() {
|
||||||
|
let identity = PublicKey::from_bytes([43; 32]);
|
||||||
|
let mut peer = application_peer(identity, 0, available_actions(true));
|
||||||
|
peer.peer_index = None;
|
||||||
|
peer.draft_peer_index = None;
|
||||||
|
peer.saved_peer_index = Some(4);
|
||||||
|
peer.label = "legacy route peer".to_owned();
|
||||||
|
peer.allowed_ips = vec!["10.0.0.43/32".to_owned()];
|
||||||
|
peer.saved_client = None;
|
||||||
|
peer.state_change = ClientStateChange::Removed;
|
||||||
|
let mut session = view(0, Vec::new());
|
||||||
|
session.application = Some(application_view(vec![peer]));
|
||||||
|
|
||||||
|
let mut state = UiState::default();
|
||||||
|
assert!(state.apply_view(session));
|
||||||
|
assert_eq!(state.selected_identity, Some(identity));
|
||||||
|
assert_eq!(state.selected_peer_index, None);
|
||||||
|
assert!(matches!(
|
||||||
|
state.selected_record(),
|
||||||
|
Some(VisibleRecord::DraftRemoved {
|
||||||
|
identity: selected,
|
||||||
|
peer: Some(record),
|
||||||
|
client: None,
|
||||||
|
}) if selected == identity && record.label == "legacy route peer"
|
||||||
|
));
|
||||||
|
assert!(!state.selected_client_mutable());
|
||||||
|
assert!(!state.selected_rename_available());
|
||||||
|
assert!(!state.selected_delete_available());
|
||||||
|
assert!(!state.selected_first_delivery_available());
|
||||||
|
assert!(!state.selected_reissue_available());
|
||||||
|
assert!(state.open_selected_record());
|
||||||
|
assert_eq!(state.screen, Screen::ClientDetails);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() {
|
fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() {
|
||||||
let mut state = UiState {
|
let mut state = UiState {
|
||||||
|
|||||||
+24
-11
@@ -771,7 +771,7 @@ fn handle_key(
|
|||||||
KeyCode::Char('n') if state.view.is_some() => forms.open_create(),
|
KeyCode::Char('n') if state.view.is_some() => forms.open_create(),
|
||||||
KeyCode::Char('i') => forms.open_import(),
|
KeyCode::Char('i') => forms.open_import(),
|
||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
if state.selected_client_mutable() {
|
if state.selected_rename_available() {
|
||||||
let selected = state.selected_identity.and_then(|id| {
|
let selected = state.selected_identity.and_then(|id| {
|
||||||
state
|
state
|
||||||
.view
|
.view
|
||||||
@@ -788,7 +788,7 @@ fn handle_key(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('d') => {
|
KeyCode::Char('d') => {
|
||||||
if state.selected_client_mutable() {
|
if state.selected_delete_available() {
|
||||||
if let (Some(id), Some(view)) = (state.selected_identity, state.view.as_ref()) {
|
if let (Some(id), Some(view)) = (state.selected_identity, state.view.as_ref()) {
|
||||||
let label = view
|
let label = view
|
||||||
.clients
|
.clients
|
||||||
@@ -806,16 +806,25 @@ fn handle_key(
|
|||||||
KeyCode::Char('p') => forms.open_draft_review(),
|
KeyCode::Char('p') => forms.open_draft_review(),
|
||||||
KeyCode::Char('c') => forms.open_commit(),
|
KeyCode::Char('c') => forms.open_commit(),
|
||||||
KeyCode::Char('x') => forms.open_discard(),
|
KeyCode::Char('x') => forms.open_discard(),
|
||||||
KeyCode::Char('R') => submit_command(worker, UiCommand::PreviewRestart, state),
|
KeyCode::Char('R') => {
|
||||||
KeyCode::Char('o') | KeyCode::Char('O') => {
|
if state
|
||||||
if state.selected_client_mutable()
|
.view
|
||||||
&& let Some(identity) = state.selected_identity
|
.as_ref()
|
||||||
&& state.view.as_ref().is_some_and(|view| {
|
.is_some_and(|view| view.restart_eligibility().is_none_or(|item| item.available))
|
||||||
view.client_states
|
|
||||||
.get(&identity)
|
|
||||||
.is_some_and(|row| row.saved.is_some())
|
|
||||||
})
|
|
||||||
{
|
{
|
||||||
|
submit_command(worker, UiCommand::PreviewRestart, state);
|
||||||
|
} else {
|
||||||
|
state.status_message =
|
||||||
|
Some("Restart preview is unavailable for the saved configuration.".to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('o') | KeyCode::Char('O') => {
|
||||||
|
let eligible = if code == KeyCode::Char('o') {
|
||||||
|
state.selected_first_delivery_available()
|
||||||
|
} else {
|
||||||
|
state.selected_reissue_available()
|
||||||
|
};
|
||||||
|
if eligible && let Some(identity) = state.selected_identity {
|
||||||
forms.open_delivery(identity, code == KeyCode::Char('O'));
|
forms.open_delivery(identity, code == KeyCode::Char('O'));
|
||||||
} else {
|
} else {
|
||||||
state.status_message =
|
state.status_message =
|
||||||
@@ -1038,6 +1047,7 @@ mod tests {
|
|||||||
statistics: RuntimeObservation::unknown(),
|
statistics: RuntimeObservation::unknown(),
|
||||||
has_draft: false,
|
has_draft: false,
|
||||||
vault_initialized: true,
|
vault_initialized: true,
|
||||||
|
application: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1319,6 +1329,7 @@ mod tests {
|
|||||||
statistics: RuntimeObservation::unknown(),
|
statistics: RuntimeObservation::unknown(),
|
||||||
has_draft: false,
|
has_draft: false,
|
||||||
vault_initialized: true,
|
vault_initialized: true,
|
||||||
|
application: None,
|
||||||
}),
|
}),
|
||||||
..UiState::default()
|
..UiState::default()
|
||||||
};
|
};
|
||||||
@@ -1350,6 +1361,7 @@ mod tests {
|
|||||||
statistics: RuntimeObservation::unknown(),
|
statistics: RuntimeObservation::unknown(),
|
||||||
has_draft: false,
|
has_draft: false,
|
||||||
vault_initialized: true,
|
vault_initialized: true,
|
||||||
|
application: None,
|
||||||
}),
|
}),
|
||||||
..UiState::default()
|
..UiState::default()
|
||||||
};
|
};
|
||||||
@@ -1584,6 +1596,7 @@ mod tests {
|
|||||||
statistics: RuntimeObservation::unknown(),
|
statistics: RuntimeObservation::unknown(),
|
||||||
has_draft: false,
|
has_draft: false,
|
||||||
vault_initialized: true,
|
vault_initialized: true,
|
||||||
|
application: None,
|
||||||
};
|
};
|
||||||
let mut worker = FakeWorker {
|
let mut worker = FakeWorker {
|
||||||
shutdown_calls: 0,
|
shutdown_calls: 0,
|
||||||
|
|||||||
@@ -102,6 +102,42 @@ fn saved_peer_projection_counts_legacy_peers_without_inventing_managed_state() {
|
|||||||
assert!(!view.has_draft);
|
assert!(!view.has_draft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_model_exposes_supported_exact_host_peer_actions_individually() {
|
||||||
|
let fixture = fixture();
|
||||||
|
let legacy_key = WireGuardPrivateKey::generate()
|
||||||
|
.expect("synthetic legacy peer key")
|
||||||
|
.public_key();
|
||||||
|
let mut server_config =
|
||||||
|
fs::read_to_string(&fixture.settings.server_config_path).expect("synthetic server config");
|
||||||
|
server_config.push_str(&format!(
|
||||||
|
"\n[Peer]\nPublicKey = {legacy_key}\nAllowedIPs = 10.20.0.2/32\n"
|
||||||
|
));
|
||||||
|
fs::write(&fixture.settings.server_config_path, server_config)
|
||||||
|
.expect("write synthetic exact-host peer");
|
||||||
|
|
||||||
|
let app = wireguard_admin::app::Application::open_from_saved_store(fixture.store.clone())
|
||||||
|
.expect("application opens with an inspect-only legacy peer");
|
||||||
|
let mut view = SessionView::from_application(&app, 0);
|
||||||
|
assert_eq!(view.clients.len(), 1);
|
||||||
|
assert_eq!(view.clients[0].mutable_identity, None);
|
||||||
|
assert_eq!(view.application.as_ref().unwrap().clients.len(), 1);
|
||||||
|
|
||||||
|
let mut state = wireguard_admin::tui::state::UiState {
|
||||||
|
view: Some(view),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(state.select_peer_index(0));
|
||||||
|
assert!(state.selected_rename_available());
|
||||||
|
assert!(state.selected_delete_available());
|
||||||
|
assert!(!state.selected_first_delivery_available());
|
||||||
|
assert!(!state.selected_reissue_available());
|
||||||
|
|
||||||
|
view = SessionView::from_application(&app, 0);
|
||||||
|
assert_eq!(view.saved_peer_count, Some(1));
|
||||||
|
assert!(view.client_states.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
struct CountingStatistics(Arc<AtomicUsize>);
|
struct CountingStatistics(Arc<AtomicUsize>);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user