chore: proxy bypass integration tests

This commit is contained in:
zhom
2026-03-02 11:57:27 +04:00
parent 6fa0f1348a
commit 2ffa37371d
+484
View File
@@ -7,6 +7,45 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::sleep;
/// Start a simple HTTP server that returns a specific body for any request.
/// Returns the (port, JoinHandle).
async fn start_mock_http_server(response_body: &'static str) -> (u16, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move {
use http_body_util::Full;
use hyper::body::Bytes;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Response, StatusCode};
use hyper_util::rt::TokioIo;
while let Ok((stream, _)) = listener.accept().await {
let io = TokioIo::new(stream);
tokio::task::spawn(async move {
let service = service_fn(move |_req| {
let body = response_body;
async move {
Ok::<_, hyper::Error>(
Response::builder()
.status(StatusCode::OK)
.body(Full::new(Bytes::from(body)))
.unwrap(),
)
}
});
let _ = http1::Builder::new().serve_connection(io, service).await;
});
}
});
// Wait for listener to be ready
sleep(Duration::from_millis(100)).await;
(port, handle)
}
/// Setup function to ensure donut-proxy binary exists and cleanup stale proxies
async fn setup_test() -> Result<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let cargo_manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?;
@@ -637,3 +676,448 @@ async fn test_proxy_stop() -> Result<(), Box<dyn std::error::Error + Send + Sync
Ok(())
}
/// Test that bypass rules cause requests to bypass the upstream proxy.
/// Requests to bypassed hosts go directly to the target, while
/// requests to non-bypassed hosts are routed through the upstream.
#[tokio::test]
#[serial]
async fn test_bypass_rules_http_direct() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
// Start a target HTTP server (this is where bypassed requests should arrive)
let (target_port, target_handle) = start_mock_http_server("DIRECT-TARGET-RESPONSE").await;
println!("Target server listening on port {target_port}");
// Start a mock upstream proxy (non-bypassed requests go here)
let (upstream_port, upstream_handle) = start_mock_http_server("UPSTREAM-PROXY-RESPONSE").await;
println!("Mock upstream proxy listening on port {upstream_port}");
// Start donut-proxy with upstream + bypass rules for "127.0.0.1"
let bypass_rules = serde_json::json!(["127.0.0.1"]).to_string();
let output = TestUtils::execute_command(
&binary_path,
&[
"proxy",
"start",
"--host",
"127.0.0.1",
"--proxy-port",
&upstream_port.to_string(),
"--type",
"http",
"--bypass-rules",
&bypass_rules,
],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
target_handle.abort();
upstream_handle.abort();
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
let local_port = config["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(proxy_id.clone());
println!("Donut-proxy started on port {local_port} with bypass rules for 127.0.0.1");
sleep(Duration::from_millis(500)).await;
// Test 1: Request to 127.0.0.1 should be BYPASSED (direct connection to target)
{
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request = format!(
"GET http://127.0.0.1:{target_port}/ HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\nConnection: close\r\n\r\n"
);
stream.write_all(request.as_bytes()).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
println!(
"Bypass response: {}",
&response_str[..response_str.len().min(300)]
);
assert!(
response_str.contains("DIRECT-TARGET-RESPONSE"),
"Bypassed request should reach target directly, got: {}",
&response_str[..response_str.len().min(300)]
);
assert!(
!response_str.contains("UPSTREAM-PROXY-RESPONSE"),
"Bypassed request should NOT go through upstream"
);
println!("Bypass test passed: request to 127.0.0.1 went directly to target");
}
// Test 2: Request to non-bypassed host should go through upstream
{
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request =
b"GET http://non-bypass-host.test/ HTTP/1.1\r\nHost: non-bypass-host.test\r\nConnection: close\r\n\r\n";
stream.write_all(request).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
println!(
"Non-bypass response: {}",
&response_str[..response_str.len().min(300)]
);
assert!(
response_str.contains("UPSTREAM-PROXY-RESPONSE"),
"Non-bypassed request should go through upstream, got: {}",
&response_str[..response_str.len().min(300)]
);
assert!(
!response_str.contains("DIRECT-TARGET-RESPONSE"),
"Non-bypassed request should NOT reach target directly"
);
println!("Non-bypass test passed: request to non-bypass-host.test went through upstream");
}
// Cleanup
tracker.cleanup_all().await;
target_handle.abort();
upstream_handle.abort();
Ok(())
}
/// Test bypass rules with regex patterns.
/// Verifies that regex-based rules match hosts correctly.
#[tokio::test]
#[serial]
async fn test_bypass_rules_regex_pattern() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
let (target_port, target_handle) = start_mock_http_server("REGEX-DIRECT-RESPONSE").await;
let (upstream_port, upstream_handle) = start_mock_http_server("REGEX-UPSTREAM-RESPONSE").await;
// Use regex bypass rule: ^127\.0\.0\.\d+ (matches any 127.0.0.x address)
let bypass_rules = serde_json::json!([r"^127\.0\.0\.\d+"]).to_string();
let output = TestUtils::execute_command(
&binary_path,
&[
"proxy",
"start",
"--host",
"127.0.0.1",
"--proxy-port",
&upstream_port.to_string(),
"--type",
"http",
"--bypass-rules",
&bypass_rules,
],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
target_handle.abort();
upstream_handle.abort();
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
let local_port = config["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(proxy_id.clone());
sleep(Duration::from_millis(500)).await;
// Request to 127.0.0.1 should match regex and be bypassed
{
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request = format!(
"GET http://127.0.0.1:{target_port}/ HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\nConnection: close\r\n\r\n"
);
stream.write_all(request.as_bytes()).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
assert!(
response_str.contains("REGEX-DIRECT-RESPONSE"),
"Regex-bypassed request should reach target directly, got: {}",
&response_str[..response_str.len().min(300)]
);
println!("Regex bypass test passed: 127.0.0.1 matched regex rule");
}
// Request to non-matching host should go through upstream
{
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request =
b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n";
stream.write_all(request).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
assert!(
response_str.contains("REGEX-UPSTREAM-RESPONSE"),
"Non-matching request should go through upstream, got: {}",
&response_str[..response_str.len().min(300)]
);
println!("Regex non-bypass test passed: example.com did not match regex rule");
}
tracker.cleanup_all().await;
target_handle.abort();
upstream_handle.abort();
Ok(())
}
/// Test that bypass rules are persisted in the proxy config on disk.
#[tokio::test]
#[serial]
async fn test_bypass_rules_in_config() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
let bypass_rules =
serde_json::json!(["example.com", "192.168.0.0/16", r".*\.internal\.net"]).to_string();
let output = TestUtils::execute_command(
&binary_path,
&["proxy", "start", "--bypass-rules", &bypass_rules],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
tracker.track_proxy(proxy_id.clone());
sleep(Duration::from_millis(500)).await;
// Read the proxy config file from disk to verify bypass rules are persisted
let proxies_dir = donutbrowser_lib::app_dirs::proxies_dir();
let config_file = proxies_dir.join(format!("{proxy_id}.json"));
assert!(
config_file.exists(),
"Proxy config file should exist at {:?}",
config_file
);
let config_content = std::fs::read_to_string(&config_file)?;
let disk_config: Value = serde_json::from_str(&config_content)?;
let rules = disk_config["bypass_rules"]
.as_array()
.expect("bypass_rules should be an array in the config");
assert_eq!(rules.len(), 3, "Should have 3 bypass rules");
assert_eq!(rules[0], "example.com");
assert_eq!(rules[1], "192.168.0.0/16");
assert_eq!(rules[2], r".*\.internal\.net");
println!(
"Config persistence test passed: {} bypass rules found in config",
rules.len()
);
tracker.cleanup_all().await;
Ok(())
}
/// Test bypass rules with multiple rule types combined (exact + regex).
#[tokio::test]
#[serial]
async fn test_bypass_rules_multiple_rules() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
{
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
let (target_port, target_handle) = start_mock_http_server("MULTI-DIRECT-RESPONSE").await;
let (upstream_port, upstream_handle) = start_mock_http_server("MULTI-UPSTREAM-RESPONSE").await;
// Multiple bypass rules: exact match + regex
let bypass_rules = serde_json::json!(["127.0.0.1", r"^localhost$"]).to_string();
let output = TestUtils::execute_command(
&binary_path,
&[
"proxy",
"start",
"--host",
"127.0.0.1",
"--proxy-port",
&upstream_port.to_string(),
"--type",
"http",
"--bypass-rules",
&bypass_rules,
],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
target_handle.abort();
upstream_handle.abort();
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
let local_port = config["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(proxy_id.clone());
sleep(Duration::from_millis(500)).await;
// Request via 127.0.0.1 (exact match rule) → bypass
{
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request = format!(
"GET http://127.0.0.1:{target_port}/ HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\nConnection: close\r\n\r\n"
);
stream.write_all(request.as_bytes()).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
assert!(
response_str.contains("MULTI-DIRECT-RESPONSE"),
"Exact-match bypassed request should reach target, got: {}",
&response_str[..response_str.len().min(300)]
);
println!("Multi-rule test: exact match bypass works");
}
// Request via localhost (regex match rule) → bypass
{
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request = format!(
"GET http://localhost:{target_port}/ HTTP/1.1\r\nHost: localhost:{target_port}\r\nConnection: close\r\n\r\n"
);
stream.write_all(request.as_bytes()).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
assert!(
response_str.contains("MULTI-DIRECT-RESPONSE"),
"Regex-match bypassed request should reach target, got: {}",
&response_str[..response_str.len().min(300)]
);
println!("Multi-rule test: regex match bypass works");
}
// Request to non-matching host → upstream
{
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request =
b"GET http://other-host.test/ HTTP/1.1\r\nHost: other-host.test\r\nConnection: close\r\n\r\n";
stream.write_all(request).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
assert!(
response_str.contains("MULTI-UPSTREAM-RESPONSE"),
"Non-matching request should go through upstream, got: {}",
&response_str[..response_str.len().min(300)]
);
println!("Multi-rule test: non-matching host goes through upstream");
}
tracker.cleanup_all().await;
target_handle.abort();
upstream_handle.abort();
Ok(())
}
/// Test that an empty bypass rules list means everything goes through upstream.
#[tokio::test]
#[serial]
async fn test_no_bypass_rules_all_through_upstream(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
let (upstream_port, upstream_handle) = start_mock_http_server("ALL-UPSTREAM-RESPONSE").await;
// Start proxy with empty bypass rules
let bypass_rules = serde_json::json!([]).to_string();
let output = TestUtils::execute_command(
&binary_path,
&[
"proxy",
"start",
"--host",
"127.0.0.1",
"--proxy-port",
&upstream_port.to_string(),
"--type",
"http",
"--bypass-rules",
&bypass_rules,
],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
upstream_handle.abort();
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
let local_port = config["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(proxy_id.clone());
sleep(Duration::from_millis(500)).await;
// All requests should go through upstream when bypass rules are empty
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request =
b"GET http://any-host.test/ HTTP/1.1\r\nHost: any-host.test\r\nConnection: close\r\n\r\n";
stream.write_all(request).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let response_str = String::from_utf8_lossy(&response);
assert!(
response_str.contains("ALL-UPSTREAM-RESPONSE"),
"With no bypass rules, all requests should go through upstream, got: {}",
&response_str[..response_str.len().min(300)]
);
println!("Empty bypass rules test passed: all traffic goes through upstream");
tracker.cleanup_all().await;
upstream_handle.abort();
Ok(())
}