2519 lines
98 KiB
Rust
2519 lines
98 KiB
Rust
//! Private fixture operations compiled only for the library test harness.
|
|
//!
|
|
//! This module is deliberately absent from the production library and binary.
|
|
|
|
use std::io::{BufRead, Write};
|
|
use std::net::Ipv4Addr;
|
|
use std::path::{Component, Path, PathBuf};
|
|
use std::process::{Command, ExitStatus, Stdio};
|
|
|
|
use crate::app::{
|
|
Application, FirstIssueAttempt, FirstIssueDeliveryOutcome, RestartChoice, RestartStage,
|
|
};
|
|
use crate::config::SettingsFile;
|
|
use crate::persistence::SavedStore;
|
|
use crate::service::{RestartExecution, RestartFailure, RestartOutcome};
|
|
|
|
const FIXTURE_ROOT: &str = "/tmp/wireguard-admin-fixture";
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
enum FixtureCommand {
|
|
CreateWireGuard {
|
|
interface: String,
|
|
},
|
|
LinkUp {
|
|
interface: String,
|
|
},
|
|
AddIpv4Address {
|
|
interface: String,
|
|
address: String,
|
|
},
|
|
AddIpv4Route {
|
|
interface: String,
|
|
route: String,
|
|
},
|
|
SetWireGuard {
|
|
interface: String,
|
|
config_path: String,
|
|
},
|
|
}
|
|
|
|
impl FixtureCommand {
|
|
fn program_and_args(&self) -> Result<(&'static str, Vec<String>), FixtureCommandError> {
|
|
self.program_and_args_with_root(Path::new(FIXTURE_ROOT))
|
|
}
|
|
|
|
fn program_and_args_with_root(
|
|
&self,
|
|
fixture_root: &Path,
|
|
) -> Result<(&'static str, Vec<String>), FixtureCommandError> {
|
|
match self {
|
|
Self::CreateWireGuard { interface } => {
|
|
validate_interface(interface)?;
|
|
Ok((
|
|
"ip",
|
|
vec![
|
|
"link".into(),
|
|
"add".into(),
|
|
"dev".into(),
|
|
interface.clone(),
|
|
"type".into(),
|
|
"wireguard".into(),
|
|
],
|
|
))
|
|
}
|
|
Self::LinkUp { interface } => {
|
|
validate_interface(interface)?;
|
|
Ok((
|
|
"ip",
|
|
vec![
|
|
"link".into(),
|
|
"set".into(),
|
|
"dev".into(),
|
|
interface.clone(),
|
|
"up".into(),
|
|
],
|
|
))
|
|
}
|
|
Self::AddIpv4Address { interface, address } => {
|
|
validate_interface(interface)?;
|
|
let address = address
|
|
.parse::<ipnet::Ipv4Net>()
|
|
.map_err(|_| FixtureCommandError)?;
|
|
Ok((
|
|
"ip",
|
|
vec![
|
|
"address".into(),
|
|
"add".into(),
|
|
address.to_string(),
|
|
"dev".into(),
|
|
interface.clone(),
|
|
],
|
|
))
|
|
}
|
|
Self::AddIpv4Route { interface, route } => {
|
|
validate_interface(interface)?;
|
|
let route = route
|
|
.parse::<ipnet::Ipv4Net>()
|
|
.map_err(|_| FixtureCommandError)?;
|
|
Ok((
|
|
"ip",
|
|
vec![
|
|
"route".into(),
|
|
"add".into(),
|
|
route.to_string(),
|
|
"dev".into(),
|
|
interface.clone(),
|
|
],
|
|
))
|
|
}
|
|
Self::SetWireGuard {
|
|
interface,
|
|
config_path,
|
|
} => {
|
|
validate_interface(interface)?;
|
|
let config_path = resolve_fixture_path(config_path, fixture_root)?;
|
|
Ok((
|
|
"wg",
|
|
vec![
|
|
"setconf".into(),
|
|
interface.clone(),
|
|
config_path.to_string_lossy().into_owned(),
|
|
],
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
struct FixtureCommandError;
|
|
|
|
impl std::fmt::Display for FixtureCommandError {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
formatter.write_str("fixture command failed")
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for FixtureCommandError {}
|
|
|
|
trait CommandRunner {
|
|
fn run(&mut self, program: &str, args: &[String]) -> Result<ExitStatus, FixtureCommandError>;
|
|
}
|
|
|
|
#[allow(dead_code)] // Executed by M8.5 fixture scenarios, never readiness.
|
|
struct BusyBoxRunner;
|
|
|
|
impl CommandRunner for BusyBoxRunner {
|
|
fn run(&mut self, program: &str, args: &[String]) -> Result<ExitStatus, FixtureCommandError> {
|
|
Command::new("/bin/busybox")
|
|
.args(["timeout", "-s", "KILL", "15", program])
|
|
.args(args)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.map_err(|_| FixtureCommandError)
|
|
}
|
|
}
|
|
|
|
fn execute_with(
|
|
command: &FixtureCommand,
|
|
runner: &mut impl CommandRunner,
|
|
) -> Result<(), FixtureCommandError> {
|
|
execute_with_root(command, Path::new(FIXTURE_ROOT), runner)
|
|
}
|
|
|
|
fn execute_with_root(
|
|
command: &FixtureCommand,
|
|
fixture_root: &Path,
|
|
runner: &mut impl CommandRunner,
|
|
) -> Result<(), FixtureCommandError> {
|
|
let (program, args) = command.program_and_args_with_root(fixture_root)?;
|
|
let status = runner.run(program, &args)?;
|
|
if status.success() {
|
|
Ok(())
|
|
} else {
|
|
Err(FixtureCommandError)
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)] // Executed by M8.5 fixture scenarios, never readiness.
|
|
fn execute_fixture_command(command: &FixtureCommand) -> Result<(), FixtureCommandError> {
|
|
execute_with(command, &mut BusyBoxRunner)
|
|
}
|
|
|
|
fn execute_fixture_command_in(
|
|
command: &FixtureCommand,
|
|
fixture_root: &Path,
|
|
) -> Result<(), FixtureCommandError> {
|
|
execute_with_root(command, fixture_root, &mut BusyBoxRunner)
|
|
}
|
|
|
|
fn validate_interface(interface: &str) -> Result<(), FixtureCommandError> {
|
|
if !interface.is_empty()
|
|
&& interface.len() <= 15
|
|
&& interface
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || b"_.-".contains(&byte))
|
|
{
|
|
Ok(())
|
|
} else {
|
|
Err(FixtureCommandError)
|
|
}
|
|
}
|
|
|
|
fn resolve_fixture_path(path: &str, root: &Path) -> Result<PathBuf, FixtureCommandError> {
|
|
let path = Path::new(path);
|
|
if !path.is_absolute()
|
|
|| path == root
|
|
|| !path.starts_with(root)
|
|
|| path
|
|
.components()
|
|
.any(|component| !matches!(component, Component::RootDir | Component::Normal(_)))
|
|
{
|
|
return Err(FixtureCommandError);
|
|
}
|
|
|
|
// Resolve every existing component without following links. Fixture roots
|
|
// are private to this test process; this also rejects an in-root link whose
|
|
// target could be replaced after a lexical check.
|
|
let relative = path.strip_prefix(root).map_err(|_| FixtureCommandError)?;
|
|
let mut current = root.to_path_buf();
|
|
let root_meta = std::fs::symlink_metadata(¤t).map_err(|_| FixtureCommandError)?;
|
|
if root_meta.file_type().is_symlink() || !root_meta.is_dir() {
|
|
return Err(FixtureCommandError);
|
|
}
|
|
for component in relative.components() {
|
|
let Component::Normal(name) = component else {
|
|
return Err(FixtureCommandError);
|
|
};
|
|
current.push(name);
|
|
let metadata = std::fs::symlink_metadata(¤t).map_err(|_| FixtureCommandError)?;
|
|
if metadata.file_type().is_symlink() {
|
|
return Err(FixtureCommandError);
|
|
}
|
|
}
|
|
let canonical_root = std::fs::canonicalize(root).map_err(|_| FixtureCommandError)?;
|
|
let canonical_path = std::fs::canonicalize(path).map_err(|_| FixtureCommandError)?;
|
|
let metadata = std::fs::metadata(&canonical_path).map_err(|_| FixtureCommandError)?;
|
|
if !canonical_path.starts_with(canonical_root) || !metadata.is_file() {
|
|
return Err(FixtureCommandError);
|
|
}
|
|
Ok(canonical_path)
|
|
}
|
|
|
|
const MAX_PHASE_REQUESTS: usize = 8;
|
|
const MAX_PHASE_LINE_BYTES: usize = 512;
|
|
static NEXT_FIXTURE_SETCONF_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
enum PhaseRequest {
|
|
Baseline,
|
|
Create {
|
|
alias: String,
|
|
},
|
|
Commit,
|
|
Deliver,
|
|
Delete {
|
|
identity: crate::domain::PublicKey,
|
|
address: String,
|
|
},
|
|
Restore {
|
|
file_name: String,
|
|
address: String,
|
|
},
|
|
Apply {
|
|
config_path: String,
|
|
released_address: Option<String>,
|
|
},
|
|
Observe,
|
|
End,
|
|
ImportReissue,
|
|
FailurePreventsApplication,
|
|
}
|
|
|
|
impl PhaseRequest {
|
|
fn parse(line: &str) -> Result<Self, PhaseProtocolError> {
|
|
if line.len() > MAX_PHASE_LINE_BYTES || line.contains('\0') {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
if line == "COMMIT" {
|
|
return Ok(Self::Commit);
|
|
}
|
|
if line == "DELIVER" {
|
|
return Ok(Self::Deliver);
|
|
}
|
|
if line == "END" {
|
|
return Ok(Self::End);
|
|
}
|
|
if line == "IMPORT_REISSUE" {
|
|
return Ok(Self::ImportReissue);
|
|
}
|
|
if line == "FAILURE_PREVENTS_APPLICATION" {
|
|
return Ok(Self::FailurePreventsApplication);
|
|
}
|
|
if line == "OBSERVE" {
|
|
return Ok(Self::Observe);
|
|
}
|
|
if line == "BASELINE" {
|
|
return Ok(Self::Baseline);
|
|
}
|
|
if let Some(alias) = line.strip_prefix("CREATE\t") {
|
|
if alias.is_empty() || alias.chars().any(char::is_control) {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
return Ok(Self::Create {
|
|
alias: alias.to_owned(),
|
|
});
|
|
}
|
|
if let Some(config_path) = line.strip_prefix("APPLY\t") {
|
|
let (config_path, released_address) = config_path
|
|
.split_once('\t')
|
|
.map_or((config_path, None), |(path, address)| (path, Some(address)));
|
|
if config_path.is_empty()
|
|
|| config_path.chars().any(char::is_control)
|
|
|| released_address
|
|
.is_some_and(|value| value.is_empty() || value.chars().any(char::is_control))
|
|
{
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
if let Some(address) = released_address {
|
|
host_route_address(address)?;
|
|
}
|
|
return Ok(Self::Apply {
|
|
config_path: config_path.to_owned(),
|
|
released_address: released_address.map(str::to_owned),
|
|
});
|
|
}
|
|
if let Some(deletion) = line.strip_prefix("DELETE\t") {
|
|
let (identity, address) = deletion.split_once('\t').ok_or(PhaseProtocolError)?;
|
|
if address.is_empty() || address.chars().any(char::is_control) {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
let identity = identity
|
|
.parse::<crate::domain::PublicKey>()
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let address = address
|
|
.parse::<ipnet::IpNet>()
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let is_host_route = match address {
|
|
ipnet::IpNet::V4(network) => network.prefix_len() == 32,
|
|
ipnet::IpNet::V6(network) => network.prefix_len() == 128,
|
|
};
|
|
if !is_host_route {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
return Ok(Self::Delete {
|
|
identity,
|
|
address: address.addr().to_string(),
|
|
});
|
|
}
|
|
if let Some(restore) = line.strip_prefix("RESTORE\t") {
|
|
let (file_name, address) = restore.split_once('\t').ok_or(PhaseProtocolError)?;
|
|
if !safe_fixture_archive_name(file_name) {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
let address = address
|
|
.parse::<ipnet::IpNet>()
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let is_host_route = match address {
|
|
ipnet::IpNet::V4(network) => network.prefix_len() == 32,
|
|
ipnet::IpNet::V6(network) => network.prefix_len() == 128,
|
|
};
|
|
if !is_host_route {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
return Ok(Self::Restore {
|
|
file_name: file_name.to_owned(),
|
|
address: address.addr().to_string(),
|
|
});
|
|
}
|
|
Err(PhaseProtocolError)
|
|
}
|
|
}
|
|
|
|
fn safe_fixture_archive_name(file_name: &str) -> bool {
|
|
!file_name.is_empty()
|
|
&& file_name.len() <= 128
|
|
&& file_name
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte))
|
|
&& file_name.ends_with(".tar")
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
struct PhaseProtocolError;
|
|
|
|
fn host_route_address(value: &str) -> Result<std::net::IpAddr, PhaseProtocolError> {
|
|
let network = value
|
|
.parse::<ipnet::IpNet>()
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let is_host_route = match network {
|
|
ipnet::IpNet::V4(network) => network.prefix_len() == 32,
|
|
ipnet::IpNet::V6(network) => network.prefix_len() == 128,
|
|
};
|
|
if is_host_route {
|
|
Ok(network.addr())
|
|
} else {
|
|
Err(PhaseProtocolError)
|
|
}
|
|
}
|
|
|
|
fn released_address_is_unreserved(
|
|
released_address: Option<&str>,
|
|
reserved_addresses: &std::collections::BTreeSet<std::net::IpAddr>,
|
|
) -> Result<bool, PhaseProtocolError> {
|
|
released_address
|
|
.map(|address| {
|
|
host_route_address(address).map(|address| !reserved_addresses.contains(&address))
|
|
})
|
|
.transpose()
|
|
.map(|released| released.unwrap_or(true))
|
|
}
|
|
|
|
impl std::fmt::Display for PhaseProtocolError {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
formatter.write_str("fixture phase request rejected")
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for PhaseProtocolError {}
|
|
|
|
#[derive(Default)]
|
|
struct PhaseState {
|
|
created: Option<crate::domain::PublicKey>,
|
|
committed: bool,
|
|
delivered: bool,
|
|
applied: bool,
|
|
}
|
|
|
|
/// In-process control protocol for M8.5. The session owns one `Application`
|
|
/// until the bounded request stream ends, preserving first-issue authority.
|
|
fn run_phase_protocol(
|
|
app: &mut Application,
|
|
fixture_root: &Path,
|
|
mut input: impl BufRead,
|
|
mut output: impl Write,
|
|
) -> Result<(), PhaseProtocolError> {
|
|
let mut state = PhaseState::default();
|
|
for count in 0..MAX_PHASE_REQUESTS {
|
|
let Some(line) = read_phase_line(&mut input)? else {
|
|
return Err(PhaseProtocolError);
|
|
};
|
|
if count >= MAX_PHASE_REQUESTS {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
let request = PhaseRequest::parse(&line)?;
|
|
let phase = match &request {
|
|
PhaseRequest::Baseline => "baseline_apply",
|
|
PhaseRequest::Create { .. } => "create",
|
|
PhaseRequest::Commit => "commit",
|
|
PhaseRequest::Deliver => "delivery",
|
|
PhaseRequest::Delete { .. } => "delete",
|
|
PhaseRequest::Restore { .. } => "restore",
|
|
PhaseRequest::Apply { .. } => "fixture_apply",
|
|
PhaseRequest::Observe => "observe",
|
|
PhaseRequest::End => "end",
|
|
PhaseRequest::ImportReissue => "import_reissue",
|
|
PhaseRequest::FailurePreventsApplication => "failure_prevents_application",
|
|
};
|
|
writeln!(output, "phase={phase} status=started").map_err(|_| PhaseProtocolError)?;
|
|
match request {
|
|
PhaseRequest::Baseline => {
|
|
let mut config_copy =
|
|
fixture_setconf_copy(&fixture_root.join("server.conf"), fixture_root)?;
|
|
let command = FixtureCommand::SetWireGuard {
|
|
interface: "wg-m83-probe".to_owned(),
|
|
config_path: config_copy.path().to_string_lossy().into_owned(),
|
|
};
|
|
execute_fixture_command_in(&command, fixture_root)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
config_copy.remove()?;
|
|
let client_text =
|
|
std::fs::read_to_string(fixture_root.join("baseline-client.conf"))
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let private_key = client_text
|
|
.lines()
|
|
.filter_map(|line| line.trim().split_once('='))
|
|
.find(|(name, _)| name.trim() == "PrivateKey")
|
|
.map(|(_, value)| value.trim())
|
|
.ok_or(PhaseProtocolError)?;
|
|
let identity = crate::vault::WireGuardPrivateKey::parse_base64(private_key)
|
|
.map_err(|_| PhaseProtocolError)?
|
|
.public_key();
|
|
writeln!(
|
|
output,
|
|
"phase=baseline_apply status=verified public_key={identity}"
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
}
|
|
PhaseRequest::Create { alias } if state.created.is_none() => {
|
|
let identity = app
|
|
.prepare_new_client(&alias, None, None)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
state.created = Some(identity);
|
|
writeln!(output, "phase=create status=ok public_key={identity}")
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
}
|
|
PhaseRequest::Commit if state.created.is_some() && !state.committed => {
|
|
let backup_path = app.commit().map_err(|_| PhaseProtocolError)?;
|
|
let backup_name = backup_path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.filter(|name| safe_fixture_archive_name(name))
|
|
.ok_or(PhaseProtocolError)?;
|
|
state.committed = true;
|
|
writeln!(output, "phase=commit status=ok backup_name={backup_name}")
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
}
|
|
PhaseRequest::Deliver if state.committed && !state.delivered => {
|
|
let identity = state.created.ok_or(PhaseProtocolError)?;
|
|
let destination = fixture_root.join("first-delivery.conf");
|
|
let prepared = app
|
|
.prepare_first_issue_destination(&destination)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let outcome = app
|
|
.deliver_first_issue_file(identity, FirstIssueAttempt::Initial, prepared)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let activation_warning = match outcome {
|
|
FirstIssueDeliveryOutcome::Delivered(result) => result.activation_warning,
|
|
_ => return Err(PhaseProtocolError),
|
|
};
|
|
state.delivered = true;
|
|
writeln!(
|
|
output,
|
|
"phase=delivery status=ok activation_warning={activation_warning}"
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
}
|
|
PhaseRequest::Delete { identity, address } => {
|
|
if !app
|
|
.delete_client(identity)
|
|
.map_err(|_| PhaseProtocolError)?
|
|
{
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
let backup_path = app.commit().map_err(|_| PhaseProtocolError)?;
|
|
let backup_name = backup_path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.filter(|name| safe_fixture_archive_name(name))
|
|
.ok_or(PhaseProtocolError)?;
|
|
let saved_vault = app
|
|
.saved_bytes()
|
|
.vault
|
|
.as_deref()
|
|
.and_then(|bytes| std::str::from_utf8(bytes).ok())
|
|
.and_then(|text| crate::vault::VaultBundle::from_toml(text).ok())
|
|
.ok_or(PhaseProtocolError)?;
|
|
if saved_vault.contains_record(identity) {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
let archive =
|
|
crate::backup::preview_archive(&backup_path).map_err(|_| PhaseProtocolError)?;
|
|
let archived_vault = archive
|
|
.saved_bytes
|
|
.vault
|
|
.as_deref()
|
|
.and_then(|bytes| std::str::from_utf8(bytes).ok())
|
|
.and_then(|text| crate::vault::VaultBundle::from_toml(text).ok())
|
|
.ok_or(PhaseProtocolError)?;
|
|
if !archived_vault.contains_record(identity) {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
let endpoint = fixture_endpoint_from_saved_settings(fixture_root)?;
|
|
*app = open_fixture_application(fixture_root, &endpoint)?;
|
|
if app.states().saved().get(&identity).is_some()
|
|
|| !app
|
|
.states()
|
|
.reserved_addresses()
|
|
.contains(&address.parse().map_err(|_| PhaseProtocolError)?)
|
|
{
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
writeln!(
|
|
output,
|
|
"phase=delete status=saved public_key={identity} current_record=removed backup_record=retained address_reserved=true backup_name={backup_name}"
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
}
|
|
PhaseRequest::Restore { file_name, address } => {
|
|
let target_address = address
|
|
.parse::<std::net::IpAddr>()
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let runtime_before = observe_fixture_peer_set()?;
|
|
restore_step(&mut output, "runtime_observed")?;
|
|
let selected_archive = fixture_root.join("Backup/archives").join(&file_name);
|
|
let archived = crate::backup::preview_archive(&selected_archive)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
if archived.reason != crate::backup::BackupReason::PreCommit {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
restore_step(&mut output, "archive_validated")?;
|
|
let archived_bytes = archived.saved_bytes;
|
|
let archived_document =
|
|
crate::wireguard::ConfigDocument::parse(&archived_bytes.server_config)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let archived_peer = archived_document
|
|
.peers()
|
|
.iter()
|
|
.find(|peer| {
|
|
peer.allowed_ips.iter().any(|network| {
|
|
network.addr() == target_address
|
|
&& match network {
|
|
ipnet::IpNet::V4(route) => route.prefix_len() == 32,
|
|
ipnet::IpNet::V6(route) => route.prefix_len() == 128,
|
|
}
|
|
})
|
|
})
|
|
.and_then(|peer| peer.identity);
|
|
let target_identity = archived_peer
|
|
.or_else(|| {
|
|
app.states()
|
|
.saved()
|
|
.clients()
|
|
.find(|client| client.addresses.iter().any(|ip| ip == target_address))
|
|
.map(|client| client.identity)
|
|
})
|
|
.ok_or(PhaseProtocolError)?;
|
|
let archive_has_target = archived_peer.is_some();
|
|
restore_step(&mut output, "target_identified")?;
|
|
let source_client_before = std::fs::read(fixture_root.join("baseline-client.conf"))
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let before = app.saved_bytes();
|
|
let before_settings = before.settings.clone();
|
|
let before_server = before.server_config.clone();
|
|
let before_state = before.internal_state.clone();
|
|
let before_vault = before.vault.clone();
|
|
let prior_reservations = app.states().reserved_addresses().clone();
|
|
let removed_client_addresses = if archive_has_target {
|
|
std::collections::BTreeSet::new()
|
|
} else {
|
|
app.states()
|
|
.saved()
|
|
.get(&target_identity)
|
|
.map(|client| client.addresses.iter().collect())
|
|
.ok_or(PhaseProtocolError)?
|
|
};
|
|
let preview = app
|
|
.preview_restore(&file_name)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
restore_step(&mut output, "application_previewed")?;
|
|
let changes = preview.change_summary(before);
|
|
let expected_change = if archive_has_target {
|
|
changes.added_clients.len() == 1
|
|
&& changes.added_clients.contains(&target_identity)
|
|
&& changes.removed_clients.is_empty()
|
|
} else {
|
|
changes.removed_clients.len() == 1
|
|
&& changes.removed_clients.contains(&target_identity)
|
|
&& changes.added_clients.is_empty()
|
|
};
|
|
if !expected_change {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
restore_step(&mut output, "change_summary_verified")?;
|
|
let precautionary_path = app
|
|
.confirm_restore(preview)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
restore_step(&mut output, "application_confirmed")?;
|
|
verify_fixture_private_archive(&precautionary_path)?;
|
|
let precautionary = crate::backup::preview_archive(&precautionary_path)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
if precautionary.reason != crate::backup::BackupReason::PreRestore {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
restore_step(&mut output, "precautionary_backup_validated")?;
|
|
if precautionary.saved_bytes.settings != before_settings
|
|
|| precautionary.saved_bytes.server_config != before_server
|
|
|| precautionary.saved_bytes.internal_state != before_state
|
|
|| precautionary.saved_bytes.vault != before_vault
|
|
{
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
restore_step(&mut output, "precautionary_contents_verified")?;
|
|
let restored = app.saved_bytes();
|
|
let restored_addresses = app
|
|
.states()
|
|
.saved()
|
|
.clients()
|
|
.flat_map(|client| client.addresses.iter())
|
|
.collect();
|
|
let expected_reservations = expected_restore_reservations(
|
|
prior_reservations,
|
|
removed_client_addresses,
|
|
restored_addresses,
|
|
);
|
|
let settings_match = restored.settings == archived_bytes.settings;
|
|
let server_match = restored.server_config == archived_bytes.server_config;
|
|
let vault_match = restored.vault == archived_bytes.vault;
|
|
let target_presence_match =
|
|
app.states().saved().get(&target_identity).is_some() == archive_has_target;
|
|
let reservations_match =
|
|
app.states().reserved_addresses() == &expected_reservations;
|
|
let actual_reservations = app.states().reserved_addresses();
|
|
writeln!(
|
|
output,
|
|
"phase=restore step=saved_state_comparison settings_match={settings_match} server_match={server_match} vault_match={vault_match} target_presence_match={target_presence_match} reservations_match={reservations_match} actual_reservation_count={} expected_reservation_count={} actual_target_reserved={} expected_target_reserved={}",
|
|
actual_reservations.len(),
|
|
expected_reservations.len(),
|
|
actual_reservations.contains(&target_address),
|
|
expected_reservations.contains(&target_address)
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
if !settings_match
|
|
|| !server_match
|
|
|| !vault_match
|
|
|| !target_presence_match
|
|
|| !reservations_match
|
|
{
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
restore_step(&mut output, "saved_state_and_reservations_verified")?;
|
|
let restored_state = restored
|
|
.internal_state
|
|
.as_deref()
|
|
.map(crate::persistence::PersistedState::from_toml)
|
|
.transpose()
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let expected_reserved_strings = expected_reservations
|
|
.iter()
|
|
.map(ToString::to_string)
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
if restored_state.as_ref().is_none_or(|state| {
|
|
state.last_verified_at_utc.is_some()
|
|
|| state
|
|
.reserved_addresses
|
|
.iter()
|
|
.cloned()
|
|
.collect::<std::collections::BTreeSet<_>>()
|
|
!= expected_reserved_strings
|
|
}) {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
restore_step(&mut output, "persisted_reservations_verified")?;
|
|
if observe_fixture_peer_set()? != runtime_before {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
restore_step(&mut output, "runtime_unchanged_verified")?;
|
|
if std::fs::read(fixture_root.join("baseline-client.conf"))
|
|
.map_err(|_| PhaseProtocolError)?
|
|
!= source_client_before
|
|
{
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
restore_step(&mut output, "source_client_preserved")?;
|
|
verify_fixture_private_saved_files(fixture_root)?;
|
|
restore_step(&mut output, "saved_file_permissions_verified")?;
|
|
writeln!(
|
|
output,
|
|
"phase=restore status=confirmed archive={file_name} reason=pre-commit precautionary_backup=pre-restore settings=true server=true vault=true target_client_present={archive_has_target} pending_address_reserved={} runtime_unchanged=true",
|
|
app.states().reserved_addresses().contains(&target_address)
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
}
|
|
PhaseRequest::Apply {
|
|
config_path,
|
|
released_address,
|
|
} if !state.applied => {
|
|
let config_path = resolve_fixture_path(&config_path, fixture_root)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
writeln!(output, "phase=fixture_apply step=path_validated")
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let mut config_copy = fixture_setconf_copy(&config_path, fixture_root)?;
|
|
writeln!(output, "phase=fixture_apply step=config_prepared")
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let apply_config_path = config_copy.path().to_path_buf();
|
|
let preview = match app.preview_restart() {
|
|
Ok(preview) => preview,
|
|
Err(_) => {
|
|
writeln!(output, "phase=fixture_apply step=preview_failed")
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
};
|
|
writeln!(output, "phase=fixture_apply step=preview_ready")
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let interface = preview.interface().to_owned();
|
|
let report = app.restart_with_fixture_apply(
|
|
preview,
|
|
RestartChoice::RestartSavedState,
|
|
move |_, _| {
|
|
let command = FixtureCommand::SetWireGuard {
|
|
interface,
|
|
config_path: apply_config_path.to_string_lossy().into_owned(),
|
|
};
|
|
run_fixture_apply_with_cleanup(
|
|
|| execute_fixture_command_in(&command, fixture_root),
|
|
|| config_copy.remove(),
|
|
)
|
|
},
|
|
);
|
|
writeln!(
|
|
output,
|
|
"phase=fixture_apply step=application_returned stage={:?} failure={:?} submitted={}",
|
|
report.stage,
|
|
report.failure,
|
|
report.restart_submitted
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
if report.stage != RestartStage::Complete || report.failure.is_some() {
|
|
writeln!(
|
|
output,
|
|
"phase=fixture_apply status=failed stage={:?} failure={:?}",
|
|
report.stage, report.failure
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
state.applied = true;
|
|
let address_released = released_address_is_unreserved(
|
|
released_address.as_deref(),
|
|
app.states().reserved_addresses(),
|
|
)?;
|
|
if !address_released {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
writeln!(
|
|
output,
|
|
"phase=fixture_apply status=verified address_released={address_released}"
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
}
|
|
PhaseRequest::Observe => {
|
|
let observation = crate::runtime::transport::observe_interface(
|
|
"wg-m83-probe",
|
|
std::time::Duration::from_secs(2),
|
|
crate::runtime::netlink::DecodeLimits::default(),
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let crate::runtime::InterfaceObservation::Present { name, peers } =
|
|
observation.interface
|
|
else {
|
|
return Err(PhaseProtocolError);
|
|
};
|
|
if peers.completeness != crate::runtime::PeerSetCompleteness::Complete
|
|
|| !peers.duplicate_public_keys.is_empty()
|
|
{
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
writeln!(
|
|
output,
|
|
"phase=observe status=complete interface={name} peers={}",
|
|
peers.peers.len()
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
for (identity, peer) in peers.peers {
|
|
let routes = match peer.allowed_ips {
|
|
crate::runtime::Observed::Available(routes) => routes
|
|
.iter()
|
|
.map(ToString::to_string)
|
|
.collect::<Vec<_>>()
|
|
.join(","),
|
|
crate::runtime::Observed::Unavailable => "unavailable".to_owned(),
|
|
};
|
|
let handshake = match peer.last_handshake {
|
|
crate::runtime::Observed::Available(Some(_)) => "present",
|
|
crate::runtime::Observed::Available(None) => "none",
|
|
crate::runtime::Observed::Unavailable => "unavailable",
|
|
};
|
|
let received = match peer.received_bytes {
|
|
crate::runtime::Observed::Available(value) => value.to_string(),
|
|
crate::runtime::Observed::Unavailable => "unavailable".to_owned(),
|
|
};
|
|
let transmitted = match peer.transmitted_bytes {
|
|
crate::runtime::Observed::Available(value) => value.to_string(),
|
|
crate::runtime::Observed::Unavailable => "unavailable".to_owned(),
|
|
};
|
|
writeln!(output, "peer={identity} routes={routes} handshake={handshake} received={received} transmitted={transmitted}")
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
}
|
|
}
|
|
PhaseRequest::End => {
|
|
writeln!(output, "phase=end status=ok").map_err(|_| PhaseProtocolError)?;
|
|
return Ok(());
|
|
}
|
|
PhaseRequest::ImportReissue => {
|
|
run_import_reissue_scenario(fixture_root, &mut output)?;
|
|
}
|
|
PhaseRequest::FailurePreventsApplication => {
|
|
run_failure_prevents_application_scenario(app, fixture_root, &mut output)?;
|
|
}
|
|
_ => return Err(PhaseProtocolError),
|
|
}
|
|
}
|
|
Err(PhaseProtocolError)
|
|
}
|
|
|
|
fn restore_step(output: &mut impl Write, step: &str) -> Result<(), PhaseProtocolError> {
|
|
writeln!(output, "phase=restore step={step}").map_err(|_| PhaseProtocolError)
|
|
}
|
|
|
|
/// Replace the fixture archive directory with a regular file. This produces a
|
|
/// deterministic ENOTDIR failure even in the root Alpine container, unlike a
|
|
/// permission-only fault injection. Drop restores the exact original directory.
|
|
struct ArchivePathBlocker {
|
|
archive_dir: PathBuf,
|
|
held_dir: PathBuf,
|
|
}
|
|
|
|
impl ArchivePathBlocker {
|
|
fn install(root: &Path) -> Result<Self, PhaseProtocolError> {
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
|
|
let archive_dir = root.join("Backup/archives");
|
|
let held_dir = root.join("Backup/archives-failure-fixture-held");
|
|
let archive_metadata =
|
|
std::fs::symlink_metadata(&archive_dir).map_err(|_| PhaseProtocolError)?;
|
|
if held_dir.exists()
|
|
|| !archive_metadata.file_type().is_dir()
|
|
|| archive_metadata.file_type().is_symlink()
|
|
{
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
std::fs::rename(&archive_dir, &held_dir).map_err(|_| PhaseProtocolError)?;
|
|
let blocker = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.mode(0o600)
|
|
.open(&archive_dir);
|
|
if blocker.is_err() {
|
|
let _ = std::fs::rename(&held_dir, &archive_dir);
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
Ok(Self {
|
|
archive_dir,
|
|
held_dir,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Drop for ArchivePathBlocker {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_file(&self.archive_dir);
|
|
let _ = std::fs::rename(&self.held_dir, &self.archive_dir);
|
|
}
|
|
}
|
|
|
|
fn archive_path_restored(root: &Path) -> bool {
|
|
let archive_dir = root.join("Backup/archives");
|
|
let held_dir = root.join("Backup/archives-failure-fixture-held");
|
|
std::fs::symlink_metadata(&archive_dir)
|
|
.is_ok_and(|metadata| metadata.file_type().is_dir() && !metadata.file_type().is_symlink())
|
|
&& !held_dir.exists()
|
|
}
|
|
|
|
fn is_commit_backup_failure(result: &Result<PathBuf, crate::app::AppError>) -> bool {
|
|
matches!(result, Err(crate::app::AppError::Backup(_)))
|
|
}
|
|
|
|
fn run_failure_prevents_application_scenario(
|
|
app: &mut Application,
|
|
fixture_root: &Path,
|
|
output: &mut impl Write,
|
|
) -> Result<(), PhaseProtocolError> {
|
|
use crate::service::{RestartExecution, RestartFailure, RestartOutcome};
|
|
|
|
let limits = crate::runtime::netlink::DecodeLimits::default();
|
|
let missing_interface = crate::runtime::transport::observe_interface(
|
|
"wga-m85-absent",
|
|
std::time::Duration::from_secs(2),
|
|
limits,
|
|
);
|
|
if !matches!(
|
|
missing_interface,
|
|
Err(crate::runtime::transport::TransportError::NoDevice)
|
|
) {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
let deadline = crate::runtime::transport::observe_interface(
|
|
"wg-m83-probe",
|
|
std::time::Duration::from_nanos(1),
|
|
limits,
|
|
);
|
|
if !matches!(
|
|
deadline,
|
|
Err(crate::runtime::transport::TransportError::Timeout)
|
|
) {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
writeln!(
|
|
output,
|
|
"phase=reader_failures status=verified missing_interface=rejected deadline=timeout empty_or_partial=false"
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
|
|
let runtime_before_commit = observe_fixture_peer_set()?;
|
|
let saved_before_commit = saved_bytes_snapshot(app.saved_bytes());
|
|
let files_before_commit = protected_saved_file_snapshot(fixture_root)?;
|
|
app.prepare_new_client("M8 backup failure", None, None)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let commit_result = {
|
|
let _blocker = ArchivePathBlocker::install(fixture_root)?;
|
|
app.commit()
|
|
};
|
|
let commit_failed_at_backup = is_commit_backup_failure(&commit_result);
|
|
let commit_archive_restored = archive_path_restored(fixture_root);
|
|
let saved_unchanged = saved_bytes_snapshot(app.saved_bytes()) == saved_before_commit
|
|
&& protected_saved_file_snapshot(fixture_root)? == files_before_commit;
|
|
let runtime_unchanged = observe_fixture_peer_set()? == runtime_before_commit;
|
|
if !commit_failed_at_backup
|
|
|| !commit_archive_restored
|
|
|| !saved_unchanged
|
|
|| !runtime_unchanged
|
|
|| !app.has_draft_changes()
|
|
{
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
writeln!(output, "phase=commit_backup_failure status=verified saved_files_unchanged=true draft_preserved=true runtime_unchanged=true archive_path_restored=true")
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
|
|
app.discard_draft().map_err(|_| PhaseProtocolError)?;
|
|
failure_step(output, "draft_discarded")?;
|
|
let baseline_client =
|
|
std::fs::read(fixture_root.join("baseline-client.conf")).map_err(|_| PhaseProtocolError)?;
|
|
let baseline_identity = crate::vault::import::parse_client_config(&baseline_client)
|
|
.map_err(|_| PhaseProtocolError)?
|
|
.identity;
|
|
failure_step(output, "baseline_identity_parsed")?;
|
|
let vault_record_removed = app
|
|
.delete_client(baseline_identity)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
if app.states().draft().get(&baseline_identity).is_some() {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
writeln!(
|
|
output,
|
|
"phase=failure_prevents_application step=baseline_client_deleted vault_record_removed={vault_record_removed}"
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
app.commit().map_err(|_| PhaseProtocolError)?;
|
|
failure_step(output, "baseline_deletion_committed")?;
|
|
let released_addresses = ["10.20.0.10"
|
|
.parse::<std::net::IpAddr>()
|
|
.map_err(|_| PhaseProtocolError)?];
|
|
let address_was_reserved = released_addresses
|
|
.iter()
|
|
.all(|address| app.states().reserved_addresses().contains(address));
|
|
if !address_was_reserved {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
failure_step(output, "deleted_address_reserved")?;
|
|
|
|
let restart_preview = app.preview_restart().map_err(|_| PhaseProtocolError)?;
|
|
failure_step(output, "restart_preview_created")?;
|
|
let saved_before_restart = saved_bytes_snapshot(app.saved_bytes());
|
|
let files_before_restart = protected_saved_file_snapshot(fixture_root)?;
|
|
let reservations_before_restart = app.states().reserved_addresses().clone();
|
|
let history_before_restart = saved_last_verified_timestamp(app.saved_bytes())?;
|
|
let applied_before_restart = app.states().applied().clone();
|
|
let applied_snapshot_before_restart = app.states().applied_snapshot().cloned();
|
|
let runtime_before_restart = observe_fixture_peer_set()?;
|
|
let apply_called = std::cell::Cell::new(false);
|
|
let report = {
|
|
let _blocker = ArchivePathBlocker::install(fixture_root)?;
|
|
failure_step(output, "archive_blocker_installed")?;
|
|
app.restart_with_fixture_apply(
|
|
restart_preview,
|
|
crate::app::RestartChoice::RestartSavedState,
|
|
|_, _| {
|
|
apply_called.set(true);
|
|
RestartExecution {
|
|
outcome: RestartOutcome::Failed(RestartFailure::RequestFailed),
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
}
|
|
},
|
|
)
|
|
};
|
|
failure_step(output, "restart_returned")?;
|
|
let restart_archive_restored = archive_path_restored(fixture_root);
|
|
let report_is_backup_failure = report.stage == crate::app::RestartStage::Backup
|
|
&& report.failure == Some(crate::app::RestartFailureKind::BackupFailed);
|
|
let no_restart_submitted = !report.restart_submitted && !apply_called.get();
|
|
let saved_unchanged = saved_bytes_snapshot(app.saved_bytes()) == saved_before_restart
|
|
&& protected_saved_file_snapshot(fixture_root)? == files_before_restart;
|
|
let reservations_unchanged = app.states().reserved_addresses() == &reservations_before_restart
|
|
&& released_addresses
|
|
.iter()
|
|
.all(|address| app.states().reserved_addresses().contains(address));
|
|
let history_unchanged = saved_last_verified_timestamp(app.saved_bytes())?
|
|
== history_before_restart
|
|
&& history_before_restart.is_none();
|
|
let applied_unchanged = app.states().applied() == &applied_before_restart
|
|
&& app.states().applied_snapshot() == applied_snapshot_before_restart.as_ref();
|
|
let runtime_after_restart = observe_fixture_peer_set()?;
|
|
let runtime_unchanged = runtime_after_restart == runtime_before_restart;
|
|
if !report_is_backup_failure
|
|
|| !restart_archive_restored
|
|
|| !no_restart_submitted
|
|
|| !saved_unchanged
|
|
|| !reservations_unchanged
|
|
|| !history_unchanged
|
|
|| !applied_unchanged
|
|
|| !runtime_unchanged
|
|
{
|
|
writeln!(output,
|
|
"phase=restart_backup_failure status=diagnostic report_backup_failure={report_is_backup_failure} archive_restored={restart_archive_restored} no_restart_submitted={no_restart_submitted} saved_unchanged={saved_unchanged} reservations_unchanged={reservations_unchanged} history_unchanged={history_unchanged} applied_unchanged={applied_unchanged} runtime_unchanged={runtime_unchanged}")
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
writeln!(
|
|
output,
|
|
"phase=restart_backup_failure status=verified apply_called=false restart_submitted=false address_reserved=true historical_state=unchanged runtime_unchanged=true archive_path_restored=true"
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
writeln!(output, "phase=failure_prevents_application status=complete")
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn failure_step(output: &mut impl Write, step: &str) -> Result<(), PhaseProtocolError> {
|
|
writeln!(output, "phase=failure_prevents_application step={step}")
|
|
.map_err(|_| PhaseProtocolError)
|
|
}
|
|
|
|
type SavedBytesSnapshot = (Vec<u8>, Vec<u8>, Option<Vec<u8>>, Option<Vec<u8>>);
|
|
type ProtectedFileSnapshot = Vec<(PathBuf, Option<Vec<u8>>)>;
|
|
|
|
fn saved_last_verified_timestamp(
|
|
saved: &crate::backup::SavedBytes,
|
|
) -> Result<Option<String>, PhaseProtocolError> {
|
|
saved
|
|
.internal_state
|
|
.as_deref()
|
|
.map(crate::persistence::PersistedState::from_toml)
|
|
.transpose()
|
|
.map(|state| state.and_then(|state| state.last_verified_at_utc))
|
|
.map_err(|_| PhaseProtocolError)
|
|
}
|
|
|
|
fn saved_bytes_snapshot(saved: &crate::backup::SavedBytes) -> SavedBytesSnapshot {
|
|
(
|
|
saved.settings.clone(),
|
|
saved.server_config.clone(),
|
|
saved.internal_state.clone(),
|
|
saved.vault.clone(),
|
|
)
|
|
}
|
|
|
|
fn protected_saved_file_snapshot(root: &Path) -> Result<ProtectedFileSnapshot, PhaseProtocolError> {
|
|
[
|
|
root.join(crate::config::SETTINGS_FILE_NAME),
|
|
root.join("server.conf"),
|
|
root.join("Backup/data/internal-state.toml"),
|
|
root.join("Backup/data/vault.toml"),
|
|
]
|
|
.into_iter()
|
|
.map(|path| {
|
|
let bytes = match std::fs::read(&path) {
|
|
Ok(bytes) => Some(bytes),
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
|
Err(_) => return Err(PhaseProtocolError),
|
|
};
|
|
Ok((path, bytes))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn run_import_reissue_scenario(
|
|
fixture_root: &Path,
|
|
output: &mut impl Write,
|
|
) -> Result<(), PhaseProtocolError> {
|
|
const VAULT_PASSWORD: &str = "m8 synthetic fixture password";
|
|
let source_valid = fixture_root.join("baseline-client.conf");
|
|
let valid_bytes = std::fs::read(&source_valid).map_err(|_| PhaseProtocolError)?;
|
|
let main_server =
|
|
std::fs::read(fixture_root.join("server.conf")).map_err(|_| PhaseProtocolError)?;
|
|
let import_root = fixture_root.join("import-reissue");
|
|
initialize_fixture_files(&import_root, "192.0.2.10:51820")?;
|
|
std::fs::write(import_root.join("server.conf"), &main_server)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
|
|
let server_text = std::str::from_utf8(&main_server).map_err(|_| PhaseProtocolError)?;
|
|
let server_public = server_text
|
|
.lines()
|
|
.filter_map(|line| line.trim().split_once('='))
|
|
.find(|(name, _)| name.trim() == "PrivateKey")
|
|
.and_then(|(_, value)| crate::vault::WireGuardPrivateKey::parse_base64(value.trim()).ok())
|
|
.map(|key| key.public_key())
|
|
.ok_or(PhaseProtocolError)?;
|
|
let conflict_private =
|
|
crate::vault::WireGuardPrivateKey::generate().map_err(|_| PhaseProtocolError)?;
|
|
let conflict_identity = conflict_private.public_key();
|
|
let mut server_file = std::fs::OpenOptions::new()
|
|
.append(true)
|
|
.open(import_root.join("server.conf"))
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
use std::io::Write as _;
|
|
writeln!(
|
|
server_file,
|
|
"\n[Peer]\nPublicKey = {conflict_identity}\nAllowedIPs = 10.20.0.12/32"
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
drop(server_file);
|
|
|
|
let source_dir = import_root.join("clients");
|
|
write_private_fixture_file(&source_dir.join("matched.conf"), &valid_bytes)?;
|
|
let conflict_text = format!(
|
|
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.13/32\n\n[Peer]\nPublicKey = {server_public}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 192.0.2.10:51820\n",
|
|
conflict_private.to_base64()
|
|
);
|
|
write_private_fixture_file(&source_dir.join("conflict.conf"), conflict_text.as_bytes())?;
|
|
write_private_fixture_file(
|
|
&source_dir.join("invalid.conf"),
|
|
b"not a WireGuard config\n",
|
|
)?;
|
|
|
|
let original_valid =
|
|
std::fs::read(source_dir.join("matched.conf")).map_err(|_| PhaseProtocolError)?;
|
|
let original_conflict =
|
|
std::fs::read(source_dir.join("conflict.conf")).map_err(|_| PhaseProtocolError)?;
|
|
let original_invalid =
|
|
std::fs::read(source_dir.join("invalid.conf")).map_err(|_| PhaseProtocolError)?;
|
|
verify_private_fixture_input(&source_dir)?;
|
|
let mut app = open_fixture_application(&import_root, "192.0.2.10:51820")?;
|
|
let report = app
|
|
.prepare_initial_setup_from_sources(VAULT_PASSWORD)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let summaries = report.reconciliation.candidate_summaries();
|
|
let matched = summaries.iter().any(|(name, status)| {
|
|
*name == "matched.conf"
|
|
&& matches!(
|
|
status,
|
|
crate::vault::import::CandidateStatus::Matched {
|
|
address_discrepancy: false,
|
|
server_identity: crate::vault::import::ServerIdentityComparison::Matches,
|
|
..
|
|
}
|
|
)
|
|
});
|
|
let conflict = summaries.iter().any(|(name, status)| {
|
|
*name == "conflict.conf"
|
|
&& matches!(
|
|
status,
|
|
crate::vault::import::CandidateStatus::Matched {
|
|
address_discrepancy: true,
|
|
server_identity: crate::vault::import::ServerIdentityComparison::Matches,
|
|
..
|
|
}
|
|
)
|
|
});
|
|
let invalid = summaries.iter().any(|(name, status)| {
|
|
*name == "invalid.conf"
|
|
&& matches!(status, crate::vault::import::CandidateStatus::Invalid(_))
|
|
});
|
|
if !report.scan_complete || !matched || !conflict || !invalid {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
let backup = app.commit().map_err(|_| PhaseProtocolError)?;
|
|
verify_fixture_private_archive(&backup)?;
|
|
|
|
// A fresh application proves reissue is authorized only from the encrypted
|
|
// saved vault. The retained source file is never used as a fallback.
|
|
app = open_fixture_application(&import_root, "192.0.2.10:51820")?;
|
|
let wrong_destination = app
|
|
.prepare_first_issue_destination(import_root.join("wrong-password.conf"))
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let wrong_password_rejected = matches!(
|
|
app.reissue_client_file(
|
|
crate::vault::import::parse_client_config(&valid_bytes)
|
|
.map_err(|_| PhaseProtocolError)?
|
|
.identity,
|
|
"wrong synthetic password",
|
|
wrong_destination,
|
|
false,
|
|
),
|
|
Err(crate::app::ReissueError {
|
|
stage: crate::app::ReissueStage::Authorization,
|
|
kind: crate::app::ReissueFailureKind::PasswordOrRecordRejected,
|
|
})
|
|
);
|
|
let wrong_file_absent = !import_root.join("wrong-password.conf").exists();
|
|
|
|
let valid_identity = crate::vault::import::parse_client_config(&valid_bytes)
|
|
.map_err(|_| PhaseProtocolError)?
|
|
.identity;
|
|
let conflict_identity = crate::vault::import::parse_client_config(&original_conflict)
|
|
.map_err(|_| PhaseProtocolError)?
|
|
.identity;
|
|
let conflict_destination = app
|
|
.prepare_first_issue_destination(import_root.join("conflict-reissue.conf"))
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let conflict_rejected = matches!(
|
|
app.reissue_client_file(
|
|
conflict_identity,
|
|
VAULT_PASSWORD,
|
|
conflict_destination,
|
|
false,
|
|
),
|
|
Err(crate::app::ReissueError {
|
|
stage: crate::app::ReissueStage::Authorization,
|
|
kind: crate::app::ReissueFailureKind::PasswordOrRecordRejected,
|
|
})
|
|
);
|
|
let conflict_file_absent = !import_root.join("conflict-reissue.conf").exists();
|
|
|
|
let destination_path = import_root.join("reissued-client.conf");
|
|
let destination = app
|
|
.prepare_first_issue_destination(&destination_path)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
app.reissue_client_file(valid_identity, VAULT_PASSWORD, destination, false)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
|
|
use std::os::unix::fs::MetadataExt;
|
|
let output_metadata =
|
|
std::fs::symlink_metadata(&destination_path).map_err(|_| PhaseProtocolError)?;
|
|
if output_metadata.file_type().is_symlink()
|
|
|| !output_metadata.is_file()
|
|
|| output_metadata.mode() & 0o777 != 0o600
|
|
|| !wrong_password_rejected
|
|
|| !wrong_file_absent
|
|
|| !conflict_rejected
|
|
|| !conflict_file_absent
|
|
|| !source_files_unchanged(
|
|
&source_dir,
|
|
&[
|
|
("matched.conf", &original_valid),
|
|
("conflict.conf", &original_conflict),
|
|
("invalid.conf", &original_invalid),
|
|
],
|
|
)
|
|
{
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
verify_fixture_private_saved_files(&import_root)?;
|
|
writeln!(
|
|
output,
|
|
"phase=import_reissue status=verified matched=true conflict_visible=true invalid_visible=true originals_unchanged=true wrong_password_blocked=true conflict_reissue_blocked=true reissue_mode=0600"
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn source_files_unchanged(source_dir: &Path, originals: &[(&str, &[u8])]) -> bool {
|
|
originals.iter().all(|(name, original)| {
|
|
std::fs::read(source_dir.join(name)).is_ok_and(|current| current == *original)
|
|
})
|
|
}
|
|
|
|
fn write_private_fixture_file(path: &Path, bytes: &[u8]) -> Result<(), PhaseProtocolError> {
|
|
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
|
let mut file = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.mode(0o600)
|
|
.open(path)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
file.write_all(bytes).map_err(|_| PhaseProtocolError)?;
|
|
file.sync_all().map_err(|_| PhaseProtocolError)?;
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
|
.map_err(|_| PhaseProtocolError)
|
|
}
|
|
|
|
fn verify_private_fixture_input(source_dir: &Path) -> Result<(), PhaseProtocolError> {
|
|
use std::os::unix::fs::MetadataExt;
|
|
let directory = std::fs::symlink_metadata(source_dir).map_err(|_| PhaseProtocolError)?;
|
|
if directory.file_type().is_symlink()
|
|
|| !directory.is_dir()
|
|
|| directory.mode() & 0o777 != 0o700
|
|
{
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
for name in ["matched.conf", "conflict.conf", "invalid.conf"] {
|
|
let metadata =
|
|
std::fs::symlink_metadata(source_dir.join(name)).map_err(|_| PhaseProtocolError)?;
|
|
if metadata.file_type().is_symlink()
|
|
|| !metadata.is_file()
|
|
|| metadata.mode() & 0o777 != 0o600
|
|
{
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn expected_restore_reservations(
|
|
prior: std::collections::BTreeSet<std::net::IpAddr>,
|
|
removed_client_addresses: std::collections::BTreeSet<std::net::IpAddr>,
|
|
restored_client_addresses: std::collections::BTreeSet<std::net::IpAddr>,
|
|
) -> std::collections::BTreeSet<std::net::IpAddr> {
|
|
let mut expected = prior;
|
|
expected.extend(removed_client_addresses);
|
|
expected.retain(|address| !restored_client_addresses.contains(address));
|
|
expected
|
|
}
|
|
|
|
fn read_phase_line(input: &mut impl BufRead) -> Result<Option<String>, PhaseProtocolError> {
|
|
let mut bytes = Vec::with_capacity(MAX_PHASE_LINE_BYTES);
|
|
loop {
|
|
let available = input.fill_buf().map_err(|_| PhaseProtocolError)?;
|
|
if available.is_empty() {
|
|
return if bytes.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Err(PhaseProtocolError)
|
|
};
|
|
}
|
|
let newline = available.iter().position(|byte| *byte == b'\n');
|
|
let consumed = newline.map_or(available.len(), |index| index + 1);
|
|
if bytes.len().saturating_add(consumed) > MAX_PHASE_LINE_BYTES + 1 {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
bytes.extend_from_slice(&available[..consumed]);
|
|
input.consume(consumed);
|
|
if newline.is_some() {
|
|
bytes.pop();
|
|
return String::from_utf8(bytes)
|
|
.map(Some)
|
|
.map_err(|_| PhaseProtocolError);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn open_fixture_application(
|
|
root: &Path,
|
|
endpoint: &str,
|
|
) -> Result<Application, PhaseProtocolError> {
|
|
initialize_fixture_files(root, endpoint)?;
|
|
let root = std::fs::canonicalize(root).map_err(|_| PhaseProtocolError)?;
|
|
let settings_path = root.join(crate::config::SETTINGS_FILE_NAME);
|
|
let settings_text = std::fs::read_to_string(settings_path).map_err(|_| PhaseProtocolError)?;
|
|
let resolved = SettingsFile::parse_toml(&settings_text)
|
|
.and_then(SettingsFile::validate)
|
|
.map(|settings| settings.resolve_paths(&root))
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let store = SavedStore::new(&root, resolved).map_err(|_| PhaseProtocolError)?;
|
|
Application::open_from_saved_store(store).map_err(|_| PhaseProtocolError)
|
|
}
|
|
|
|
fn verify_fixture_private_saved_files(root: &Path) -> Result<(), PhaseProtocolError> {
|
|
use std::os::unix::fs::MetadataExt;
|
|
|
|
for path in [
|
|
root.join(crate::config::SETTINGS_FILE_NAME),
|
|
root.join("server.conf"),
|
|
root.join("Backup/data/internal-state.toml"),
|
|
root.join("Backup/data/vault.toml"),
|
|
] {
|
|
let metadata = std::fs::symlink_metadata(path).map_err(|_| PhaseProtocolError)?;
|
|
if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.mode() & 0o077 != 0
|
|
{
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_fixture_private_archive(path: &Path) -> Result<(), PhaseProtocolError> {
|
|
use std::os::unix::fs::MetadataExt;
|
|
|
|
let metadata = std::fs::symlink_metadata(path).map_err(|_| PhaseProtocolError)?;
|
|
if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.mode() & 0o077 != 0 {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn observe_fixture_peer_set() -> Result<
|
|
std::collections::BTreeMap<crate::domain::PublicKey, Vec<ipnet::IpNet>>,
|
|
PhaseProtocolError,
|
|
> {
|
|
let observation = crate::runtime::transport::observe_interface(
|
|
"wg-m83-probe",
|
|
std::time::Duration::from_secs(2),
|
|
crate::runtime::netlink::DecodeLimits::default(),
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let crate::runtime::InterfaceObservation::Present { peers, .. } = observation.interface else {
|
|
return Err(PhaseProtocolError);
|
|
};
|
|
if peers.completeness != crate::runtime::PeerSetCompleteness::Complete
|
|
|| !peers.duplicate_public_keys.is_empty()
|
|
{
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
peers
|
|
.peers
|
|
.into_iter()
|
|
.map(|(identity, peer)| match peer.allowed_ips {
|
|
crate::runtime::Observed::Available(routes) => Ok((identity, routes)),
|
|
crate::runtime::Observed::Unavailable => Err(PhaseProtocolError),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn fixture_endpoint_from_saved_settings(root: &Path) -> Result<String, PhaseProtocolError> {
|
|
let settings = std::fs::read_to_string(root.join(crate::config::SETTINGS_FILE_NAME))
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let endpoint = SettingsFile::parse_toml(&settings)
|
|
.and_then(SettingsFile::validate)
|
|
.map_err(|_| PhaseProtocolError)?
|
|
.server_endpoint;
|
|
let host = match endpoint.host {
|
|
crate::config::EndpointHost::Hostname(host) => host,
|
|
crate::config::EndpointHost::Ip(std::net::IpAddr::V4(address)) => address.to_string(),
|
|
crate::config::EndpointHost::Ip(std::net::IpAddr::V6(address)) => format!("[{address}]"),
|
|
};
|
|
Ok(format!("{host}:{}", endpoint.port))
|
|
}
|
|
|
|
fn initialize_fixture_files(root: &Path, endpoint: &str) -> Result<(), PhaseProtocolError> {
|
|
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
|
|
|
std::fs::create_dir_all(root).map_err(|_| PhaseProtocolError)?;
|
|
std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700))
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let source = root.join("clients");
|
|
let backup = root.join("Backup");
|
|
let data = backup.join("data");
|
|
let archives = backup.join("archives");
|
|
for directory in [&source, &backup, &data, &archives] {
|
|
std::fs::create_dir_all(directory).map_err(|_| PhaseProtocolError)?;
|
|
std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
}
|
|
|
|
let settings = format!(
|
|
r#"interface_name = "wg-m83-probe"
|
|
server_config_path = "server.conf"
|
|
client_source_directory = "clients"
|
|
server_endpoint = "{endpoint}"
|
|
ipv4_allocation_range = "10.20.0.0/24"
|
|
ipv6_allocation_range = "fd20::/64"
|
|
dns_addresses = ["10.20.0.1"]
|
|
systemd_unit = "wg-quick@wg0.service"
|
|
restart_timeout_seconds = 30
|
|
"#
|
|
);
|
|
let settings_path = root.join(crate::config::SETTINGS_FILE_NAME);
|
|
if !settings_path.exists() {
|
|
let mut file = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.mode(0o600)
|
|
.open(&settings_path)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
use std::io::Write;
|
|
file.write_all(settings.as_bytes())
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
}
|
|
let server_path = root.join("server.conf");
|
|
if !server_path.exists() {
|
|
let server_key =
|
|
crate::vault::WireGuardPrivateKey::generate().map_err(|_| PhaseProtocolError)?;
|
|
let server = format!(
|
|
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24, fd20::1/64\nListenPort = 51820\n",
|
|
server_key.to_base64()
|
|
);
|
|
let mut file = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.mode(0o600)
|
|
.open(&server_path)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
use std::io::Write;
|
|
file.write_all(server.as_bytes())
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn initialize_baseline_fixture(root: &Path, endpoint: &str) -> Result<(), PhaseProtocolError> {
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
|
|
let baseline_path = root.join("baseline-client.conf");
|
|
if baseline_path.exists() {
|
|
return Ok(());
|
|
}
|
|
let server_path = root.join("server.conf");
|
|
let server_text = std::fs::read_to_string(&server_path).map_err(|_| PhaseProtocolError)?;
|
|
let server_private = server_text
|
|
.lines()
|
|
.filter_map(|line| line.trim().split_once('='))
|
|
.find(|(name, _)| name.trim() == "PrivateKey")
|
|
.map(|(_, value)| value.trim())
|
|
.ok_or(PhaseProtocolError)?;
|
|
let server_private = crate::vault::WireGuardPrivateKey::parse_base64(server_private)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let client_private =
|
|
crate::vault::WireGuardPrivateKey::generate().map_err(|_| PhaseProtocolError)?;
|
|
let client_public = client_private.public_key();
|
|
let client_address = "10.20.0.10/32";
|
|
let mut server_file = std::fs::OpenOptions::new()
|
|
.append(true)
|
|
.open(&server_path)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
use std::io::Write;
|
|
writeln!(
|
|
server_file,
|
|
"\n[Peer]\nPublicKey = {client_public}\nAllowedIPs = {client_address}"
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
drop(server_file);
|
|
let document = crate::wireguard::ConfigDocument::parse(
|
|
&std::fs::read(&server_path).map_err(|_| PhaseProtocolError)?,
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let document = document
|
|
.insert_legacy_metadata(&client_public, "M8 baseline", None)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
std::fs::write(&server_path, document.render()).map_err(|_| PhaseProtocolError)?;
|
|
|
|
let client_config = format!(
|
|
"[Interface]\nPrivateKey = {}\nAddress = {client_address}\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = {endpoint}\n",
|
|
client_private.to_base64(),
|
|
server_private.public_key()
|
|
);
|
|
let mut client_file = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.mode(0o600)
|
|
.open(&baseline_path)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
client_file
|
|
.write_all(client_config.as_bytes())
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn remove_fixture_file(path: &Path) -> Result<(), PhaseProtocolError> {
|
|
match std::fs::remove_file(path) {
|
|
Ok(()) => Ok(()),
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(_) => Err(PhaseProtocolError),
|
|
}
|
|
}
|
|
|
|
struct FixtureSetconfCopy {
|
|
path: PathBuf,
|
|
armed: bool,
|
|
}
|
|
|
|
impl FixtureSetconfCopy {
|
|
fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
|
|
fn remove(&mut self) -> Result<(), PhaseProtocolError> {
|
|
remove_fixture_file(&self.path)?;
|
|
self.armed = false;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for FixtureSetconfCopy {
|
|
fn drop(&mut self) {
|
|
if self.armed {
|
|
let _ = remove_fixture_file(&self.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_fixture_apply_with_cleanup(
|
|
apply: impl FnOnce() -> Result<(), FixtureCommandError>,
|
|
cleanup: impl FnOnce() -> Result<(), PhaseProtocolError>,
|
|
) -> RestartExecution {
|
|
let applied = apply().is_ok();
|
|
let removed = cleanup().is_ok();
|
|
RestartExecution {
|
|
outcome: if applied && removed {
|
|
RestartOutcome::Succeeded
|
|
} else {
|
|
RestartOutcome::Failed(RestartFailure::RequestFailed)
|
|
},
|
|
submitted: applied,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
}
|
|
}
|
|
|
|
fn fixture_setconf_copy(
|
|
source: &Path,
|
|
fixture_root: &Path,
|
|
) -> Result<FixtureSetconfCopy, PhaseProtocolError> {
|
|
let bytes = std::fs::read(source).map_err(|_| PhaseProtocolError)?;
|
|
if bytes.len() > 4 * 1024 * 1024 {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
let text = std::str::from_utf8(&bytes).map_err(|_| PhaseProtocolError)?;
|
|
let mut wireguard_config = String::with_capacity(text.len());
|
|
for line in text.split_inclusive('\n') {
|
|
let trimmed = line.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
|
|
let is_address_directive = trimmed
|
|
.split_once('=')
|
|
.is_some_and(|(name, _)| name.trim().eq_ignore_ascii_case("Address"));
|
|
if !is_address_directive {
|
|
wireguard_config.push_str(line);
|
|
}
|
|
}
|
|
let id = NEXT_FIXTURE_SETCONF_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let path = fixture_root.join(format!(
|
|
"server.wg-setconf-{}-{id}.conf",
|
|
std::process::id()
|
|
));
|
|
write_fixture_setconf_copy(&path, wireguard_config.as_bytes(), |file, contents| {
|
|
use std::io::Write;
|
|
file.write_all(contents)
|
|
})
|
|
}
|
|
|
|
fn write_fixture_setconf_copy(
|
|
path: &Path,
|
|
contents: &[u8],
|
|
write_contents: impl FnOnce(&mut std::fs::File, &[u8]) -> std::io::Result<()>,
|
|
) -> Result<FixtureSetconfCopy, PhaseProtocolError> {
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
|
|
let mut file = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.mode(0o600)
|
|
.open(path)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
let copy = FixtureSetconfCopy {
|
|
path: path.to_path_buf(),
|
|
armed: true,
|
|
};
|
|
write_contents(&mut file, contents).map_err(|_| PhaseProtocolError)?;
|
|
Ok(copy)
|
|
}
|
|
|
|
fn fixture_underlay_endpoint() -> Result<String, PhaseProtocolError> {
|
|
let output = Command::new("/bin/busybox")
|
|
.args([
|
|
"timeout", "-s", "KILL", "15", "ip", "-4", "-o", "addr", "show", "dev", "eth0",
|
|
])
|
|
.stdin(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.output()
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
if !output.status.success() || output.stdout.len() > 128 {
|
|
return Err(PhaseProtocolError);
|
|
}
|
|
let line = std::str::from_utf8(&output.stdout).map_err(|_| PhaseProtocolError)?;
|
|
let address = line
|
|
.split_whitespace()
|
|
.collect::<Vec<_>>()
|
|
.windows(2)
|
|
.find(|pair| pair[0] == "inet")
|
|
.and_then(|pair| pair[1].split('/').next())
|
|
.and_then(|address| address.parse::<Ipv4Addr>().ok())
|
|
.filter(|address| !address.is_unspecified() && !address.is_loopback())
|
|
.ok_or(PhaseProtocolError)?;
|
|
Ok(format!("{address}:51820"))
|
|
}
|
|
|
|
/// Explicitly selected in-container Application scenario driver. All control
|
|
/// messages and results are bounded and secret-free; fixture root is supplied
|
|
/// only as a private test-directory path, never as a secret or VPN config.
|
|
#[test]
|
|
#[ignore = "selected explicitly by the M8.5 in-container scenario harness"]
|
|
fn alpine_scenario_driver() {
|
|
let result = (|| {
|
|
let root = std::env::var_os("WIREGUARD_ADMIN_FIXTURE_ROOT").ok_or(PhaseProtocolError)?;
|
|
let root = PathBuf::from(root);
|
|
let endpoint = fixture_underlay_endpoint()?;
|
|
initialize_fixture_files(&root, &endpoint)?;
|
|
initialize_baseline_fixture(&root, &endpoint)?;
|
|
let mut app = open_fixture_application(&root, &endpoint)?;
|
|
if !app.vault_initialized() {
|
|
let vault = crate::vault::VaultBundle::create("m8 synthetic fixture password")
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
app.prepare_initial_vault(vault)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
app.commit().map_err(|_| PhaseProtocolError)?;
|
|
}
|
|
writeln!(
|
|
std::io::stdout().lock(),
|
|
"phase=fixture_bootstrap status=ok"
|
|
)
|
|
.map_err(|_| PhaseProtocolError)?;
|
|
run_phase_protocol(
|
|
&mut app,
|
|
&root,
|
|
std::io::stdin().lock(),
|
|
std::io::stdout().lock(),
|
|
)
|
|
})();
|
|
if result.is_err() {
|
|
eprintln!("fixture phase protocol failed (redacted)");
|
|
panic!("fixture phase protocol failed");
|
|
}
|
|
}
|
|
|
|
/// Harmless startup probe selected by `runtime-check.sh --driver PATH`.
|
|
/// Running this proves that the test harness can load and execute in Alpine;
|
|
/// it does not inspect or mutate a WireGuard interface.
|
|
#[test]
|
|
#[ignore = "selected explicitly by the Alpine runtime readiness wrapper"]
|
|
fn alpine_readiness() {
|
|
assert_eq!(std::env::consts::OS, "linux");
|
|
assert_eq!(std::env::consts::ARCH, "x86_64");
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn scenario_bootstrap_creates_private_synthetic_saved_fixture() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let root = tempfile::tempdir().expect("fixture root");
|
|
initialize_fixture_files(root.path(), "192.0.2.10:51820").expect("fixture bootstrap");
|
|
let app = open_fixture_application(root.path(), "192.0.2.10:51820")
|
|
.expect("fixture application opens");
|
|
|
|
assert!(!app.vault_initialized());
|
|
for path in [
|
|
root.path().to_path_buf(),
|
|
root.path().join("clients"),
|
|
root.path().join("Backup"),
|
|
root.path().join("Backup/data"),
|
|
root.path().join("Backup/archives"),
|
|
] {
|
|
assert_eq!(
|
|
std::fs::metadata(path)
|
|
.expect("private fixture directory")
|
|
.permissions()
|
|
.mode()
|
|
& 0o777,
|
|
0o700
|
|
);
|
|
}
|
|
for name in [crate::config::SETTINGS_FILE_NAME, "server.conf"] {
|
|
assert_eq!(
|
|
std::fs::metadata(root.path().join(name))
|
|
.expect("private fixture file")
|
|
.permissions()
|
|
.mode()
|
|
& 0o777,
|
|
0o600
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fixture_setconf_copy_removes_only_wg_quick_address_directive_privately() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let root = tempfile::tempdir().expect("fixture root");
|
|
let source = root.path().join("server.conf");
|
|
std::fs::write(
|
|
&source,
|
|
"[Interface]\nPrivateKey = synthetic\nAddress = 10.20.0.1/24\nListenPort = 51820\n\n[Peer]\nPublicKey = synthetic-peer\nAllowedIPs = 10.20.0.2/32\n",
|
|
)
|
|
.expect("synthetic server config");
|
|
|
|
let copy = fixture_setconf_copy(&source, root.path()).expect("wg setconf copy");
|
|
let rendered = std::fs::read_to_string(copy.path()).expect("fixture setconf file");
|
|
assert!(rendered.contains("PrivateKey = synthetic"));
|
|
assert!(rendered.contains("[Peer]"));
|
|
assert!(!rendered.contains("Address ="));
|
|
assert_eq!(
|
|
std::fs::metadata(copy.path())
|
|
.expect("private fixture setconf file")
|
|
.permissions()
|
|
.mode()
|
|
& 0o777,
|
|
0o600
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn setconf_copy_is_removed_when_restart_preview_fails() {
|
|
let root = tempfile::tempdir().expect("fixture root");
|
|
let source = root.path().join("server.conf");
|
|
let original = b"[Interface]\nPrivateKey = synthetic\nAddress = 10.20.0.1/24\n";
|
|
std::fs::write(&source, original).expect("synthetic source config");
|
|
|
|
let copy_path = {
|
|
let copy = fixture_setconf_copy(&source, root.path()).expect("setconf copy");
|
|
let copy_path = copy.path().to_path_buf();
|
|
// Returning from this scope stands in for preview or preflight failure.
|
|
copy_path
|
|
};
|
|
|
|
assert!(!copy_path.exists());
|
|
assert_eq!(std::fs::read(source).expect("original config"), original);
|
|
}
|
|
|
|
#[test]
|
|
fn setconf_copy_is_removed_after_partial_write_failure() {
|
|
let root = tempfile::tempdir().expect("fixture root");
|
|
let path = root.path().join("partial-secret.conf");
|
|
let result = write_fixture_setconf_copy(&path, b"synthetic secret", |file, contents| {
|
|
use std::io::Write;
|
|
file.write_all(&contents[..8])?;
|
|
Err(std::io::Error::from(std::io::ErrorKind::WriteZero))
|
|
});
|
|
|
|
assert!(matches!(result, Err(PhaseProtocolError)));
|
|
assert!(!path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn cleanup_failure_preserves_successful_setconf_submission() {
|
|
let execution = run_fixture_apply_with_cleanup(|| Ok(()), || Err(PhaseProtocolError));
|
|
|
|
assert_eq!(
|
|
execution,
|
|
RestartExecution {
|
|
outcome: RestartOutcome::Failed(RestartFailure::RequestFailed),
|
|
submitted: true,
|
|
phase: crate::service::RestartPhase::WaitForJob,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct RecordingRunner {
|
|
invocation: Option<(String, Vec<String>)>,
|
|
succeeds: bool,
|
|
}
|
|
|
|
impl CommandRunner for RecordingRunner {
|
|
fn run(
|
|
&mut self,
|
|
program: &str,
|
|
args: &[String],
|
|
) -> Result<ExitStatus, FixtureCommandError> {
|
|
self.invocation = Some((program.to_owned(), args.to_vec()));
|
|
Ok(test_status(self.succeeds))
|
|
}
|
|
}
|
|
|
|
fn test_status(success: bool) -> ExitStatus {
|
|
// `true` and `false` are used only to obtain portable std::process
|
|
// ExitStatus values; they are never executed by the fixture driver.
|
|
let code = if success { 0 } else { 1 };
|
|
use std::os::unix::process::ExitStatusExt;
|
|
ExitStatus::from_raw(code << 8)
|
|
}
|
|
|
|
#[test]
|
|
fn fixture_apply_emits_only_explicit_typed_ipv4_commands() {
|
|
let command = FixtureCommand::AddIpv4Route {
|
|
interface: "wg-fixture".into(),
|
|
route: "10.88.0.0/24".into(),
|
|
};
|
|
let mut runner = RecordingRunner {
|
|
succeeds: true,
|
|
..RecordingRunner::default()
|
|
};
|
|
|
|
execute_with(&command, &mut runner).expect("fixture route applies");
|
|
|
|
assert_eq!(
|
|
runner.invocation,
|
|
Some((
|
|
"ip".into(),
|
|
vec!["route", "add", "10.88.0.0/24", "dev", "wg-fixture"]
|
|
.into_iter()
|
|
.map(str::to_owned)
|
|
.collect()
|
|
))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fixture_setup_creates_a_wireguard_link_without_a_shell() {
|
|
let command = FixtureCommand::CreateWireGuard {
|
|
interface: "wg-fixture".into(),
|
|
};
|
|
let mut runner = RecordingRunner {
|
|
succeeds: true,
|
|
..RecordingRunner::default()
|
|
};
|
|
|
|
execute_with(&command, &mut runner).expect("fixture link created");
|
|
|
|
assert_eq!(
|
|
runner.invocation,
|
|
Some((
|
|
"ip".into(),
|
|
vec!["link", "add", "dev", "wg-fixture", "type", "wireguard"]
|
|
.into_iter()
|
|
.map(str::to_owned)
|
|
.collect()
|
|
))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fixture_setup_adds_only_a_validated_ipv4_address() {
|
|
let command = FixtureCommand::AddIpv4Address {
|
|
interface: "wg-fixture".into(),
|
|
address: "10.88.0.1/24".into(),
|
|
};
|
|
let mut runner = RecordingRunner {
|
|
succeeds: true,
|
|
..RecordingRunner::default()
|
|
};
|
|
|
|
execute_with(&command, &mut runner).expect("fixture address added");
|
|
|
|
assert_eq!(
|
|
runner.invocation,
|
|
Some((
|
|
"ip".into(),
|
|
vec!["address", "add", "10.88.0.1/24", "dev", "wg-fixture"]
|
|
.into_iter()
|
|
.map(str::to_owned)
|
|
.collect()
|
|
))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fixture_apply_rejects_shell_text_and_paths_outside_private_root() {
|
|
assert_eq!(
|
|
FixtureCommand::LinkUp {
|
|
interface: "wg0; touch /tmp/unexpected".into(),
|
|
}
|
|
.program_and_args(),
|
|
Err(FixtureCommandError)
|
|
);
|
|
assert_eq!(
|
|
FixtureCommand::SetWireGuard {
|
|
interface: "wg0".into(),
|
|
config_path: "/etc/wireguard/wg0.conf".into(),
|
|
}
|
|
.program_and_args(),
|
|
Err(FixtureCommandError)
|
|
);
|
|
assert_eq!(
|
|
FixtureCommand::SetWireGuard {
|
|
interface: "wg0".into(),
|
|
config_path: format!("{FIXTURE_ROOT}/session/../outside.conf"),
|
|
}
|
|
.program_and_args(),
|
|
Err(FixtureCommandError)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fixture_apply_failure_is_a_fixed_redacted_category() {
|
|
let command = FixtureCommand::LinkUp {
|
|
interface: "wg0".into(),
|
|
};
|
|
let mut runner = RecordingRunner {
|
|
succeeds: false,
|
|
..RecordingRunner::default()
|
|
};
|
|
|
|
assert_eq!(
|
|
execute_with(&command, &mut runner),
|
|
Err(FixtureCommandError)
|
|
);
|
|
assert_eq!(FixtureCommandError.to_string(), "fixture command failed");
|
|
}
|
|
|
|
#[test]
|
|
fn server_config_path_is_a_fixture_file_path_without_command_interpretation() {
|
|
let directory = tempfile::tempdir().expect("fixture directory");
|
|
let root = directory.path().join("fixture");
|
|
std::fs::create_dir(&root).expect("private fixture root");
|
|
let session = root.join("session");
|
|
std::fs::create_dir(&session).expect("fixture session");
|
|
let config = session.join("server.wg");
|
|
std::fs::write(&config, b"[Interface]\nPrivateKey = synthetic\n")
|
|
.expect("synthetic config");
|
|
let command = FixtureCommand::SetWireGuard {
|
|
interface: "wg0".into(),
|
|
config_path: config.to_string_lossy().into_owned(),
|
|
};
|
|
assert_eq!(
|
|
command
|
|
.program_and_args_with_root(&root)
|
|
.expect("valid fixture path"),
|
|
(
|
|
"wg",
|
|
vec![
|
|
"setconf".into(),
|
|
"wg0".into(),
|
|
config.to_string_lossy().into_owned()
|
|
]
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn setconf_rejects_a_symlink_that_escapes_the_private_fixture_root() {
|
|
let directory = tempfile::tempdir().expect("fixture directory");
|
|
let root = directory.path().join("fixture");
|
|
let session = root.join("session");
|
|
std::fs::create_dir_all(&session).expect("fixture session");
|
|
let outside = directory.path().join("outside.wg");
|
|
std::fs::write(&outside, b"synthetic secret config").expect("outside config");
|
|
let link = session.join("server.wg");
|
|
std::os::unix::fs::symlink(&outside, &link).expect("escaping symlink");
|
|
let command = FixtureCommand::SetWireGuard {
|
|
interface: "wg0".into(),
|
|
config_path: link.to_string_lossy().into_owned(),
|
|
};
|
|
|
|
assert_eq!(
|
|
command.program_and_args_with_root(&root),
|
|
Err(FixtureCommandError)
|
|
);
|
|
assert_eq!(FixtureCommandError.to_string(), "fixture command failed");
|
|
}
|
|
|
|
#[test]
|
|
fn baseline_fixture_peer_has_managed_metadata_and_an_ipv4_host_route() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let root = tempfile::tempdir().expect("fixture root");
|
|
initialize_fixture_files(root.path(), "192.0.2.9:51820").expect("fixture files");
|
|
initialize_baseline_fixture(root.path(), "192.0.2.9:51820").expect("baseline fixture");
|
|
let server = std::fs::read(root.path().join("server.conf")).expect("server config");
|
|
let document = crate::wireguard::ConfigDocument::parse(&server).expect("valid document");
|
|
let client = std::fs::read_to_string(root.path().join("baseline-client.conf"))
|
|
.expect("baseline client file");
|
|
let private_key = client
|
|
.lines()
|
|
.filter_map(|line| line.trim().split_once('='))
|
|
.find(|(name, _)| name.trim() == "PrivateKey")
|
|
.map(|(_, value)| value.trim())
|
|
.expect("client private key");
|
|
let identity = crate::vault::WireGuardPrivateKey::parse_base64(private_key)
|
|
.expect("valid synthetic key")
|
|
.public_key();
|
|
let peer = document
|
|
.peers()
|
|
.iter()
|
|
.find(|peer| peer.identity == Some(identity))
|
|
.expect("baseline server peer");
|
|
assert_eq!(
|
|
peer.allowed_ips,
|
|
["10.20.0.10/32".parse().expect("IPv4 route")]
|
|
);
|
|
assert!(matches!(
|
|
peer.metadata,
|
|
crate::wireguard::MetadataAttachment::Valid { ref metadata, .. }
|
|
if metadata.alias == "M8 baseline"
|
|
));
|
|
assert_eq!(
|
|
std::fs::metadata(root.path().join("baseline-client.conf"))
|
|
.expect("client metadata")
|
|
.permissions()
|
|
.mode()
|
|
& 0o777,
|
|
0o600
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn phase_protocol_rejects_unbounded_or_untyped_requests() {
|
|
let identity = crate::domain::PublicKey::from_bytes([7; 32]);
|
|
assert_eq!(PhaseRequest::parse("COMMIT"), Ok(PhaseRequest::Commit));
|
|
assert_eq!(
|
|
PhaseRequest::parse("IMPORT_REISSUE"),
|
|
Ok(PhaseRequest::ImportReissue)
|
|
);
|
|
assert_eq!(
|
|
PhaseRequest::parse("FAILURE_PREVENTS_APPLICATION"),
|
|
Ok(PhaseRequest::FailurePreventsApplication)
|
|
);
|
|
assert_eq!(
|
|
PhaseRequest::parse("CREATE\tSynthetic client"),
|
|
Ok(PhaseRequest::Create {
|
|
alias: "Synthetic client".into()
|
|
})
|
|
);
|
|
assert_eq!(PhaseRequest::parse("APPLY\t"), Err(PhaseProtocolError));
|
|
assert_eq!(
|
|
PhaseRequest::parse("APPLY\t/tmp/server.conf\t10.20.0.2/32"),
|
|
Ok(PhaseRequest::Apply {
|
|
config_path: "/tmp/server.conf".into(),
|
|
released_address: Some("10.20.0.2/32".into()),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
PhaseRequest::parse("APPLY\t/tmp/server.conf\t10.20.0.2/24"),
|
|
Err(PhaseProtocolError)
|
|
);
|
|
assert_eq!(
|
|
PhaseRequest::parse(&format!("DELETE\t{identity}\t10.20.0.2/32")),
|
|
Ok(PhaseRequest::Delete {
|
|
identity,
|
|
address: "10.20.0.2".into(),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
PhaseRequest::parse(&format!("DELETE\t{identity}\t10.20.0.2/24")),
|
|
Err(PhaseProtocolError)
|
|
);
|
|
assert_eq!(
|
|
PhaseRequest::parse(&format!("DELETE\t{identity}\tnot-an-address")),
|
|
Err(PhaseProtocolError)
|
|
);
|
|
assert_eq!(
|
|
PhaseRequest::parse("RESTORE\twga-pre-commit.tar\t10.20.0.2/32"),
|
|
Ok(PhaseRequest::Restore {
|
|
file_name: "wga-pre-commit.tar".into(),
|
|
address: "10.20.0.2".into(),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
PhaseRequest::parse("RESTORE\t../outside.tar\t10.20.0.2/32"),
|
|
Err(PhaseProtocolError)
|
|
);
|
|
assert_eq!(
|
|
PhaseRequest::parse("RESTORE\twga-pre-commit.tar\t10.20.0.2/24"),
|
|
Err(PhaseProtocolError)
|
|
);
|
|
assert_eq!(
|
|
PhaseRequest::parse("COMMIT\t/etc/passwd"),
|
|
Err(PhaseProtocolError)
|
|
);
|
|
assert_eq!(
|
|
PhaseRequest::parse(&"x".repeat(MAX_PHASE_LINE_BYTES + 1)),
|
|
Err(PhaseProtocolError)
|
|
);
|
|
assert_eq!(
|
|
PhaseProtocolError.to_string(),
|
|
"fixture phase request rejected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn archive_path_blocker_forces_not_a_directory_and_restores_archives() {
|
|
use std::io::ErrorKind;
|
|
|
|
let root = tempfile::tempdir().expect("private fixture root");
|
|
let archive_dir = root.path().join("Backup/archives");
|
|
std::fs::create_dir_all(&archive_dir).expect("archive directory");
|
|
std::fs::write(archive_dir.join("retained.tar"), b"synthetic archive")
|
|
.expect("synthetic archive");
|
|
|
|
{
|
|
let _blocker = ArchivePathBlocker::install(root.path()).expect("path blocker");
|
|
let error = std::fs::create_dir(archive_dir.join("new-archive"))
|
|
.expect_err("a regular file cannot be traversed as a directory");
|
|
assert_eq!(error.kind(), ErrorKind::NotADirectory);
|
|
}
|
|
|
|
assert_eq!(
|
|
std::fs::read(archive_dir.join("retained.tar")).expect("restored archive"),
|
|
b"synthetic archive"
|
|
);
|
|
assert!(
|
|
!root
|
|
.path()
|
|
.join("Backup/archives-failure-fixture-held")
|
|
.exists()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn commit_backup_failure_requires_the_typed_backup_error() {
|
|
let validation_error = Err(crate::app::AppError::InvalidSavedState);
|
|
assert!(!is_commit_backup_failure(&validation_error));
|
|
|
|
let backup_error = Err(crate::app::AppError::Backup(
|
|
crate::backup::BackupError::TooLarge,
|
|
));
|
|
assert!(is_commit_backup_failure(&backup_error));
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_peer_deletion_succeeds_without_a_vault_record() {
|
|
let root = tempfile::tempdir().expect("fixture root");
|
|
initialize_fixture_files(root.path(), "192.0.2.10:51820").expect("fixture files");
|
|
initialize_baseline_fixture(root.path(), "192.0.2.10:51820")
|
|
.expect("baseline client fixture");
|
|
let baseline =
|
|
std::fs::read(root.path().join("baseline-client.conf")).expect("baseline client file");
|
|
let identity = crate::vault::import::parse_client_config(&baseline)
|
|
.expect("baseline client parses")
|
|
.identity;
|
|
let mut app = open_fixture_application(root.path(), "192.0.2.10:51820")
|
|
.expect("fixture application opens");
|
|
|
|
assert!(app.states().draft().get(&identity).is_some());
|
|
assert!(
|
|
!app.delete_client(identity)
|
|
.expect("legacy peer deletion succeeds")
|
|
);
|
|
assert!(app.states().draft().get(&identity).is_none());
|
|
|
|
app.commit().expect("legacy peer deletion commits");
|
|
assert!(app.states().saved().get(&identity).is_none());
|
|
assert!(
|
|
app.states()
|
|
.reserved_addresses()
|
|
.contains(&"10.20.0.10".parse().expect("baseline IPv4 address"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn released_ipv4_host_cidr_is_checked_against_reserved_ip_address() {
|
|
let reserved = ["10.20.0.2".parse().expect("IPv4 address")]
|
|
.into_iter()
|
|
.collect();
|
|
assert_eq!(
|
|
released_address_is_unreserved(Some("10.20.0.2/32"), &reserved),
|
|
Ok(false)
|
|
);
|
|
assert_eq!(
|
|
released_address_is_unreserved(Some("10.20.0.3/32"), &reserved),
|
|
Ok(true)
|
|
);
|
|
assert_eq!(
|
|
released_address_is_unreserved(Some("10.20.0.2/24"), &reserved),
|
|
Err(PhaseProtocolError)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn phase_protocol_keeps_creation_commit_and_first_delivery_in_one_app_session() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
const SETTINGS: &str = r#"
|
|
interface_name = "wg-fixture"
|
|
server_config_path = "server.conf"
|
|
client_source_directory = "clients"
|
|
server_endpoint = "10.88.0.1:51820"
|
|
ipv4_allocation_range = "10.88.0.0/24"
|
|
ipv6_allocation_range = "fd88::/64"
|
|
dns_addresses = ["10.88.0.1"]
|
|
systemd_unit = "wg-quick@wg-fixture.service"
|
|
restart_timeout_seconds = 30
|
|
"#;
|
|
let directory = tempfile::tempdir().expect("fixture directory");
|
|
let root = directory.path();
|
|
let resolved = SettingsFile::parse_toml(SETTINGS)
|
|
.expect("settings parse")
|
|
.validate()
|
|
.expect("settings validate")
|
|
.resolve_paths(root);
|
|
std::fs::write(root.join(crate::config::SETTINGS_FILE_NAME), SETTINGS)
|
|
.expect("fixture settings");
|
|
let server_key = crate::vault::WireGuardPrivateKey::generate()
|
|
.expect("synthetic server key")
|
|
.to_base64();
|
|
std::fs::write(
|
|
&resolved.server_config_path,
|
|
format!(
|
|
"[Interface]\nPrivateKey = {server_key}\nAddress = 10.88.0.1/24, fd88::1/64\nListenPort = 51820\n"
|
|
),
|
|
)
|
|
.expect("fixture server config");
|
|
std::fs::set_permissions(
|
|
&resolved.server_config_path,
|
|
std::fs::Permissions::from_mode(0o600),
|
|
)
|
|
.expect("private server config");
|
|
std::fs::create_dir_all(&resolved.data_directory).expect("fixture data directory");
|
|
let vault = crate::vault::VaultBundle::create("synthetic fixture password")
|
|
.expect("synthetic vault")
|
|
.to_toml()
|
|
.expect("vault serialization");
|
|
let vault_path = resolved.data_directory.join("vault.toml");
|
|
std::fs::write(&vault_path, vault).expect("fixture vault");
|
|
std::fs::set_permissions(&vault_path, std::fs::Permissions::from_mode(0o600))
|
|
.expect("private vault");
|
|
|
|
let store = SavedStore::new(root, resolved).expect("fixture store");
|
|
let mut app = Application::open_from_saved_store(store).expect("fixture application");
|
|
let mut result = Vec::new();
|
|
run_phase_protocol(
|
|
&mut app,
|
|
root,
|
|
std::io::Cursor::new(b"CREATE\tSynthetic fixture client\nCOMMIT\nDELIVER\nEND\n"),
|
|
&mut result,
|
|
)
|
|
.expect("bounded lifecycle completes");
|
|
let report = String::from_utf8(result).expect("phase output is UTF-8");
|
|
assert!(report.contains("phase=create status=ok public_key="));
|
|
assert!(report.contains("phase=commit status=ok"));
|
|
assert!(report.contains("phase=delivery status=ok activation_warning=true"));
|
|
assert!(report.contains("phase=end status=ok"));
|
|
assert!(root.join("first-delivery.conf").is_file());
|
|
assert_eq!(app.display_projection().clients.len(), 1);
|
|
|
|
let public_key = report
|
|
.split_once("phase=create status=ok public_key=")
|
|
.and_then(|(_, tail)| tail.split_whitespace().next())
|
|
.expect("created public identity")
|
|
.to_owned();
|
|
let delivery = std::fs::read_to_string(root.join("first-delivery.conf"))
|
|
.expect("synthetic delivered file");
|
|
let address = delivery
|
|
.lines()
|
|
.find_map(|line| line.trim().strip_prefix("Address = "))
|
|
.and_then(|value| value.split(',').next())
|
|
.and_then(|value| value.trim().parse::<ipnet::IpNet>().ok())
|
|
.expect("client host address");
|
|
let delete_request = format!("DELETE\t{public_key}\t{address}\nEND\n");
|
|
let mut deletion_output = Vec::new();
|
|
run_phase_protocol(
|
|
&mut app,
|
|
root,
|
|
std::io::Cursor::new(delete_request.into_bytes()),
|
|
&mut deletion_output,
|
|
)
|
|
.expect("saved deletion and reopen complete");
|
|
let deletion_report = String::from_utf8(deletion_output).expect("phase output is UTF-8");
|
|
assert!(
|
|
deletion_report
|
|
.contains("current_record=removed backup_record=retained address_reserved=true")
|
|
);
|
|
assert!(app.display_projection().clients.is_empty());
|
|
assert!(app.states().saved().clients().next().is_none());
|
|
assert!(app.states().reserved_addresses().contains(&address.addr()));
|
|
}
|
|
|
|
#[test]
|
|
fn restore_expectation_keeps_every_removed_client_address_until_apply() {
|
|
use std::net::IpAddr;
|
|
|
|
let prior = ["10.88.0.9".parse().expect("prior reservation")]
|
|
.into_iter()
|
|
.collect();
|
|
let removed = [
|
|
"10.88.0.2".parse().expect("first removed address"),
|
|
"10.88.0.3".parse().expect("second removed address"),
|
|
]
|
|
.into_iter()
|
|
.collect();
|
|
let restored = ["10.88.0.1".parse().expect("restored client address")]
|
|
.into_iter()
|
|
.collect();
|
|
|
|
let expected = expected_restore_reservations(prior, removed, restored);
|
|
|
|
assert_eq!(
|
|
expected,
|
|
[
|
|
"10.88.0.2".parse::<IpAddr>().expect("removed address"),
|
|
"10.88.0.3".parse::<IpAddr>().expect("removed address"),
|
|
"10.88.0.9".parse::<IpAddr>().expect("prior reservation"),
|
|
]
|
|
.into_iter()
|
|
.collect()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ip_address_input_is_parsed_as_ipv4_cidr_before_use() {
|
|
let command = FixtureCommand::AddIpv4Address {
|
|
interface: "wg0".into(),
|
|
address: format!("{}/24; reboot", Ipv4Addr::new(10, 88, 0, 1)),
|
|
};
|
|
assert_eq!(command.program_and_args(), Err(FixtureCommandError));
|
|
}
|
|
|
|
#[test]
|
|
fn import_reissue_preserves_sources_and_enforces_password_and_conflict_boundaries() {
|
|
let root = tempfile::tempdir().expect("fixture root");
|
|
initialize_fixture_files(root.path(), "192.0.2.10:51820").expect("fixture files");
|
|
initialize_baseline_fixture(root.path(), "192.0.2.10:51820")
|
|
.expect("generated valid baseline client");
|
|
let original = std::fs::read(root.path().join("baseline-client.conf"))
|
|
.expect("original generated client");
|
|
|
|
let mut report = Vec::new();
|
|
run_import_reissue_scenario(root.path(), &mut report)
|
|
.expect("import and password-authorized reissue scenario");
|
|
|
|
let report = String::from_utf8(report).expect("secret-free report");
|
|
assert!(report.contains("matched=true conflict_visible=true invalid_visible=true"));
|
|
assert!(report.contains("originals_unchanged=true wrong_password_blocked=true"));
|
|
assert!(report.contains("conflict_reissue_blocked=true reissue_mode=0600"));
|
|
assert_eq!(
|
|
std::fs::read(root.path().join("baseline-client.conf"))
|
|
.expect("original client remains readable"),
|
|
original
|
|
);
|
|
assert!(
|
|
root.path()
|
|
.join("import-reissue/reissued-client.conf")
|
|
.is_file()
|
|
);
|
|
assert!(
|
|
!root
|
|
.path()
|
|
.join("import-reissue/wrong-password.conf")
|
|
.exists()
|
|
);
|
|
assert!(
|
|
!root
|
|
.path()
|
|
.join("import-reissue/conflict-reissue.conf")
|
|
.exists()
|
|
);
|
|
assert!(!report.contains("PrivateKey"));
|
|
}
|
|
|
|
#[test]
|
|
fn source_preservation_check_detects_changed_invalid_candidate() {
|
|
let source_dir = tempfile::tempdir().expect("source directory");
|
|
let originals = [
|
|
("matched.conf", b"matched source".as_slice()),
|
|
("conflict.conf", b"conflicting source".as_slice()),
|
|
("invalid.conf", b"invalid source".as_slice()),
|
|
];
|
|
for (name, contents) in originals {
|
|
std::fs::write(source_dir.path().join(name), contents).expect("write source");
|
|
}
|
|
|
|
let snapshots = [
|
|
("matched.conf", b"matched source".as_slice()),
|
|
("conflict.conf", b"conflicting source".as_slice()),
|
|
("invalid.conf", b"invalid source".as_slice()),
|
|
];
|
|
assert!(source_files_unchanged(source_dir.path(), &snapshots));
|
|
|
|
std::fs::write(
|
|
source_dir.path().join("invalid.conf"),
|
|
b"changed invalid source",
|
|
)
|
|
.expect("modify invalid source");
|
|
assert!(!source_files_unchanged(source_dir.path(), &snapshots));
|
|
}
|
|
}
|