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.