summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorAndrew Guschin <guschin@altlinux.org>2026-10-30 04:43:59 +0400
committerAndrew Guschin <guschin@altlinux.org>2026-10-30 04:43:59 +0400
commit7999fab25c5172207ee41e38732f4923565ff6b6 (patch)
tree0e239d6ba65d4babfc0f1007ac350f6f4f9e73d1 /tests
parentfc3c1d51b1be9db6ba6ad286584fbc34bb305f38 (diff)
Validate controller diagnostics, reboot protection, and terminal authentication
Diffstat (limited to 'tests')
-rw-r--r--tests/diagnostics_acceptance.rs605
-rw-r--r--tests/vm_boot_acceptance.py129
-rw-r--r--tests/vm_controller_acceptance.py126
-rw-r--r--tests/vm_diagnostics_acceptance.py135
4 files changed, 994 insertions, 1 deletions
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<Vec<u8>> {
+ 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<std::sync::Mutex<ProbeMode>>,
+ impostor: String,
+}
+struct FixtureProbe {
+ child: Option<tokio::process::Child>,
+ _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<Self>) {
+ 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<Box<dyn ProbeSession>> {
+ 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<std::sync::Mutex<ProbeMode>>,
+}
+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<serde_json::Value> {
+ 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<roentgen::model::ConfigurationSummary> = 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'<html>unexpected page</html>'
+ 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)