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, Issuer, KeyPair, KeyUsagePurpose, SanType, }; use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; use rustls::ServerConfig; use time::{Duration, OffsetDateTime}; const CA_VALIDITY_DAYS: i64 = 3650; // 10 years const CERT_VALIDITY_DAYS: i64 = 365; // 1 year /// Common Name on Numa's local CA. Referenced by trust-store helpers /// (`security`, `certutil`) when locating the cert for removal. pub const CA_COMMON_NAME: &str = "Numa Local CA"; /// Filename of the CA certificate inside the data dir. pub const CA_FILE_NAME: &str = "ca.pem"; /// 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, Vec::new(), &ctx.data_dir) { Ok(new_config) => { tls.store(new_config); info!("TLS cert regenerated for {} services", names.len()); } Err(e) => warn!("TLS regeneration failed: {}", e), } } /// Advisory for TLS-setup failures caused by a non-writable data dir; /// `None` if not applicable so the caller can fall back to the raw error. pub fn try_data_dir_advisory(err: &crate::Error, data_dir: &Path) -> Option { let io_err = err.downcast_ref::()?; if io_err.kind() != std::io::ErrorKind::PermissionDenied { return None; } let o = "\x1b[1;38;2;192;98;58m"; let r = "\x1b[0m"; Some(format!( " {o}Numa{r} — HTTPS proxy disabled: cannot write TLS CA to {}. The data directory is not writable by the current user. Numa needs to persist a local Certificate Authority there to serve .numa over HTTPS. DNS resolution and plain-HTTP proxy continue to work. Fix — pick one: 1. Install Numa as the system resolver (sets up a writable data dir): sudo numa install (on Windows, run as Administrator) 2. Point data_dir at a path you can write. Create {} with: [server] data_dir = \"/path/you/can/write\" ", data_dir.display(), crate::suggested_config_path().display() )) } /// 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. /// `alpn` is advertised in the TLS ServerHello — pass empty for the proxy /// (which accepts any ALPN), or `[b"dot"]` for DoT (RFC 7858 §3.2). /// `data_dir` is where the CA material is stored — taken from /// `[server] data_dir` in numa.toml (defaults to `crate::data_dir()`). pub fn build_tls_config( tld: &str, service_names: &[String], alpn: Vec>, data_dir: &Path, ) -> crate::Result> { let (ca_der, issuer) = ensure_ca(data_dir)?; let (cert_chain, key) = generate_service_cert(&ca_der, &issuer, tld, service_names)?; // Ensure a crypto provider is installed (rustls needs one) let _ = rustls::crypto::ring::default_provider().install_default(); let mut config = ServerConfig::builder() .with_no_client_auth() .with_single_cert(cert_chain, key)?; config.alpn_protocols = alpn; info!( "TLS configured for {} .{} domains", service_names.len(), tld ); Ok(Arc::new(config)) } fn ensure_ca(dir: &Path) -> crate::Result<(CertificateDer<'static>, Issuer<'static, KeyPair>)> { let ca_key_path = dir.join("ca.key"); let ca_cert_path = dir.join(CA_FILE_NAME); if ca_key_path.exists() && ca_cert_path.exists() { let key_pem = std::fs::read_to_string(&ca_key_path)?; let cert_pem = std::fs::read_to_string(&ca_cert_path)?; let key_pair = KeyPair::from_pem(&key_pem)?; let ca_der = rustls_pemfile::certs(&mut cert_pem.as_bytes()) .next() .ok_or("empty CA PEM file")??; let issuer = Issuer::from_ca_cert_der(&ca_der, key_pair)?; info!("loaded CA from {:?}", ca_cert_path); return Ok((ca_der, issuer)); } // Generate new CA std::fs::create_dir_all(dir)?; let key_pair = KeyPair::generate()?; let mut params = CertificateParams::default(); params .distinguished_name .push(DnType::CommonName, CA_COMMON_NAME); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; params.not_before = OffsetDateTime::now_utc(); params.not_after = OffsetDateTime::now_utc() + Duration::days(CA_VALIDITY_DAYS); let cert = params.self_signed(&key_pair)?; std::fs::write(&ca_key_path, key_pair.serialize_pem())?; std::fs::write(&ca_cert_path, cert.pem())?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&ca_key_path, std::fs::Permissions::from_mode(0o600))?; } info!("generated CA at {:?}", ca_cert_path); let ca_der = cert.der().clone(); let issuer = Issuer::new(params, key_pair); Ok((ca_der, issuer)) } /// Generate a cert with explicit SANs for each service name. /// Always regenerated at startup (~5ms) — no disk caching needed. fn generate_service_cert( ca_der: &CertificateDer<'static>, issuer: &Issuer<'_, KeyPair>, tld: &str, service_names: &[String], ) -> crate::Result<(Vec>, PrivateKeyDer<'static>)> { let key_pair = KeyPair::generate()?; let mut params = CertificateParams::default(); params .distinguished_name .push(DnType::CommonName, format!("Numa .{} services", tld)); // Add a wildcard SAN so any .numa domain gets a valid cert (including // unregistered services — lets the proxy show a styled 404 over HTTPS). // Also add each service explicitly for clients that don't match wildcards. let mut sans = Vec::new(); let wildcard = format!("*.{}", tld); match wildcard.clone().try_into() { Ok(ia5) => sans.push(SanType::DnsName(ia5)), Err(e) => warn!("invalid wildcard SAN {}: {}", wildcard, e), } for name in service_names { let fqdn = format!("{}.{}", name, tld); match fqdn.clone().try_into() { Ok(ia5) => sans.push(SanType::DnsName(ia5)), Err(e) => warn!("invalid SAN {}: {}", fqdn, e), } } if sans.is_empty() { return Err("no valid service names for TLS cert".into()); } params.subject_alt_names = sans; params.not_before = OffsetDateTime::now_utc(); params.not_after = OffsetDateTime::now_utc() + Duration::days(CERT_VALIDITY_DAYS); let cert = params.signed_by(&key_pair, issuer)?; info!( "generated TLS cert for: {}", service_names .iter() .map(|n| format!("{}.{}", n, tld)) .collect::>() .join(", ") ); let cert_der = cert.der().clone(); let ca_cert_der = ca_der.clone(); let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); Ok((vec![cert_der, ca_cert_der], key_der)) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; #[test] fn try_data_dir_advisory_permission_denied() { let err: crate::Error = Box::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied)); let path = PathBuf::from("/usr/local/var/numa"); let msg = try_data_dir_advisory(&err, &path).expect("should advise"); assert!(msg.contains("HTTPS proxy disabled")); assert!(msg.contains("/usr/local/var/numa")); assert!(msg.contains("numa install")); assert!(msg.contains("data_dir")); } #[test] fn try_data_dir_advisory_skips_other_io_kinds() { let err: crate::Error = Box::new(std::io::Error::from(std::io::ErrorKind::NotFound)); assert!(try_data_dir_advisory(&err, &PathBuf::from("/x")).is_none()); } #[test] fn try_data_dir_advisory_skips_non_io_errors() { let err: crate::Error = "rcgen failure".into(); assert!(try_data_dir_advisory(&err, &PathBuf::from("/x")).is_none()); } }