Complete terminal management workflow
This commit is contained in:
+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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user