//! Bootstrap-only TCP admission. The caller supplies the broker's captured image //! and actual bound port; the CLI's executable and requested port are not evidence. //! Rule admission is deliberately not an end-to-end reachability verdict. use std::path::Path; #[cfg(windows)] mod windows; #[cfg(target_os = "linux")] mod linux; #[cfg(windows)] use windows as platform; #[cfg(target_os = "linux")] use linux as platform; /// Honor the existing host-firewall mutation opt-out before requesting elevation /// as well as inside the privileged helper (installer/CI safety boundary). // [impl->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] pub fn mutation_permitted() -> Result<(), String> { if std::env::var_os("SPT_INSTALL_NO_FIREWALL").is_some() { Err("SPT_INSTALL_NO_FIREWALL disables host firewall mutation".to_string()) } else { Ok(()) } } /// Observe the owned rule without changing host policy. // [impl->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] pub fn verify(binder: &Path, port: u16) -> Result { if !binder.is_absolute() || port == 0 { return Err("broker did not supply an absolute binder and actual TCP port".to_string()); } #[cfg(any(windows, target_os = "linux"))] { platform::verify(binder, port) } #[cfg(not(any(windows, target_os = "linux")))] { Err("bootstrap firewall tooling is unavailable on this platform".to_string()) } } /// Reconcile only bootstrap-owned resources, then observe the result. The caller /// requests elevation; a daemon/fixture start alone never changes the host firewall. // [impl->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] pub fn reconcile(binder: &Path, port: u16) -> Result<(), String> { mutation_permitted()?; if !binder.is_absolute() || port == 0 { return Err("broker did not supply an absolute binder and actual TCP port".to_string()); } #[cfg(any(windows, target_os = "linux"))] { platform::reconcile(binder, port) } #[cfg(not(any(windows, target_os = "linux")))] { Err("bootstrap firewall tooling is unavailable on this platform".to_string()) } } /// True only when no bootstrap-owned resource remains. Read-only, including when /// unelevated: an unreadable policy is an error, not proof that cleanup succeeded. // [impl->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] pub fn is_clean() -> Result { #[cfg(any(windows, target_os = "linux"))] { platform::is_clean() } #[cfg(not(any(windows, target_os = "linux")))] { Err("bootstrap firewall tooling is unavailable on this platform".to_string()) } } /// Remove only provably owned admission. Listener shutdown must happen first and /// must not depend on this operation succeeding. // [impl->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] pub fn cleanup() -> Result<(), String> { mutation_permitted()?; #[cfg(any(windows, target_os = "linux"))] { platform::cleanup() } #[cfg(not(any(windows, target_os = "linux")))] { Err("bootstrap firewall tooling is unavailable on this platform".to_string()) } } pub fn cleanup_command() -> String { #[cfg(any(windows, target_os = "linux"))] { platform::cleanup_command() } #[cfg(not(any(windows, target_os = "linux")))] { "inspect host policy for bootstrap-owned TCP admission".to_string() } } /// Time every invocation and name its outcome in the daemon's own log. /// /// F-A1-1 (releases#304 W2, HFENDULEAM field leg 2026-09-12) was a verify whose /// WRITES HAD ALREADY LANDED while the command was still walking the store: the /// event log timestamped them ~2.5-3.2 s before the command returned, and the /// only way to learn that was to read the Windows event log by hand. Nothing in /// the product could say which leg spent the budget, or whether the child was /// killed at the deadline rather than failing on its own. /// /// `label` is the LEG, not the program: every Windows leg runs the same /// `powershell.exe`, so a program name cannot tell a reader which one timed out. /// /// OUTCOME IS LOGGED BESIDE THE WALL BECAUSE A WALL ALONE IS NOT A MEASUREMENT /// (hertz's register line, releases#304): a script that fails to parse is the /// fastest run there is -- 209 ms against a real leg's ~2000 ms -- so a harness /// ranking legs by wall alone puts a script that executed no statement first. // [impl->REQ-BOOTSTRAP-FIREWALL-INVOCATION-LOG] #[cfg(any(windows, target_os = "linux"))] fn run(label: &str, program: &str, args: &[&str], budget: std::time::Duration) -> Result { let started = std::time::Instant::now(); let mut killed = false; let result = run_bounded(program, args, budget, &mut killed); eprintln!( "bootstrap-firewall leg={label} program={program} wall_ms={} outcome={}", started.elapsed().as_millis(), outcome(killed, result.is_err()) ); result } /// How an invocation ended, for the log line beside its wall. /// /// A KILLED INVOCATION MUST NOT READ AS A FAILED ONE: the budget expiring and /// the command refusing are different findings, and F-A1-1 is exactly the case /// where the work had already landed when the budget expired -- so a killed leg /// says nothing about whether the host changed. Split out to be assertable /// without spawning a child. // [impl->REQ-BOOTSTRAP-FIREWALL-INVOCATION-LOG] #[cfg(any(windows, target_os = "linux"))] fn outcome(killed: bool, failed: bool) -> &'static str { match (killed, failed) { (true, _) => "killed", (false, false) => "completed", (false, true) => "failed", } } /// Bound the complete invocation with the platform's operation budget, killing /// and reaping on expiry. Pipe completion shares the same deadline. /// /// `killed` reports whether the BUDGET expired, for the caller's log line. It is /// set only on deadline expiry: a `try_wait` failure also kills the child, but /// calling that "killed" would dress a different failure as a timeout. // [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT] #[cfg(any(windows, target_os = "linux"))] fn run_bounded( program: &str, args: &[&str], budget: std::time::Duration, killed: &mut bool, ) -> Result { use std::io::Read; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; const LIMIT: u64 = 1024 * 1024; let deadline = Instant::now() + budget; let mut command = Command::new(program); command.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH] #[cfg(windows)] { use std::os::windows::process::CommandExt; command.creation_flags(0x0800_0000); } let mut child = command.spawn().map_err(|error| format!("{program}: {error}"))?; let stdout = child.stdout.take().expect("piped stdout"); let stderr = child.stderr.take().expect("piped stderr"); let (tx, rx) = std::sync::mpsc::channel(); let err_tx = tx.clone(); std::thread::spawn(move || { let mut bytes = Vec::new(); let result = stdout.take(LIMIT + 1).read_to_end(&mut bytes).map(|_| bytes); let _ = tx.send((true, result)); }); std::thread::spawn(move || { let mut bytes = Vec::new(); let result = stderr.take(LIMIT + 1).read_to_end(&mut bytes).map(|_| bytes); let _ = err_tx.send((false, result)); }); let status = loop { match child.try_wait() { Ok(Some(status)) => break status, Ok(None) if Instant::now() < deadline => { std::thread::sleep(Duration::from_millis(10)); } result => { let _ = child.kill(); let _ = child.wait(); *killed = matches!(result, Ok(None)); return Err(match result { Err(error) => format!("{program}: waiting failed: {error}"), _ => format!("{program}: firewall command timed out"), }); } } }; let mut out = String::new(); let mut err = String::new(); for _ in 0..2 { let (is_stdout, bytes) = rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) .map_err(|_| format!("{program}: firewall output did not complete"))?; let bytes = bytes.map_err(|error| format!("{program}: reading output: {error}"))?; if bytes.len() as u64 > LIMIT { return Err(format!("{program}: firewall output exceeded {LIMIT} bytes")); } let text = String::from_utf8(bytes).map_err(|_| format!("{program}: non-UTF-8 output"))?; if is_stdout { out = text; } else { err = text; } } if status.success() { Ok(out) } else { Err(format!("{program}: exit {status}: {}", err.trim())) } } #[cfg(test)] mod tests { use super::*; /// A KILLED INVOCATION MUST NOT WEAR A FAILED OR COMPLETED FACE. The three /// outcomes answer different questions: completed means the wall beside it /// measures real work, failed means the command refused, and killed means /// the budget expired with the host's state unknown -- which is F-A1-1, /// where the writes had already landed when the budget ran out. // [unit->REQ-BOOTSTRAP-FIREWALL-INVOCATION-LOG] #[cfg(any(windows, target_os = "linux"))] #[test] fn an_invocation_outcome_separates_killed_from_failed_and_completed() { assert_eq!(outcome(false, false), "completed"); assert_eq!(outcome(false, true), "failed"); // Killed wins over the error the kill itself produced: every timeout // also returns an Err, so reading `failed` off that Err would make // "killed" unreachable and the distinction decorative. assert_eq!(outcome(true, true), "killed"); assert_eq!(outcome(true, false), "killed"); } // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT] #[cfg(windows)] #[test] fn a_hung_firewall_child_is_killed_at_its_operation_budget() { let mut killed = false; let result = run_bounded( "powershell.exe", &["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30"], std::time::Duration::from_millis(100), &mut killed, ); assert!(killed, "an unfinished child must be killed, not abandoned"); assert!(result.is_err(), "a timed-out observation cannot certify admission"); } use std::ffi::OsString; use std::sync::{Mutex, OnceLock}; const OPT_OUT: &str = "SPT_INSTALL_NO_FIREWALL"; /// `set_var` is process-global, so these cases serialize against each other and /// restore the caller's exact prior state — including "set but empty", which is /// not the same observation as "unset". (nextest's process-per-test isolates /// them anyway; this keeps a bare `cargo test` honest too.) fn env_lock() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())).lock().unwrap_or_else(|e| e.into_inner()) } struct OptOut(Option); impl OptOut { fn capture() -> Self { Self(std::env::var_os(OPT_OUT)) } fn set(value: &str) { std::env::set_var(OPT_OUT, value); } fn clear() { std::env::remove_var(OPT_OUT); } } impl Drop for OptOut { fn drop(&mut self) { match self.0.take() { Some(value) => std::env::set_var(OPT_OUT, value), None => std::env::remove_var(OPT_OUT), } } } /// An absolute path that is never used for a host query: every assertion below /// stops at a guard, so no NetSecurity/firewalld command is ever spawned. fn unreachable_binder() -> std::path::PathBuf { if cfg!(windows) { std::path::PathBuf::from("C:\\spt-core-test-never-queried\\spt.exe") } else { std::path::PathBuf::from("/spt-core-test-never-queried/spt") } } /// Presence disables mutation, not truthiness: `0` and the empty string are /// still an opt-out, because a caller that exported the name at all has /// declared the host firewall off-limits. // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] #[test] fn the_opt_out_disables_mutation_for_every_value_including_empty_and_zero() { let _lock = env_lock(); let _restore = OptOut::capture(); OptOut::clear(); assert!(mutation_permitted().is_ok(), "an unset opt-out must permit mutation"); for value in ["1", "", "0", "false"] { OptOut::set(value); let error = mutation_permitted().expect_err( "an exported SPT_INSTALL_NO_FIREWALL must disable mutation regardless of value", ); assert!( error.contains(OPT_OUT), "refusal must name the variable that caused it, got {error:?} for value {value:?}" ); } // Negative control: the refusal above is caused by the variable, not by the // function being unconditional. OptOut::clear(); assert!(mutation_permitted().is_ok(), "clearing the opt-out must restore mutation"); } /// The opt-out is consulted BEFORE argument validation and before any platform /// command, so an opted-out caller cannot reach elevation or host policy even /// with arguments that would otherwise be rejected later. // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] #[test] fn the_opt_out_refuses_before_the_argument_guard_and_before_any_host_command() { let _lock = env_lock(); let _restore = OptOut::capture(); OptOut::set("1"); let expected = mutation_permitted().expect_err("the opt-out is set for this case"); // Valid arguments: only the opt-out can be refusing here. assert_eq!( reconcile(&unreachable_binder(), 5470).expect_err("reconcile must refuse"), expected, "reconcile must refuse with the opt-out reason before touching host policy" ); assert_eq!( cleanup().expect_err("cleanup must refuse"), expected, "cleanup must refuse with the opt-out reason before touching host policy" ); // Invalid arguments: the opt-out still wins, which is what proves it is // checked first rather than merely also present. assert_eq!( reconcile(std::path::Path::new("relative/spt.exe"), 0) .expect_err("reconcile must refuse"), expected, "the opt-out must be consulted ahead of the binder/port guard" ); } /// Observation is read-only and must stay available while mutation is disabled, /// but it still refuses arguments the broker did not actually supply. Neither /// case reaches a platform command. // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] #[test] fn verify_refuses_a_relative_binder_and_port_zero_under_either_opt_out_state() { let _lock = env_lock(); let _restore = OptOut::capture(); for opted_out in [false, true] { if opted_out { OptOut::set("1"); } else { OptOut::clear(); } let relative = verify(std::path::Path::new("relative/spt.exe"), 5470) .expect_err("a relative binder is not evidence of the listener's executable"); assert!( relative.contains("absolute binder") && relative.contains("actual TCP port"), "refusal must name the missing evidence, got {relative:?}" ); assert!( !relative.contains(OPT_OUT), "read-only verification must not be refused as a mutation, got {relative:?}" ); let zero = verify(&unreachable_binder(), 0) .expect_err("port zero is a request, not the actual bound port"); assert_eq!(zero, relative, "both guards report the same missing-evidence refusal"); } } /// The residual-cleanup command handed to an operator must be the same guarded /// removal the process performs, and must never be able to create a rule. // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] #[cfg(windows)] #[test] fn the_residual_cleanup_command_is_a_self_verifying_removal_that_creates_nothing() { use base64::Engine; const PREFIX: &str = "powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand "; let command = cleanup_command(); let encoded = command .strip_prefix(PREFIX) .unwrap_or_else(|| panic!("cleanup command must be pasteable as-is, got {command:?}")); let bytes = base64::engine::general_purpose::STANDARD .decode(encoded) .expect("-EncodedCommand payload must be valid base64"); assert_eq!(bytes.len() % 2, 0, "-EncodedCommand payload must be UTF-16LE"); let script: String = char::decode_utf16( bytes.chunks_exact(2).map(|pair| u16::from_le_bytes([pair[0], pair[1]])), ) .collect::>() .expect("-EncodedCommand payload must decode as UTF-16"); for required in [ "$ErrorActionPreference = 'Stop'", "Import-Module NetSecurity", "spt-core-bootstrap-inbound-tcp", // The LAN half is removable by the same command, and named here so // a cleanup that silently stopped covering one half of the pair // cannot read as a passing self-verifying removal. "spt-core-bootstrap-inbound-tcp-lan", "spt-core bootstrap TCP", "Assert-Owned", "Remove-NetFirewallRule", "Remove-Owned", "PersistentStore", "ActiveStore", "Bootstrap rules remain", ] { assert!(script.contains(required), "cleanup script is missing {required:?}"); } // Negative control: a removal command that can also add admission is not a // cleanup command, and an unguarded removal is not an owned one. assert!( !script.contains("New-NetFirewallRule"), "the cleanup command must never be able to create admission" ); assert!( !script.contains("-ErrorAction SilentlyContinue"), "the cleanup command must not swallow NetSecurity failures" ); } }