use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
use bytes::Bytes;
use http_body_util::{BodyExt, Empty, Full};
use hyper::body::Incoming;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::net::{TcpListener, TcpStream};
use tokio_rustls::TlsConnector;
use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
use tokio_rustls::rustls::{ClientConfig, HandshakeKind, RootCertStore, crypto::aws_lc_rs};
use plecto_control::{Control, Host, Manifest, MemoryStore, ResolvedArtifact};
use plecto_host::test_support::{TestSigner, bound_sbom, filter_hello_component};
use plecto_server::serve;
struct TestCert {
_dir: tempfile::TempDir,
cert_path: String,
key_path: String,
cert_der: CertificateDer<'static>,
key_der: PrivateKeyDer<'static>,
}
fn make_cert() -> TestCert {
make_cert_for("localhost")
}
fn make_cert_for(host: &str) -> TestCert {
let generated = rcgen::generate_simple_self_signed(vec![host.to_string()]).unwrap();
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
std::fs::write(&cert_path, generated.cert.pem()).unwrap();
std::fs::write(&key_path, generated.key_pair.serialize_pem()).unwrap();
TestCert {
cert_der: generated.cert.der().clone(),
key_der: PrivateKeyDer::try_from(generated.key_pair.serialize_der()).unwrap(),
cert_path: cert_path.to_str().unwrap().to_string(),
key_path: key_path.to_str().unwrap().to_string(),
_dir: dir,
}
}
async fn echo(_req: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
Ok(Response::builder()
.status(200)
.header("x-from", "upstream")
.body(Full::new(Bytes::from_static(b"upstream-ok")))
.unwrap())
}
async fn spawn_upstream() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(stream), service_fn(echo))
.await;
});
}
});
addr
}
fn manifest_toml(upstream: SocketAddr, digest: &str, tls_block: &str) -> String {
format!(
r#"
[[filter]]
id = "fh"
source = "fh"
digest = "{digest}"
isolation = "trusted"
[[upstream]]
name = "echo"
addresses = ["{upstream}"]
[upstream.health]
path = "/healthz"
interval_ms = 50
[[route]]
filters = ["fh"]
upstream = "echo"
strip_prefix = "/api"
[route.match]
path_prefix = "/api"
{tls_block}
"#
)
}
fn loaded_control(toml: &str) -> Result<Control, plecto_control::ControlError> {
let component = filter_hello_component();
let signer = TestSigner::new().unwrap();
let component_signature = signer.sign(&component).unwrap();
let sbom = bound_sbom(&component);
let sbom_signature = signer.sign(&sbom).unwrap();
let mut store = MemoryStore::new();
let digest = store.insert(
"fh",
ResolvedArtifact {
component,
component_signature,
sbom,
sbom_signature,
},
);
let toml = toml.replace("{digest}", &digest);
let manifest = Manifest::from_toml(&toml).unwrap();
let host = Host::new(signer.trust_policy().unwrap()).unwrap();
Control::load(host, &manifest, Box::new(store))
}
async fn spawn_proxy(control: Arc<Control>) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = serve(control, listener).await;
});
addr
}
async fn https_get(
proxy: SocketAddr,
root: CertificateDer<'static>,
path: &str,
) -> (StatusCode, Option<String>, String) {
let mut roots = RootCertStore::empty();
roots.add(root).unwrap();
let config = ClientConfig::builder_with_provider(Arc::new(aws_lc_rs::default_provider()))
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(roots)
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let tcp = TcpStream::connect(proxy).await.unwrap();
let server_name = ServerName::try_from("localhost").unwrap();
let tls = connector.connect(server_name, tcp).await.unwrap();
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(tls))
.await
.unwrap();
tokio::spawn(async move {
let _ = conn.await;
});
let req = Request::builder()
.method("GET")
.uri(path)
.header("host", "localhost")
.body(Empty::<Bytes>::new())
.unwrap();
let resp = sender.send_request(req).await.unwrap();
let (parts, body) = resp.into_parts();
let alt_svc = parts
.headers
.get("alt-svc")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = body.collect().await.unwrap().to_bytes();
(
parts.status,
alt_svc,
String::from_utf8_lossy(&bytes).into_owned(),
)
}
fn resuming_client_config(root: CertificateDer<'static>) -> Arc<ClientConfig> {
resuming_client_config_with_roots(vec![root])
}
fn resuming_client_config_with_roots(
root_certs: Vec<CertificateDer<'static>>,
) -> Arc<ClientConfig> {
let mut roots = RootCertStore::empty();
for root in root_certs {
roots.add(root).unwrap();
}
Arc::new(
ClientConfig::builder_with_provider(Arc::new(aws_lc_rs::default_provider()))
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(roots)
.with_no_client_auth(),
)
}
fn client_config_with_identity(
root: CertificateDer<'static>,
identity: &TestCert,
) -> Arc<ClientConfig> {
let mut roots = RootCertStore::empty();
roots.add(root).unwrap();
Arc::new(
ClientConfig::builder_with_provider(Arc::new(aws_lc_rs::default_provider()))
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(roots)
.with_client_auth_cert(
vec![identity.cert_der.clone()],
identity.key_der.clone_key(),
)
.unwrap(),
)
}
async fn try_https_get(
proxy: SocketAddr,
config: Arc<ClientConfig>,
path: &str,
) -> Result<StatusCode, String> {
let connector = TlsConnector::from(config);
let tcp = TcpStream::connect(proxy).await.map_err(|e| e.to_string())?;
let server_name = ServerName::try_from("localhost").unwrap();
let tls = connector
.connect(server_name, tcp)
.await
.map_err(|e| e.to_string())?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(tls))
.await
.map_err(|e| e.to_string())?;
tokio::spawn(async move {
let _ = conn.await;
});
let req = Request::builder()
.method("GET")
.uri(path)
.header("host", "localhost")
.body(Empty::<Bytes>::new())
.unwrap();
let resp = sender.send_request(req).await.map_err(|e| e.to_string())?;
Ok(resp.status())
}
fn make_stek(fill: u8) -> (tempfile::TempDir, String) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stek.key");
std::fs::write(&path, [fill; 64]).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
(dir, path.to_str().unwrap().to_string())
}
async fn https_get_kind(
proxy: SocketAddr,
config: Arc<ClientConfig>,
path: &str,
) -> (StatusCode, HandshakeKind) {
https_get_kind_sni(proxy, config, "localhost", path).await
}
async fn https_get_kind_sni(
proxy: SocketAddr,
config: Arc<ClientConfig>,
sni: &str,
path: &str,
) -> (StatusCode, HandshakeKind) {
let connector = TlsConnector::from(config);
let tcp = TcpStream::connect(proxy).await.unwrap();
let server_name = ServerName::try_from(sni.to_string()).unwrap();
let tls = connector.connect(server_name, tcp).await.unwrap();
let kind = tls.get_ref().1.handshake_kind().unwrap();
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(tls))
.await
.unwrap();
tokio::spawn(async move {
let _ = conn.await;
});
let req = Request::builder()
.method("GET")
.uri(path)
.header("host", "localhost")
.body(Empty::<Bytes>::new())
.unwrap();
let resp = sender.send_request(req).await.unwrap();
let (parts, body) = resp.into_parts();
let _ = body.collect().await.unwrap();
(parts.status, kind)
}
#[tokio::test]
async fn terminates_tls_then_routes_and_forwards() {
let cert = make_cert();
let upstream = spawn_upstream().await;
let tls_block = format!(
"\n[[tls]]\ncert_path = \"{}\"\nkey_path = \"{}\"\n",
cert.cert_path, cert.key_path
);
let control = loaded_control(&manifest_toml(upstream, "{digest}", &tls_block)).unwrap();
let proxy = spawn_proxy(Arc::new(control)).await;
let (status, alt_svc, body) = loop {
let r = https_get(proxy, cert.cert_der.clone(), "/api/hello").await;
if r.0 != StatusCode::SERVICE_UNAVAILABLE {
break r;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
};
assert_eq!(
status,
StatusCode::OK,
"HTTPS request routes + forwards 200"
);
assert_eq!(
body, "upstream-ok",
"the upstream body streams back over TLS"
);
let port = proxy.port();
assert_eq!(
alt_svc.as_deref(),
Some(format!("h3=\":{port}\"; ma=86400").as_str()),
"TCP responses advertise h3 on the same port via Alt-Svc"
);
}
#[tokio::test]
async fn second_connection_resumes_with_stateless_ticket() {
let cert = make_cert();
let upstream = spawn_upstream().await;
let tls_block = format!(
"\n[[tls]]\ncert_path = \"{}\"\nkey_path = \"{}\"\n",
cert.cert_path, cert.key_path
);
let control = loaded_control(&manifest_toml(upstream, "{digest}", &tls_block)).unwrap();
let proxy = spawn_proxy(Arc::new(control)).await;
let config = resuming_client_config(cert.cert_der.clone());
let (_, first) = https_get_kind(proxy, config.clone(), "/api/hello").await;
assert_eq!(
first,
HandshakeKind::Full,
"an empty client session cache means the first handshake is full"
);
let (_, second) = https_get_kind(proxy, config, "/api/hello").await;
assert_eq!(
second,
HandshakeKind::Resumed,
"the second connection offers the first's ticket and resumes"
);
}
#[tokio::test]
async fn ticket_resumes_across_config_rebuilds() {
let cert = make_cert();
let upstream = spawn_upstream().await;
let tls_block = format!(
"\n[[tls]]\ncert_path = \"{}\"\nkey_path = \"{}\"\n",
cert.cert_path, cert.key_path
);
let toml = manifest_toml(upstream, "{digest}", &tls_block);
let proxy_a = spawn_proxy(Arc::new(loaded_control(&toml).unwrap())).await;
let proxy_b = spawn_proxy(Arc::new(loaded_control(&toml).unwrap())).await;
let config = resuming_client_config(cert.cert_der.clone());
let (_, first) = https_get_kind(proxy_a, config.clone(), "/api/hello").await;
assert_eq!(first, HandshakeKind::Full);
let (_, cross) = https_get_kind(proxy_b, config, "/api/hello").await;
assert_eq!(
cross,
HandshakeKind::Resumed,
"a ticket from before the rebuild resumes after it (process-lifetime key, ADR 000052)"
);
}
#[tokio::test]
async fn shared_stek_ticket_resumes_across_replicas() {
let cert = make_cert();
let (_stek_dir, stek_path) = make_stek(7);
let upstream = spawn_upstream().await;
let block = format!(
"\n[[tls]]\ncert_path = \"{}\"\nkey_path = \"{}\"\n\n[resumption]\nstek_file = \"{}\"\n",
cert.cert_path, cert.key_path, stek_path
);
let toml = manifest_toml(upstream, "{digest}", &block);
let proxy_a = spawn_proxy(Arc::new(loaded_control(&toml).unwrap())).await;
let proxy_b = spawn_proxy(Arc::new(loaded_control(&toml).unwrap())).await;
let config = resuming_client_config(cert.cert_der.clone());
let (_, first) = https_get_kind(proxy_a, config.clone(), "/api/hello").await;
assert_eq!(first, HandshakeKind::Full);
let (_, cross) = https_get_kind(proxy_b, config, "/api/hello").await;
assert_eq!(
cross,
HandshakeKind::Resumed,
"replica B accepts replica A's ticket (shared STEK, same cert set — ADR 000062)"
);
}
#[tokio::test]
async fn shared_stek_ticket_does_not_cross_cert_sets() {
let cert_a = make_cert();
let cert_b = make_cert(); let (_stek_dir, stek_path) = make_stek(7);
let upstream = spawn_upstream().await;
let block = |cert: &TestCert| {
format!(
"\n[[tls]]\ncert_path = \"{}\"\nkey_path = \"{}\"\n\n[resumption]\nstek_file = \"{}\"\n",
cert.cert_path, cert.key_path, stek_path
)
};
let proxy_a = spawn_proxy(Arc::new(
loaded_control(&manifest_toml(upstream, "{digest}", &block(&cert_a))).unwrap(),
))
.await;
let proxy_b = spawn_proxy(Arc::new(
loaded_control(&manifest_toml(upstream, "{digest}", &block(&cert_b))).unwrap(),
))
.await;
let config =
resuming_client_config_with_roots(vec![cert_a.cert_der.clone(), cert_b.cert_der.clone()]);
let (_, first) = https_get_kind(proxy_a, config.clone(), "/api/hello").await;
assert_eq!(first, HandshakeKind::Full);
let (_, cross) = https_get_kind(proxy_b, config, "/api/hello").await;
assert_eq!(
cross,
HandshakeKind::Full,
"a different cert set must NOT accept the ticket despite the shared key file \
(cert binding, ADR 000062 (a))"
);
}
#[derive(Debug)]
struct SniConfusedStore(tokio_rustls::rustls::client::ClientSessionMemoryCache);
fn pinned() -> ServerName<'static> {
ServerName::try_from("pinned.invalid").unwrap()
}
impl tokio_rustls::rustls::client::ClientSessionStore for SniConfusedStore {
fn set_kx_hint(&self, _: ServerName<'static>, group: tokio_rustls::rustls::NamedGroup) {
self.0.set_kx_hint(pinned(), group);
}
fn kx_hint(&self, _: &ServerName<'_>) -> Option<tokio_rustls::rustls::NamedGroup> {
self.0.kx_hint(&pinned())
}
fn set_tls12_session(
&self,
_: ServerName<'static>,
value: tokio_rustls::rustls::client::Tls12ClientSessionValue,
) {
self.0.set_tls12_session(pinned(), value);
}
fn tls12_session(
&self,
_: &ServerName<'_>,
) -> Option<tokio_rustls::rustls::client::Tls12ClientSessionValue> {
self.0.tls12_session(&pinned())
}
fn remove_tls12_session(&self, _: &ServerName<'static>) {
self.0.remove_tls12_session(&pinned());
}
fn insert_tls13_ticket(
&self,
_: ServerName<'static>,
value: tokio_rustls::rustls::client::Tls13ClientSessionValue,
) {
self.0.insert_tls13_ticket(pinned(), value);
}
fn take_tls13_ticket(
&self,
_: &ServerName<'static>,
) -> Option<tokio_rustls::rustls::client::Tls13ClientSessionValue> {
self.0.take_tls13_ticket(&pinned())
}
}
#[tokio::test]
async fn ticket_does_not_resume_across_sni_hosts_within_one_proxy() {
let cert_a = make_cert_for("a.localhost");
let cert_b = make_cert_for("b.localhost");
let (_stek_dir, stek_path) = make_stek(7);
let upstream = spawn_upstream().await;
let block = format!(
"\n[[tls]]\nhost = \"a.localhost\"\ncert_path = \"{}\"\nkey_path = \"{}\"\n\
\n[[tls]]\nhost = \"b.localhost\"\ncert_path = \"{}\"\nkey_path = \"{}\"\n\
\n[resumption]\nstek_file = \"{}\"\n",
cert_a.cert_path, cert_a.key_path, cert_b.cert_path, cert_b.key_path, stek_path
);
let control = loaded_control(&manifest_toml(upstream, "{digest}", &block)).unwrap();
let proxy = spawn_proxy(Arc::new(control)).await;
let mut roots = RootCertStore::empty();
roots.add(cert_a.cert_der.clone()).unwrap();
roots.add(cert_b.cert_der.clone()).unwrap();
let mut config = ClientConfig::builder_with_provider(Arc::new(aws_lc_rs::default_provider()))
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(roots)
.with_no_client_auth();
config.resumption =
tokio_rustls::rustls::client::Resumption::store(Arc::new(SniConfusedStore(
tokio_rustls::rustls::client::ClientSessionMemoryCache::new(256),
)));
let config = Arc::new(config);
let (_, first) = https_get_kind_sni(proxy, config.clone(), "a.localhost", "/api/hello").await;
assert_eq!(first, HandshakeKind::Full);
let (_, same) = https_get_kind_sni(proxy, config.clone(), "a.localhost", "/api/hello").await;
assert_eq!(same, HandshakeKind::Resumed, "same-SNI resumption works");
let (_, cross) = https_get_kind_sni(proxy, config, "b.localhost", "/api/hello").await;
assert_eq!(
cross,
HandshakeKind::Full,
"a ticket from a.localhost must not resume a b.localhost handshake"
);
}
#[tokio::test]
async fn bad_cert_path_fails_closed_at_load() {
let upstream = spawn_upstream().await;
let tls_block =
"\n[[tls]]\ncert_path = \"/nonexistent/cert.pem\"\nkey_path = \"/nonexistent/key.pem\"\n";
let result = loaded_control(&manifest_toml(upstream, "{digest}", tls_block));
match result {
Ok(_) => panic!("a missing cert file must fail the load (fail-closed), not serve plain"),
Err(plecto_control::ControlError::TlsCert { reason, .. }) => {
assert!(
reason.contains("read failed"),
"reason should name the read failure"
);
}
Err(e) => panic!("expected a TlsCert error, got: {e}"),
}
}
fn client_auth_block(server: &TestCert, client_ca: &TestCert) -> String {
format!(
"\n[[tls]]\ncert_path = \"{}\"\nkey_path = \"{}\"\n\n[listen.client_auth]\nca_path = \"{}\"\n",
server.cert_path, server.key_path, client_ca.cert_path
)
}
async fn https_get_kind_ready(
proxy: SocketAddr,
config: Arc<ClientConfig>,
path: &str,
) -> (StatusCode, HandshakeKind) {
for _ in 0..100 {
let r = https_get_kind(proxy, config.clone(), path).await;
if r.0 != StatusCode::SERVICE_UNAVAILABLE {
return r;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("upstream never became healthy within the readiness window");
}
#[tokio::test]
async fn client_auth_listener_serves_an_authenticated_client_and_refuses_an_anonymous_one() {
let server = make_cert();
let identity = make_cert_for("plecto-client");
let upstream = spawn_upstream().await;
let control = loaded_control(&manifest_toml(
upstream,
"{digest}",
&client_auth_block(&server, &identity),
))
.unwrap();
let proxy = spawn_proxy(Arc::new(control)).await;
let authed = client_config_with_identity(server.cert_der.clone(), &identity);
let (status, _) = https_get_kind_ready(proxy, authed, "/api/hello").await;
assert_eq!(status, StatusCode::OK, "an authenticated client is served");
let anon = resuming_client_config(server.cert_der.clone());
assert!(
try_https_get(proxy, anon, "/api/hello").await.is_err(),
"an anonymous client must be refused at the TLS layer, not served an HTTP response"
);
}
#[tokio::test]
async fn client_auth_with_shared_stek_fails_the_load_closed() {
let server = make_cert();
let identity = make_cert_for("plecto-client");
let (_stek_dir, stek_path) = make_stek(9);
let upstream = spawn_upstream().await;
let tls_block = format!(
"{}\n[resumption]\nstek_file = \"{stek_path}\"\n",
client_auth_block(&server, &identity)
);
match loaded_control(&manifest_toml(upstream, "{digest}", &tls_block)) {
Ok(_) => panic!("[listen.client_auth] + [resumption] must fail the load (ADR 000062 (b))"),
Err(plecto_control::ControlError::Stek { reason, .. }) => {
assert!(
reason.contains("client_auth"),
"the error should name the crossing rule, got: {reason}"
);
}
Err(e) => panic!("expected the Stek crossing error, got: {e}"),
}
}
#[tokio::test]
async fn client_auth_listener_refuses_an_untrusted_client_certificate() {
let server = make_cert();
let trusted = make_cert_for("plecto-client");
let untrusted = make_cert_for("imposter");
let upstream = spawn_upstream().await;
let control = loaded_control(&manifest_toml(
upstream,
"{digest}",
&client_auth_block(&server, &trusted),
))
.unwrap();
let proxy = spawn_proxy(Arc::new(control)).await;
let bad = client_config_with_identity(server.cert_der.clone(), &untrusted);
assert!(
try_https_get(proxy, bad, "/api/hello").await.is_err(),
"a client certificate that does not chain to ca_path must be refused at the TLS layer"
);
let good = client_config_with_identity(server.cert_der, &trusted);
let (status, _) = https_get_kind_ready(proxy, good, "/api/hello").await;
assert_eq!(
status,
StatusCode::OK,
"sanity: a trusted identity is still served on the same listener"
);
}
#[tokio::test]
async fn client_authenticated_sessions_still_resume_within_a_node() {
let server = make_cert();
let identity = make_cert_for("plecto-client");
let upstream = spawn_upstream().await;
let control = loaded_control(&manifest_toml(
upstream,
"{digest}",
&client_auth_block(&server, &identity),
))
.unwrap();
let proxy = spawn_proxy(Arc::new(control)).await;
let anon = resuming_client_config(server.cert_der.clone());
assert!(
try_https_get(proxy, anon, "/api/hello").await.is_err(),
"sanity: the listener really requires a client certificate"
);
let config = client_config_with_identity(server.cert_der.clone(), &identity);
let (status, first) = https_get_kind_ready(proxy, config.clone(), "/api/hello").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(first, HandshakeKind::Full);
let (status, second) = https_get_kind(proxy, config, "/api/hello").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
second,
HandshakeKind::Resumed,
"per-node resumption stays on for client-authenticated sessions (ADR 000078)"
);
}