From 048bbe8de585ad6399562a09653000c85156e126 Mon Sep 17 00:00:00 2001 From: Louis Chmn Date: Wed, 16 Sep 2026 22:31:16 +0200 Subject: [PATCH] feat(proxy): Support direct TLS connection This allows runing haze proxy without a reverse proxy while still having the possiblity to use HTTPS. Signed-off-by: Louis Chmn --- Cargo.lock | 2 ++ Cargo.toml | 1 + README.md | 29 +++++++++++++++++++----- src/config.rs | 4 ++++ src/proxy.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 87 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9a62e09..32ae3ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -933,6 +933,7 @@ dependencies = [ "tar", "termion", "tokio", + "tokio-rustls", "tokio-stream", "toml", "tracing", @@ -2016,6 +2017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", + "log", "once_cell", "rustls-pki-types", "rustls-webpki", diff --git a/Cargo.toml b/Cargo.toml index 324dd21..298e0c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ tokio = { version = "1.53.1", features = [ "rt-multi-thread", "signal" ] } +tokio-rustls = "0.26" tokio-stream = { version = "0.1.19", features = ["net"] } toml = "1.1.4" tracing = "0.1.44" diff --git a/README.md b/README.md index d5a3f9b..6feae7b 100644 --- a/README.md +++ b/README.md @@ -299,14 +299,16 @@ By default, instances can be accessed by their IP. In order to get more memorable URLs and allow supporting https, haze comes with a builtin reverse proxy to allow using a wildcard domain. -### Requirements +### DNS Setup + +#### Requirements - A domain name you can set wildcard DNS records for - A reverse proxy like Nginx or Apache - (optionally) a wildcard ssl certificate (can be acquiring using letsencrypt and dns verification) -### DNS Setup +#### Steps - Set a DNS record for `*.haze.example.com` and `haze.example.com` pointing to your development machine. @@ -334,8 +336,23 @@ mkcert -cert-file haze.test.crt -key-file , + #[serde(default)] + pub key: Option, } impl ProxyConfig { diff --git a/src/proxy.rs b/src/proxy.rs index ffc6e4c..2223299 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -32,6 +32,10 @@ use tokio::net::UnixListener; use tokio::signal::ctrl_c; use tokio::spawn; use tokio::time::sleep; +use tokio_rustls::TlsAcceptor; +use tokio_rustls::rustls::ServerConfig; +use tokio_rustls::rustls::pki_types::pem::PemObject; +use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; use tokio_stream::wrappers::{TcpListenerStream, UnixListenerStream}; use tracing::{debug, error, info}; @@ -147,9 +151,34 @@ pub async fn proxy(docker: Docker, config: HazeConfig) -> Result<()> { } let listen = config.proxy.listen.clone(); + let acceptor = match (&config.proxy.cert, &config.proxy.key) { + (None, None) => None, + (Some(_), None) => return Err(miette!("`cert` is set without `key`")), + (None, Some(_)) => return Err(miette!("`key` is set without `cert`")), + (Some(cert), Some(key)) => Some(tls_acceptor(cert, key)?), + }; + let base_address = config.proxy.address.clone(); let instances = ActiveInstances::new(docker, config); - serve(instances, listen, base_address).await + serve(instances, listen, base_address, acceptor).await +} + +/// Build a TLS acceptor from a PEM encoded certificate chain and private key on disk +fn tls_acceptor(cert: &str, key: &str) -> Result { + let certs = CertificateDer::pem_file_iter(cert) + .map_err(|e| miette!("failed to load certificate from {cert}: {e}"))? + .collect::, _>>() + .map_err(|e| miette!("failed to load certificate from {cert}: {e}"))?; + let key = PrivateKeyDer::from_pem_file(key) + .map_err(|e| miette!("failed to load private key from {key}: {e}"))?; + + let mut server_config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .into_diagnostic()?; + server_config.alpn_protocols = vec![b"http/1.1".to_vec()]; + + Ok(TlsAcceptor::from(Arc::new(server_config))) } #[derive(Clone)] @@ -159,7 +188,12 @@ struct AppState { proxy_client: Arc, } -async fn serve(instances: ActiveInstances, listen: String, base_address: String) -> Result<()> { +async fn serve( + instances: ActiveInstances, + listen: String, + base_address: String, + acceptor: Option, +) -> Result<()> { let instances = Arc::new(instances); let base_address = Arc::new(base_address); let last_instances = instances.clone(); @@ -188,12 +222,13 @@ async fn serve(instances: ActiveInstances, listen: String, base_address: String) if !listen.starts_with('/') { let addr: SocketAddr = listen.parse().into_diagnostic()?; let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - println!("listening on {}", listener.local_addr().unwrap()); + let scheme = if acceptor.is_some() { "https" } else { "http" }; + println!("Listening on {scheme}://{}", listener.local_addr().unwrap()); let mut connections = pin!(TcpListenerStream::new(listener).take_until(cancel)); while let Some(stream) = connections.next().await { match stream { - Ok(stream) => handle_connection(state.clone(), stream), + Ok(stream) => handle_connection(state.clone(), stream, acceptor.clone()).await, Err(error) => { error!(%error, "connection failed"); } @@ -216,7 +251,7 @@ async fn serve(instances: ActiveInstances, listen: String, base_address: String) while let Some(stream) = connections.next().await { match stream { - Ok(stream) => handle_connection(state.clone(), stream), + Ok(stream) => handle_connection(state.clone(), stream, None).await, Err(error) => { error!(%error, "connection failed"); } @@ -227,7 +262,22 @@ async fn serve(instances: ActiveInstances, listen: String, base_address: String) Ok(()) } -fn handle_connection( +async fn handle_connection( + state: AppState, + stream: I, + acceptor: Option, +) { + // Spawn a tokio task to serve multiple connections concurrently + match acceptor { + Some(acceptor) => match acceptor.accept(stream).await { + Ok(stream) => serve_connection(state, stream).await, + Err(error) => error!(%error, "tls handshake failed"), + }, + None => serve_connection(state, stream).await, + } +} + +async fn serve_connection( state: AppState, stream: I, ) {