//! Test harness for VPN integration tests. //! //! This module provides Docker-based test infrastructure for WireGuard tests. //! In CI environments, it uses pre-configured service containers. //! In local development, it spawns Docker containers on demand. //! //! Note: These utilities are available for tests that need Docker containers, //! but may not be used in all test configurations. #![allow(dead_code)] use std::process::Command; use std::time::Duration; use tokio::time::sleep; const WIREGUARD_IMAGE: &str = "linuxserver/wireguard:latest"; const WG_CONTAINER: &str = "donut-wg-test"; /// Check if running in CI environment pub fn is_ci() -> bool { std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok() } fn has_external_wireguard_service() -> bool { std::env::var("VPN_TEST_WG_HOST").is_ok() } /// Check if Docker is available pub fn is_docker_available() -> bool { Command::new("docker") .arg("version") .output() .map(|o| o.status.success()) .unwrap_or(false) } /// Start a WireGuard test server and return client config pub async fn start_wireguard_server() -> Result { if has_external_wireguard_service() { let host = std::env::var("VPN_TEST_WG_HOST").unwrap_or_else(|_| "localhost".into()); let port = std::env::var("VPN_TEST_WG_PORT").unwrap_or_else(|_| "51820".into()); return get_ci_wireguard_config(&host, &port); } if !is_docker_available() { return Err("Docker is not available for local testing".to_string()); } // Stop any existing container let _ = Command::new("docker") .args(["rm", "-f", WG_CONTAINER]) .output(); // Start WireGuard container let output = Command::new("docker") .args([ "run", "-d", "--name", WG_CONTAINER, "--cap-add=NET_ADMIN", "-p", "51820:51820/udp", "-e", "PEERS=1", "-e", "SERVERURL=127.0.0.1", "-e", "SERVERPORT=51820", "-e", "PEERDNS=auto", "-e", "INTERNAL_SUBNET=10.64.0.0", WIREGUARD_IMAGE, ]) .output() .map_err(|e| format!("Failed to start WireGuard container: {e}"))?; if !output.status.success() { return Err(format!( "Docker run failed: {}", String::from_utf8_lossy(&output.stderr) )); } // Wait for container to generate configs and bring up the WireGuard interface. // A fixed sleep is flaky — on busy machines the interface takes longer. Instead // we poll `wg show` inside the container until it reports an active interface, // with a generous upper bound. let wg_ready_deadline = tokio::time::Instant::now() + Duration::from_secs(45); loop { sleep(Duration::from_secs(2)).await; // Check if peer config file has been generated let config_check = Command::new("docker") .args(["exec", WG_CONTAINER, "cat", "/config/peer1/peer1.conf"]) .output(); let config_exists = config_check .as_ref() .map(|o| o.status.success()) .unwrap_or(false); // Check if WireGuard interface is actually up and listening let wg_check = Command::new("docker") .args(["exec", WG_CONTAINER, "wg", "show"]) .output(); let wg_up = wg_check .as_ref() .map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains("listening port")) .unwrap_or(false); if config_exists && wg_up { break; } if tokio::time::Instant::now() >= wg_ready_deadline { return Err("WireGuard container did not become ready within 45s".to_string()); } } // Extract client config from container let config_output = Command::new("docker") .args(["exec", WG_CONTAINER, "cat", "/config/peer1/peer1.conf"]) .output() .map_err(|e| format!("Failed to get client config: {e}"))?; if !config_output.status.success() { return Err(format!( "Failed to read config: {}", String::from_utf8_lossy(&config_output.stderr) )); } let config_str = String::from_utf8_lossy(&config_output.stdout).to_string(); let mut config = parse_wireguard_test_config(&config_str)?; // Start a lightweight HTTP server inside the container on the WireGuard // interface so tests can verify traffic flows through the tunnel without // depending on internet access (Docker Desktop for Mac can't reliably NAT // WireGuard tunnel traffic to the internet). The linuxserver/wireguard // image doesn't have python3 or busybox httpd, but it has nc (netcat). let _ = Command::new("docker") .args([ "exec", "-d", WG_CONTAINER, "sh", "-c", r#"while true; do printf "HTTP/1.1 200 OK\r\nContent-Length: 13\r\nConnection: close\r\n\r\nWG-TUNNEL-OK\n" | nc -l -p 8080 2>/dev/null; done"#, ]) .output(); // Give the nc loop a moment to start accepting sleep(Duration::from_millis(500)).await; // Extract the server's tunnel IP (first octet group from INTERNAL_SUBNET + .1) config.server_tunnel_ip = "10.64.0.1".to_string(); Ok(config) } /// Stop all VPN test servers pub async fn stop_vpn_servers() { let _ = Command::new("docker") .args(["rm", "-f", WG_CONTAINER]) .output(); } /// WireGuard test configuration pub struct WireGuardTestConfig { pub private_key: String, pub address: String, pub dns: Option, pub peer_public_key: String, pub peer_endpoint: String, pub allowed_ips: Vec, pub preshared_key: Option, /// IP of the WireGuard server on the tunnel interface (e.g. 10.64.0.1). /// Tests use this to reach an HTTP server inside the container without /// needing internet access from Docker. pub server_tunnel_ip: String, } /// Parse WireGuard test config from INI content fn parse_wireguard_test_config(content: &str) -> Result { let mut private_key = String::new(); let mut address = String::new(); let mut dns = None; let mut peer_public_key = String::new(); let mut peer_endpoint = String::new(); let mut allowed_ips = vec!["0.0.0.0/0".to_string()]; let mut preshared_key = None; let mut current_section = ""; for line in content.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } if line == "[Interface]" { current_section = "interface"; continue; } if line == "[Peer]" { current_section = "peer"; continue; } if let Some((key, value)) = line.split_once('=') { let key = key.trim(); let value = value.trim(); match (current_section, key) { ("interface", "PrivateKey") => private_key = value.to_string(), ("interface", "Address") => address = value.to_string(), ("interface", "DNS") => dns = Some(value.to_string()), ("peer", "PublicKey") => peer_public_key = value.to_string(), ("peer", "Endpoint") => peer_endpoint = value.to_string(), ("peer", "PresharedKey") => preshared_key = Some(value.to_string()), ("peer", "AllowedIPs") => { allowed_ips = value.split(',').map(|s| s.trim().to_string()).collect(); } _ => {} } } } if private_key.is_empty() || address.is_empty() || peer_public_key.is_empty() { return Err("Invalid WireGuard config: missing required fields".to_string()); } // Replace Endpoint with localhost for local testing if peer_endpoint.contains("10.") || peer_endpoint.contains("172.") { let port = peer_endpoint.split(':').next_back().unwrap_or("51820"); peer_endpoint = format!("127.0.0.1:{port}"); } Ok(WireGuardTestConfig { private_key, address, dns, peer_public_key, peer_endpoint, allowed_ips, preshared_key, server_tunnel_ip: String::new(), // filled in by caller }) } /// Get WireGuard config from CI environment fn get_ci_wireguard_config(host: &str, port: &str) -> Result { if std::env::var("VPN_TEST_WG_PRIVATE_KEY").is_err() || std::env::var("VPN_TEST_WG_PUBLIC_KEY").is_err() { return Err( "External WireGuard test service is configured, but VPN_TEST_WG_PRIVATE_KEY and VPN_TEST_WG_PUBLIC_KEY are missing" .to_string(), ); } let private_key = std::env::var("VPN_TEST_WG_PRIVATE_KEY").unwrap_or_else(|_| "test-private-key".to_string()); let public_key = std::env::var("VPN_TEST_WG_PUBLIC_KEY").unwrap_or_else(|_| "test-public-key".to_string()); Ok(WireGuardTestConfig { private_key, address: std::env::var("VPN_TEST_WG_ADDRESS").unwrap_or_else(|_| "10.0.0.2/24".to_string()), dns: Some("1.1.1.1".to_string()), peer_public_key: public_key, peer_endpoint: format!("{host}:{port}"), allowed_ips: vec!["0.0.0.0/0".to_string()], preshared_key: std::env::var("VPN_TEST_WG_PRESHARED_KEY").ok(), server_tunnel_ip: std::env::var("VPN_TEST_WG_SERVER_IP") .unwrap_or_else(|_| "10.0.0.1".to_string()), }) }