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");
|
||||
|
||||
@@ -7,5 +7,6 @@ pub mod persistence;
|
||||
pub mod runtime;
|
||||
pub mod service;
|
||||
pub mod startup;
|
||||
pub mod tui;
|
||||
pub mod vault;
|
||||
pub mod wireguard;
|
||||
|
||||
+19
-5
@@ -5,7 +5,7 @@ use wireguard_admin::startup::{self, StartupAction};
|
||||
|
||||
const USAGE: &str = "Usage: wireguard-admin [--help | --version]\n\
|
||||
With no arguments, starts the root-only management interface.\n\
|
||||
The management interface is not implemented yet.";
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let arguments: Vec<OsString> = env::args_os().skip(1).collect();
|
||||
@@ -22,13 +22,27 @@ fn main() -> ExitCode {
|
||||
println!("wireguard-admin {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Ok(StartupAction::ManagementUnavailable) => {
|
||||
eprintln!("The management interface is not implemented yet.");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
Ok(StartupAction::Management) => match run_management() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error}.\n{USAGE}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_management() -> Result<(), String> {
|
||||
let executable = env::current_exe().map_err(|_| "cannot locate executable".to_owned())?;
|
||||
let directory = executable
|
||||
.parent()
|
||||
.ok_or_else(|| "executable has no parent directory".to_owned())?;
|
||||
wireguard_admin::tui::terminal::check_suitable().map_err(|error| error.to_string())?;
|
||||
let app =
|
||||
startup::open_management_session(directory, || true).map_err(|error| error.to_string())?;
|
||||
wireguard_admin::tui::terminal::run(app).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
+138
-4
@@ -1,11 +1,20 @@
|
||||
use std::ffi::OsString;
|
||||
use std::fmt;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::app::Application;
|
||||
use crate::config::{ResolvedSettings, SettingsFile};
|
||||
use crate::persistence::SavedStore;
|
||||
|
||||
const MAX_SETTINGS_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum StartupAction {
|
||||
Help,
|
||||
Version,
|
||||
ManagementUnavailable,
|
||||
Management,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -25,6 +34,64 @@ impl fmt::Display for StartupError {
|
||||
|
||||
impl std::error::Error for StartupError {}
|
||||
|
||||
/// Fixed, secret-free reasons management session startup can fail.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SessionLoadError {
|
||||
RootRequired,
|
||||
SettingsUnavailable,
|
||||
SettingsInvalid,
|
||||
StoreInvalid,
|
||||
SavedStateInvalid,
|
||||
}
|
||||
|
||||
impl fmt::Display for SessionLoadError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::RootRequired => "management operations require root",
|
||||
Self::SettingsUnavailable => "executable-relative settings are unavailable",
|
||||
Self::SettingsInvalid => "executable-relative settings are invalid",
|
||||
Self::StoreInvalid => "saved-state paths are unsafe or inconsistent",
|
||||
Self::SavedStateInvalid => "saved state could not be loaded safely",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SessionLoadError {}
|
||||
|
||||
/// Load a management session only after checking privilege. Settings are read
|
||||
/// beside the executable, then SavedStore performs journal recovery and
|
||||
/// validates the complete saved state before Application is exposed.
|
||||
/// Terminal suitability is checked by the later terminal-startup boundary.
|
||||
pub fn open_management_session(
|
||||
executable_directory: &Path,
|
||||
effective_uid_is_root: impl FnOnce() -> bool,
|
||||
) -> Result<Application, SessionLoadError> {
|
||||
if !effective_uid_is_root() {
|
||||
return Err(SessionLoadError::RootRequired);
|
||||
}
|
||||
let settings_path = ResolvedSettings::settings_path(executable_directory);
|
||||
let metadata =
|
||||
fs::symlink_metadata(&settings_path).map_err(|_| SessionLoadError::SettingsUnavailable)?;
|
||||
if !metadata.file_type().is_file() || metadata.len() > MAX_SETTINGS_BYTES {
|
||||
return Err(SessionLoadError::SettingsUnavailable);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
||||
File::open(&settings_path)
|
||||
.and_then(|file| file.take(MAX_SETTINGS_BYTES + 1).read_to_end(&mut bytes))
|
||||
.map_err(|_| SessionLoadError::SettingsUnavailable)?;
|
||||
if bytes.len() as u64 > MAX_SETTINGS_BYTES {
|
||||
return Err(SessionLoadError::SettingsUnavailable);
|
||||
}
|
||||
let text = std::str::from_utf8(&bytes).map_err(|_| SessionLoadError::SettingsInvalid)?;
|
||||
let resolved = SettingsFile::parse_toml(text)
|
||||
.and_then(SettingsFile::validate)
|
||||
.map(|validated| validated.resolve_paths(executable_directory))
|
||||
.map_err(|_| SessionLoadError::SettingsInvalid)?;
|
||||
let store = SavedStore::new(executable_directory, resolved)
|
||||
.map_err(|_| SessionLoadError::StoreInvalid)?;
|
||||
Application::open_from_saved_store(store).map_err(|_| SessionLoadError::SavedStateInvalid)
|
||||
}
|
||||
|
||||
/// Resolves the supported command line and checks management privilege lazily.
|
||||
///
|
||||
/// The injected root check keeps this boundary testable without running tests as
|
||||
@@ -36,7 +103,7 @@ pub fn start(
|
||||
match arguments {
|
||||
[] => {
|
||||
if effective_uid_is_root() {
|
||||
Ok(StartupAction::ManagementUnavailable)
|
||||
Ok(StartupAction::Management)
|
||||
} else {
|
||||
Err(StartupError::RootRequired)
|
||||
}
|
||||
@@ -72,9 +139,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_management_startup_reports_unimplemented_without_side_effects() {
|
||||
fn root_management_startup_is_authorized_for_terminal_boundary() {
|
||||
let result = start(&[], || true);
|
||||
assert_eq!(result, Ok(StartupAction::ManagementUnavailable));
|
||||
assert_eq!(result, Ok(StartupAction::Management));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -89,4 +156,71 @@ mod tests {
|
||||
assert_eq!(result, Err(StartupError::UnknownArguments));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn management_session_checks_root_before_reading_settings() {
|
||||
let result = super::open_management_session(
|
||||
std::path::Path::new("/path/that/does/not/exist"),
|
||||
|| false,
|
||||
);
|
||||
assert_eq!(result.err(), Some(super::SessionLoadError::RootRequired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn management_session_loads_supported_state_and_keeps_unsupported_peers_visible() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"wireguard-admin-startup-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
std::fs::write(
|
||||
directory.join(crate::config::SETTINGS_FILE_NAME),
|
||||
r#"interface_name = "wg0"
|
||||
server_config_path = "wg0.conf"
|
||||
client_source_directory = "clients"
|
||||
server_endpoint = "vpn.example.org:51820"
|
||||
ipv4_allocation_range = "10.80.0.0/24"
|
||||
ipv6_allocation_range = "fd80::/64"
|
||||
dns_addresses = ["10.80.0.1"]
|
||||
systemd_unit = "wg-quick@wg0.service"
|
||||
restart_timeout_seconds = 10
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let server_path = directory.join("wg0.conf");
|
||||
std::fs::write(
|
||||
&server_path,
|
||||
b"[Interface]\nAddress = 10.80.0.1/24\n# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=Y2xlYW4\n# wg-admin:end\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.80.0.2/32, fd80::2/128\n[Peer]\nPublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\nAllowedIPs = 10.80.0.0/24\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&server_path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
|
||||
let app = super::open_management_session(&directory, || true)
|
||||
.expect("valid synthetic installation should open");
|
||||
let projection = app.display_projection();
|
||||
assert_eq!(projection.clients.len(), 2);
|
||||
assert_eq!(app.states().saved().clients().count(), 1);
|
||||
assert!(projection.clients[0].mutable_identity.is_some());
|
||||
assert!(projection.clients[1].mutable_identity.is_none());
|
||||
assert_eq!(projection.clients[1].allowed_ips, ["10.80.0.0/24"]);
|
||||
assert!(!format!("{projection:?}").contains("PrivateKey"));
|
||||
let internal_state = directory.join("Backup/data");
|
||||
std::fs::create_dir_all(&internal_state).unwrap();
|
||||
std::fs::write(
|
||||
internal_state.join("internal-state.toml"),
|
||||
b"not valid state",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
super::open_management_session(&directory, || true).err(),
|
||||
Some(super::SessionLoadError::SavedStateInvalid),
|
||||
"corrupt persisted state must stop session loading rather than start empty"
|
||||
);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
//! Native terminal workflow state and the serialized application worker.
|
||||
//!
|
||||
//! Terminal input/rendering adapters are added in the subsequent M7 tasks.
|
||||
|
||||
pub mod events;
|
||||
pub mod forms;
|
||||
pub mod render;
|
||||
pub mod state;
|
||||
pub mod terminal;
|
||||
|
||||
pub use events::{ApplicationWorker, UiCommand, WorkerConfig, WorkerEvent};
|
||||
pub use state::{SessionView, UiState};
|
||||
+1430
File diff suppressed because it is too large
Load Diff
+1065
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,690 @@
|
||||
//! Responsive, secret-free rendering for the client list and detail screens.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
|
||||
|
||||
use crate::app::ClientDisplay;
|
||||
use crate::domain::PublicKey;
|
||||
use crate::runtime::{InterfaceObservation, Observed, RuntimeObservation, RuntimePeer};
|
||||
|
||||
use super::forms::FormController;
|
||||
use super::state::{Screen, SessionView, UiState};
|
||||
|
||||
pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
|
||||
let area = frame.area();
|
||||
if area.width < 34 || area.height < 8 {
|
||||
let message = if forms.qr_output().is_some() {
|
||||
"Terminal too small for QR. Resize to at least 34 × 8, or use the saved client file; Esc clears the QR."
|
||||
} else {
|
||||
"Terminal too small. Resize to at least 34 × 8."
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(message)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("WireGuard Admin"),
|
||||
)
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.split(area);
|
||||
let status = state
|
||||
.view
|
||||
.as_ref()
|
||||
.map_or("Loading saved state".to_owned(), state_summary);
|
||||
frame.render_widget(
|
||||
Paragraph::new(status)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("WireGuard Admin"),
|
||||
)
|
||||
.wrap(Wrap { trim: true }),
|
||||
rows[0],
|
||||
);
|
||||
|
||||
if forms.is_active() {
|
||||
if let Some(qr) = forms.qr_output() {
|
||||
render_qr_disclosure(frame, rows[1], qr, &forms.display_lines());
|
||||
} else {
|
||||
let lines = if forms.is_draft_review() {
|
||||
draft_review_lines(state)
|
||||
} else {
|
||||
forms.display_lines()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Text::from(
|
||||
lines.into_iter().map(Line::from).collect::<Vec<_>>(),
|
||||
))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Management form"),
|
||||
)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
|
||||
rows[1],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
match state.screen {
|
||||
Screen::Help => render_help(frame, rows[1]),
|
||||
Screen::ClientDetails => render_details(frame, rows[1], state),
|
||||
Screen::Progress => {
|
||||
let text = state
|
||||
.status_message
|
||||
.as_deref()
|
||||
.unwrap_or("Operation in progress…");
|
||||
frame.render_widget(
|
||||
Paragraph::new(safe_text(text))
|
||||
.block(Block::default().borders(Borders::ALL).title("Progress"))
|
||||
.wrap(Wrap { trim: false }),
|
||||
rows[1],
|
||||
);
|
||||
}
|
||||
Screen::Message => {
|
||||
let text = state.status_message.as_deref().unwrap_or("Ready");
|
||||
frame.render_widget(
|
||||
Paragraph::new(safe_text(text))
|
||||
.block(Block::default().borders(Borders::ALL).title("Status"))
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
|
||||
rows[1],
|
||||
);
|
||||
}
|
||||
Screen::ClientList => render_list(frame, rows[1], state),
|
||||
}
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new("n new r rename d delete p preview c Commit R restart o first file O reissue b backups B backup s setup i import x discard q exit")
|
||||
.block(Block::default().borders(Borders::ALL)),
|
||||
rows[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn render_qr_disclosure(
|
||||
frame: &mut Frame<'_>,
|
||||
area: ratatui::layout::Rect,
|
||||
qr: &crate::delivery::QrCodeOutput,
|
||||
lines: &[String],
|
||||
) {
|
||||
let inner_width = area.width.saturating_sub(2) as usize;
|
||||
let header_rows = lines.len();
|
||||
let available_height = (area.height as usize).saturating_sub(2 + header_rows);
|
||||
let side = qr.side();
|
||||
let mut text = lines.iter().map(|line| safe_text(line)).collect::<Vec<_>>();
|
||||
if !qr_fits(side, inner_width, available_height) {
|
||||
text.push("QR is too large for this terminal. Resize to fit the complete code or use the saved client file.".to_owned());
|
||||
} else {
|
||||
for y in 0..side {
|
||||
let mut row = String::with_capacity(side * 2);
|
||||
for x in 0..side {
|
||||
row.push_str(if qr.module(x, y).unwrap_or(false) {
|
||||
"██"
|
||||
} else {
|
||||
" "
|
||||
});
|
||||
}
|
||||
text.push(row);
|
||||
}
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(Text::from(
|
||||
text.into_iter().map(Line::from).collect::<Vec<_>>(),
|
||||
))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Secret disclosure · Esc clears"),
|
||||
)
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn qr_fits(side: usize, inner_width: usize, available_height: usize) -> bool {
|
||||
side.saturating_mul(2) <= inner_width && side <= available_height
|
||||
}
|
||||
|
||||
fn draft_review_lines(state: &UiState) -> Vec<String> {
|
||||
let Some(view) = state.view.as_ref() else {
|
||||
return vec!["Draft state is still loading.".to_owned()];
|
||||
};
|
||||
let mut lines = vec![
|
||||
"Semantic draft preview".to_owned(),
|
||||
format!(
|
||||
"Pending draft: {}",
|
||||
if view.has_draft { "yes" } else { "no" }
|
||||
),
|
||||
];
|
||||
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 {
|
||||
lines.push(
|
||||
"A vault or managed metadata draft is pending; no client state differs.".to_owned(),
|
||||
);
|
||||
} else if lines.len() == 2 {
|
||||
lines.push("No semantic client changes are pending.".to_owned());
|
||||
}
|
||||
lines.push("No raw server configuration is displayed.".to_owned());
|
||||
lines
|
||||
}
|
||||
|
||||
fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiState) {
|
||||
let Some(view) = state.view.as_ref() else {
|
||||
frame.render_widget(Paragraph::new("Loading client projection…"), area);
|
||||
return;
|
||||
};
|
||||
if view.clients.is_empty() {
|
||||
let text = if view.document_issues.is_empty() {
|
||||
"No peers are configured."
|
||||
} else {
|
||||
"No peer rows. Document issues are listed below."
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("Clients")),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mut items: Vec<ListItem<'_>> = view
|
||||
.clients
|
||||
.iter()
|
||||
.map(|client| {
|
||||
let identity = client
|
||||
.identity
|
||||
.map(|key| short_key(&key))
|
||||
.unwrap_or_else(|| format!("peer at line {}", client.line));
|
||||
let supported = if client.mutable_identity.is_some() {
|
||||
"supported"
|
||||
} else {
|
||||
"inspection only"
|
||||
};
|
||||
let addresses = safe_text(&client.allowed_ips.join(", "));
|
||||
let row_state = client
|
||||
.identity
|
||||
.and_then(|identity| view.client_states.get(&identity))
|
||||
.map_or_else(
|
||||
|| "state: unavailable".to_owned(),
|
||||
|row| match (&row.saved, &row.draft) {
|
||||
(None, Some(_)) => "Draft added".to_owned(),
|
||||
(Some(saved), Some(draft)) if saved != draft => "Draft changed".to_owned(),
|
||||
(Some(_), None) => "Draft removed".to_owned(),
|
||||
(Some(_), Some(_)) => "Saved".to_owned(),
|
||||
(None, None) => "State unavailable".to_owned(),
|
||||
},
|
||||
);
|
||||
let anomaly_marker = if client.issues.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
" ! anomaly".to_owned()
|
||||
};
|
||||
let anomaly_details = if client.issues.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", safe_text(&format!("{:?}", client.issues)))
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::raw(format!("{row_state}{anomaly_marker} ")),
|
||||
Span::styled(safe_text(&client.label), Style::default().fg(Color::Cyan)),
|
||||
Span::raw(format!(
|
||||
" [{identity}] {supported} {addresses}{anomaly_details}"
|
||||
)),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
items.extend(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;
|
||||
}
|
||||
let ipv6 = saved
|
||||
.addresses
|
||||
.ipv6
|
||||
.map_or_else(|| "none".to_owned(), |address| address.to_string());
|
||||
Some(ListItem::new(format!(
|
||||
"Draft removed {} [{}] saved addresses: {}, {}",
|
||||
safe_text(&saved.alias),
|
||||
short_key(identity),
|
||||
saved.addresses.ipv4,
|
||||
ipv6
|
||||
)))
|
||||
}));
|
||||
items.extend(view.document_issues.iter().map(|issue| {
|
||||
ListItem::new(format!(
|
||||
"Document issue: {}",
|
||||
safe_text(&format!("{issue:?}"))
|
||||
))
|
||||
.style(Style::default().fg(Color::Yellow))
|
||||
}));
|
||||
let selected = state
|
||||
.selected_peer_index
|
||||
.and_then(|index| view.clients.iter().position(|row| row.peer_index == index))
|
||||
.or_else(|| {
|
||||
state.selected_identity.and_then(|identity| {
|
||||
view.clients
|
||||
.iter()
|
||||
.position(|row| row.identity == Some(identity))
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Clients and document issues"),
|
||||
)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
let mut list_state = ListState::default().with_selected(Some(selected));
|
||||
frame.render_stateful_widget(list, area, &mut list_state);
|
||||
}
|
||||
|
||||
fn render_details(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiState) {
|
||||
let Some(view) = state.view.as_ref() else {
|
||||
frame.render_widget(Paragraph::new("Loading client projection…"), area);
|
||||
return;
|
||||
};
|
||||
let selected = state
|
||||
.selected_peer_index
|
||||
.and_then(|index| view.clients.iter().find(|row| row.peer_index == index))
|
||||
.or_else(|| {
|
||||
state.selected_identity.and_then(|identity| {
|
||||
view.clients
|
||||
.iter()
|
||||
.find(|row| row.identity == Some(identity))
|
||||
})
|
||||
});
|
||||
let Some(client) = selected else {
|
||||
frame.render_widget(
|
||||
Paragraph::new("Select a peer from the client list.").block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Client details"),
|
||||
),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
};
|
||||
let lines = detail_lines(client, view);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Text::from(lines))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Client details"),
|
||||
)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>> {
|
||||
let mut lines = vec![
|
||||
Line::from(format!("Label: {}", safe_text(&client.label))),
|
||||
Line::from(format!(
|
||||
"Public key: {}",
|
||||
client
|
||||
.identity
|
||||
.map_or_else(|| "unavailable".to_owned(), |key| key.to_string())
|
||||
)),
|
||||
Line::from(format!(
|
||||
"Allowed IPs: {}",
|
||||
safe_text(&client.allowed_ips.join(", "))
|
||||
)),
|
||||
Line::from(format!(
|
||||
"Mutation: {}",
|
||||
if client.mutable_identity.is_some() {
|
||||
"supported"
|
||||
} else {
|
||||
"disabled: ambiguous or unsupported peer"
|
||||
}
|
||||
)),
|
||||
];
|
||||
if let Some(identity) = client.identity {
|
||||
if let Some(row) = view.client_states.get(&identity) {
|
||||
lines.push(Line::from(format!(
|
||||
"Draft: {}",
|
||||
safe_text(row.draft.as_ref().map_or("absent", |c| c.alias.as_str()))
|
||||
)));
|
||||
lines.push(Line::from(format!(
|
||||
"Saved: {}",
|
||||
safe_text(row.saved.as_ref().map_or("absent", |c| c.alias.as_str()))
|
||||
)));
|
||||
if row.draft != row.saved {
|
||||
lines.push(Line::from("Draft differs from saved state."));
|
||||
if let Some(client) = &row.draft {
|
||||
lines.push(Line::from(format!(
|
||||
"Draft addresses: IPv4 {}, IPv6 {}",
|
||||
client.addresses.ipv4,
|
||||
client
|
||||
.addresses
|
||||
.ipv6
|
||||
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
|
||||
)));
|
||||
}
|
||||
if let Some(client) = &row.saved {
|
||||
lines.push(Line::from(format!(
|
||||
"Saved addresses: IPv4 {}, IPv6 {}",
|
||||
client.addresses.ipv4,
|
||||
client
|
||||
.addresses
|
||||
.ipv6
|
||||
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(peer) = runtime_peer(&view.statistics, &identity) {
|
||||
lines.extend(statistics_lines(peer));
|
||||
} else {
|
||||
lines.extend([
|
||||
Line::from("Last handshake: unavailable"),
|
||||
Line::from("Current received: unavailable"),
|
||||
Line::from("Current transmitted: unavailable"),
|
||||
]);
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(format!("Applied state: {:?}", view.applied)));
|
||||
for issue in &client.issues {
|
||||
lines.push(Line::from(format!(
|
||||
"Peer issue: {}",
|
||||
safe_text(&format!("{issue:?}"))
|
||||
)));
|
||||
}
|
||||
for issue in &view.document_issues {
|
||||
lines.push(Line::from(format!(
|
||||
"Document issue: {}",
|
||||
safe_text(&format!("{issue:?}"))
|
||||
)));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn runtime_peer<'a>(
|
||||
observation: &'a RuntimeObservation,
|
||||
key: &PublicKey,
|
||||
) -> Option<&'a RuntimePeer> {
|
||||
match &observation.interface {
|
||||
InterfaceObservation::Present { peers, .. } => peers.peers.get(key),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn statistics_lines(peer: &RuntimePeer) -> [Line<'static>; 3] {
|
||||
let handshake = match &peer.last_handshake {
|
||||
Observed::Unavailable => "unavailable".to_owned(),
|
||||
Observed::Available(None) => "never".to_owned(),
|
||||
Observed::Available(Some(time)) => time
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| "available (time formatting failed)".to_owned()),
|
||||
};
|
||||
[
|
||||
Line::from(format!("Last handshake: {handshake}")),
|
||||
Line::from(format!(
|
||||
"Current received: {}",
|
||||
counter(&peer.received_bytes)
|
||||
)),
|
||||
Line::from(format!(
|
||||
"Current transmitted: {}",
|
||||
counter(&peer.transmitted_bytes)
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
fn counter(value: &Observed<u64>) -> String {
|
||||
match value {
|
||||
Observed::Available(bytes) => format!("{bytes} bytes"),
|
||||
Observed::Unavailable => "unavailable".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn state_summary(view: &SessionView) -> String {
|
||||
format!(
|
||||
"Draft / saved / applied: {} pending client changes / {} saved clients / {:?} Issues: {}",
|
||||
view.client_states
|
||||
.values()
|
||||
.filter(|row| row.draft != row.saved)
|
||||
.count(),
|
||||
view.client_states
|
||||
.values()
|
||||
.filter(|row| row.saved.is_some())
|
||||
.count(),
|
||||
view.applied,
|
||||
view.document_issues.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn render_help(frame: &mut Frame<'_>, area: ratatui::layout::Rect) {
|
||||
frame.render_widget(
|
||||
Paragraph::new("↑/↓ selects a peer. Enter opens details. ! opens document and peer issues. Esc cancels a form or returns to the list. ? opens help. q requests exit and warns about a draft.\n\nn new client, r rename, d delete, p preview draft, c Commit, x discard draft, R preview explicit restart, o first file delivery, O password reissue, b list backups/restore, B create manual backup, s initial vault setup, i import one selected source file.\n\nPeer rows with malformed, duplicate, or unsupported configuration remain visible for inspection. Mutation requires an unambiguous supported full public key.\n\nCommit and restore only change saved files after backup. Restart is explicit, previewed, backed up, and verified. Statistics are read-only observations; a refresh never marks changes applied.")
|
||||
.block(Block::default().borders(Borders::ALL).title("Help"))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn short_key(key: &PublicKey) -> String {
|
||||
let value = key.to_string();
|
||||
format!("{}…{}", &value[..8], &value[value.len() - 6..])
|
||||
}
|
||||
|
||||
/// Render control characters visibly so untrusted labels cannot move the cursor
|
||||
/// or forge terminal output.
|
||||
pub fn safe_text(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.flat_map(|character| {
|
||||
if character.is_control() {
|
||||
character
|
||||
.escape_default()
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
vec![character]
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
#[test]
|
||||
fn terminal_controls_are_escaped_for_display() {
|
||||
assert_eq!(
|
||||
safe_text("client\u{1b}[31m\nname"),
|
||||
"client\\u{1b}[31m\\nname"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_never_and_unavailable_have_distinct_text() {
|
||||
assert_eq!(counter(&Observed::Available(0)), "0 bytes");
|
||||
assert_eq!(counter(&Observed::Unavailable), "unavailable");
|
||||
let now = time::OffsetDateTime::UNIX_EPOCH;
|
||||
assert!(
|
||||
statistics_lines(&RuntimePeer {
|
||||
allowed_ips: Observed::Unavailable,
|
||||
last_handshake: Observed::Available(None),
|
||||
received_bytes: Observed::Available(0),
|
||||
transmitted_bytes: Observed::Unavailable,
|
||||
})[0]
|
||||
.to_string()
|
||||
.contains("never")
|
||||
);
|
||||
assert!(now.unix_timestamp() == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qr_fit_requires_the_complete_quiet_zone_in_both_dimensions() {
|
||||
assert!(qr_fits(25, 50, 25));
|
||||
assert!(!qr_fits(25, 49, 25));
|
||||
assert!(!qr_fits(25, 50, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_rows_show_draft_state_and_escape_anomalous_unsupported_peers() {
|
||||
let identity = PublicKey::from_bytes([7; 32]);
|
||||
let saved = crate::domain::Client::new(
|
||||
identity,
|
||||
"old alias",
|
||||
crate::domain::ClientAddresses {
|
||||
ipv4: Ipv4Addr::new(10, 20, 0, 2),
|
||||
ipv6: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let draft =
|
||||
crate::domain::Client::new(identity, "new alias", saved.addresses.clone()).unwrap();
|
||||
let unsupported_key = PublicKey::from_bytes([8; 32]);
|
||||
let removed_key = PublicKey::from_bytes([9; 32]);
|
||||
let removed =
|
||||
crate::domain::Client::new(removed_key, "removed alias", saved.addresses.clone())
|
||||
.unwrap();
|
||||
let view = SessionView {
|
||||
generation: 1,
|
||||
clients: vec![
|
||||
ClientDisplay {
|
||||
peer_index: 0,
|
||||
line: 1,
|
||||
identity: Some(identity),
|
||||
label: "new alias".to_owned(),
|
||||
allowed_ips: vec!["10.20.0.2/32".to_owned()],
|
||||
issues: vec![],
|
||||
mutable_identity: Some(identity),
|
||||
},
|
||||
ClientDisplay {
|
||||
peer_index: 1,
|
||||
line: 5,
|
||||
identity: Some(unsupported_key),
|
||||
label: "odd\u{1b}[31m peer".to_owned(),
|
||||
allowed_ips: vec!["bad\nroute".to_owned()],
|
||||
issues: vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape {
|
||||
field: 0,
|
||||
item: 1,
|
||||
}],
|
||||
mutable_identity: None,
|
||||
},
|
||||
],
|
||||
client_states: BTreeMap::from([
|
||||
(
|
||||
identity,
|
||||
super::super::state::ClientStateRow {
|
||||
saved: Some(saved),
|
||||
draft: Some(draft),
|
||||
},
|
||||
),
|
||||
(
|
||||
removed_key,
|
||||
super::super::state::ClientStateRow {
|
||||
saved: Some(removed),
|
||||
draft: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
document_issues: vec![],
|
||||
applied: super::super::state::AppliedState::Unknown,
|
||||
reserved_addresses: Default::default(),
|
||||
statistics: RuntimeObservation::unknown(),
|
||||
has_draft: true,
|
||||
vault_initialized: true,
|
||||
};
|
||||
let state = UiState {
|
||||
view: Some(view),
|
||||
..UiState::default()
|
||||
};
|
||||
let mut terminal = Terminal::new(TestBackend::new(220, 12)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| render_list(frame, frame.area(), &state))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
let rendered = (0..buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(rendered.contains("Draft changed"));
|
||||
assert!(rendered.contains("inspection only"));
|
||||
assert!(rendered.contains("removed alias"));
|
||||
assert!(rendered.contains("Draft removed"));
|
||||
assert!(rendered.contains("UnsupportedAllowedIpShape"));
|
||||
assert!(rendered.contains("odd\\u{1b}[31m peer"));
|
||||
assert!(rendered.contains("bad\\nroute"));
|
||||
|
||||
let mut narrow = Terminal::new(TestBackend::new(34, 12)).unwrap();
|
||||
narrow
|
||||
.draw(|frame| render_list(frame, frame.area(), &state))
|
||||
.unwrap();
|
||||
let narrow_buffer = narrow.backend().buffer();
|
||||
let narrow_rendered = (0..narrow_buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..narrow_buffer.area.width)
|
||||
.map(|x| narrow_buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(narrow_rendered.contains("Draft changed"));
|
||||
assert!(narrow_rendered.contains("! anomaly"));
|
||||
assert!(narrow_rendered.contains("Draft removed"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
//! Secret-free navigation and session projections for the terminal interface.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::app::{Application, ClientDisplay};
|
||||
use crate::domain::{Client, PublicKey, RuntimeObservation};
|
||||
use crate::runtime::RuntimeObservation as Statistics;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Screen {
|
||||
ClientList,
|
||||
ClientDetails,
|
||||
Help,
|
||||
Progress,
|
||||
Message,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Focus {
|
||||
ClientList,
|
||||
Details,
|
||||
Dialog,
|
||||
Help,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum DialogKind {
|
||||
Rename,
|
||||
Delete,
|
||||
Create,
|
||||
Commit,
|
||||
Restart,
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum AppliedState {
|
||||
Unknown,
|
||||
Unavailable,
|
||||
Verified,
|
||||
}
|
||||
|
||||
/// Public client values for both states. No configuration bytes or secrets are
|
||||
/// retained in a view snapshot.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ClientStateRow {
|
||||
pub saved: Option<Client>,
|
||||
pub draft: Option<Client>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SessionView {
|
||||
pub generation: u64,
|
||||
pub clients: Vec<ClientDisplay>,
|
||||
pub client_states: std::collections::BTreeMap<PublicKey, ClientStateRow>,
|
||||
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
|
||||
pub applied: AppliedState,
|
||||
pub reserved_addresses: BTreeSet<IpAddr>,
|
||||
pub statistics: Statistics,
|
||||
pub has_draft: bool,
|
||||
pub vault_initialized: bool,
|
||||
}
|
||||
|
||||
impl SessionView {
|
||||
pub fn from_application(app: &Application, generation: u64) -> Self {
|
||||
let states = app.states();
|
||||
let mut identities = BTreeSet::new();
|
||||
identities.extend(states.saved().clients().map(|client| client.identity));
|
||||
identities.extend(states.draft().clients().map(|client| client.identity));
|
||||
let client_states = identities
|
||||
.into_iter()
|
||||
.map(|identity| {
|
||||
(
|
||||
identity,
|
||||
ClientStateRow {
|
||||
saved: states.saved().get(&identity).cloned(),
|
||||
draft: states.draft().get(&identity).cloned(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let applied = match states.applied() {
|
||||
RuntimeObservation::Unknown => AppliedState::Unknown,
|
||||
RuntimeObservation::Unavailable => AppliedState::Unavailable,
|
||||
RuntimeObservation::Verified => AppliedState::Verified,
|
||||
};
|
||||
let projection = app.display_projection();
|
||||
Self {
|
||||
generation,
|
||||
clients: projection.clients,
|
||||
client_states,
|
||||
document_issues: projection.document_issues,
|
||||
applied,
|
||||
reserved_addresses: states.reserved_addresses().clone(),
|
||||
statistics: Statistics::unknown(),
|
||||
has_draft: app.has_draft_changes(),
|
||||
vault_initialized: app.vault_initialized(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains_identity(&self, identity: &PublicKey) -> bool {
|
||||
self.clients
|
||||
.iter()
|
||||
.any(|client| client.identity == Some(*identity))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum OperationKind {
|
||||
CreateClient,
|
||||
RenameClient,
|
||||
DeleteClient,
|
||||
DiscardDraft,
|
||||
Commit,
|
||||
CreateBackup,
|
||||
ListBackups,
|
||||
ImportSelectedFile,
|
||||
InitialSetup,
|
||||
PreviewRestart,
|
||||
Restart,
|
||||
DeliverClient,
|
||||
Restore,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct PendingOperation {
|
||||
pub request_id: u64,
|
||||
pub kind: OperationKind,
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// Mutable UI navigation and latest accepted worker projection.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct UiState {
|
||||
pub screen: Screen,
|
||||
pub focus: Focus,
|
||||
pub dialog: Option<DialogKind>,
|
||||
pub selected_identity: Option<PublicKey>,
|
||||
/// Authoritative document index used only to inspect malformed peers that
|
||||
/// have no public key. Mutating actions must always use selected_identity.
|
||||
pub selected_peer_index: Option<usize>,
|
||||
pub scroll_offset: usize,
|
||||
pub generation: u64,
|
||||
pub view: Option<SessionView>,
|
||||
pub pending: Option<PendingOperation>,
|
||||
pub status_message: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
screen: Screen::ClientList,
|
||||
focus: Focus::ClientList,
|
||||
dialog: None,
|
||||
selected_identity: None,
|
||||
selected_peer_index: None,
|
||||
scroll_offset: 0,
|
||||
generation: 0,
|
||||
view: None,
|
||||
pending: None,
|
||||
status_message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
/// Replace the view only when it belongs to the current generation. The
|
||||
/// selected full public key remains stable through sorting and refreshes.
|
||||
pub fn apply_view(&mut self, view: SessionView) -> bool {
|
||||
if view.generation != self.generation {
|
||||
return false;
|
||||
}
|
||||
if self
|
||||
.selected_identity
|
||||
.is_some_and(|identity| !view.contains_identity(&identity))
|
||||
{
|
||||
self.selected_identity = None;
|
||||
}
|
||||
if let Some(identity) = self.selected_identity {
|
||||
self.selected_peer_index = view
|
||||
.clients
|
||||
.iter()
|
||||
.find(|client| client.identity == Some(identity))
|
||||
.map(|client| client.peer_index);
|
||||
} else if self
|
||||
.selected_peer_index
|
||||
.is_some_and(|index| !view.clients.iter().any(|client| client.peer_index == index))
|
||||
{
|
||||
self.selected_peer_index = None;
|
||||
}
|
||||
self.view = Some(view);
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply only statistics sampled from the still-current saved/draft view.
|
||||
/// This never changes the applied-state marker.
|
||||
pub fn apply_statistics(&mut self, generation: u64, statistics: Statistics) -> bool {
|
||||
if generation != self.generation {
|
||||
return false;
|
||||
}
|
||||
let Some(view) = self.view.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if view.generation != generation {
|
||||
return false;
|
||||
}
|
||||
view.statistics = statistics;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn begin_operation(&mut self, operation: PendingOperation) -> bool {
|
||||
if self.pending.is_some() || operation.generation < self.generation {
|
||||
return false;
|
||||
}
|
||||
self.generation = operation.generation;
|
||||
self.pending = Some(operation);
|
||||
self.screen = Screen::Progress;
|
||||
true
|
||||
}
|
||||
|
||||
/// Close a form/dialog without cancelling work already submitted to the
|
||||
/// application worker. The pending progress state remains visible.
|
||||
pub fn cancel_dialog(&mut self) {
|
||||
self.dialog = None;
|
||||
if self.pending.is_some() {
|
||||
self.screen = Screen::Progress;
|
||||
} else {
|
||||
self.screen = if self.selected_identity.is_some() {
|
||||
Screen::ClientDetails
|
||||
} else {
|
||||
Screen::ClientList
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish_operation(&mut self, operation: PendingOperation) -> bool {
|
||||
if self.pending != Some(operation) {
|
||||
return false;
|
||||
}
|
||||
self.pending = None;
|
||||
self.screen = if self.dialog.is_some() {
|
||||
Screen::Message
|
||||
} else if self.selected_identity.is_some() {
|
||||
Screen::ClientDetails
|
||||
} else {
|
||||
Screen::ClientList
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
pub fn select(&mut self, identity: PublicKey) -> bool {
|
||||
if !self
|
||||
.view
|
||||
.as_ref()
|
||||
.is_some_and(|view| view.contains_identity(&identity))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.selected_identity = Some(identity);
|
||||
self.selected_peer_index = self.view.as_ref().and_then(|view| {
|
||||
view.clients
|
||||
.iter()
|
||||
.find(|client| client.identity == Some(identity))
|
||||
.map(|client| client.peer_index)
|
||||
});
|
||||
self.screen = Screen::ClientDetails;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn select_peer_index(&mut self, peer_index: usize) -> bool {
|
||||
let Some(client) = self.view.as_ref().and_then(|view| {
|
||||
view.clients
|
||||
.iter()
|
||||
.find(|client| client.peer_index == peer_index)
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
self.selected_peer_index = Some(peer_index);
|
||||
self.selected_identity = client.identity;
|
||||
self.screen = Screen::ClientDetails;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn selected_client_mutable(&self) -> bool {
|
||||
let Some(identity) = self.selected_identity else {
|
||||
return false;
|
||||
};
|
||||
self.view.as_ref().is_some_and(|view| {
|
||||
view.clients.iter().any(|client| {
|
||||
client.identity == Some(identity) && client.mutable_identity == Some(identity)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::PublicKey;
|
||||
use crate::wireguard::PeerIssue;
|
||||
|
||||
fn row(identity: PublicKey, label: &str, mutable: bool) -> ClientDisplay {
|
||||
ClientDisplay {
|
||||
peer_index: 0,
|
||||
line: 1,
|
||||
identity: Some(identity),
|
||||
label: label.to_owned(),
|
||||
allowed_ips: vec!["10.0.0.2/32".to_owned()],
|
||||
issues: if mutable {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![PeerIssue::MissingIpv4HostRoute]
|
||||
},
|
||||
mutable_identity: mutable.then_some(identity),
|
||||
}
|
||||
}
|
||||
|
||||
fn view(generation: u64, clients: Vec<ClientDisplay>) -> SessionView {
|
||||
SessionView {
|
||||
generation,
|
||||
clients,
|
||||
client_states: Default::default(),
|
||||
document_issues: Vec::new(),
|
||||
applied: AppliedState::Unknown,
|
||||
reserved_addresses: BTreeSet::new(),
|
||||
statistics: Statistics::unknown(),
|
||||
has_draft: false,
|
||||
vault_initialized: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_preserves_selection_by_public_key_not_alias_or_row_position() {
|
||||
let a = PublicKey::from_bytes([1; 32]);
|
||||
let b = PublicKey::from_bytes([2; 32]);
|
||||
let mut state = UiState::default();
|
||||
assert!(state.apply_view(view(
|
||||
0,
|
||||
vec![row(a, "same alias", true), row(b, "same alias", true)]
|
||||
)));
|
||||
assert!(state.select(b));
|
||||
assert!(state.apply_view(view(
|
||||
0,
|
||||
vec![row(b, "renamed", true), row(a, "same alias", true)]
|
||||
)));
|
||||
assert_eq!(state.selected_identity, Some(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_view_is_rejected_and_unsupported_selected_peer_is_not_mutable() {
|
||||
let key = PublicKey::from_bytes([3; 32]);
|
||||
let mut state = UiState::default();
|
||||
assert!(state.apply_view(view(0, vec![row(key, "legacy", false)])));
|
||||
assert!(state.select(key));
|
||||
assert!(!state.selected_client_mutable());
|
||||
state.generation = 1;
|
||||
assert!(!state.apply_view(view(0, vec![row(key, "stale", true)])));
|
||||
assert_eq!(state.selected_identity, Some(key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_peer_can_be_inspected_by_document_location_without_mutation_identity() {
|
||||
let mut malformed = row(PublicKey::from_bytes([8; 32]), "malformed", false);
|
||||
malformed.identity = None;
|
||||
malformed.mutable_identity = None;
|
||||
malformed.peer_index = 3;
|
||||
let mut state = UiState::default();
|
||||
assert!(state.apply_view(view(0, vec![malformed])));
|
||||
assert!(state.select_peer_index(3));
|
||||
assert_eq!(state.selected_identity, None);
|
||||
assert_eq!(state.selected_peer_index, Some(3));
|
||||
assert!(!state.selected_client_mutable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() {
|
||||
let mut state = UiState {
|
||||
dialog: Some(DialogKind::Commit),
|
||||
..UiState::default()
|
||||
};
|
||||
let operation = PendingOperation {
|
||||
request_id: 4,
|
||||
kind: OperationKind::Commit,
|
||||
generation: 1,
|
||||
};
|
||||
assert!(state.begin_operation(operation));
|
||||
state.cancel_dialog();
|
||||
assert_eq!(state.pending, Some(operation));
|
||||
assert_eq!(state.screen, Screen::Progress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statistics_refresh_is_generation_bound_and_never_changes_applied_state() {
|
||||
let key = PublicKey::from_bytes([4; 32]);
|
||||
let mut state = UiState::default();
|
||||
assert!(state.apply_view(view(0, vec![row(key, "client", true)])));
|
||||
assert!(state.apply_statistics(0, Statistics::unknown()));
|
||||
assert_eq!(state.view.as_ref().unwrap().applied, AppliedState::Unknown);
|
||||
state.generation = 1;
|
||||
assert!(!state.apply_statistics(0, Statistics::unknown()));
|
||||
}
|
||||
}
|
||||
+1077
File diff suppressed because it is too large
Load Diff
+194
-2
@@ -1,6 +1,6 @@
|
||||
//! Bounded, read-only access to configured client source files.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Take};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -12,7 +12,7 @@ use thiserror::Error;
|
||||
use crate::config::ResolvedSettings;
|
||||
use crate::wireguard::ConfigDocument;
|
||||
|
||||
use super::import::{ImportReport, reconcile, reconcile_zeroizing};
|
||||
use super::import::{CandidateStatus, ImportReport, reconcile, reconcile_zeroizing};
|
||||
use super::{VaultBundle, VaultError};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
@@ -68,12 +68,97 @@ pub struct PreparedImportSetup {
|
||||
pub report: SourceImportReport,
|
||||
}
|
||||
|
||||
/// Secret-free result from importing one explicitly selected source entry.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SelectedFileImportReport {
|
||||
pub scan_complete: bool,
|
||||
pub issues: Vec<SourceIssue>,
|
||||
pub candidates: Vec<(String, CandidateStatus)>,
|
||||
pub server_only: Vec<crate::domain::PublicKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Eq, PartialEq)]
|
||||
pub enum SourceImportError {
|
||||
#[error("vault setup failed")]
|
||||
Vault(#[from] VaultError),
|
||||
#[error("source directory is invalid")]
|
||||
InvalidDirectory,
|
||||
#[error("selected source entry is invalid")]
|
||||
InvalidSelection,
|
||||
}
|
||||
|
||||
/// Read one explicitly selected immediate `*.conf` entry from the configured
|
||||
/// source directory, reconcile it against the server document, and encrypt it
|
||||
/// into the caller's draft bundle. The original entry is never modified.
|
||||
pub fn import_selected_file(
|
||||
bundle: &mut VaultBundle,
|
||||
settings: &ResolvedSettings,
|
||||
server: &ConfigDocument,
|
||||
selected_name: &OsStr,
|
||||
) -> Result<SelectedFileImportReport, SourceImportError> {
|
||||
if !is_candidate_name(selected_name)
|
||||
|| Path::new(selected_name).components().count() != 1
|
||||
|| selected_name == OsStr::new(".")
|
||||
|| selected_name == OsStr::new("..")
|
||||
{
|
||||
return Err(SourceImportError::InvalidSelection);
|
||||
}
|
||||
let display_name = safe_file_name(selected_name);
|
||||
let empty_report = || reconcile(server, []);
|
||||
let directory = match open_source_directory(&settings.client_source_directory) {
|
||||
Ok(directory) => directory,
|
||||
Err(_) => {
|
||||
return Ok(selected_failure_report(
|
||||
display_name,
|
||||
SourceFailureKind::DirectoryUnavailable,
|
||||
empty_report(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let bytes = match read_candidate(&directory, selected_name, 0, MAX_SCAN_BYTES) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(kind) => return Ok(selected_failure_report(display_name, kind, empty_report())),
|
||||
};
|
||||
let reconciliation = reconcile_zeroizing(server, [(display_name, bytes)]);
|
||||
let summaries = reconciliation.candidate_summaries();
|
||||
let mut issues = Vec::new();
|
||||
if let Some((name, status)) = summaries.first()
|
||||
&& !matches!(status, CandidateStatus::Invalid(_))
|
||||
&& let Err(error) = reconciliation.encrypt_candidate(bundle, 0)
|
||||
{
|
||||
issues.push(SourceIssue {
|
||||
file_name: Some((*name).to_owned()),
|
||||
kind: match error {
|
||||
VaultError::DuplicateRecord => SourceFailureKind::DuplicateRecordEqualityUnverified,
|
||||
_ => SourceFailureKind::EncryptionFailed,
|
||||
},
|
||||
});
|
||||
}
|
||||
Ok(SelectedFileImportReport {
|
||||
scan_complete: true,
|
||||
issues,
|
||||
candidates: summaries
|
||||
.into_iter()
|
||||
.map(|(name, status)| (name.to_owned(), status))
|
||||
.collect(),
|
||||
server_only: reconciliation.server_only().to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_failure_report(
|
||||
display_name: String,
|
||||
kind: SourceFailureKind,
|
||||
reconciliation: ImportReport,
|
||||
) -> SelectedFileImportReport {
|
||||
SelectedFileImportReport {
|
||||
scan_complete: false,
|
||||
issues: vec![SourceIssue {
|
||||
file_name: Some(display_name),
|
||||
kind,
|
||||
}],
|
||||
candidates: Vec::new(),
|
||||
server_only: reconciliation.server_only().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new password-protected vault and encrypt valid candidates from the
|
||||
@@ -372,6 +457,113 @@ mod tests {
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
|
||||
fn resolved_settings(source: &Path) -> ResolvedSettings {
|
||||
crate::config::SettingsFile::parse_toml(
|
||||
r#"interface_name = "wg0"
|
||||
server_config_path = "wg0.conf"
|
||||
client_source_directory = "clients"
|
||||
server_endpoint = "vpn.example.org:51820"
|
||||
ipv4_allocation_range = "10.8.0.0/24"
|
||||
ipv6_allocation_range = "fd80::/64"
|
||||
dns_addresses = ["10.8.0.1"]
|
||||
systemd_unit = "wg-quick@wg0.service"
|
||||
restart_timeout_seconds = 10
|
||||
"#,
|
||||
)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.unwrap()
|
||||
.resolve_paths(source)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_file_import_is_bounded_read_only_and_stages_encryption() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let source_directory = root.path().join("clients");
|
||||
fs::create_dir(&source_directory).unwrap();
|
||||
let server_key = WireGuardPrivateKey::generate().unwrap();
|
||||
let client_key = WireGuardPrivateKey::generate().unwrap();
|
||||
let server = ConfigDocument::parse(
|
||||
format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n",
|
||||
server_key.to_base64(),
|
||||
client_key.public_key()
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
let original = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.org:51820\n",
|
||||
client_key.to_base64(),
|
||||
server_key.public_key()
|
||||
);
|
||||
let selected_path = source_directory.join("client.conf");
|
||||
fs::write(&selected_path, original.as_bytes()).unwrap();
|
||||
make_private(&selected_path);
|
||||
let mut bundle = VaultBundle::create("synthetic password").unwrap();
|
||||
|
||||
let report = import_selected_file(
|
||||
&mut bundle,
|
||||
&resolved_settings(root.path()),
|
||||
&server,
|
||||
OsStr::new("client.conf"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(report.scan_complete);
|
||||
assert!(report.issues.is_empty());
|
||||
assert_eq!(bundle.record_count(), 1);
|
||||
assert!(matches!(
|
||||
report.candidates[0].1,
|
||||
CandidateStatus::Matched {
|
||||
address_discrepancy: false,
|
||||
server_identity: super::super::import::ServerIdentityComparison::Matches,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(fs::read(&selected_path).unwrap(), original.as_bytes());
|
||||
let duplicate = import_selected_file(
|
||||
&mut bundle,
|
||||
&resolved_settings(root.path()),
|
||||
&server,
|
||||
OsStr::new("client.conf"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
duplicate.issues[0].kind,
|
||||
SourceFailureKind::DuplicateRecordEqualityUnverified
|
||||
);
|
||||
assert_eq!(bundle.record_count(), 1);
|
||||
assert!(
|
||||
import_selected_file(
|
||||
&mut bundle,
|
||||
&resolved_settings(root.path()),
|
||||
&server,
|
||||
OsStr::new("../client.conf")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&selected_path, source_directory.join("linked.conf"))
|
||||
.unwrap();
|
||||
let link_report = import_selected_file(
|
||||
&mut bundle,
|
||||
&resolved_settings(root.path()),
|
||||
&server,
|
||||
OsStr::new("linked.conf"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!link_report.scan_complete);
|
||||
assert_eq!(
|
||||
link_report.issues[0].kind,
|
||||
SourceFailureKind::FileUnavailable
|
||||
);
|
||||
assert_eq!(bundle.record_count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_enumeration_error_is_visible_and_other_files_still_import() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user