eggress-cli 1.0.2

CLI binary for the eggress multi-protocol proxy
Documentation
//! Interoperability tests using curl as an external client/server.
//!
//! These tests verify that eggress works correctly with real-world tools.
//! Gated by `EGRESS_REQUIRE_CURL_INTEROP=1` env var.
//! When run with `--ignored`, tests panic if curl is unavailable.

use std::sync::Arc;
use std::time::Duration;

use eggress_core::listener::{TcpListener, TcpListenerConfig};
use eggress_routing::{RouteActionSpec, RouteService, Router};
use eggress_server::ConnectionConfig;
use tokio_util::sync::CancellationToken;

fn require_curl_interop() {
    if std::env::var("EGRESS_REQUIRE_CURL_INTEROP").is_err() {
        panic!("EGRESS_REQUIRE_CURL_INTEROP not set");
    }
}

fn curl_available() -> bool {
    std::process::Command::new("curl")
        .arg("--version")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

fn skip_if_unavailable() {
    require_curl_interop();
    if !curl_available() {
        eprintln!("skipping: curl not available");
        panic!("curl not available");
    }
}

async fn start_eggress_server(
    protocols: Vec<eggress_core::ProtocolId>,
) -> (
    std::net::SocketAddr,
    CancellationToken,
    tokio::task::JoinHandle<()>,
) {
    let config = TcpListenerConfig {
        bind_addr: "127.0.0.1:0".parse().unwrap(),
        protocols,
        auth_required: false,
        handshake_timeout: Duration::from_secs(5),
        connection_limit: 10,
    };
    let cancel = CancellationToken::new();
    let listener = TcpListener::new(&config, cancel.clone()).await.unwrap();
    let addr = listener.local_addr().unwrap();

    let conn_protocols: std::sync::Arc<[eggress_core::ProtocolId]> =
        config.protocols.clone().into();
    let jh = tokio::spawn(async move {
        loop {
            let conn = match listener.accept().await {
                Ok(c) => c,
                Err(_) => break,
            };
            let config = ConnectionConfig {
                routing: Arc::new(Router::new(vec![], RouteActionSpec::Direct))
                    as Arc<dyn RouteService>,
                context: eggress_server::ConnectionContext::default(),
                handshake_timeout: Duration::from_secs(5),
                connect_timeout: Duration::from_secs(10),
                protocols: conn_protocols.clone(),
                authentication: eggress_server::accept::InboundAuthentication::None,
                metrics: None,
                udp: None,
                tls_client_config: None,
                shadowsocks: None,
                shadowsocks_metrics: None,
                trojan: None,
                fixed_target: None,
                local_bind: None,
            };
            tokio::spawn(async move {
                let _ = eggress_server::serve_connection(conn.stream, config).await;
            });
        }
    });

    (addr, cancel, jh)
}

#[tokio::test]
#[ignore = "requires EGRESS_REQUIRE_CURL_INTEROP=1 and curl"]
async fn test_curl_http_connect() {
    skip_if_unavailable();

    let (origin_addr, origin_jh) = eggress_testkit::start_http_origin_server().await;
    let (proxy_addr, cancel, proxy_jh) =
        start_eggress_server(vec![eggress_core::ProtocolId::Http]).await;

    tokio::time::sleep(Duration::from_millis(50)).await;

    let url = format!("http://{}:{}", origin_addr.ip(), origin_addr.port());
    let output = tokio::task::spawn_blocking(move || {
        std::process::Command::new("curl")
            .args([
                "--proxy",
                &format!("http://{}", proxy_addr),
                "--max-time",
                "10",
                &url,
            ])
            .output()
            .expect("failed to execute curl")
    })
    .await
    .unwrap();

    assert!(
        output.status.success(),
        "curl failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("hello from origin"),
        "expected 'hello from origin', got: {stdout}"
    );

    cancel.cancel();
    let _ = proxy_jh.await;
    origin_jh.abort();
}

#[tokio::test]
#[ignore = "requires EGRESS_REQUIRE_CURL_INTEROP=1 and curl"]
async fn test_curl_socks5() {
    skip_if_unavailable();

    let (echo_addr, echo_jh) = eggress_testkit::start_echo_server().await;
    let (proxy_addr, cancel, proxy_jh) =
        start_eggress_server(vec![eggress_core::ProtocolId::Socks5]).await;

    tokio::time::sleep(Duration::from_millis(50)).await;

    let target = format!("socks5h://{}:{}", echo_addr.ip(), echo_addr.port());
    let proxy_url = format!("socks5://{}", proxy_addr);
    let output = tokio::task::spawn_blocking(move || {
        std::process::Command::new("curl")
            .args([
                "--proxy",
                &proxy_url,
                "--max-time",
                "10",
                "-o",
                "/dev/null",
                "-w",
                "%{http_code}",
                &target,
            ])
            .output()
            .expect("failed to execute curl")
    })
    .await
    .unwrap();

    // curl through SOCKS5 to an echo server may not produce HTTP output,
    // but the connection should succeed (exit code 0 or connection-related error)
    // We mainly test that the proxy accepts and forwards the connection
    let stderr = String::from_utf8_lossy(&output.stderr);
    // The echo server won't respond with HTTP, so curl may fail with a protocol error
    // but the connection through the proxy should have been established
    assert!(
        output.status.success()
            || stderr.contains("echo")
            || !stderr.contains("Connection refused"),
        "connection through SOCKS5 proxy should succeed, stderr: {stderr}"
    );

    cancel.cancel();
    let _ = proxy_jh.await;
    echo_jh.abort();
}

#[tokio::test]
#[ignore = "requires EGRESS_REQUIRE_CURL_INTEROP=1 and curl"]
async fn test_curl_socks4a() {
    skip_if_unavailable();

    let (echo_addr, echo_jh) = eggress_testkit::start_echo_server().await;
    let (proxy_addr, cancel, proxy_jh) =
        start_eggress_server(vec![eggress_core::ProtocolId::Socks4]).await;

    tokio::time::sleep(Duration::from_millis(50)).await;

    let target = format!("socks4a://{}:{}", echo_addr.ip(), echo_addr.port());
    let proxy_url = format!("socks4a://{}", proxy_addr);
    let output = tokio::task::spawn_blocking(move || {
        std::process::Command::new("curl")
            .args([
                "--proxy",
                &proxy_url,
                "--max-time",
                "10",
                "-o",
                "/dev/null",
                "-w",
                "%{http_code}",
                &target,
            ])
            .output()
            .expect("failed to execute curl")
    })
    .await
    .unwrap();

    let stderr = String::from_utf8_lossy(&output.stderr);
    // Similar to SOCKS5 test - the echo server won't respond with HTTP,
    // but the connection through the proxy should have been established
    assert!(
        output.status.success()
            || stderr.contains("echo")
            || !stderr.contains("Connection refused"),
        "connection through SOCKS4a proxy should succeed, stderr: {stderr}"
    );

    cancel.cancel();
    let _ = proxy_jh.await;
    echo_jh.abort();
}