227 lines
8.4 KiB
Rust
227 lines
8.4 KiB
Rust
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<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
|
|
/// root. Help, version, and invalid arguments do not query process privileges.
|
|
pub fn start(
|
|
arguments: &[OsString],
|
|
effective_uid_is_root: impl FnOnce() -> bool,
|
|
) -> Result<StartupAction, StartupError> {
|
|
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();
|
|
}
|
|
}
|