diff options
Diffstat (limited to 'tests/diagnostics_acceptance.rs')
| -rw-r--r-- | tests/diagnostics_acceptance.rs | 605 |
1 files changed, 605 insertions, 0 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()) + ); +} |