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";
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);
pub fn probe_connect_host(host: &str) -> &str {
if host.is_empty() || host == "0.0.0.0" || host == "::" {
"127.0.0.1"
} else {
host
}
}
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;
}
}
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
}
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");
}
}