product-os-proxy 0.0.19

Product OS : Proxy builds on the work of hudsucker, taking it to the next level with a man-in-the-middle proxy server that can tunnel traffic through a VPN utilising Product OS : VPN.
//! Active HTTPS validation through a running MITM proxy.

use std::time::Duration;

use product_os_security::certificates::ManagedCa;
use rustls::pki_types::ServerName;
use rustls::{ClientConfig, RootCertStore};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;

const PROBE_HOST: &str = "example.com";
/// Host used for session/platform trust probes (matches Chrome startup URL).
pub const SESSION_PROBE_HOST: &str = "bootstrap.local";
const PROXY_READY_TIMEOUT: Duration = Duration::from_secs(10);
const PROXY_READY_INTERVAL: Duration = Duration::from_millis(100);

/// Normalize a listen address for local probe connections.
pub fn probe_connect_host(host: &str) -> &str {
    if host.is_empty() || host == "0.0.0.0" || host == "::" {
        "127.0.0.1"
    } else {
        host
    }
}

/// Wait until the proxy accepts TCP connections or the timeout elapses.
pub async fn wait_for_proxy_ready(host: &str, port: u16) -> Result<(), String> {
    wait_for_proxy_ready_timeout(host, port, PROXY_READY_TIMEOUT).await
}

async fn wait_for_proxy_ready_timeout(
    host: &str,
    port: u16,
    timeout: Duration,
) -> Result<(), String> {
    let connect_host = probe_connect_host(host);
    let addr = format!("{connect_host}:{port}");
    let deadline = tokio::time::Instant::now() + timeout;

    loop {
        if tokio::time::Instant::now() >= deadline {
            return Err(format!(
                "proxy at {addr} did not become ready within {timeout:?}"
            ));
        }
        if TcpStream::connect(&addr).await.is_ok() {
            return Ok(());
        }
        tokio::time::sleep(PROXY_READY_INTERVAL).await;
    }
}

/// Perform CONNECT + TLS through the proxy using only the managed CA as trust anchor.
/// Internal MITM health check — does not reflect OS/Chrome trust.
pub async fn active_https_self_test(
    managed: &ManagedCa,
    proxy_host: &str,
    proxy_port: u16,
) -> Result<(), String> {
    active_https_self_test_host(managed, proxy_host, proxy_port, PROBE_HOST).await
}

/// CONNECT + TLS through the proxy using the OS trust store (Chrome/curl parity).
pub async fn active_platform_https_self_test(
    proxy_host: &str,
    proxy_port: u16,
) -> Result<(), String> {
    active_platform_https_self_test_host(proxy_host, proxy_port, SESSION_PROBE_HOST).await
}

async fn active_https_self_test_host(
    managed: &ManagedCa,
    proxy_host: &str,
    proxy_port: u16,
    probe_host: &str,
) -> Result<(), String> {
    ensure_rustls_crypto_provider();
    let connect_host = probe_connect_host(proxy_host);
    let ca_der = parse_ca_der(managed)?;
    let mut roots = RootCertStore::empty();
    roots
        .add(rustls::pki_types::CertificateDer::from(ca_der))
        .map_err(|e| format!("failed to add managed CA to root store: {e}"))?;
    tls_through_proxy(&roots, connect_host, proxy_port, probe_host).await
}

async fn active_platform_https_self_test_host(
    proxy_host: &str,
    proxy_port: u16,
    probe_host: &str,
) -> Result<(), String> {
    let connect_host = probe_connect_host(proxy_host);
    #[cfg(unix)]
    if let Ok(()) = curl_platform_https_self_test(connect_host, proxy_port, probe_host).await {
        return Ok(());
    }
    rustls_platform_https_self_test(connect_host, proxy_port, probe_host).await
}

#[cfg(unix)]
async fn curl_platform_https_self_test(
    connect_host: &str,
    proxy_port: u16,
    probe_host: &str,
) -> Result<(), String> {
    use tokio::process::Command;

    let proxy = format!("http://{connect_host}:{proxy_port}");
    let url = format!("https://{probe_host}/");
    let output = Command::new("curl")
        .args([
            "-sS",
            "--max-time",
            "15",
            "--proxy",
            &proxy,
            &url,
            "-o",
            "/dev/null",
            "-w",
            "%{ssl_verify_result}",
        ])
        .output()
        .await
        .map_err(|e| format!("curl platform probe failed to start: {e}"))?;
    let verify = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if output.status.success() && verify == "0" {
        return Ok(());
    }
    Err(format!(
        "curl platform TLS probe failed (exit={}, ssl_verify_result={verify}): {}",
        output.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&output.stderr)
    ))
}

async fn rustls_platform_https_self_test(
    connect_host: &str,
    proxy_port: u16,
    probe_host: &str,
) -> Result<(), String> {
    ensure_rustls_crypto_provider();
    let mut roots = RootCertStore::empty();
    let mut loaded = 0u32;
    let native_cert_result = rustls_native_certs::load_native_certs();
    for cert in native_cert_result.certs {
        if roots.add(cert).is_ok() {
            loaded += 1;
        }
    }
    if loaded == 0 {
        return Err("no native trust anchors loaded for platform probe".into());
    }
    tls_through_proxy(&roots, connect_host, proxy_port, probe_host).await
}

async fn tls_through_proxy(
    roots: &RootCertStore,
    connect_host: &str,
    proxy_port: u16,
    probe_host: &str,
) -> Result<(), String> {
    let config = ClientConfig::builder()
        .with_root_certificates(roots.clone())
        .with_no_client_auth();
    let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(config));

    let mut stream = TcpStream::connect(format!("{connect_host}:{proxy_port}"))
        .await
        .map_err(|e| format!("failed to connect to proxy: {e}"))?;

    let connect = format!("CONNECT {probe_host}:443 HTTP/1.1\r\nHost: {probe_host}:443\r\n\r\n");
    stream
        .write_all(connect.as_bytes())
        .await
        .map_err(|e| format!("CONNECT write failed: {e}"))?;

    let mut buf = vec![0u8; 4096];
    let n = stream
        .read(&mut buf)
        .await
        .map_err(|e| format!("CONNECT read failed: {e}"))?;
    let response = String::from_utf8_lossy(&buf[..n]);
    if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
        return Err(format!("CONNECT failed: {response}"));
    }

    let server_name =
        ServerName::try_from(probe_host.to_string()).map_err(|e| format!("invalid SNI: {e}"))?;
    let mut tls = connector
        .connect(server_name, stream)
        .await
        .map_err(|e| format!("TLS handshake through proxy failed (platform/OS trust): {e}"))?;

    tls.write_all(
        format!("GET / HTTP/1.1\r\nHost: {probe_host}\r\nConnection: close\r\n\r\n").as_bytes(),
    )
    .await
    .map_err(|e| format!("HTTPS request failed: {e}"))?;
    let _ = tls.read(&mut buf).await;

    Ok(())
}

fn ensure_rustls_crypto_provider() {
    use std::sync::Once;
    static ONCE: Once = Once::new();
    ONCE.call_once(|| {
        let _ = rustls::crypto::ring::default_provider().install_default();
    });
}

fn parse_ca_der(managed: &ManagedCa) -> Result<Vec<u8>, String> {
    use x509_parser::pem::parse_x509_pem;
    let (_, pem) =
        parse_x509_pem(managed.cert_pem()).map_err(|e| format!("failed to parse CA PEM: {e}"))?;
    Ok(pem.contents.into())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_ca_der_from_managed() {
        let dir = tempfile::tempdir().unwrap();
        let config = product_os_security::certificates::ManagedCaConfig::with_storage_dir(
            dir.path().to_path_buf(),
        );
        let managed =
            product_os_security::certificates::ManagedCa::load_or_create(&config).unwrap();
        assert!(!parse_ca_der(&managed).unwrap().is_empty());
    }

    #[test]
    fn probe_connect_host_normalizes_wildcard_listen() {
        assert_eq!(probe_connect_host("0.0.0.0"), "127.0.0.1");
        assert_eq!(probe_connect_host("127.0.0.1"), "127.0.0.1");
    }
}