Complete terminal management workflow
This commit is contained in:
+276
-2
@@ -5,6 +5,7 @@
|
||||
//! explicit runtime-restart confirmation boundary.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt;
|
||||
use std::net::IpAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -28,11 +29,34 @@ use crate::runtime::{
|
||||
self, RuntimeObservation as TechnicalObservation, SavedPeerProjection, TechnicalMatch,
|
||||
};
|
||||
use crate::service::{self, RestartDeadline, RestartExecution, RestartOutcome};
|
||||
use crate::vault::source::{PreparedImportSetup, SourceImportReport};
|
||||
use crate::vault::source::{
|
||||
PreparedImportSetup, SelectedFileImportReport, SourceImportError, SourceImportReport,
|
||||
};
|
||||
use crate::vault::{VaultBundle, VaultError, WireGuardPrivateKey};
|
||||
use crate::wireguard::SectionKind;
|
||||
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 issues: Vec<crate::wireguard::PeerIssue>,
|
||||
pub mutable_identity: Option<PublicKey>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// change independently of installed bytes until [`Self::commit`] succeeds.
|
||||
pub struct Application {
|
||||
@@ -295,6 +319,106 @@ impl RestorePreview {
|
||||
pub fn historical_apply(&self) -> &HistoricalApplyStatus {
|
||||
&self.historical_apply
|
||||
}
|
||||
|
||||
/// Safe comparison against current saved bytes for a restore confirmation screen.
|
||||
pub fn change_summary(&self, current: &SavedBytes) -> RestoreChangeSummary {
|
||||
let peer_map = |bytes: &[u8]| {
|
||||
ConfigDocument::parse(bytes)
|
||||
.map(|document| {
|
||||
document
|
||||
.peers()
|
||||
.iter()
|
||||
.filter_map(|peer| {
|
||||
peer.identity.map(|key| {
|
||||
(
|
||||
key,
|
||||
peer.allowed_ips
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let current_peer_map = peer_map(¤t.server_config);
|
||||
let proposed_peer_map = peer_map(&self.saved_bytes.server_config);
|
||||
let current_peers = current_peer_map.keys().copied().collect::<BTreeSet<_>>();
|
||||
let proposed_peers = proposed_peer_map.keys().copied().collect::<BTreeSet<_>>();
|
||||
let changed_clients = current_peers
|
||||
.intersection(&proposed_peers)
|
||||
.filter(|key| current_peer_map.get(key) != proposed_peer_map.get(key))
|
||||
.copied()
|
||||
.collect();
|
||||
RestoreChangeSummary {
|
||||
settings_changed: current.settings != self.saved_bytes.settings,
|
||||
settings_fields_changed: settings_fields_changed(
|
||||
¤t.settings,
|
||||
&self.saved_bytes.settings,
|
||||
),
|
||||
added_clients: proposed_peers.difference(¤t_peers).copied().collect(),
|
||||
removed_clients: current_peers.difference(&proposed_peers).copied().collect(),
|
||||
changed_clients,
|
||||
server_config_changed: current.server_config != self.saved_bytes.server_config,
|
||||
vault_changed: current.vault != self.saved_bytes.vault,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RestoreChangeSummary {
|
||||
pub settings_changed: bool,
|
||||
pub settings_fields_changed: Vec<&'static str>,
|
||||
pub server_config_changed: bool,
|
||||
pub vault_changed: bool,
|
||||
pub added_clients: Vec<PublicKey>,
|
||||
pub removed_clients: Vec<PublicKey>,
|
||||
pub changed_clients: Vec<PublicKey>,
|
||||
}
|
||||
|
||||
fn settings_fields_changed(current: &[u8], proposed: &[u8]) -> Vec<&'static str> {
|
||||
let parse = |bytes: &[u8]| {
|
||||
std::str::from_utf8(bytes)
|
||||
.ok()
|
||||
.and_then(|text| crate::config::SettingsFile::parse_toml(text).ok())
|
||||
};
|
||||
let (Some(current), Some(proposed)) = (parse(current), parse(proposed)) else {
|
||||
return vec!["settings file"];
|
||||
};
|
||||
let mut fields = Vec::new();
|
||||
if current.interface_name != proposed.interface_name {
|
||||
fields.push("interface name");
|
||||
}
|
||||
if current.server_config_path != proposed.server_config_path {
|
||||
fields.push("server configuration path");
|
||||
}
|
||||
if current.client_source_directory != proposed.client_source_directory {
|
||||
fields.push("client source directory");
|
||||
}
|
||||
if current.server_endpoint != proposed.server_endpoint {
|
||||
fields.push("server endpoint");
|
||||
}
|
||||
if current.ipv4_allocation_range != proposed.ipv4_allocation_range {
|
||||
fields.push("IPv4 allocation range");
|
||||
}
|
||||
if current.ipv6_allocation_range != proposed.ipv6_allocation_range {
|
||||
fields.push("IPv6 allocation range");
|
||||
}
|
||||
if current.dns_addresses != proposed.dns_addresses {
|
||||
fields.push("DNS addresses");
|
||||
}
|
||||
if current.systemd_unit != proposed.systemd_unit {
|
||||
fields.push("systemd unit");
|
||||
}
|
||||
if current.restart_timeout_seconds != proposed.restart_timeout_seconds {
|
||||
fields.push("restart timeout");
|
||||
}
|
||||
if current.data_root != proposed.data_root {
|
||||
fields.push("data directory");
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
impl fmt::Debug for RestorePreview {
|
||||
@@ -334,8 +458,30 @@ impl fmt::Debug for Application {
|
||||
impl Application {
|
||||
/// Open validated installed bytes and apply conservative persisted address
|
||||
/// reservations to the caller's narrower client-state model.
|
||||
pub fn open(store: SavedStore, mut states: ClientStates) -> Result<Self, AppError> {
|
||||
pub fn open(store: SavedStore, states: ClientStates) -> Result<Self, AppError> {
|
||||
let saved = store.read_saved()?;
|
||||
Self::open_saved(store, saved, states)
|
||||
}
|
||||
|
||||
/// Open a session by deriving its supported state from the authoritative
|
||||
/// saved server document. Unsupported peers remain available through
|
||||
/// [`Self::display_projection`] but never gain a guessed mutation identity.
|
||||
pub fn open_from_saved_store(store: SavedStore) -> Result<Self, AppError> {
|
||||
let saved = store.read_saved()?;
|
||||
let document = ConfigDocument::parse(&saved.server_config)?;
|
||||
let states = ClientStates::new(
|
||||
snapshot_from_document(&document),
|
||||
None,
|
||||
RuntimeObservation::Unknown,
|
||||
)?;
|
||||
Self::open_saved(store, saved, states)
|
||||
}
|
||||
|
||||
fn open_saved(
|
||||
store: SavedStore,
|
||||
saved: SavedBytes,
|
||||
mut states: ClientStates,
|
||||
) -> Result<Self, AppError> {
|
||||
states.mark_runtime_unknown();
|
||||
let document = ConfigDocument::parse(&saved.server_config)?;
|
||||
let vault = saved
|
||||
@@ -377,6 +523,49 @@ impl Application {
|
||||
&self.states
|
||||
}
|
||||
|
||||
/// Return a secret-free projection that includes unsupported and
|
||||
/// ambiguous peers instead of silently omitting them from the client list.
|
||||
pub fn display_projection(&self) -> SessionDisplay {
|
||||
let clients = self
|
||||
.document
|
||||
.peers()
|
||||
.iter()
|
||||
.map(|peer| {
|
||||
let projected = peer
|
||||
.identity
|
||||
.and_then(|identity| self.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(),
|
||||
issues: peer.issues.clone(),
|
||||
mutable_identity: projected
|
||||
.filter(|client| self.states.draft().get(&client.identity).is_some())
|
||||
.map(|client| client.identity),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
SessionDisplay {
|
||||
clients,
|
||||
document_issues: self.document.issues().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add one prepared public-key client to the byte-preserving server draft
|
||||
/// and the narrower in-memory client draft.
|
||||
pub fn add_client(
|
||||
@@ -1001,6 +1190,33 @@ impl Application {
|
||||
self.vault.as_ref()
|
||||
}
|
||||
|
||||
/// Whether a vault is present in either saved state or the current draft.
|
||||
/// This reveals only setup availability, never vault contents.
|
||||
pub fn vault_initialized(&self) -> bool {
|
||||
self.vault.is_some()
|
||||
}
|
||||
|
||||
/// Whether the in-memory client/document/vault draft differs from saved.
|
||||
pub fn has_draft_changes(&self) -> bool {
|
||||
self.has_draft()
|
||||
}
|
||||
|
||||
/// Import one explicitly selected configured-directory entry into the
|
||||
/// in-memory vault draft. The result contains only redacted reconciliation
|
||||
/// data; saved files are changed only by a later Commit.
|
||||
pub fn import_selected_file(
|
||||
&mut self,
|
||||
selected_name: &OsStr,
|
||||
) -> Result<SelectedFileImportReport, AppError> {
|
||||
let settings = self.store.resolved_settings_for(&self.saved)?;
|
||||
let vault = self
|
||||
.vault
|
||||
.as_mut()
|
||||
.ok_or(AppError::VaultMissingForCreation)?;
|
||||
crate::vault::source::import_selected_file(vault, &settings, &self.document, selected_name)
|
||||
.map_err(AppError::SourceImport)
|
||||
}
|
||||
|
||||
/// Replace a client's alias through the span-preserving config editor.
|
||||
/// A peer outside the narrower M1 state projection remains editable.
|
||||
pub fn rename_client(&mut self, identity: PublicKey, alias: &str) -> Result<(), AppError> {
|
||||
@@ -1086,6 +1302,20 @@ impl Application {
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Prepare a password-protected vault and import configured source files
|
||||
/// against this session's authoritative document. All results remain in
|
||||
/// the in-memory draft until Commit.
|
||||
pub fn prepare_initial_setup_from_sources(
|
||||
&mut self,
|
||||
vault_password: &str,
|
||||
) -> Result<SourceImportReport, AppError> {
|
||||
let settings = self.store.resolved_settings_for(&self.saved)?;
|
||||
let prepared =
|
||||
crate::vault::source::prepare_initial_setup(&settings, &self.document, vault_password)
|
||||
.map_err(AppError::SourceImport)?;
|
||||
self.prepare_initial_setup(prepared)
|
||||
}
|
||||
|
||||
/// Drop all uncommitted edits and restore the in-memory drafts from the last
|
||||
/// successfully installed session snapshot. No files are written.
|
||||
pub fn discard_draft(&mut self) -> Result<(), AppError> {
|
||||
@@ -2118,10 +2348,15 @@ pub enum AppError {
|
||||
Domain(#[from] DomainError),
|
||||
#[error(transparent)]
|
||||
Delivery(#[from] crate::delivery::DeliveryError),
|
||||
#[error(transparent)]
|
||||
FileOutput(#[from] FileOutputError),
|
||||
#[error(transparent)]
|
||||
SourceImport(#[from] SourceImportError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -2166,6 +2401,15 @@ systemd_unit = "wg-quick@wg0.service"
|
||||
restart_timeout_seconds = 30
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn restore_settings_projection_reports_changed_fields_without_exposing_values() {
|
||||
let proposed = SETTINGS.replace("vpn.example.test:51820", "vpn-new.example.test:51820");
|
||||
let fields = super::settings_fields_changed(SETTINGS.as_bytes(), proposed.as_bytes());
|
||||
assert_eq!(fields, ["server endpoint"]);
|
||||
let malformed = super::settings_fields_changed(SETTINGS.as_bytes(), b"invalid = [");
|
||||
assert_eq!(malformed, ["settings file"]);
|
||||
}
|
||||
|
||||
fn setup(
|
||||
root: &Path,
|
||||
archive_as_file: bool,
|
||||
@@ -2333,6 +2577,36 @@ restart_timeout_seconds = 30
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_file_import_uses_the_existing_vault_draft_without_saving() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
let mut app = setup_with_vault(root.path());
|
||||
let source_directory = root.path().join("clients");
|
||||
fs::create_dir_all(&source_directory).expect("source directory");
|
||||
fs::write(
|
||||
source_directory.join("invalid.conf"),
|
||||
b"not a client config\n",
|
||||
)
|
||||
.expect("synthetic invalid source file");
|
||||
let saved_vault = app.saved_bytes().vault.clone();
|
||||
let draft_records = app.vault().expect("vault exists").record_count();
|
||||
|
||||
let report = app
|
||||
.import_selected_file(OsStr::new("invalid.conf"))
|
||||
.expect("invalid selected file is reported rather than fatal");
|
||||
|
||||
assert!(report.scan_complete);
|
||||
assert!(matches!(
|
||||
report.candidates[0].1,
|
||||
crate::vault::import::CandidateStatus::Invalid(_)
|
||||
));
|
||||
assert_eq!(
|
||||
app.vault().expect("vault remains present").record_count(),
|
||||
draft_records
|
||||
);
|
||||
assert_eq!(app.saved_bytes().vault, saved_vault);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_addresses_reject_occupied_and_out_of_range_without_partial_staging() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
|
||||
Reference in New Issue
Block a user