From 455d5bb757ebfd476316ed45847b3514a23cdb2a Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 16 Feb 2026 03:00:39 +0000 Subject: [PATCH] feat(tls): add shared TLS acceptor with SNI resolver and session resumption; prefer shared acceptor and fall back to per-connection when routes specify custom TLS versions --- changelog.md | 9 ++ .../rustproxy-passthrough/src/tcp_listener.rs | 76 +++++++++++++---- .../rustproxy-passthrough/src/tls_handler.rs | 84 ++++++++++++++++++- ts/00_commitinfo_data.ts | 2 +- 4 files changed, 155 insertions(+), 16 deletions(-) diff --git a/changelog.md b/changelog.md index cb1b210..87fbd25 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-02-16 - 25.5.0 - feat(tls) +add shared TLS acceptor with SNI resolver and session resumption; prefer shared acceptor and fall back to per-connection when routes specify custom TLS versions + +- Add CertResolver that pre-parses PEM certs/keys into CertifiedKey instances for SNI-based lookup and cheap runtime resolution +- Introduce build_shared_tls_acceptor to create a shared ServerConfig with session cache (4096) and Ticketer for session ticket resumption +- Add ArcSwap> shared_tls_acceptor to tcp_listener for hot-reloadable, pre-built acceptor and update accept loop/handlers to use it +- set_tls_configs now attempts to build and store the shared TLS acceptor, falling back to per-connection acceptors on failure; raw PEM configs are still retained for route-level fallbacks +- Add get_tls_acceptor helper: prefer shared acceptor for performance and session resumption, but build per-connection acceptor when a route requests custom TLS versions + ## 2026-02-16 - 25.4.0 - feat(rustproxy) support dynamically loaded TLS certificates via loadCertificate IPC and include them in listener TLS configs for rebuilds and hot-swap diff --git a/rust/crates/rustproxy-passthrough/src/tcp_listener.rs b/rust/crates/rustproxy-passthrough/src/tcp_listener.rs index 5affcdd..47dc291 100644 --- a/rust/crates/rustproxy-passthrough/src/tcp_listener.rs +++ b/rust/crates/rustproxy-passthrough/src/tcp_listener.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; use arc_swap::ArcSwap; use tokio::net::TcpListener; +use tokio_rustls::TlsAcceptor; use tokio_util::sync::CancellationToken; use tracing::{info, error, debug, warn}; use thiserror::Error; @@ -122,8 +123,10 @@ pub struct TcpListenerManager { route_manager: Arc>, /// Shared metrics collector metrics: Arc, - /// TLS acceptors indexed by domain (ArcSwap for hot-reload visibility in accept loops) + /// Raw PEM TLS configs indexed by domain (kept for fallback with custom TLS versions) tls_configs: Arc>>, + /// Shared TLS acceptor (pre-parsed certs + session cache). None when no certs configured. + shared_tls_acceptor: Arc>>, /// HTTP proxy service for HTTP-level forwarding http_proxy: Arc, /// Connection configuration @@ -154,6 +157,7 @@ impl TcpListenerManager { route_manager: Arc::new(ArcSwap::from(route_manager)), metrics, tls_configs: Arc::new(ArcSwap::from(Arc::new(HashMap::new()))), + shared_tls_acceptor: Arc::new(ArcSwap::from(Arc::new(None))), http_proxy, conn_config: Arc::new(conn_config), conn_tracker, @@ -179,6 +183,7 @@ impl TcpListenerManager { route_manager: Arc::new(ArcSwap::from(route_manager)), metrics, tls_configs: Arc::new(ArcSwap::from(Arc::new(HashMap::new()))), + shared_tls_acceptor: Arc::new(ArcSwap::from(Arc::new(None))), http_proxy, conn_config: Arc::new(conn_config), conn_tracker, @@ -197,8 +202,26 @@ impl TcpListenerManager { } /// Set TLS certificate configurations. + /// Builds a shared TLS acceptor with pre-parsed certs and session resumption support. /// Uses ArcSwap so running accept loops immediately see the new certs. pub fn set_tls_configs(&self, configs: HashMap) { + if !configs.is_empty() { + match tls_handler::CertResolver::new(&configs) + .and_then(tls_handler::build_shared_tls_acceptor) + { + Ok(acceptor) => { + info!("Built shared TLS acceptor for {} domain(s)", configs.len()); + self.shared_tls_acceptor.store(Arc::new(Some(acceptor))); + } + Err(e) => { + warn!("Failed to build shared TLS acceptor: {}, falling back to per-connection", e); + self.shared_tls_acceptor.store(Arc::new(None)); + } + } + } else { + self.shared_tls_acceptor.store(Arc::new(None)); + } + // Keep raw PEM configs for fallback (routes with custom TLS versions) self.tls_configs.store(Arc::new(configs)); } @@ -224,6 +247,7 @@ impl TcpListenerManager { let route_manager_swap = Arc::clone(&self.route_manager); let metrics = Arc::clone(&self.metrics); let tls_configs = Arc::clone(&self.tls_configs); + let shared_tls_acceptor = Arc::clone(&self.shared_tls_acceptor); let http_proxy = Arc::clone(&self.http_proxy); let conn_config = Arc::clone(&self.conn_config); let conn_tracker = Arc::clone(&self.conn_tracker); @@ -233,7 +257,7 @@ impl TcpListenerManager { let handle = tokio::spawn(async move { Self::accept_loop( listener, port, route_manager_swap, metrics, tls_configs, - http_proxy, conn_config, conn_tracker, cancel, relay, + shared_tls_acceptor, http_proxy, conn_config, conn_tracker, cancel, relay, ).await; }); @@ -322,6 +346,7 @@ impl TcpListenerManager { route_manager_swap: Arc>, metrics: Arc, tls_configs: Arc>>, + shared_tls_acceptor: Arc>>, http_proxy: Arc, conn_config: Arc, conn_tracker: Arc, @@ -353,6 +378,8 @@ impl TcpListenerManager { let m = Arc::clone(&metrics); // Load the latest TLS configs from ArcSwap on each connection let tc = tls_configs.load_full(); + // Load the latest shared TLS acceptor from ArcSwap + let sa = shared_tls_acceptor.load_full(); let hp = Arc::clone(&http_proxy); let cc = Arc::clone(&conn_config); let ct = Arc::clone(&conn_tracker); @@ -362,7 +389,7 @@ impl TcpListenerManager { tokio::spawn(async move { let result = Self::handle_connection( - stream, port, peer_addr, rm, m, tc, hp, cc, cn, sr, + stream, port, peer_addr, rm, m, tc, sa, hp, cc, cn, sr, ).await; if let Err(e) = result { debug!("Connection error from {}: {}", peer_addr, e); @@ -388,6 +415,7 @@ impl TcpListenerManager { route_manager: Arc, metrics: Arc, tls_configs: Arc>, + shared_tls_acceptor: Arc>, http_proxy: Arc, conn_config: Arc, cancel: CancellationToken, @@ -777,13 +805,9 @@ impl TcpListenerManager { Ok(()) } Some(rustproxy_config::TlsMode::Terminate) => { - let tls_config = Self::find_tls_config(&domain, &tls_configs)?; - - // TLS accept with timeout, applying route-level TLS settings + // Use shared acceptor (session resumption) or fall back to per-connection let route_tls = route_match.route.action.tls.as_ref(); - let acceptor = tls_handler::build_tls_acceptor_with_config( - &tls_config.cert_pem, &tls_config.key_pem, route_tls, - )?; + let acceptor = Self::get_tls_acceptor(&domain, &tls_configs, &*shared_tls_acceptor, route_tls)?; let tls_stream = match tokio::time::timeout( std::time::Duration::from_millis(conn_config.initial_data_timeout_ms), tls_handler::accept_tls(stream, &acceptor), @@ -846,7 +870,8 @@ impl TcpListenerManager { let route_tls = route_match.route.action.tls.as_ref(); Self::handle_tls_terminate_reencrypt( stream, n, &domain, &target_host, target_port, - peer_addr, &tls_configs, Arc::clone(&metrics), route_id, &conn_config, route_tls, + peer_addr, &tls_configs, &shared_tls_acceptor, + Arc::clone(&metrics), route_id, &conn_config, route_tls, ).await } None => { @@ -991,15 +1016,14 @@ impl TcpListenerManager { target_port: u16, peer_addr: std::net::SocketAddr, tls_configs: &HashMap, + shared_tls_acceptor: &Option, metrics: Arc, route_id: Option<&str>, conn_config: &ConnectionConfig, route_tls: Option<&rustproxy_config::RouteTls>, ) -> Result<(), Box> { - let tls_config = Self::find_tls_config(domain, tls_configs)?; - let acceptor = tls_handler::build_tls_acceptor_with_config( - &tls_config.cert_pem, &tls_config.key_pem, route_tls, - )?; + // Use shared acceptor (session resumption) or fall back to per-connection + let acceptor = Self::get_tls_acceptor(domain, tls_configs, shared_tls_acceptor, route_tls)?; // Accept TLS from client with timeout let client_tls = match tokio::time::timeout( @@ -1069,6 +1093,30 @@ impl TcpListenerManager { Ok(()) } + /// Get a TLS acceptor, preferring the shared one (with session resumption) + /// and falling back to per-connection when custom TLS versions are configured. + fn get_tls_acceptor( + domain: &Option, + tls_configs: &HashMap, + shared_tls_acceptor: &Option, + route_tls: Option<&rustproxy_config::RouteTls>, + ) -> Result> { + let has_custom_versions = route_tls + .and_then(|t| t.versions.as_ref()) + .map(|v| !v.is_empty()) + .unwrap_or(false); + + if !has_custom_versions { + if let Some(shared) = shared_tls_acceptor { + return Ok(shared.clone()); // TlsAcceptor wraps Arc, clone is cheap + } + } + + // Fallback: per-connection acceptor (custom TLS versions or shared build failed) + let tls_config = Self::find_tls_config(domain, tls_configs)?; + tls_handler::build_tls_acceptor_with_config(&tls_config.cert_pem, &tls_config.key_pem, route_tls) + } + /// Find the TLS config for a given domain. fn find_tls_config<'a>( domain: &Option, diff --git a/rust/crates/rustproxy-passthrough/src/tls_handler.rs b/rust/crates/rustproxy-passthrough/src/tls_handler.rs index 5abddb5..4ad959a 100644 --- a/rust/crates/rustproxy-passthrough/src/tls_handler.rs +++ b/rust/crates/rustproxy-passthrough/src/tls_handler.rs @@ -1,17 +1,99 @@ +use std::collections::HashMap; use std::io::BufReader; use std::sync::Arc; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::server::ResolvesServerCert; +use rustls::sign::CertifiedKey; use rustls::ServerConfig; use tokio::net::TcpStream; use tokio_rustls::{TlsAcceptor, TlsConnector, server::TlsStream as ServerTlsStream}; -use tracing::debug; +use tracing::{debug, info}; + +use crate::tcp_listener::TlsCertConfig; /// Ensure the default crypto provider is installed. fn ensure_crypto_provider() { let _ = rustls::crypto::ring::default_provider().install_default(); } +/// SNI-based certificate resolver with pre-parsed CertifiedKeys. +/// Enables shared ServerConfig across connections — avoids per-connection PEM parsing +/// and enables TLS session resumption. +#[derive(Debug)] +pub struct CertResolver { + certs: HashMap>, + fallback: Option>, +} + +impl CertResolver { + /// Build a resolver from PEM-encoded cert/key configs. + /// Parses all PEM data upfront so connections only do a cheap HashMap lookup. + pub fn new(configs: &HashMap) -> Result> { + ensure_crypto_provider(); + let provider = rustls::crypto::ring::default_provider(); + let mut certs = HashMap::new(); + let mut fallback = None; + + for (domain, cfg) in configs { + let cert_chain = load_certs(&cfg.cert_pem)?; + let key = load_private_key(&cfg.key_pem)?; + let ck = Arc::new(CertifiedKey::from_der(cert_chain, key, &provider) + .map_err(|e| format!("CertifiedKey for {}: {}", domain, e))?); + if domain == "*" { + fallback = Some(Arc::clone(&ck)); + } + certs.insert(domain.clone(), ck); + } + + // If no explicit "*" fallback, use the first available cert + if fallback.is_none() { + fallback = certs.values().next().map(Arc::clone); + } + + Ok(Self { certs, fallback }) + } +} + +impl ResolvesServerCert for CertResolver { + fn resolve(&self, client_hello: rustls::server::ClientHello<'_>) -> Option> { + let domain = match client_hello.server_name() { + Some(name) => name, + None => return self.fallback.clone(), + }; + // Exact match + if let Some(ck) = self.certs.get(domain) { + return Some(Arc::clone(ck)); + } + // Wildcard: sub.example.com → *.example.com + if let Some(dot) = domain.find('.') { + let wc = format!("*.{}", &domain[dot + 1..]); + if let Some(ck) = self.certs.get(&wc) { + return Some(Arc::clone(ck)); + } + } + self.fallback.clone() + } +} + +/// Build a shared TLS acceptor with SNI resolution, session cache, and session tickets. +/// The returned acceptor can be reused across all connections (cheap Arc clone). +pub fn build_shared_tls_acceptor(resolver: CertResolver) -> Result> { + ensure_crypto_provider(); + let mut config = ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(Arc::new(resolver)); + + // Shared session cache — enables session ID resumption across connections + config.session_storage = rustls::server::ServerSessionMemoryCache::new(4096); + // Session ticket resumption (12-hour lifetime, Chacha20Poly1305 encrypted) + config.ticketer = rustls::crypto::ring::Ticketer::new() + .map_err(|e| format!("Ticketer: {}", e))?; + + info!("Built shared TLS config with session cache (4096) and ticket support"); + Ok(TlsAcceptor::from(Arc::new(config))) +} + /// Build a TLS acceptor from PEM-encoded cert and key data. pub fn build_tls_acceptor(cert_pem: &str, key_pem: &str) -> Result> { build_tls_acceptor_with_config(cert_pem, key_pem, None) diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index e8fe426..00577a2 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '25.4.0', + version: '25.5.0', description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' }