From 1ae2e23bb69ed9f5505745d75cd17a84475184e1 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 16:14:06 +0200 Subject: [PATCH] fix: regenerate TLS cert when services change (hot-reload via ArcSwap) HTTPS proxy certs were generated once at startup. Services added at runtime via API or LAN discovery got "not secure" in the browser because their SAN wasn't in the cert. Now the cert is regenerated on every service add/remove and swapped atomically via ArcSwap. In-flight connections are unaffected. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 10 ++++++++++ Cargo.toml | 1 + src/api.rs | 9 +++++++-- src/ctx.rs | 3 +++ src/lan.rs | 24 +++++++++++++++++++++--- src/main.rs | 43 ++++++++++++++++++++++--------------------- src/proxy.rs | 21 ++++++++++++--------- src/service_store.rs | 9 +++++++++ src/tls.rs | 23 +++++++++++++++++++++++ 9 files changed, 108 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f275f6..fb82483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "arc-swap" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +dependencies = [ + "rustversion", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -934,6 +943,7 @@ dependencies = [ name = "numa" version = "0.4.0" dependencies = [ + "arc-swap", "axum", "env_logger", "futures", diff --git a/Cargo.toml b/Cargo.toml index 84e9c85..f9382cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,4 @@ rcgen = { version = "0.13", features = ["pem", "x509-parser"] } time = "0.3" rustls = "0.23" tokio-rustls = "0.26" +arc-swap = "1" diff --git a/src/api.rs b/src/api.rs index 98ceafd..8dde3a3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -711,7 +711,11 @@ async fn create_service( } let tld = &ctx.proxy_tld; + let is_new = !ctx.services.lock().unwrap().has_name(&name); ctx.services.lock().unwrap().insert(&name, req.target_port); + if is_new { + crate::tls::regenerate_tls(&ctx); + } let localhost = std::net::SocketAddr::from(([127, 0, 0, 1], req.target_port)); let lan_addr = @@ -740,8 +744,9 @@ async fn remove_service(State(ctx): State>, Path(name): Path>, } pub async fn handle_query( diff --git a/src/lan.rs b/src/lan.rs index 609a351..db210e9 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -33,11 +33,18 @@ impl PeerStore { } } - pub fn update(&mut self, host: IpAddr, services: &[(String, u16)]) { + /// Returns true if a previously-unseen name was inserted. + pub fn update(&mut self, host: IpAddr, services: &[(String, u16)]) -> bool { let now = Instant::now(); + let mut changed = false; for (name, port) in services { - self.peers.insert(name.to_lowercase(), (host, *port, now)); + let key = name.to_lowercase(); + if !self.peers.contains_key(&key) { + changed = true; + } + self.peers.insert(key, (host, *port, now)); } + changed } pub fn lookup(&mut self, name: &str) -> Option<(IpAddr, u16)> { @@ -67,6 +74,13 @@ impl PeerStore { .collect() } + pub fn names(&mut self) -> Vec { + let now = Instant::now(); + self.peers + .retain(|_, (_, _, seen)| now.duration_since(*seen) < self.timeout); + self.peers.keys().cloned().collect() + } + pub fn clear(&mut self) { self.peers.clear(); } @@ -189,10 +203,14 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { continue; } if !ann.services.is_empty() { - ctx.lan_peers + let changed = ctx + .lan_peers .lock() .unwrap() .update(ann.peer_ip, &ann.services); + if changed { + crate::tls::regenerate_tls(&ctx); + } debug!( "LAN: {} services from {} (mDNS)", ann.services.len(), diff --git a/src/main.rs b/src/main.rs index 056978b..60d3a95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use std::net::SocketAddr; use std::sync::{Arc, Mutex}; use std::time::Duration; +use arc_swap::ArcSwap; use log::{error, info}; use tokio::net::UdpSocket; @@ -137,6 +138,20 @@ async fn main() -> numa::Result<()> { let forwarding_rules = system_dns.forwarding_rules; + // Build initial TLS config before ServerCtx (so ArcSwap is ready at construction) + let initial_tls = if config.proxy.enabled && config.proxy.tls_port > 0 { + let service_names = service_store.names(); + match numa::tls::build_tls_config(&config.proxy.tld, &service_names) { + Ok(tls_config) => Some(ArcSwap::from(tls_config)), + Err(e) => { + log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); + None + } + } + } else { + None + }; + let ctx = Arc::new(ServerCtx { socket: UdpSocket::bind(&config.server.bind_addr).await?, zone_map: build_zone_map(&config.zones)?, @@ -168,6 +183,7 @@ async fn main() -> numa::Result<()> { config_found, config_dir: numa::config_dir(), data_dir: numa::data_dir(), + tls_config: initial_tls, }); let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); @@ -336,27 +352,12 @@ async fn main() -> numa::Result<()> { } // Spawn HTTPS reverse proxy with TLS termination - if config.proxy.enabled && config.proxy.tls_port > 0 { - let service_names: Vec = ctx - .services - .lock() - .unwrap() - .list() - .iter() - .map(|e| e.name.clone()) - .collect(); - match numa::tls::build_tls_config(&config.proxy.tld, &service_names) { - Ok(tls_config) => { - let proxy_ctx = Arc::clone(&ctx); - let tls_port = config.proxy.tls_port; - tokio::spawn(async move { - numa::proxy::start_proxy_tls(proxy_ctx, tls_port, proxy_bind, tls_config).await; - }); - } - Err(e) => { - log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); - } - } + if config.proxy.enabled && config.proxy.tls_port > 0 && ctx.tls_config.is_some() { + let proxy_ctx = Arc::clone(&ctx); + let tls_port = config.proxy.tls_port; + tokio::spawn(async move { + numa::proxy::start_proxy_tls(proxy_ctx, tls_port, proxy_bind).await; + }); } // Spawn network change watcher (upstream re-detection, LAN IP update, peer flush) diff --git a/src/proxy.rs b/src/proxy.rs index c4c2ca6..ce592f7 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -11,7 +11,6 @@ use hyper::StatusCode; use hyper_util::client::legacy::Client; use hyper_util::rt::TokioExecutor; use log::{debug, error, info, warn}; -use rustls::ServerConfig; use tokio::io::copy_bidirectional; use tokio_rustls::TlsAcceptor; @@ -50,12 +49,7 @@ pub async fn start_proxy(ctx: Arc, port: u16, bind_addr: Ipv4Addr) { axum::serve(listener, app).await.unwrap(); } -pub async fn start_proxy_tls( - ctx: Arc, - port: u16, - bind_addr: Ipv4Addr, - tls_config: Arc, -) { +pub async fn start_proxy_tls(ctx: Arc, port: u16, bind_addr: Ipv4Addr) { let addr: SocketAddr = (bind_addr, port).into(); let listener = match tokio::net::TcpListener::bind(addr).await { Ok(l) => l, @@ -69,11 +63,17 @@ pub async fn start_proxy_tls( }; info!("HTTPS proxy listening on {}", addr); - let acceptor = TlsAcceptor::from(tls_config); + if ctx.tls_config.is_none() { + warn!("proxy: no TLS config — HTTPS proxy disabled"); + return; + } + let client: HttpClient = Client::builder(TokioExecutor::new()) .http1_preserve_header_case(true) .build_http(); + // Hold a separate Arc so we can access tls_config after ctx moves into ProxyState + let tls_holder = Arc::clone(&ctx); let state = ProxyState { ctx, client }; let app = Router::new().fallback(any(proxy_handler)).with_state(state); @@ -87,7 +87,10 @@ pub async fn start_proxy_tls( } }; - let acceptor = acceptor.clone(); + // Load the latest TLS config on each connection (picks up new service certs) + // unwrap safe: guarded by is_none() check above + let acceptor = + TlsAcceptor::from(Arc::clone(&*tls_holder.tls_config.as_ref().unwrap().load())); let app = app.clone(); tokio::spawn(async move { diff --git a/src/service_store.rs b/src/service_store.rs index 7db3ffd..f2c72c7 100644 --- a/src/service_store.rs +++ b/src/service_store.rs @@ -154,6 +154,15 @@ impl ServiceStore { entries } + pub fn names(&self) -> Vec { + self.entries.keys().cloned().collect() + } + + /// Returns true if the name is new (not already registered). + pub fn has_name(&self, name: &str) -> bool { + self.entries.contains_key(&name.to_lowercase()) + } + /// Load user-defined services from ~/.config/numa/services.json pub fn load_persisted(&mut self) { if !self.persist_path.exists() { diff --git a/src/tls.rs b/src/tls.rs index 5118390..966b1f1 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -1,7 +1,10 @@ +use std::collections::HashSet; use std::path::Path; use std::sync::Arc; use log::{info, warn}; + +use crate::ctx::ServerCtx; use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType}; use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; use rustls::ServerConfig; @@ -10,6 +13,26 @@ use time::{Duration, OffsetDateTime}; const CA_VALIDITY_DAYS: i64 = 3650; // 10 years const CERT_VALIDITY_DAYS: i64 = 365; // 1 year +/// Collect all service + LAN peer names and regenerate the TLS cert. +pub fn regenerate_tls(ctx: &ServerCtx) { + let tls = match &ctx.tls_config { + Some(t) => t, + None => return, + }; + + let mut names: HashSet = ctx.services.lock().unwrap().names().into_iter().collect(); + names.extend(ctx.lan_peers.lock().unwrap().names()); + let names: Vec = names.into_iter().collect(); + + match build_tls_config(&ctx.proxy_tld, &names) { + Ok(new_config) => { + tls.store(new_config); + info!("TLS cert regenerated for {} services", names.len()); + } + Err(e) => warn!("TLS regeneration failed: {}", e), + } +} + /// Build a TLS config with a cert covering all provided service names. /// Wildcards under single-label TLDs (*.numa) are rejected by browsers, /// so we list each service explicitly as a SAN.