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, Management, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum StartupError { UnknownArguments, RootRequired, } impl fmt::Display for StartupError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::UnknownArguments => write!(formatter, "unknown or unsupported arguments"), Self::RootRequired => write!(formatter, "management operations require root"), } } } 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 { 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 /// root. Help, version, and invalid arguments do not query process privileges. pub fn start( arguments: &[OsString], effective_uid_is_root: impl FnOnce() -> bool, ) -> Result { match arguments { [] => { if effective_uid_is_root() { Ok(StartupAction::Management) } else { Err(StartupError::RootRequired) } } [argument] if argument == "--help" => Ok(StartupAction::Help), [argument] if argument == "--version" => Ok(StartupAction::Version), _ => Err(StartupError::UnknownArguments), } } #[cfg(test)] mod tests { use super::{StartupAction, StartupError, start}; use std::ffi::OsString; #[test] fn help_and_version_do_not_query_root() { for (argument, expected) in [ ("--help", StartupAction::Help), ("--version", StartupAction::Version), ] { let result = start(&[OsString::from(argument)], || { panic!("root check must not run for {argument}") }); assert_eq!(result, Ok(expected)); } } #[test] fn non_root_is_rejected_before_management_startup() { let result = start(&[], || false); assert_eq!(result, Err(StartupError::RootRequired)); } #[test] fn root_management_startup_is_authorized_for_terminal_boundary() { let result = start(&[], || true); assert_eq!(result, Ok(StartupAction::Management)); } #[test] fn unknown_or_extra_arguments_fail_without_root_check() { for arguments in [ vec![OsString::from("--settings")], vec![OsString::from("--help"), OsString::from("unexpected")], ] { let result = start(&arguments, || { panic!("root check must not run for invalid arguments") }); 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(); } }