diff --git a/README.md b/README.md index 83b6bd3..661662f 100644 --- a/README.md +++ b/README.md @@ -473,9 +473,13 @@ fully quit it and reopen it; also disable Chrome Secure DNS if it bypasses the macOS resolver. Aster prints the same checks when DNS validation fails. The proxy binds only to `127.0.0.1`, routes only to configured named ports, -and supports HTTP upgrades for development WebSockets/HMR. Use port 8443 when -the operating system restricts unprivileged processes from binding port 443. -Do not run the complete development stack as root. +and supports HTTP upgrades for development WebSockets/HMR. A suffix route may +accept names deeper than a static wildcard certificate covers. For those SNI +names, Aster asks `mkcert` for an exact certificate on first use and caches it +under `.aster/tls//hosts/`; issuance is restricted to configured routes +and capped at 64 exact certificates per edge to bound local resource use. +Use port 8443 when the operating system restricts unprivileged processes from +binding port 443. Do not run the complete development stack as root. When a TLS route points to another service's named port, that service's dashboard `[open]` action uses the route's HTTPS hostname. Exact routes infer diff --git a/src/config/workspace.rs b/src/config/workspace.rs index f8dcc8c..1b71f65 100644 --- a/src/config/workspace.rs +++ b/src/config/workspace.rs @@ -199,7 +199,7 @@ impl DevWorkspaceConfig { if !accepted { bail!("TLS proxy service '{name}' route open_host '{open_host}' does not match its host selector"); } - if !tls_certificate_covers(tls, open_host) { + if route.host.is_some() && !tls_certificate_covers(tls, open_host) { bail!("TLS proxy service '{name}' route open_host '{open_host}' is not covered by certificate_hosts"); } } diff --git a/src/dev/tls.rs b/src/dev/tls.rs index 536a670..2e17e2c 100644 --- a/src/dev/tls.rs +++ b/src/dev/tls.rs @@ -1,12 +1,15 @@ +use std::collections::HashMap; use std::convert::Infallible; -use std::fs; +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; use std::io::BufReader; use std::path::{Component, Path, PathBuf}; use std::process::Command; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use anyhow::{anyhow, bail, Context, Result}; use bytes::Bytes; +use fs2::FileExt; use http_body_util::{combinators::BoxBody, BodyExt, Full}; use hyper::body::Incoming; use hyper::header::{HeaderValue, CONNECTION, HOST, UPGRADE}; @@ -19,6 +22,8 @@ use hyper_util::client::legacy::Client; use hyper_util::rt::{TokioExecutor, TokioIo}; use tokio::io::copy_bidirectional; use tokio::net::TcpListener; +use tokio_rustls::rustls::server::{ClientHello, ResolvesServerCert}; +use tokio_rustls::rustls::sign::CertifiedKey; use tokio_rustls::rustls::{self, ServerConfig}; use tokio_rustls::TlsAcceptor; @@ -26,6 +31,9 @@ use crate::config::{DevServiceConfig, DevTlsProxyConfig, DevWorkspaceConfig}; const CERT_FILE: &str = "cert.pem"; const KEY_FILE: &str = "key.pem"; +const DYNAMIC_CERT_DIR: &str = "hosts"; +const DYNAMIC_CERT_LOCK: &str = "hosts.lock"; +const MAX_DYNAMIC_CERTIFICATES: usize = 64; pub fn setup_tls(workspace_root: &Path, config: &DevWorkspaceConfig, edge: &str) -> Result<()> { let (_, tls) = configured_proxy(config, edge)?; @@ -47,6 +55,12 @@ pub fn setup_tls(workspace_root: &Path, config: &DevWorkspaceConfig, edge: &str) let cert_path = cert_dir.join(CERT_FILE); let key_path = cert_dir.join(KEY_FILE); + let _dynamic_lock = lock_dynamic_certificates(&cert_dir)?; + let dynamic_dir = cert_dir.join(DYNAMIC_CERT_DIR); + if dynamic_dir.exists() { + fs::remove_dir_all(&dynamic_dir) + .with_context(|| format!("failed to clear {}", dynamic_dir.display()))?; + } let mut command = Command::new(&mkcert); command .arg("-cert-file") @@ -129,8 +143,10 @@ pub fn serve_tls(workspace_root: &Path, config: &DevWorkspaceConfig, edge: &str) runtime.block_on(serve( edge.to_string(), listen_port, + cert_dir, cert_path, key_path, + tls.certificate_hosts.clone(), Arc::new(routes), )) } @@ -138,11 +154,19 @@ pub fn serve_tls(workspace_root: &Path, config: &DevWorkspaceConfig, edge: &str) async fn serve( edge: String, listen_port: u16, + cert_dir: PathBuf, cert_path: PathBuf, key_path: PathBuf, + certificate_hosts: Vec, routes: Arc>, ) -> Result<()> { - let tls = Arc::new(load_server_config(&cert_path, &key_path)?); + let tls = Arc::new(load_server_config( + &cert_path, + &key_path, + cert_dir, + certificate_hosts, + routes.clone(), + )?); let listener = TcpListener::bind(("127.0.0.1", listen_port)) .await .with_context(|| format!("TLS edge '{edge}' could not bind 127.0.0.1:{listen_port}"))?; @@ -323,21 +347,195 @@ fn match_route(hostname: &str, routes: &[ResolvedRoute]) -> Option { .map(|route| route.port) } -fn load_server_config(cert_path: &Path, key_path: &Path) -> Result { +fn load_certified_key(cert_path: &Path, key_path: &Path) -> Result> { let mut cert_reader = BufReader::new(fs::File::open(cert_path)?); let certificates = rustls_pemfile::certs(&mut cert_reader).collect::, _>>()?; let mut key_reader = BufReader::new(fs::File::open(key_path)?); let key = rustls_pemfile::private_key(&mut key_reader)? .ok_or_else(|| anyhow!("no private key found in {}", key_path.display()))?; + let provider = rustls::crypto::aws_lc_rs::default_provider(); + Ok(Arc::new( + CertifiedKey::from_der(certificates, key, &provider) + .context("certificate and private key do not match")?, + )) +} + +fn load_server_config( + cert_path: &Path, + key_path: &Path, + cert_dir: PathBuf, + certificate_hosts: Vec, + routes: Arc>, +) -> Result { + let default = load_certified_key(cert_path, key_path)?; + let resolver = DynamicCertificateResolver { + default, + certificate_hosts, + routes, + cert_dir, + mkcert: std::env::var_os("ASTER_MKCERT_BIN").unwrap_or_else(|| "mkcert".into()), + cache: Mutex::new(HashMap::new()), + }; let mut config = rustls::ServerConfig::builder() .with_no_client_auth() - .with_single_cert(certificates, key) - .context("certificate and private key do not match")?; + .with_cert_resolver(Arc::new(resolver)); config.alpn_protocols = vec![b"http/1.1".to_vec()]; Ok(config) } +struct DynamicCertificateResolver { + default: Arc, + certificate_hosts: Vec, + routes: Arc>, + cert_dir: PathBuf, + mkcert: OsString, + cache: Mutex>>, +} + +impl std::fmt::Debug for DynamicCertificateResolver { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DynamicCertificateResolver") + .field("certificate_hosts", &self.certificate_hosts) + .field("cert_dir", &self.cert_dir) + .finish_non_exhaustive() + } +} + +impl ResolvesServerCert for DynamicCertificateResolver { + fn resolve(&self, client_hello: ClientHello<'_>) -> Option> { + let hostname = client_hello.server_name()?.to_ascii_lowercase(); + if self + .certificate_hosts + .iter() + .any(|configured| certificate_host_covers(configured, &hostname)) + { + return Some(self.default.clone()); + } + match_route(&hostname, &self.routes)?; + + let mut cache = self.cache.lock().ok()?; + if let Some(certified) = cache.get(&hostname) { + return Some(certified.clone()); + } + match issue_dynamic_certificate(&self.mkcert, &self.cert_dir, &hostname) { + Ok(certified) => { + cache.insert(hostname, certified.clone()); + Some(certified) + } + Err(error) => { + eprintln!("TLS certificate issuance failed: {error:#}"); + None + } + } + } +} + +fn certificate_host_covers(configured: &str, hostname: &str) -> bool { + let configured = configured.to_ascii_lowercase(); + if let Some(suffix) = configured.strip_prefix("*.") { + hostname.strip_suffix(suffix).is_some_and(|prefix| { + prefix.ends_with('.') && !prefix[..prefix.len() - 1].contains('.') + }) + } else { + hostname == configured + } +} + +fn issue_dynamic_certificate( + mkcert: &OsString, + cert_dir: &Path, + hostname: &str, +) -> Result> { + let _dynamic_lock = lock_dynamic_certificates(cert_dir)?; + let dynamic_dir = cert_dir.join(DYNAMIC_CERT_DIR); + let host_dir = dynamic_dir.join(hostname); + let cert_path = host_dir.join(CERT_FILE); + let key_path = host_dir.join(KEY_FILE); + if cert_path.is_file() && key_path.is_file() { + if let Ok(certified) = load_certified_key(&cert_path, &key_path) { + return Ok(certified); + } + } + if host_dir.exists() { + fs::remove_dir_all(&host_dir) + .with_context(|| format!("failed to clear invalid cache {}", host_dir.display()))?; + } + fs::create_dir_all(&dynamic_dir)?; + let certificate_count = fs::read_dir(&dynamic_dir)? + .filter_map(std::result::Result::ok) + .filter(|entry| entry.path().is_dir()) + .count(); + if certificate_count >= MAX_DYNAMIC_CERTIFICATES { + bail!("dynamic TLS certificate limit of {MAX_DYNAMIC_CERTIFICATES} reached"); + } + fs::create_dir(&host_dir) + .with_context(|| format!("failed to create {}", host_dir.display()))?; + + let process_id = std::process::id(); + let temp_cert_path = host_dir.join(format!(".{CERT_FILE}.{process_id}.tmp")); + let temp_key_path = host_dir.join(format!(".{KEY_FILE}.{process_id}.tmp")); + let mut command = Command::new(mkcert); + command + .arg("-cert-file") + .arg(&temp_cert_path) + .arg("-key-file") + .arg(&temp_key_path) + .arg(hostname); + if let Err(error) = run_mkcert( + &mut command, + &format!("generate a certificate for {hostname}"), + ) { + let _ = fs::remove_file(&temp_cert_path); + let _ = fs::remove_file(&temp_key_path); + let _ = fs::remove_dir(&host_dir); + return Err(error); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(error) = fs::set_permissions(&temp_key_path, fs::Permissions::from_mode(0o600)) { + clear_failed_certificate(&host_dir, &temp_cert_path, &temp_key_path); + return Err(error.into()); + } + } + let certified = match load_certified_key(&temp_cert_path, &temp_key_path) { + Ok(certified) => certified, + Err(error) => { + clear_failed_certificate(&host_dir, &temp_cert_path, &temp_key_path); + return Err(error); + } + }; + if let Err(error) = fs::rename(&temp_cert_path, &cert_path) { + clear_failed_certificate(&host_dir, &temp_cert_path, &temp_key_path); + return Err(error.into()); + } + if let Err(error) = fs::rename(&temp_key_path, &key_path) { + clear_failed_certificate(&host_dir, &cert_path, &temp_key_path); + return Err(error.into()); + } + Ok(certified) +} + +fn lock_dynamic_certificates(cert_dir: &Path) -> Result { + fs::create_dir_all(cert_dir)?; + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(cert_dir.join(DYNAMIC_CERT_LOCK))?; + FileExt::lock_exclusive(&lock)?; + Ok(lock) +} + +fn clear_failed_certificate(host_dir: &Path, cert_path: &Path, key_path: &Path) { + let _ = fs::remove_file(cert_path); + let _ = fs::remove_file(key_path); + let _ = fs::remove_dir(host_dir); +} + fn configured_proxy<'a>( config: &'a DevWorkspaceConfig, edge: &str, @@ -440,37 +638,38 @@ mod tests { #[test] fn host_authority_accepts_numeric_ports_and_rejects_userinfo() { - let authority = parse_host_authority("intern.dev:8443").unwrap(); - assert_eq!(authority.host(), "intern.dev"); + let authority = parse_host_authority("app.example.test:8443").unwrap(); + assert_eq!(authority.host(), "app.example.test"); assert_eq!(authority.port_u16(), Some(8443)); - assert!(parse_host_authority("intern.dev:443@evil.example").is_none()); - assert!(parse_host_authority("intern.dev:not-a-port").is_none()); + assert!(parse_host_authority("app.example.test:443@evil.example").is_none()); + assert!(parse_host_authority("app.example.test:not-a-port").is_none()); } #[test] fn dns_validation_probes_apex_open_host_and_a_wildcard_site_hostname() { let tls = DevTlsProxyConfig { certificate_hosts: vec![ - "intern.dev".to_string(), - "*.local.sites.intern.dev".to_string(), + "app.example.test".to_string(), + "*.local.example.test".to_string(), ], - open_host: "intern.dev".to_string(), - dns_domain: Some("intern.dev".to_string()), + open_host: "app.example.test".to_string(), + dns_domain: Some("example.test".to_string()), routes: vec![crate::config::DevTlsRouteConfig { host: None, - host_suffix: Some(".sites.intern.dev".to_string()), - open_host: Some("test.local.sites.intern.dev".to_string()), + host_suffix: Some(".example.test".to_string()), + open_host: Some("demo.team.example.test".to_string()), upstream_port: "gateway".to_string(), }], }; assert_eq!( - dns_probe_hostnames(&tls, "intern.dev") + dns_probe_hostnames(&tls, "example.test") .into_iter() .collect::>(), [ - "aster-dns-probe.local.sites.intern.dev", - "intern.dev", - "test.local.sites.intern.dev" + "app.example.test", + "aster-dns-probe.local.example.test", + "demo.team.example.test", + "example.test" ] ); } diff --git a/tests/dev_tls.rs b/tests/dev_tls.rs index 92ea8ce..48d3126 100644 --- a/tests/dev_tls.rs +++ b/tests/dev_tls.rs @@ -9,7 +9,10 @@ use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; -use rcgen::generate_simple_self_signed; +use rcgen::{ + generate_simple_self_signed, BasicConstraints, CertificateParams, DnType, IsCa, Issuer, + KeyPair, KeyUsagePurpose, +}; use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName}; use tokio_rustls::rustls::{ClientConfig, ClientConnection, RootCertStore, StreamOwned}; @@ -60,7 +63,7 @@ gateway = 3800 worker = 3900 [dev.service_groups] -intern = ["frontend", "gateway", "worker", "edge"] +web = ["frontend", "gateway", "worker", "edge"] [dev.services.frontend] target = "//frontend:dev" @@ -77,7 +80,7 @@ port = "worker" [dev.services.edge] port = "https" -tls_proxy = { certificate_hosts = ["intern.dev", "*.local.sites.intern.dev"], open_host = "intern.dev", routes = [{ host = "intern.dev", upstream_port = "frontend" }, { host_suffix = ".sites.intern.dev", open_host = "test.local.sites.intern.dev", upstream_port = "gateway" }] } +tls_proxy = { certificate_hosts = ["app.example.test"], open_host = "app.example.test", routes = [{ host = "app.example.test", upstream_port = "frontend" }, { host_suffix = ".example.test", open_host = "demo.team.example.test", upstream_port = "gateway" }] } "#, ) .unwrap(); @@ -85,7 +88,7 @@ tls_proxy = { certificate_hosts = ["intern.dev", "*.local.sites.intern.dev"], op // Public boundary: the same resolved plan feeds the dashboard [open] // action; dry-run renders those concrete URLs without launching services. let output = Command::new(env!("CARGO_BIN_EXE_aster")) - .args(["services", "up", "intern", "--dry-run"]) + .args(["services", "up", "web", "--dry-run"]) .current_dir(root) .output() .unwrap(); @@ -95,13 +98,13 @@ tls_proxy = { certificate_hosts = ["intern.dev", "*.local.sites.intern.dev"], op // Observable result: routed services open through trusted HTTPS, including // their own path, while an unpublished service retains localhost fallback. assert!( - plan.contains("frontend :3100 -> //frontend:dev [open https://intern.dev:8443/login]"), + plan.contains( + "frontend :3100 -> //frontend:dev [open https://app.example.test:8443/login]" + ), "{plan}" ); assert!( - plan.contains( - "gateway :3800 -> //gateway:dev [open https://test.local.sites.intern.dev:8443]" - ), + plan.contains("gateway :3800 -> //gateway:dev [open https://demo.team.example.test:8443]"), "{plan}" ); assert!( @@ -126,7 +129,7 @@ upstream = 3000 [dev.services.edge] port = "https" -tls_proxy = { certificate_hosts = ["intern.dev", "*.local.sites.intern.dev"], open_host = "intern.dev", routes = [{ host = "intern.dev", upstream_port = "upstream" }] } +tls_proxy = { certificate_hosts = ["app.example.test", "*.local.example.test"], open_host = "app.example.test", routes = [{ host = "app.example.test", upstream_port = "upstream" }] } "#, ) .unwrap(); @@ -166,7 +169,7 @@ printf 'private-key' > "$key" let calls = fs::read_to_string(log).unwrap(); assert!(calls.lines().any(|line| line == "-install"), "{calls}"); assert!( - calls.contains("intern.dev *.local.sites.intern.dev"), + calls.contains("app.example.test *.local.example.test"), "{calls}" ); let cert_dir = root.join(".aster/tls/edge"); @@ -182,9 +185,9 @@ printf 'private-key' > "$key" } #[test] -fn service_group_serves_two_https_hosts_through_real_tls_and_http_boundaries() { - // Setup: two real loopback HTTP services and a real certificate trusted by - // this test client model the frontend and wildcard Sites gateway. +fn suffix_route_issues_and_caches_exact_sni_certificate_through_real_tls() { + // Setup: two real loopback HTTP services, a static frontend certificate, + // and a fake mkcert issuer for a nested site hostname. let temp = tempfile::tempdir().unwrap(); let root = temp.path(); fs::create_dir(root.join(".git")).unwrap(); @@ -209,21 +212,37 @@ frontend = {front_port} gateway = {site_port} [dev.service_groups] -intern = ["intern-edge"] +web = ["edge"] -[dev.services.intern-edge] +[dev.services.edge] port = "https" -tls_proxy = {{ certificate_hosts = ["intern.dev", "*.local.sites.intern.dev"], open_host = "intern.dev", routes = [{{ host = "intern.dev", upstream_port = "frontend" }}, {{ host_suffix = ".sites.intern.dev", open_host = "test.local.sites.intern.dev", upstream_port = "gateway" }}] }} +inherit_env = ["ASTER_MKCERT_BIN", "ASTER_TEST_MKCERT_LOG", "ASTER_TEST_DYNAMIC_CERT", "ASTER_TEST_DYNAMIC_KEY"] +tls_proxy = {{ certificate_hosts = ["app.example.test"], open_host = "app.example.test", routes = [{{ host = "app.example.test", upstream_port = "frontend" }}, {{ host_suffix = ".example.test", open_host = "docs.acme.example.test", upstream_port = "gateway" }}] }} "# ), ) .unwrap(); - let certified = generate_simple_self_signed(vec![ - "intern.dev".to_string(), - "*.local.sites.intern.dev".to_string(), - ]) - .unwrap(); - let cert_dir = root.join(".aster/tls/intern-edge"); + let certified = generate_simple_self_signed(vec!["app.example.test".to_string()]).unwrap(); + let mut ca_params = CertificateParams::new(Vec::new()).unwrap(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_params + .distinguished_name + .push(DnType::CommonName, "Aster test local CA"); + ca_params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::DigitalSignature, + ]; + let ca_key = KeyPair::generate().unwrap(); + let ca_cert = ca_params.self_signed(&ca_key).unwrap(); + let issuer = Issuer::new(ca_params, ca_key); + let site_key_pair = KeyPair::generate().unwrap(); + let mut site_params = + CertificateParams::new(vec!["docs.acme.example.test".to_string()]).unwrap(); + site_params + .distinguished_name + .push(DnType::CommonName, "docs.acme.example.test"); + let site_certified = site_params.signed_by(&site_key_pair, &issuer).unwrap(); + let cert_dir = root.join(".aster/tls/edge"); fs::create_dir_all(&cert_dir).unwrap(); fs::write(cert_dir.join("cert.pem"), certified.cert.pem()).unwrap(); fs::write( @@ -232,16 +251,44 @@ tls_proxy = {{ certificate_hosts = ["intern.dev", "*.local.sites.intern.dev"], o ) .unwrap(); let cert_der = CertificateDer::from(certified.cert.der().to_vec()); + let site_cert = root.join("site-cert.pem"); + let site_key = root.join("site-key.pem"); + fs::write(&site_cert, site_certified.pem()).unwrap(); + fs::write(&site_key, site_key_pair.serialize_pem()).unwrap(); + let mkcert_log = root.join("mkcert.log"); + let fake_mkcert = root.join("mkcert"); + fs::write( + &fake_mkcert, + r#"#!/bin/sh +printf '%s\n' "$*" >> "$ASTER_TEST_MKCERT_LOG" +while test "$#" -gt 0; do + case "$1" in + -cert-file) cert=$2; shift 2 ;; + -key-file) key=$2; shift 2 ;; + *) host=$1; shift ;; + esac +done +test "$host" = "docs.acme.example.test" || exit 2 +cp "$ASTER_TEST_DYNAMIC_CERT" "$cert" +cp "$ASTER_TEST_DYNAMIC_KEY" "$key" +"#, + ) + .unwrap(); + fs::set_permissions(&fake_mkcert, fs::Permissions::from_mode(0o700)).unwrap(); // Process boundary: launch the public Aster supervisor and its built-in TLS - // service as a member of the named Intern group. + // service as a member of a named service group. drop(tls_reservation); drop(control_reservation); let stdout = tempfile::NamedTempFile::new().unwrap(); let stderr = tempfile::NamedTempFile::new().unwrap(); let mut aster = Command::new(env!("CARGO_BIN_EXE_aster")) - .args(["services", "up", "intern", "--no-ui", "--no-watch"]) + .args(["services", "up", "web", "--no-ui", "--no-watch"]) .current_dir(root) + .env("ASTER_MKCERT_BIN", &fake_mkcert) + .env("ASTER_TEST_MKCERT_LOG", &mkcert_log) + .env("ASTER_TEST_DYNAMIC_CERT", &site_cert) + .env("ASTER_TEST_DYNAMIC_KEY", &site_key) .stdout(stdout.reopen().unwrap()) .stderr(stderr.reopen().unwrap()) .spawn() @@ -252,8 +299,13 @@ tls_proxy = {{ certificate_hosts = ["intern.dev", "*.local.sites.intern.dev"], o // Observable behavior: exact and suffix hosts cross real TLS and HTTP // sockets, preserve logical Host, and select different upstream services. - let front_authority = format!("intern.dev:{tls_port}"); - let front_response = https_get(tls_port, "intern.dev", &front_authority, cert_der.clone()); + let front_authority = format!("app.example.test:{tls_port}"); + let front_response = https_get( + tls_port, + "app.example.test", + &front_authority, + cert_der.clone(), + ); assert!(front_response.contains("200 OK"), "{front_response}"); assert!( front_response.contains(&format!( @@ -261,24 +313,37 @@ tls_proxy = {{ certificate_hosts = ["intern.dev", "*.local.sites.intern.dev"], o )), "{front_response}" ); - let site_response = https_get( + let site_response = https_get_with_roots( tls_port, - "test.local.sites.intern.dev", - "test.local.sites.intern.dev", - cert_der.clone(), + "docs.acme.example.test", + "docs.acme.example.test", + vec![CertificateDer::from(ca_cert.der().to_vec())], ); assert!( site_response.contains( - "site-gateway host=test.local.sites.intern.dev forwarded=test.local.sites.intern.dev proto=https" + "site-gateway host=docs.acme.example.test forwarded=docs.acme.example.test proto=https" ), "{site_response}" ); - let unknown = https_get(tls_port, "intern.dev", "unknown.test", cert_der); + let cached_site_response = https_get_with_roots( + tls_port, + "docs.acme.example.test", + "docs.acme.example.test", + vec![CertificateDer::from(ca_cert.der().to_vec())], + ); + assert!( + cached_site_response.contains("200 OK"), + "{cached_site_response}" + ); + assert_eq!(fs::read_to_string(&mkcert_log).unwrap().lines().count(), 1); + assert_tls_handshake_rejected(tls_port, "outside.invalid", certified.cert.der().to_vec()); + assert_eq!(fs::read_to_string(&mkcert_log).unwrap().lines().count(), 1); + let unknown = https_get(tls_port, "app.example.test", "unknown.test", cert_der); assert!(unknown.contains("421 Misdirected Request"), "{unknown}"); let malformed = https_get( tls_port, - "intern.dev", - "intern.dev:443@evil.example", + "app.example.test", + "app.example.test:443@evil.example", CertificateDer::from(certified.cert.der().to_vec()), ); assert!(malformed.contains("400 Bad Request"), "{malformed}"); @@ -286,7 +351,7 @@ tls_proxy = {{ certificate_hosts = ["intern.dev", "*.local.sites.intern.dev"], o malformed.contains("missing or invalid Host header"), "{malformed}" ); - assert_tls_upgrade_echo(tls_port, "intern.dev", certified.cert.der().to_vec()); + assert_tls_upgrade_echo(tls_port, "app.example.test", certified.cert.der().to_vec()); // Lifecycle outcome: authenticated control shutdown stops the group and // leaves durable evidence that the TLS edge reached readiness. @@ -299,13 +364,52 @@ tls_proxy = {{ certificate_hosts = ["intern.dev", "*.local.sites.intern.dev"], o assert!(aster.wait().unwrap().success()); assert!(!token_path.exists()); assert!(TcpStream::connect(("127.0.0.1", tls_port)).is_err()); + + // Restart boundary: the next supervisor reuses the validated disk cache; + // it does not invoke mkcert again for the same nested SNI hostname. + let second_stdout = tempfile::NamedTempFile::new().unwrap(); + let second_stderr = tempfile::NamedTempFile::new().unwrap(); + let mut restarted = Command::new(env!("CARGO_BIN_EXE_aster")) + .args(["services", "up", "web", "--no-ui", "--no-watch"]) + .current_dir(root) + .env("ASTER_MKCERT_BIN", &fake_mkcert) + .env("ASTER_TEST_MKCERT_LOG", &mkcert_log) + .env("ASTER_TEST_DYNAMIC_CERT", &site_cert) + .env("ASTER_TEST_DYNAMIC_KEY", &site_key) + .stdout(second_stdout.reopen().unwrap()) + .stderr(second_stderr.reopen().unwrap()) + .spawn() + .unwrap(); + wait_until(Duration::from_secs(8), || { + TcpStream::connect(("127.0.0.1", tls_port)).is_ok() + }); + let persisted_site_response = https_get_with_roots( + tls_port, + "docs.acme.example.test", + "docs.acme.example.test", + vec![CertificateDer::from(ca_cert.der().to_vec())], + ); + assert!( + persisted_site_response.contains("200 OK"), + "{persisted_site_response}" + ); + assert_eq!(fs::read_to_string(&mkcert_log).unwrap().lines().count(), 1); + let (second_token_path, second_token) = wait_for_control_token(control_port, restarted.id()); + let response = control_request( + control_port, + &serde_json::json!({"command":"shutdown", "token":second_token}).to_string(), + ); + assert_eq!(response["ok"], true); + assert!(restarted.wait().unwrap().success()); + assert!(!second_token_path.exists()); + let durable = root .join(".aster/logs") .join(root.file_name().unwrap()) - .join("intern-edge/logs.txt"); + .join("edge/logs.txt"); assert!(fs::read_to_string(durable) .unwrap() - .contains("TLS edge 'intern-edge' ready")); + .contains("TLS edge 'edge' ready")); drop(front); drop(site); } @@ -400,8 +504,39 @@ fn assert_tls_upgrade_echo(port: u16, host: &str, cert: Vec) { } fn https_get(port: u16, sni: &str, host: &str, cert: CertificateDer<'static>) -> String { + https_get_with_roots(port, sni, host, vec![cert]) +} + +fn assert_tls_handshake_rejected(port: u16, sni: &str, cert: Vec) { let mut roots = RootCertStore::empty(); - roots.add(cert).unwrap(); + roots.add(CertificateDer::from(cert)).unwrap(); + let config = ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + let connection = ClientConnection::new( + Arc::new(config), + ServerName::try_from(sni.to_string()).unwrap(), + ) + .unwrap(); + let socket = TcpStream::connect(("127.0.0.1", port)).unwrap(); + let mut tls = StreamOwned::new(connection, socket); + assert!( + tls.write_all(b"GET / HTTP/1.1\r\nHost: outside.invalid\r\nConnection: close\r\n\r\n") + .is_err(), + "unrouted SNI completed a TLS handshake" + ); +} + +fn https_get_with_roots( + port: u16, + sni: &str, + host: &str, + certs: Vec>, +) -> String { + let mut roots = RootCertStore::empty(); + for cert in certs { + roots.add(cert).unwrap(); + } let config = ClientConfig::builder() .with_root_certificates(roots) .with_no_client_auth();