- HttpClientConfig.retry_policy: ExponentialBackoff (semver anchor to a reqwest-retry concrete type) replaced by retry: RetryConfig — an owned struct of plain scalars (max_retries, initial_backoff, max_retry_interval, defaults matching the previous backoff exactly); the ExponentialBackoff policy is built internally by the middleware stack; no reqwest_retry type is public anymore - ClientCertConfig fields documented (none had docs) - new tests/client_tls.rs: per-test rcgen private PKI + tokio-rustls HTTPS server; drives the real SharedHttpClient through HttpClientConfig file paths — CA-bundle success path, private-roots rejection (source-chain assertion: invalid peer certificate), mTLS end-to-end with client identity, mTLS rejection without identity, and reload-to-CA-bundle interplay - dev-deps: rcgen 0.14, tokio-rustls 0.26, rustls 0.23 (aws_lc_rs), rustls-pki-types 1, uuid Verified: cargo test (288 + 5 TLS), --all-features (359 + suites), --no-default-features (288; pre-existing warnings only), clippy --all-targets -D warnings (default + all-features), fmt --check, cargo doc --no-deps. Tasks: review-001-client-config-and-cert-coverage
376 lines
14 KiB
Rust
376 lines
14 KiB
Rust
//! TLS-path coverage for the outbound client (COV-02, review-001
|
|
//! follow-up): the `ca_bundle` and `client_cert` build paths had only
|
|
//! error-path tests (missing files); these tests exercise the success
|
|
//! end-to-end against a local private-roots TLS server —
|
|
//!
|
|
//! - private-roots verification: a client built with a CA bundle
|
|
//! connects to a server whose cert is issued by that CA;
|
|
//! - mTLS: a server requiring client certificates completes the
|
|
//! handshake only when the client presents its own identity;
|
|
//! - the full middleware stack (redirect policy + retry gate) rides on
|
|
//! the same builder, so a plain GET through `SharedHttpClient`
|
|
//! covers the TLS-configured construction path.
|
|
//!
|
|
//! Uses `rcgen` to mint a throwaway private PKI per test and
|
|
//! `tokio-rustls` for the server side; the client side is the real
|
|
//! `SharedHttpClient` configured via `HttpClientConfig`.
|
|
|
|
use std::net::SocketAddr;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use alkhttp::client::{ClientCertConfig, HttpClientConfig, SharedHttpClient};
|
|
|
|
/// A throwaway private PKI: CA, server leaf for `127.0.0.1`/`localhost`,
|
|
/// and a client leaf, freshly minted per test.
|
|
struct TestPki {
|
|
ca_pem: Vec<u8>,
|
|
server_pem: Vec<u8>,
|
|
server_key_pem: Vec<u8>,
|
|
client_pem: Vec<u8>,
|
|
client_key_pem: Vec<u8>,
|
|
}
|
|
|
|
impl TestPki {
|
|
fn generate() -> Self {
|
|
let mut ca_params =
|
|
rcgen::CertificateParams::new(vec!["alkhttp test CA".to_string()]).expect("CA params");
|
|
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
|
|
let ca_key = rcgen::KeyPair::generate().expect("CA key");
|
|
let ca_cert = ca_params.self_signed(&ca_key).expect("self-signed CA");
|
|
let issuer = rcgen::Issuer::from_params(&ca_params, &ca_key);
|
|
|
|
let mut server_params =
|
|
rcgen::CertificateParams::new(vec!["127.0.0.1".to_string(), "localhost".to_string()])
|
|
.expect("server params");
|
|
server_params.is_ca = rcgen::IsCa::NoCa;
|
|
let server_key = rcgen::KeyPair::generate().expect("server key");
|
|
let server_cert = server_params
|
|
.signed_by(&server_key, &issuer)
|
|
.expect("server leaf");
|
|
|
|
let mut client_params =
|
|
rcgen::CertificateParams::new(vec!["alkhttp test client".to_string()])
|
|
.expect("client params");
|
|
client_params.is_ca = rcgen::IsCa::NoCa;
|
|
client_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth];
|
|
let client_key = rcgen::KeyPair::generate().expect("client key");
|
|
let client_cert = client_params
|
|
.signed_by(&client_key, &issuer)
|
|
.expect("client leaf");
|
|
|
|
Self {
|
|
ca_pem: ca_cert.pem().into_bytes(),
|
|
server_pem: server_cert.pem().into_bytes(),
|
|
server_key_pem: server_key.serialize_pem().into_bytes(),
|
|
client_pem: client_cert.pem().into_bytes(),
|
|
client_key_pem: client_key.serialize_pem().into_bytes(),
|
|
}
|
|
}
|
|
|
|
/// Writes the CA bundle (and, when `with_client_cert`, the client
|
|
/// identity) to a fresh temp directory, as `HttpClientConfig`
|
|
/// expects paths. Returns the config pieces plus the temp dir.
|
|
fn write_config_files(
|
|
&self,
|
|
with_client_cert: bool,
|
|
) -> (Option<PathBuf>, Option<ClientCertConfig>, PathBuf) {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"alkhttp-tls-test-{}-{}",
|
|
std::process::id(),
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
|
let write = |name: &str, bytes: &[u8]| {
|
|
let path = dir.join(name);
|
|
std::fs::write(&path, bytes).expect("write pem");
|
|
path
|
|
};
|
|
let ca = Some(write("ca.pem", &self.ca_pem));
|
|
let client = if with_client_cert {
|
|
Some(ClientCertConfig {
|
|
cert_pem: write("client-cert.pem", &self.client_pem),
|
|
key_pem: write("client-key.pem", &self.client_key_pem),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
(ca, client, dir)
|
|
}
|
|
}
|
|
|
|
/// A minimal HTTPS/1.1 test server on 127.0.0.1 that either requires a
|
|
/// client certificate (mTLS) or accepts anonymous clients, answers
|
|
/// every request with `200 ok`, and counts completed TLS handshakes.
|
|
struct TlsTestServer {
|
|
origin: String,
|
|
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
|
|
handshakes: Arc<AtomicU32>,
|
|
}
|
|
|
|
impl TlsTestServer {
|
|
fn handshakes(&self) -> u32 {
|
|
self.handshakes.load(Ordering::SeqCst)
|
|
}
|
|
|
|
async fn spawn(pki: &TestPki, require_client_cert: bool) -> Self {
|
|
use rustls_pki_types::pem::PemObject;
|
|
|
|
let server_certs: Vec<rustls_pki_types::CertificateDer<'static>> =
|
|
rustls_pki_types::pem::PemObject::pem_slice_iter(&pki.server_pem)
|
|
.map(|c: Result<rustls_pki_types::CertificateDer<'_>, _>| {
|
|
c.expect("server cert parses")
|
|
})
|
|
.collect();
|
|
let server_key = rustls_pki_types::PrivateKeyDer::from_pem_slice(&pki.server_key_pem)
|
|
.expect("server key parses");
|
|
|
|
let server_trust = if require_client_cert {
|
|
let mut trust = rustls::RootCertStore::empty();
|
|
let ca_iter = rustls_pki_types::pem::PemObject::pem_slice_iter(&pki.ca_pem).map(
|
|
|c: Result<rustls_pki_types::CertificateDer<'_>, _>| c.expect("CA cert parses"),
|
|
);
|
|
for ca in ca_iter {
|
|
trust.add(ca).expect("CA added to server trust store");
|
|
}
|
|
Some(trust)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let config = match &server_trust {
|
|
Some(trust) => {
|
|
let verifier =
|
|
rustls::server::WebPkiClientVerifier::builder(Arc::new(trust.clone()))
|
|
.build()
|
|
.expect("client verifier");
|
|
rustls::ServerConfig::builder()
|
|
.with_client_cert_verifier(verifier)
|
|
.with_single_cert(server_certs, server_key)
|
|
.expect("server config with client auth")
|
|
}
|
|
None => rustls::ServerConfig::builder()
|
|
.with_no_client_auth()
|
|
.with_single_cert(server_certs, server_key)
|
|
.expect("server config"),
|
|
};
|
|
let tls_config = Arc::new(config);
|
|
let handshakes = Arc::new(AtomicU32::new(0));
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind 127.0.0.1:0");
|
|
let addr: SocketAddr = listener.local_addr().expect("local addr");
|
|
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
|
let hs_counter = Arc::clone(&handshakes);
|
|
|
|
tokio::spawn(async move {
|
|
let acceptor = tokio_rustls::TlsAcceptor::from(tls_config);
|
|
let mut shutdown = std::pin::pin!(shutdown_rx);
|
|
loop {
|
|
let accept = tokio::select! {
|
|
_ = &mut shutdown => break,
|
|
accepted = listener.accept() => match accepted {
|
|
Ok((sock, _)) => sock,
|
|
Err(_) => break,
|
|
},
|
|
};
|
|
let acceptor = acceptor.clone();
|
|
let hs = Arc::clone(&hs_counter);
|
|
tokio::spawn(async move {
|
|
let Ok(mut tls_stream) = acceptor.accept(accept).await else {
|
|
return;
|
|
};
|
|
hs.fetch_add(1, Ordering::SeqCst);
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
let mut buf = [0u8; 4096];
|
|
loop {
|
|
let n = tls_stream.read(&mut buf).await.unwrap_or(0);
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
if String::from_utf8_lossy(&buf[..n]).contains("\r\n\r\n") {
|
|
break;
|
|
}
|
|
}
|
|
let body = b"ok";
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
String::from_utf8_lossy(body),
|
|
);
|
|
let _ = tls_stream.write_all(response.as_bytes()).await;
|
|
let _ = tls_stream.shutdown().await;
|
|
});
|
|
}
|
|
});
|
|
|
|
Self {
|
|
origin: format!("https://127.0.0.1:{}", addr.port()),
|
|
shutdown: Some(shutdown_tx),
|
|
handshakes,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for TlsTestServer {
|
|
fn drop(&mut self) {
|
|
if let Some(shutdown) = self.shutdown.take() {
|
|
let _ = shutdown.send(());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn client_config(ca: Option<PathBuf>, cert: Option<ClientCertConfig>) -> HttpClientConfig {
|
|
HttpClientConfig {
|
|
ca_bundle: ca,
|
|
client_cert: cert,
|
|
..HttpClientConfig::default()
|
|
}
|
|
}
|
|
|
|
fn cleanup_dir(dir: &PathBuf) {
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn client_with_ca_bundle_connects_to_private_roots_server() {
|
|
let pki = TestPki::generate();
|
|
let server = TlsTestServer::spawn(&pki, false).await;
|
|
let (ca, _cert, dir) = pki.write_config_files(false);
|
|
|
|
let http = SharedHttpClient::new(client_config(ca, None)).expect("client builds with CA");
|
|
let response = http
|
|
.client()
|
|
.get(format!("{}/ping", server.origin))
|
|
.send()
|
|
.await
|
|
.expect("request over private roots succeeds");
|
|
assert_eq!(response.status(), 200, "server answers over TLS");
|
|
assert_eq!(
|
|
response.text().await.unwrap(),
|
|
"ok",
|
|
"the TLS-secured body arrives intact"
|
|
);
|
|
assert_eq!(
|
|
server.handshakes(),
|
|
1,
|
|
"exactly one TLS handshake was completed"
|
|
);
|
|
cleanup_dir(&dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn client_without_ca_bundle_rejects_private_roots_server() {
|
|
let pki = TestPki::generate();
|
|
let server = TlsTestServer::spawn(&pki, false).await;
|
|
|
|
let http = SharedHttpClient::new(HttpClientConfig::default())
|
|
.expect("client builds with default (webpki) roots");
|
|
let result = http
|
|
.client()
|
|
.get(format!("{}/ping", server.origin))
|
|
.send()
|
|
.await;
|
|
let error = result
|
|
.expect_err("a private-roots server must be rejected by a client without the CA bundle");
|
|
let text = error_chain_text(&error);
|
|
assert!(
|
|
text.contains("certificate"),
|
|
"the chain names the TLS verification failure, got: {text}"
|
|
);
|
|
}
|
|
|
|
/// Walks the full `std::error::Error` source chain (the retry
|
|
/// middleware wraps the transport error, so the TLS detail sits in the
|
|
/// `Caused by` chain) and joins it into one lowercase string.
|
|
fn error_chain_text(error: &reqwest_middleware::Error) -> String {
|
|
let mut text = error.to_string().to_lowercase();
|
|
let mut source = std::error::Error::source(error);
|
|
while let Some(err) = source {
|
|
text.push(' ');
|
|
text.push_str(&err.to_string().to_lowercase());
|
|
source = err.source();
|
|
}
|
|
text
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mtls_client_cert_is_presented_and_accepted_end_to_end() {
|
|
let pki = TestPki::generate();
|
|
let server = TlsTestServer::spawn(&pki, true).await;
|
|
let (ca, cert, dir) = pki.write_config_files(true);
|
|
|
|
let http = SharedHttpClient::new(client_config(ca, cert))
|
|
.expect("client builds with CA bundle + client identity");
|
|
let response = http
|
|
.client()
|
|
.get(format!("{}/ping", server.origin))
|
|
.send()
|
|
.await
|
|
.expect("mTLS handshake with client identity succeeds");
|
|
assert_eq!(response.status(), 200, "server answers the mTLS client");
|
|
assert_eq!(response.text().await.unwrap(), "ok");
|
|
assert_eq!(
|
|
server.handshakes(),
|
|
1,
|
|
"the client-cert handshake completed through the full middleware stack"
|
|
);
|
|
cleanup_dir(&dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mtls_server_rejects_client_without_identity() {
|
|
let pki = TestPki::generate();
|
|
let server = TlsTestServer::spawn(&pki, true).await;
|
|
let (ca, _cert, dir) = pki.write_config_files(false);
|
|
|
|
let http = SharedHttpClient::new(client_config(ca, None))
|
|
.expect("client builds with CA bundle but no client identity");
|
|
let result = http
|
|
.client()
|
|
.get(format!("{}/ping", server.origin))
|
|
.send()
|
|
.await;
|
|
let error = result
|
|
.expect_err("an mTLS-requiring server must reject a client that presents no certificate");
|
|
let text = error_chain_text(&error);
|
|
assert!(
|
|
text.contains("certificate") || text.contains("alert") || text.contains("handshake"),
|
|
"the chain names a TLS/certificate-level rejection, got: {text}"
|
|
);
|
|
cleanup_dir(&dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reload_to_a_ca_bundle_backed_client_succeeds() {
|
|
let pki = TestPki::generate();
|
|
let server = TlsTestServer::spawn(&pki, false).await;
|
|
let (ca, _cert, dir) = pki.write_config_files(false);
|
|
|
|
let http = SharedHttpClient::new(HttpClientConfig::default()).expect("initial client");
|
|
assert!(
|
|
http.client()
|
|
.get(format!("{}/ping", server.origin))
|
|
.send()
|
|
.await
|
|
.is_err(),
|
|
"before the reload the private-roots server is unreachable"
|
|
);
|
|
let reloaded = client_config(ca, None);
|
|
http.reload(reloaded)
|
|
.await
|
|
.expect("reload with a valid CA bundle succeeds");
|
|
let response = http
|
|
.client()
|
|
.get(format!("{}/ping", server.origin))
|
|
.send()
|
|
.await
|
|
.expect("after the reload the CA bundle is trusted");
|
|
assert_eq!(response.status(), 200);
|
|
cleanup_dir(&dir);
|
|
tokio::time::sleep(Duration::from_millis(1)).await;
|
|
}
|