From 7999fab25c5172207ee41e38732f4923565ff6b6 Mon Sep 17 00:00:00 2001 From: Andrew Guschin Date: Fri, 30 Oct 2026 04:43:59 +0400 Subject: Validate controller diagnostics, reboot protection, and terminal authentication --- tests/diagnostics_acceptance.rs | 605 +++++++++++++++++++++++++++++++++++++ tests/vm_boot_acceptance.py | 129 ++++++++ tests/vm_controller_acceptance.py | 126 +++++++- tests/vm_diagnostics_acceptance.py | 135 +++++++++ 4 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 tests/diagnostics_acceptance.rs create mode 100644 tests/vm_boot_acceptance.py create mode 100644 tests/vm_diagnostics_acceptance.py (limited to 'tests') diff --git a/tests/diagnostics_acceptance.rs b/tests/diagnostics_acceptance.rs new file mode 100644 index 0000000..f1db2de --- /dev/null +++ b/tests/diagnostics_acceptance.rs @@ -0,0 +1,605 @@ +use anyhow::{Result, bail}; +use async_trait::async_trait; +use roentgen::{ + controller::{Caller, Controller, Operation}, + model::Trojan, + runtime::{ProbeConnection, ProbeSession, Runtime}, + subscription::SubscriptionSource, + xray::{ProbeListener, probe_configuration}, +}; +use std::{path::PathBuf, process::Stdio, time::Duration}; +use tempfile::TempDir; + +struct Source { + port: u16, +} +#[async_trait] +impl SubscriptionSource for Source { + async fn fetch(&self, _: &str) -> Result> { + Ok(format!( + "trojan://fixture-password@127.0.0.1:{}?sni=localhost#Fixture\n", + self.port + ) + .into_bytes()) + } +} + +#[derive(Clone)] +enum ProbeMode { + Available, + Unavailable, + Impostor, +} +struct FixtureRuntime { + mode: std::sync::Arc>, + impostor: String, +} +struct FixtureProbe { + child: Option, + _directory: TempDir, + proxy: String, + certificate: String, +} +#[async_trait] +impl ProbeSession for FixtureProbe { + fn connection(&self) -> ProbeConnection<'_> { + ProbeConnection { + proxy_url: &self.proxy, + certificate: &self.certificate, + } + } + async fn stop(mut self: Box) { + if let Some(child) = &mut self.child { + let _ = child.kill().await; + let _ = child.wait().await; + } + } +} +#[async_trait] +impl Runtime for FixtureRuntime { + async fn arm(&mut self) -> Result<()> { + bail!("Host activation is outside this diagnostic fixture") + } + async fn apply(&mut self, _: Option<&Trojan>, _: u64) -> Result<()> { + bail!("No TUN in diagnostic fixture") + } + async fn disable(&mut self) -> Result<()> { + Ok(()) + } + fn routing(&self) -> &str { + "direct" + } + async fn probe(&mut self, connection: &Trojan, _: u64) -> Result> { + let mode = self.mode.lock().unwrap().clone(); + if matches!(mode, ProbeMode::Unavailable) { + bail!("Fixture probe could not start"); + } + let socket = std::net::TcpListener::bind("127.0.0.1:0")?; + let port = socket.local_addr()?.port(); + let certificate = rcgen::generate_simple_self_signed(vec!["127.0.0.1".into()])?; + let certificate_pem = certificate.cert.pem(); + let key = certificate.key_pair.serialize_pem(); + let directory = tempfile::tempdir()?; + let path = directory.path().join("probe.json"); + if matches!(mode, ProbeMode::Impostor) { + return Ok(Box::new(FixtureProbe { + child: None, + _directory: directory, + proxy: self.impostor.clone(), + certificate: certificate_pem, + })); + } + let config = probe_configuration( + connection, + &ProbeListener { + port, + password: "private-probe-password", + certificate: &certificate_pem, + private_key: &key, + }, + ); + std::fs::write(&path, serde_json::to_vec(&config)?)?; + drop(socket); + // The older distribution core is used only as a non-TUN fixture. Product + // host activation retains its independent compatibility requirement. + let child = tokio::process::Command::new("/usr/bin/xray") + .args(["run", "-config"]) + .arg(path) + .kill_on_drop(true) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + let probe = FixtureProbe { + child: Some(child), + _directory: directory, + proxy: format!("https://roentgen:private-probe-password@127.0.0.1:{port}"), + certificate: certificate_pem, + }; + for _ in 0..100 { + if tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .is_ok() + { + return Ok(Box::new(probe)); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + bail!("Fixture Xray proxy did not start") + } +} + +#[derive(serde::Deserialize)] +struct Fixture { + origin: String, + bad_origin: String, + trojan_port: u16, + access_log: PathBuf, + requests_log: PathBuf, +} + +struct Context { + _home: TempDir, + controller: Controller, + fixture: Fixture, + id: String, + mode: std::sync::Arc>, +} +impl Context { + async fn open() -> Self { + let hostname = std::fs::read_to_string("/etc/hostname").unwrap(); + assert_eq!( + hostname.trim(), + "altctlrs", + "Run diagnostic acceptance only on the authorized VM" + ); + let path = std::env::var("ROENTGEN_DIAGNOSTIC_FIXTURE") + .expect("Use tests/vm_diagnostics_acceptance.py on the supplied VM"); + let fixture: Fixture = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + let home = tempfile::tempdir().unwrap(); + let mode = std::sync::Arc::new(std::sync::Mutex::new(ProbeMode::Available)); + let mut impostor = url::Url::parse(&fixture.bad_origin).unwrap(); + impostor.set_username("roentgen").unwrap(); + impostor + .set_password(Some("private-probe-password")) + .unwrap(); + let runtime = FixtureRuntime { + mode: mode.clone(), + impostor: impostor.to_string(), + }; + let mut controller = Controller::open( + home.path(), + Box::new(runtime), + Box::new(Source { + port: fixture.trojan_port, + }), + ) + .unwrap(); + let root = Caller::root(); + controller + .execute( + &root, + Operation::AddSubscription { + name: "Fixture".into(), + url: "https://subscription.invalid/fixture".into(), + }, + ) + .await + .unwrap(); + let id = controller.configurations(None).unwrap()[0].id.clone(); + controller + .execute(&root, Operation::Select { id: id.clone() }) + .await + .unwrap(); + let mut result = Self { + _home: home, + controller, + fixture, + id, + mode, + }; + result.settings("/empty", "/large", 1, 1).await; + result + } + async fn settings(&mut self, ping: &str, speed: &str, mib: u64, seconds: u64) { + let mut settings = self.controller.settings(); + settings.ping_url = format!("{}{}", self.fixture.origin, ping); + settings.speed_url = format!("{}{}", self.fixture.origin, speed); + settings.maximum_download_mib = mib; + settings.maximum_duration_seconds = seconds; + self.controller + .execute(&Caller::root(), Operation::SaveSettings { settings }) + .await + .unwrap(); + } + fn requests(&self) -> Vec { + std::fs::read_to_string(&self.fixture.requests_log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } +} + +#[tokio::test] +async fn controller_ping_uses_the_selected_trojan_path_with_verified_tls_without_enabling() { + let mut context = Context::open().await; + let accepted_before = std::fs::read_to_string(&context.fixture.access_log) + .unwrap_or_default() + .lines() + .filter(|line| line.contains("accepted")) + .count(); + let observation = context + .controller + .diagnostic(&Caller::root(), None, false) + .await + .unwrap(); + assert!(observation.success); + assert_eq!(observation.bytes, 0); + assert_eq!(observation.stop_reason, "complete"); + assert_eq!(observation.configuration_id, context.id); + assert!(!context.controller.status().unwrap().requested_enabled); + assert_eq!(context.controller.status().unwrap().routing, "direct"); + let accepted_after = std::fs::read_to_string(&context.fixture.access_log) + .unwrap() + .lines() + .filter(|line| line.contains("accepted")) + .count(); + assert!( + accepted_after > accepted_before, + "The fixture Trojan endpoint must carry this diagnostic connection" + ); + assert!(context.requests().iter().all(|request| { + request["user_agent"] + .as_str() + .is_some_and(|value| value.starts_with("Roentgen/")) + })); + assert!( + context + .requests() + .iter() + .all(|request| request["proxy_authorization"].is_null()), + "Local proxy credentials must not reach the target" + ); +} + +#[tokio::test] +async fn controller_speed_stops_at_the_first_byte_or_duration_limit() { + let mut context = Context::open().await; + let bounded = context + .controller + .diagnostic(&Caller::root(), None, true) + .await + .unwrap(); + assert!(bounded.success); + assert_eq!(bounded.bytes, 1_048_576); + assert_eq!(bounded.stop_reason, "byte-limit"); + assert!(bounded.bytes_per_second.unwrap() > 0.0); + context.settings("/empty", "/slow", 1, 1).await; + let timed = context + .controller + .diagnostic(&Caller::root(), None, true) + .await + .unwrap(); + assert!(timed.success); + assert_eq!(timed.bytes, 16384); + assert_eq!(timed.stop_reason, "duration-limit"); + assert!(timed.elapsed_ms >= 900 && timed.elapsed_ms < 4000); + assert!(!context.controller.status().unwrap().requested_enabled); +} + +async fn private_bus(controller: Controller) -> (tokio::process::Child, zbus::Connection, String) { + use tokio::io::{AsyncBufReadExt, BufReader}; + let mut bus = tokio::process::Command::new("/bin/dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let mut address = String::new(); + BufReader::new(bus.stdout.take().unwrap()) + .read_line(&mut address) + .await + .unwrap(); + let address = address.trim().to_owned(); + let connection = zbus::connection::Builder::address(address.as_str()) + .unwrap() + .name(roentgen::bus::NAME) + .unwrap() + .serve_at( + roentgen::bus::PATH, + roentgen::bus::Service { + controller: std::sync::Arc::new(tokio::sync::Mutex::new(controller)), + cancellation: std::sync::Arc::new(tokio::sync::Notify::new()), + }, + ) + .unwrap() + .build() + .await + .unwrap(); + (bus, connection, address) +} + +#[tokio::test] +async fn cli_exits_unsuccessfully_when_the_provider_rate_limits_a_diagnostic() { + let mut context = Context::open().await; + context.settings("/rate", "/large", 1, 1).await; + let (_bus, _service, address) = private_bus(context.controller).await; + let binary = std::env::var("ROENTGEN_TEST_BINARY") + .expect("Set ROENTGEN_TEST_BINARY to the VM candidate executable"); + let output = tokio::process::Command::new(binary) + .arg("ping") + .env("DBUS_SYSTEM_BUS_ADDRESS", address) + .stdin(Stdio::null()) + .output() + .await + .unwrap(); + assert!( + !output.status.success(), + "A failed diagnostic must not produce a successful CLI exit: {}", + String::from_utf8_lossy(&output.stdout) + ); + let error = String::from_utf8_lossy(&output.stderr); + assert!( + error.contains("provider-rate-limit") && error.contains("60"), + "The failure should report the provider delay: {error}" + ); +} + +#[tokio::test] +async fn controller_records_a_failed_check_when_a_previously_working_probe_cannot_start() { + let mut context = Context::open().await; + context + .controller + .diagnostic(&Caller::root(), None, false) + .await + .unwrap(); + assert_eq!(context.controller.status().unwrap().health, "reachable"); + *context.mode.lock().unwrap() = ProbeMode::Unavailable; + assert!( + context + .controller + .diagnostic(&Caller::root(), None, false) + .await + .is_err() + ); + assert_eq!(context.controller.status().unwrap().health, "check-failed"); + assert!( + context.controller.configurations(None).unwrap()[0] + .ping_ms + .is_none() + ); + assert!(!context.controller.status().unwrap().requested_enabled); +} + +#[tokio::test] +async fn controller_rejects_redirects_oversized_ping_and_error_downloads() { + let mut context = Context::open().await; + context + .controller + .diagnostic(&Caller::root(), None, false) + .await + .unwrap(); + for (path, speed) in [ + ("/html", true), + ("/partial", true), + ("/empty", true), + ("/oversize", false), + ("/redirect", false), + ("/error", true), + ("/json-error", true), + ] { + context.settings(path, path, 1, 1).await; + let before = context.requests(); + assert!( + context + .controller + .diagnostic(&Caller::root(), None, speed) + .await + .is_err(), + "Diagnostic unexpectedly accepted {path}" + ); + let after = context.requests(); + assert!( + after[before.len()..] + .iter() + .any(|request| request["path"] == path), + "The response fixture was not reached for {path}" + ); + if path == "/redirect" { + assert!( + !after[before.len()..] + .iter() + .any(|request| request["path"] == "/empty"), + "Redirects must not be followed" + ); + } + assert!(!context.controller.status().unwrap().requested_enabled); + } +} + +#[tokio::test] +async fn controller_rejects_an_untrusted_target_or_local_proxy_without_direct_fallback() { + let mut context = Context::open().await; + context + .controller + .diagnostic(&Caller::root(), None, false) + .await + .unwrap(); + let before = context.requests().len(); + let mut settings = context.controller.settings(); + settings.ping_url = format!("{}/empty", context.fixture.bad_origin); + context + .controller + .execute(&Caller::root(), Operation::SaveSettings { settings }) + .await + .unwrap(); + assert!( + context + .controller + .diagnostic(&Caller::root(), None, false) + .await + .is_err() + ); + assert_eq!( + context.requests().len(), + before, + "An untrusted target must not receive an HTTP request" + ); + context.settings("/empty", "/large", 1, 1).await; + *context.mode.lock().unwrap() = ProbeMode::Impostor; + assert!( + context + .controller + .diagnostic(&Caller::root(), None, false) + .await + .is_err() + ); + assert_eq!( + context.requests().len(), + before, + "An impostor proxy must not receive credentials or cause direct target fallback" + ); + assert!(!context.controller.status().unwrap().requested_enabled); +} + +#[tokio::test] +async fn provider_retry_after_blocks_manual_ping_and_speed_for_both_header_forms() { + for path in ["/rate", "/rate-date"] { + let mut context = Context::open().await; + context.settings(path, "/large", 1, 1).await; + let observation = context + .controller + .diagnostic(&Caller::root(), None, false) + .await + .unwrap(); + assert!(!observation.success); + assert_eq!(observation.stop_reason, "provider-rate-limit"); + assert!(observation.retry_after_seconds.unwrap() >= 60); + let before = context.requests().len(); + for speed in [false, true] { + let error = context + .controller + .diagnostic(&Caller::root(), None, speed) + .await + .unwrap_err(); + assert!(error.to_string().contains("Retry-After")); + } + assert_eq!( + context.requests().len(), + before, + "Cooldown must prevent all further provider requests" + ); + } +} + +#[tokio::test] +async fn scheduled_checks_use_only_selection_never_run_speed_and_do_not_overlap() { + let mut context = Context::open().await; + context + .controller + .execute( + &Caller::root(), + Operation::AddSubscription { + name: "Other".into(), + url: "https://subscription.invalid/other".into(), + }, + ) + .await + .unwrap(); + let before = context.requests().len(); + context.controller.scheduled_check().await.unwrap(); + context.controller.scheduled_check().await.unwrap(); + let after = context.requests(); + assert_eq!(after.len(), before + 1, "Only one selected Ping is due"); + assert_eq!(after.last().unwrap()["path"], "/empty"); + let entries = context.controller.configurations(None).unwrap(); + assert!( + entries + .iter() + .find(|entry| entry.id == context.id) + .unwrap() + .ping_ms + .is_some() + ); + assert!( + entries + .iter() + .filter(|entry| entry.id != context.id) + .all(|entry| entry.ping_ms.is_none()) + ); + assert!( + entries + .iter() + .all(|entry| entry.speed_bytes_per_second.is_none()) + ); + assert!(!context.controller.status().unwrap().requested_enabled); +} + +#[tokio::test] +async fn cancelling_a_bus_diagnostic_stops_it_without_changing_proxy_intent() { + let mut context = Context::open().await; + context.settings("/empty", "/slow", 1, 10).await; + let before = context.requests().len(); + let requests = context.fixture.requests_log.clone(); + let (_bus, _service, address) = private_bus(context.controller).await; + let connection = zbus::connection::Builder::address(address.as_str()) + .unwrap() + .build() + .await + .unwrap(); + let request_connection = connection.clone(); + let request = tokio::spawn(async move { + let proxy = zbus::Proxy::new( + &request_connection, + roentgen::bus::NAME, + roentgen::bus::PATH, + roentgen::bus::INTERFACE, + ) + .await + .unwrap(); + proxy + .call::<_, _, String>("Diagnostic", &(String::new(), true, false)) + .await + }); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let count = std::fs::read_to_string(&requests).unwrap().lines().count(); + if count > before { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "Diagnostic did not reach its fixture" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + let proxy = zbus::Proxy::new( + &connection, + roentgen::bus::NAME, + roentgen::bus::PATH, + roentgen::bus::INTERFACE, + ) + .await + .unwrap(); + let _: () = proxy.call("CancelDiagnostic", &(false,)).await.unwrap(); + let result = tokio::time::timeout(Duration::from_secs(2), request) + .await + .unwrap() + .unwrap(); + assert!(result.unwrap_err().to_string().contains("cancelled")); + let status: String = proxy.call("Status", &()).await.unwrap(); + let status: roentgen::model::Status = serde_json::from_str(&status).unwrap(); + assert!(!status.requested_enabled); + assert_eq!(status.selected_configuration, Some(context.id)); + let rows: String = proxy + .call("Configurations", &(String::new(),)) + .await + .unwrap(); + let rows: Vec = serde_json::from_str(&rows).unwrap(); + assert!( + rows.iter() + .all(|entry| entry.speed_bytes_per_second.is_none()) + ); +} diff --git a/tests/vm_boot_acceptance.py b/tests/vm_boot_acceptance.py new file mode 100644 index 0000000..9b7b1d9 --- /dev/null +++ b/tests/vm_boot_acceptance.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""CLI/systemd acceptance on the supplied ALT VM. No successful TUN core is assumed.""" +import argparse +import contextlib +import json +import os +from pathlib import Path +import pwd +import signal +import socket +import sqlite3 +import subprocess +import time + +ROOT = Path('/var/tmp/roentgen-implementation') +BIN = str(ROOT / 'roentgen') + + +def run(*args, timeout=30, check=True): + result = subprocess.run(args, check=check, text=True, capture_output=True, timeout=timeout) + return result.stdout.strip() + + +def require_vm(): + assert os.geteuid() == 0 and socket.gethostname() == 'altctlrs' + assert (ROOT / 'allow-network-tests').exists() + + +def stage(): + require_vm() + for directory in ['/etc/roentgen', '/var/lib/roentgen']: + Path(directory).mkdir(mode=0o700, exist_ok=True) + try: + pwd.getpwnam('roentgen-xray') + except KeyError: + run('useradd', '--system', '--user-group', '--no-create-home', '--shell', '/sbin/nologin', 'roentgen-xray') + paths = { + 'roentgen.service': '/etc/systemd/system/roentgen.service', + 'roentgen-guard.service': '/etc/systemd/system/roentgen-guard.service', + 'roentgen-xray.service': '/etc/systemd/system/roentgen-xray.service', + 'org.roentgen.Controller1.conf': '/etc/dbus-1/system.d/org.roentgen.Controller1.conf', + 'org.roentgen.policy': '/usr/share/polkit-1/actions/org.roentgen.policy', + } + for name, destination in paths.items(): + Path(destination).write_bytes((ROOT / 'assets' / name).read_bytes()) + Path(destination).chmod(0o644) + for unit in ['NetworkManager', 'systemd-networkd', 'network']: + target = Path(f'/etc/systemd/system/{unit}.service.d/50-roentgen-guard.conf') + target.parent.mkdir(exist_ok=True) + target.write_bytes((ROOT / 'assets/roentgen-network-guard.conf').read_bytes()) + binary = Path('/usr/local/bin/roentgen') + binary.write_bytes(Path(BIN).read_bytes()); binary.chmod(0o755) + guard = Path('/etc/roentgen/guard.toml') + if not guard.exists(): guard.write_text('version = 1\nenabled = false\n') + run('systemctl', 'daemon-reload') + run('busctl', 'call', 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', 'ReloadConfig') + run('systemctl', 'start', 'roentgen-guard.service', 'roentgen.service') + + +def enabled_fixture(): + run('systemctl', 'stop', 'roentgen.service') + with sqlite3.connect('/var/lib/roentgen/state.sqlite3') as database: + database.execute('UPDATE state SET enabled=1,selected=NULL WHERE singleton=1') + Path('/etc/roentgen/guard.toml').write_text('version = 1\nenabled = true\n') + run(BIN, 'guard') + run('systemctl', 'start', 'roentgen.service') + status = json.loads(run(BIN, 'status', '--json')) + assert status['requested_enabled'] and status['routing'] == 'blocked' + + +def hang_recovery(): + stage(); enabled_fixture() + pid = int(run('systemctl', 'show', '--value', '-p', 'MainPID', 'roentgen.service')) + os.kill(pid, signal.SIGSTOP) + try: + rules = run('nft', 'list', 'table', 'inet', 'roentgen') + assert 'policy drop' in rules and 'oifname "roentgen0" accept' not in rules + started = time.monotonic() + run(BIN, 'recover', '--disable', timeout=25) + elapsed = time.monotonic() - started + assert elapsed < 20, f'Hung controller recovery took {elapsed:.1f}s' + with sqlite3.connect('/var/lib/roentgen/state.sqlite3') as database: + assert database.execute('SELECT enabled FROM state').fetchone()[0] == 0 + assert 'policy drop' not in run('nft', 'list', 'table', 'inet', 'roentgen') + print(json.dumps({'passed':['guard remains armed while controller is stopped', 'explicit recovery terminates a hung controller and persists disabled intent before releasing protection'], 'recovery_seconds':round(elapsed, 2)}, indent=2)) + finally: + with contextlib.suppress(ProcessLookupError): os.kill(pid, signal.SIGCONT) + + +def prepare_boot(): + stage(); enabled_fixture() + run('systemctl', 'enable', 'roentgen-guard.service', 'roentgen.service') + (ROOT / 'before-boot-id').write_text(Path('/proc/sys/kernel/random/boot_id').read_text()) + print('Enabled/blocked boot fixture prepared; reboot the VM next.') + + +def verify_boot(): + require_vm() + assert Path('/proc/sys/kernel/random/boot_id').read_text() != (ROOT / 'before-boot-id').read_text() + status = json.loads(run(BIN, 'status', '--json')) + assert status['requested_enabled'] and status['selected_configuration'] is None and status['routing'] == 'blocked' + def timestamp(unit): + return int(run('systemctl', 'show', '--value', '-p', 'ActiveEnterTimestampMonotonic', unit)) + guard, manager = timestamp('roentgen-guard.service'), timestamp('NetworkManager.service') + assert 0 < guard < manager, f'Guard/NetworkManager ordering violated: {guard}, {manager}' + rules = run('nft', 'list', 'table', 'inet', 'roentgen') + assert 'policy drop' in rules and 'oifname "roentgen0" accept' not in rules + old_pid = int(run('systemctl', 'show', '--value', '-p', 'MainPID', 'roentgen.service')) + os.kill(old_pid, signal.SIGKILL) + assert 'policy drop' in run('nft', 'list', 'table', 'inet', 'roentgen') + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + pid = int(run('systemctl', 'show', '--value', '-p', 'MainPID', 'roentgen.service')) + if pid and pid != old_pid: + result = subprocess.run([BIN, 'status', '--json'], text=True, capture_output=True, timeout=2) + if result.returncode == 0: + assert json.loads(result.stdout)['requested_enabled'] + break + time.sleep(0.2) + else: raise AssertionError('Controller did not recover from SIGKILL') + run(BIN, 'recover', '--disable') + print(json.dumps({'passed':['normal reboot restores enabled/blocked intent', 'guard becomes active before NetworkManager', 'SIGKILL does not release guard protection', 'systemd restarts the controller without disabling intent'], 'guard_active_us':guard, 'network_manager_active_us':manager}, indent=2)) + + +if __name__ == '__main__': + parser=argparse.ArgumentParser() + parser.add_argument('mode', choices=['hang', 'prepare-boot', 'verify-boot']) + mode=parser.parse_args().mode + {'hang':hang_recovery, 'prepare-boot':prepare_boot, 'verify-boot':verify_boot}[mode]() diff --git a/tests/vm_controller_acceptance.py b/tests/vm_controller_acceptance.py index 1d3405a..a6b6bc4 100644 --- a/tests/vm_controller_acceptance.py +++ b/tests/vm_controller_acceptance.py @@ -13,6 +13,8 @@ import termios import fcntl import struct import pwd +import signal +import secrets import subprocess import threading import time @@ -38,6 +40,119 @@ def cli(*args, **kwargs): return command(BIN, *args, **kwargs) def status(): return json.loads(cli('status', '--json').stdout) +def cancel_terminal_authorization(): + before = status() + process, terminal = pty.fork() + if process == 0: + user = pwd.getpwnam('rustacean') + os.initgroups(user.pw_name, user.pw_gid) + os.setgid(user.pw_gid); os.setuid(user.pw_uid) + os.environ.update(LANG='C', LC_ALL='C', TMPDIR='/var/tmp') + os.execv(BIN, [BIN, 'configuration', 'unset']) + output = b'' + reaped = False + try: + deadline = time.monotonic() + 10 + while b'Password:' not in output and time.monotonic() < deadline: + if select.select([terminal], [], [], 0.2)[0]: + try: output += os.read(terminal, 65536) + except OSError: break + assert b'Password:' in output, f'Terminal authentication did not prompt: {output.decode(errors="replace")}' + os.write(terminal, b'\x03') + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + pid, result = os.waitpid(process, os.WNOHANG) + if pid: + reaped = True + assert os.waitstatus_to_exitcode(result) != 0 + break + time.sleep(0.05) + assert reaped, 'Cancelled terminal authentication did not exit' + after = status() + assert after['selected_configuration'] == before['selected_configuration'] + assert after['requested_enabled'] == before['requested_enabled'] + assert after['generation'] == before['generation'] + finally: + with contextlib.suppress(ProcessLookupError): os.killpg(process, signal.SIGTERM) + if not reaped: + with contextlib.suppress(ChildProcessError): os.waitpid(process, 0) + os.close(terminal) + + +def terminal_password_attempt(password, succeeds): + process, terminal = pty.fork() + if process == 0: + user = pwd.getpwnam('rustacean') + os.initgroups(user.pw_name, user.pw_gid) + os.setgid(user.pw_gid); os.setuid(user.pw_uid) + os.environ.update(LANG='C', LC_ALL='C', TMPDIR='/var/tmp') + os.execv(BIN, [BIN, 'configuration', 'unset']) + output = b'' + reaped = False + try: + deadline = time.monotonic() + 12 + while b'Password:' not in output and time.monotonic() < deadline: + if select.select([terminal], [], [], 0.2)[0]: + try: output += os.read(terminal, 65536) + except OSError: break + assert b'Password:' in output, 'The VM fixture admin did not receive a terminal password prompt' + time.sleep(0.1) + assert not termios.tcgetattr(terminal)[3] & termios.ECHO, 'Password input must not echo' + os.write(terminal, password.encode() + b'\n') + if not succeeds: + observed = b'' + while time.monotonic() < deadline: + if select.select([terminal], [], [], 0.2)[0]: + try: observed += os.read(terminal, 65536) + except OSError: break + if b'failed' in observed.lower() or b'failure' in observed.lower() or b'Password:' in observed: break + assert b'failed' in observed.lower() or b'failure' in observed.lower() or b'Password:' in observed, 'Wrong-password rejection was not observed' + os.write(terminal, b'\x03') + while time.monotonic() < deadline: + if select.select([terminal], [], [], 0.1)[0]: + with contextlib.suppress(OSError): os.read(terminal, 65536) + pid, result = os.waitpid(process, os.WNOHANG) + if pid: + reaped = True + assert (os.waitstatus_to_exitcode(result) == 0) == succeeds + return + raise AssertionError('Terminal authentication did not complete within the deadline') + finally: + with contextlib.suppress(ProcessLookupError): os.killpg(process, signal.SIGTERM) + if not reaped: + with contextlib.suppress(ChildProcessError): os.waitpid(process, 0) + os.close(terminal) + + +def password_authorization_fixture(): + name = 'roentgen-auth-fixture' + try: + pwd.getpwnam(name) + except KeyError: pass + else: raise AssertionError('Refusing to modify an existing VM fixture account') + policy = Path('/etc/polkit-1/rules.d/00-000-roentgen-auth-fixture.rules') + command('useradd', '--no-create-home', '--shell', '/bin/sh', name) + try: + # This temporary VM-only password is test data. It never enters argv, + # logs, repository files or the real user's authentication records. + password = secrets.token_urlsafe(24) + command('chpasswd', input=name + ':' + password + '\n') + policy.write_text('polkit.addAdminRule(function(action, subject) { if (action.id == "org.roentgen.control" && subject.user == "rustacean") return ["unix-user:roentgen-auth-fixture"]; });\n') + policy.chmod(0o644); time.sleep(1) + before = status() + terminal_password_attempt('deliberately-wrong-fixture-password', succeeds=False) + after = status() + assert after['generation'] == before['generation'] and after['selected_configuration'] == before['selected_configuration'] + terminal_password_attempt(password, succeeds=True) + after = status() + assert after['selected_configuration'] is None and after['generation'] == before['generation'] + 1 + assert after['requested_enabled'] == before['requested_enabled'] + if before['selected_configuration']: cli('configuration', 'select', before['selected_configuration']) + finally: + policy.unlink(missing_ok=True) + command('userdel', name) + + class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) @@ -55,7 +170,12 @@ def main(): # an assertion that product install succeeds with the VM's unsupported core. Path('/etc/roentgen').mkdir(mode=0o700, exist_ok=True) Path('/var/lib/roentgen').mkdir(mode=0o700, exist_ok=True) - assets = ROOT / 'assets' + guard = Path('/etc/roentgen/guard.toml') + if not guard.exists(): + assert not Path('/var/lib/roentgen/state.sqlite3').exists(), 'Refusing to fabricate guard input for existing state' + guard.write_text('version = 1\nenabled = false\n') + guard.chmod(0o600) + assets = ROOT / 'assets' destinations = { 'roentgen.service': '/etc/systemd/system/roentgen.service', 'roentgen-guard.service': '/etc/systemd/system/roentgen-guard.service', @@ -97,6 +217,10 @@ def main(): assert status()['selected_configuration'] == selected and status()['generation'] == before['generation'] assert 'authorization' in denied.stderr.lower() or 'accessdenied' in denied.stderr.lower() results.append('noninteractive non-root mutation is denied with durable state unchanged') + cancel_terminal_authorization() + results.append('terminal polkit prompts under an ordinary user; cancellation leaves selection, intent and generation unchanged') + password_authorization_fixture() + results.append('terminal polkit rejects a wrong password and authorizes the real mutation after valid VM-fixture PAM authentication') policy.write_text('polkit.addRule(function(action, subject) { if (action.id == "org.roentgen.control" && subject.user == "rustacean") return polkit.Result.YES; });\n') policy.chmod(0o644) time.sleep(1) diff --git a/tests/vm_diagnostics_acceptance.py b/tests/vm_diagnostics_acceptance.py new file mode 100644 index 0000000..1984585 --- /dev/null +++ b/tests/vm_diagnostics_acceptance.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Run controller acceptance against real HTTPS and Trojan fixtures on the ALT VM.""" +import argparse +import contextlib +import http.server +import json +import os +from pathlib import Path +import socket +import ssl +import subprocess +import tempfile +import threading +import time +import uuid +from email.utils import formatdate + +ROOT = Path('/var/tmp/roentgen-implementation') + + +def run(*args): + return subprocess.run(args, check=True, capture_output=True, text=True, timeout=30) + + +def available_port(): + with socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + return listener.getsockname()[1] + + +def certificate(directory, ca=False, signed=False): + name = 'ca' if ca else ('server' if signed else 'untrusted') + key, cert = directory / (name + '.key'), directory / (name + '.pem') + if ca: + run('openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-subj', '/CN=Roentgen VM fixture CA', '-addext', 'basicConstraints=critical,CA:TRUE', '-keyout', str(key), '-out', str(cert)) + elif signed: + csr = directory / 'server.csr' + extension = directory / 'server.ext' + extension.write_text('subjectAltName=DNS:localhost,IP:127.0.0.1\nextendedKeyUsage=serverAuth\nbasicConstraints=CA:FALSE\n') + run('openssl', 'req', '-new', '-newkey', 'rsa:2048', '-nodes', '-subj', '/CN=localhost', '-keyout', str(key), '-out', str(csr)) + run('openssl', 'x509', '-req', '-in', str(csr), '-CA', str(directory/'ca.pem'), '-CAkey', str(directory/'ca.key'), '-CAcreateserial', '-days', '1', '-extfile', str(extension), '-out', str(cert)) + else: + run('openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1', '-keyout', str(key), '-out', str(cert)) + return cert, key + + +def main(binary): + assert os.geteuid() == 0 and socket.gethostname() == 'altctlrs' + assert (ROOT / 'allow-network-tests').exists() + with tempfile.TemporaryDirectory(prefix='roentgen-diagnostics-') as temporary: + directory = Path(temporary) + ca, _ = certificate(directory, ca=True) + cert, key = certificate(directory, signed=True) + bad_cert, bad_key = certificate(directory) + anchor = Path('/etc/pki/ca-trust/source/anchors') / f'roentgen-vm-{uuid.uuid4().hex}.pem' + anchor.write_bytes(ca.read_bytes()); anchor.chmod(0o644) + requests = directory / 'requests.jsonl'; requests.touch() + lock = threading.Lock() + class Handler(http.server.BaseHTTPRequestHandler): + def record(self, method): + with lock: + with requests.open('a') as output: + output.write(json.dumps({'method':method,'path':self.path,'user_agent':self.headers.get('User-Agent'), 'proxy_authorization':self.headers.get('Proxy-Authorization')}) + '\n') + def do_CONNECT(self): + self.record('CONNECT'); self.send_error(502) + def do_GET(self): + self.record('GET') + if self.path == '/rate': + self.send_response(429); self.send_header('Retry-After', '60'); self.end_headers(); return + if self.path == '/rate-date': + self.send_response(503); self.send_header('Retry-After', formatdate(time.time()+65, usegmt=True)); self.end_headers(); return + if self.path == '/redirect': + self.send_response(302); self.send_header('Location', '/empty'); self.end_headers(); return + if self.path == '/partial': + self.send_response(206); self.end_headers(); self.wfile.write(b'partial'); return + self.send_response(200) + if self.path == '/error': self.send_header('Content-Type', 'application/problem+json') + if self.path == '/json-error': self.send_header('Content-Type', 'application/json') + body = b'' + if self.path in ['/error', '/json-error']: body = b'{"error":"fixture-provider-error"}' + if self.path == '/html': body = b'unexpected page' + if self.path == '/oversize': body = b'x' * 4097 + if self.path == '/large': body = b'x' * (8 * 1048576) + if self.path != '/slow': self.send_header('Content-Length', str(len(body))) + self.end_headers() + try: + if self.path == '/slow': + self.wfile.write(b'x' * 16384); self.wfile.flush(); time.sleep(3) + else: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError, ssl.SSLError): pass + def log_message(self, *args): pass + class Server(http.server.ThreadingHTTPServer): + def handle_error(self, *args): pass + def origin(cert, key): + server = Server(('127.0.0.1', 0), Handler) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(cert, key) + server.socket = context.wrap_socket(server.socket, server_side=True) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server + servers = [] + trojan = None + try: + run('update-ca-trust') + servers = [origin(cert,key), origin(bad_cert,bad_key)] + port = available_port() + access = directory / 'trojan-access.log' + config = directory / 'trojan.json' + config.write_text(json.dumps({'log':{'loglevel':'warning','access':str(access)}, 'inbounds':[{'listen':'127.0.0.1','port':port,'protocol':'trojan','settings':{'clients':[{'password':'fixture-password'}]},'streamSettings':{'network':'tcp','security':'tls','tlsSettings':{'certificates':[{'certificateFile':str(cert),'keyFile':str(key)}]}}}], 'routing':{'domainStrategy':'AsIs','rules':[{'type':'field','ip':['127.0.0.1/32'],'outboundTag':'direct'}]}, 'outbounds':[{'tag':'block','protocol':'blackhole','settings':{}},{'tag':'direct','protocol':'freedom','settings':{}}]})) + trojan = subprocess.Popen(['/usr/bin/xray','run','-config',str(config)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + deadline = time.monotonic()+5 + while time.monotonic() < deadline: + try: + with socket.create_connection(('127.0.0.1', port), timeout=0.1): break + except OSError: time.sleep(0.05) + else: raise AssertionError('Trojan fixture did not start') + metadata = directory / 'fixture.json' + metadata.write_text(json.dumps({'origin':f'https://127.0.0.1:{servers[0].server_port}', 'bad_origin':f'https://127.0.0.1:{servers[1].server_port}', 'trojan_port':port, 'access_log':str(access), 'requests_log':str(requests)})) + environment = dict(os.environ, ROENTGEN_DIAGNOSTIC_FIXTURE=str(metadata), ROENTGEN_TEST_BINARY=str(ROOT/'roentgen'), SSL_CERT_FILE=str(ca), HTTPS_PROXY='http://127.0.0.1:1', HTTP_PROXY='http://127.0.0.1:1', ALL_PROXY='http://127.0.0.1:1') + subprocess.run([binary, '--nocapture', '--test-threads=1'], env=environment, check=True, timeout=120) + finally: + if trojan: + trojan.terminate() + with contextlib.suppress(subprocess.TimeoutExpired): trojan.wait(timeout=3) + if trojan.poll() is None: trojan.kill(); trojan.wait() + for server in servers: server.shutdown(); server.server_close() + anchor.unlink(missing_ok=True) + run('update-ca-trust') + + +if __name__ == '__main__': + parser=argparse.ArgumentParser() + parser.add_argument('binary') + main(parser.parse_args().binary) -- cgit v1.2.3