Complete terminal management workflow

This commit is contained in:
2026-10-21 23:25:31 +03:00
parent f718f739b4
commit 2ccd67d64c
19 changed files with 7313 additions and 53 deletions
+276 -2
View File
@@ -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(&current.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(
&current.settings,
&self.saved_bytes.settings,
),
added_clients: proposed_peers.difference(&current_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");