add TLS, service persistence, blocking panel, query types

- Local TLS: auto-generated CA + per-service certs (explicit SANs, not
  wildcards — browsers reject *.numa under single-label TLDs). HTTPS
  proxy on :443 via rustls/tokio-rustls. `numa install` trusts CA in
  macOS Keychain / Linux ca-certificates.
- Service persistence: user-added services saved to
  ~/.config/numa/services.json, survive restarts.
- Blocking panel: renamed "Check Domain" to "Blocking" with sources
  display, allowlist management UI, unpause button.
- Query types: recognize SOA, PTR, TXT, SRV, HTTPS (type 65) instead
  of logging as UNKNOWN.
- Blocklist gzip: reqwest now decompresses gzip responses from CDNs.
- Unified config_dir() in lib.rs for consistent path resolution under
  sudo and launchd. TLS certs use /usr/local/var/numa/ (writable as
  root daemon).
- Dashboard UX: panel subtitles differentiating overrides vs services,
  better placeholders, proxy route display, 600px query log height.
- Deploy: make deploy handles build+copy+codesign+restart cycle.
- Demo: scripts/record-demo.sh for recording hero GIF with CDP.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-21 01:15:07 +02:00
parent 10502f2db2
commit 3bfcd827ac
17 changed files with 1377 additions and 68 deletions

View File

@@ -35,6 +35,7 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
.route("/blocking/stats", get(blocking_stats))
.route("/blocking/toggle", put(blocking_toggle))
.route("/blocking/pause", post(blocking_pause))
.route("/blocking/unpause", post(blocking_unpause))
.route("/blocking/allowlist", get(blocking_allowlist))
.route("/blocking/allowlist", post(blocking_allowlist_add))
.route("/blocking/check/{domain}", get(blocking_check))
@@ -536,6 +537,11 @@ async fn blocking_pause(
Json(serde_json::json!({ "paused_minutes": req.minutes }))
}
async fn blocking_unpause(State(ctx): State<Arc<ServerCtx>>) -> Json<serde_json::Value> {
ctx.blocklist.lock().unwrap().unpause();
Json(serde_json::json!({ "paused": false }))
}
async fn blocking_check(
State(ctx): State<Arc<ServerCtx>>,
Path(domain): Path<String>,

View File

@@ -161,6 +161,10 @@ impl BlocklistStore {
self.paused_until = Some(Instant::now() + std::time::Duration::from_secs(seconds));
}
pub fn unpause(&mut self) {
self.paused_until = None;
}
pub fn is_paused(&self) -> bool {
self.paused_until
.map(|until| Instant::now() < until)
@@ -233,6 +237,7 @@ pub fn parse_blocklist(text: &str) -> HashSet<String> {
pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.gzip(true)
.build()
.unwrap_or_default();

View File

@@ -166,6 +166,8 @@ pub struct ProxyConfig {
pub enabled: bool,
#[serde(default = "default_proxy_port")]
pub port: u16,
#[serde(default = "default_proxy_tls_port")]
pub tls_port: u16,
#[serde(default = "default_proxy_tld")]
pub tld: String,
}
@@ -175,6 +177,7 @@ impl Default for ProxyConfig {
ProxyConfig {
enabled: default_proxy_enabled(),
port: default_proxy_port(),
tls_port: default_proxy_tls_port(),
tld: default_proxy_tld(),
}
}
@@ -186,6 +189,9 @@ fn default_proxy_enabled() -> bool {
fn default_proxy_port() -> u16 {
80
}
fn default_proxy_tls_port() -> u16 {
443
}
fn default_proxy_tld() -> String {
"numa".to_string()
}

View File

@@ -14,7 +14,34 @@ pub mod question;
pub mod record;
pub mod service_store;
pub mod stats;
pub mod tls;
pub mod system_dns;
pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Result<T> = std::result::Result<T, Error>;
/// Shared config directory: ~/.config/numa/
/// Handles sudo (uses SUDO_USER) and launchd (falls back to /usr/local/var/numa/).
pub fn config_dir() -> std::path::PathBuf {
// When run via sudo, SUDO_USER has the real user
if let Ok(user) = std::env::var("SUDO_USER") {
let home = if cfg!(target_os = "macos") {
format!("/Users/{}", user)
} else {
format!("/home/{}", user)
};
return std::path::PathBuf::from(home).join(".config").join("numa");
}
// Normal user (not root)
if let Ok(home) = std::env::var("HOME") {
let path = std::path::PathBuf::from(&home);
// /var/root on macOS is read-only (SIP), use /usr/local/var/numa instead
if !home.starts_with("/var/root") && !home.starts_with("/root") {
return path.join(".config").join("numa");
}
}
// Running as root daemon (launchd/systemd) — use system-wide path
std::path::PathBuf::from("/usr/local/var/numa")
}

View File

@@ -104,12 +104,13 @@ async fn main() -> numa::Result<()> {
blocklist.set_enabled(false);
}
// Build service store from config, always include numa dashboard
// Build service store: config services + persisted user services
let mut service_store = ServiceStore::new();
service_store.insert("numa", config.server.api_port);
service_store.insert_from_config("numa", config.server.api_port);
for svc in &config.services {
service_store.insert(&svc.name, svc.target_port);
service_store.insert_from_config(&svc.name, svc.target_port);
}
service_store.load_persisted();
let forwarding_rules = system_dns.forwarding_rules;
@@ -150,8 +151,12 @@ async fn main() -> numa::Result<()> {
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mBlocking\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m",
if config.blocking.enabled { format!("{} lists", config.blocking.lists.len()) } else { "disabled".to_string() });
if config.proxy.enabled {
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mProxy\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m",
format!("http://*.{} on :{}", config.proxy.tld, config.proxy.port));
let schemes = if config.proxy.tls_port > 0 {
format!("http://:{} https://:{}", config.proxy.port, config.proxy.tls_port)
} else {
format!("http://*.{} on :{}", config.proxy.tld, config.proxy.port)
};
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mProxy\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", schemes);
}
if !ctx.forwarding_rules.is_empty() {
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mRouting\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m",
@@ -198,12 +203,35 @@ async fn main() -> numa::Result<()> {
if config.proxy.enabled {
let proxy_ctx = Arc::clone(&ctx);
let proxy_port = config.proxy.port;
let proxy_tld = config.proxy.tld.clone();
tokio::spawn(async move {
numa::proxy::start_proxy(proxy_ctx, proxy_port, &proxy_tld).await;
numa::proxy::start_proxy(proxy_ctx, proxy_port).await;
});
}
// Spawn HTTPS reverse proxy with TLS termination
if config.proxy.enabled && config.proxy.tls_port > 0 {
let service_names: Vec<String> = 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, tls_config).await;
});
}
Err(e) => {
log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e);
}
}
}
// UDP DNS listener
#[allow(clippy::infinite_loop)]
loop {

View File

@@ -11,7 +11,9 @@ 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;
use crate::ctx::ServerCtx;
@@ -21,10 +23,9 @@ type HttpClient = Client<hyper_util::client::legacy::connect::HttpConnector, Bod
struct ProxyState {
ctx: Arc<ServerCtx>,
client: HttpClient,
tld_suffix: String, // pre-computed ".{tld}"
}
pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16, tld: &str) {
pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16) {
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
@@ -45,7 +46,6 @@ pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16, tld: &str) {
let state = ProxyState {
ctx,
client,
tld_suffix: format!(".{}", tld),
};
let app = Router::new().fallback(any(proxy_handler)).with_state(state);
@@ -53,6 +53,68 @@ pub async fn start_proxy(ctx: Arc<ServerCtx>, port: u16, tld: &str) {
axum::serve(listener, app).await.unwrap();
}
pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, tls_config: Arc<ServerConfig>) {
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => {
warn!(
"proxy: could not bind TLS port {} ({}) — HTTPS proxy disabled",
port, e
);
return;
}
};
info!("HTTPS proxy listening on {}", addr);
let acceptor = TlsAcceptor::from(tls_config);
let client: HttpClient = Client::builder(TokioExecutor::new())
.http1_preserve_header_case(true)
.build_http();
let state = ProxyState {
ctx,
client,
};
let app = Router::new().fallback(any(proxy_handler)).with_state(state);
loop {
let (tcp_stream, remote_addr) = match listener.accept().await {
Ok(conn) => conn,
Err(e) => {
error!("TLS accept error: {}", e);
continue;
}
};
let acceptor = acceptor.clone();
let app = app.clone();
tokio::spawn(async move {
let tls_stream = match acceptor.accept(tcp_stream).await {
Ok(s) => s,
Err(e) => {
debug!("TLS handshake failed from {}: {}", remote_addr, e);
return;
}
};
let io = hyper_util::rt::TokioIo::new(tls_stream);
let svc = hyper_util::service::TowerToHyperService::new(app.into_service());
if let Err(e) = hyper::server::conn::http1::Builder::new()
.preserve_header_case(true)
.serve_connection(io, svc)
.with_upgrades()
.await
{
debug!("TLS connection error from {}: {}", remote_addr, e);
}
});
}
}
fn extract_host(req: &Request) -> Option<String> {
req.headers()
.get(hyper::header::HOST)
@@ -68,12 +130,12 @@ async fn proxy_handler(State(state): State<ProxyState>, req: Request) -> axum::r
}
};
let service_name = match hostname.strip_suffix(state.tld_suffix.as_str()) {
let service_name = match hostname.strip_suffix(state.ctx.proxy_tld_suffix.as_str()) {
Some(name) => name.to_string(),
None => {
return (
StatusCode::BAD_GATEWAY,
format!("not a {} domain: {}", state.tld_suffix, hostname),
format!("not a {} domain: {}", state.ctx.proxy_tld_suffix, hostname),
)
.into_response()
}
@@ -86,7 +148,7 @@ async fn proxy_handler(State(state): State<ProxyState>, req: Request) -> axum::r
None => {
return (
StatusCode::BAD_GATEWAY,
format!("unknown service: {}{}", service_name, state.tld_suffix),
format!("unknown service: {}{}", service_name, state.ctx.proxy_tld_suffix),
)
.into_response()
}
@@ -98,7 +160,7 @@ async fn proxy_handler(State(state): State<ProxyState>, req: Request) -> axum::r
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/");
let target_uri: hyper::Uri = format!("http://127.0.0.1:{}{}", target_port, path_and_query)
let target_uri: hyper::Uri = format!("http://localhost:{}{}", target_port, path_and_query)
.parse()
.unwrap();

View File

@@ -7,8 +7,13 @@ pub enum QueryType {
A, // 1
NS, // 2
CNAME, // 5
SOA, // 6
PTR, // 12
MX, // 15
TXT, // 16
AAAA, // 28
SRV, // 33
HTTPS, // 65
}
impl QueryType {
@@ -18,8 +23,13 @@ impl QueryType {
QueryType::A => 1,
QueryType::NS => 2,
QueryType::CNAME => 5,
QueryType::SOA => 6,
QueryType::PTR => 12,
QueryType::MX => 15,
QueryType::TXT => 16,
QueryType::AAAA => 28,
QueryType::SRV => 33,
QueryType::HTTPS => 65,
}
}
@@ -28,8 +38,13 @@ impl QueryType {
1 => QueryType::A,
2 => QueryType::NS,
5 => QueryType::CNAME,
6 => QueryType::SOA,
12 => QueryType::PTR,
15 => QueryType::MX,
16 => QueryType::TXT,
28 => QueryType::AAAA,
33 => QueryType::SRV,
65 => QueryType::HTTPS,
_ => QueryType::UNKNOWN(num),
}
}
@@ -39,25 +54,30 @@ impl QueryType {
QueryType::A => "A",
QueryType::NS => "NS",
QueryType::CNAME => "CNAME",
QueryType::SOA => "SOA",
QueryType::PTR => "PTR",
QueryType::MX => "MX",
QueryType::TXT => "TXT",
QueryType::AAAA => "AAAA",
QueryType::SRV => "SRV",
QueryType::HTTPS => "HTTPS",
QueryType::UNKNOWN(_) => "UNKNOWN",
}
}
pub fn parse_str(s: &str) -> Option<QueryType> {
if s.eq_ignore_ascii_case("A") {
Some(QueryType::A)
} else if s.eq_ignore_ascii_case("NS") {
Some(QueryType::NS)
} else if s.eq_ignore_ascii_case("CNAME") {
Some(QueryType::CNAME)
} else if s.eq_ignore_ascii_case("MX") {
Some(QueryType::MX)
} else if s.eq_ignore_ascii_case("AAAA") {
Some(QueryType::AAAA)
} else {
None
match s.to_ascii_uppercase().as_str() {
"A" => Some(QueryType::A),
"NS" => Some(QueryType::NS),
"CNAME" => Some(QueryType::CNAME),
"SOA" => Some(QueryType::SOA),
"PTR" => Some(QueryType::PTR),
"MX" => Some(QueryType::MX),
"TXT" => Some(QueryType::TXT),
"AAAA" => Some(QueryType::AAAA),
"SRV" => Some(QueryType::SRV),
"HTTPS" => Some(QueryType::HTTPS),
_ => None,
}
}
}

View File

@@ -141,7 +141,7 @@ impl DnsRecord {
ttl,
})
}
QueryType::UNKNOWN(_) => {
_ => {
buffer.step(data_len as usize)?;
Ok(DnsRecord::UNKNOWN {

View File

@@ -1,8 +1,10 @@
use std::collections::HashMap;
use std::path::PathBuf;
use serde::Serialize;
use log::{info, warn};
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize)]
#[derive(Clone, Serialize, Deserialize)]
pub struct ServiceEntry {
pub name: String,
pub target_port: u16,
@@ -10,6 +12,9 @@ pub struct ServiceEntry {
pub struct ServiceStore {
entries: HashMap<String, ServiceEntry>,
/// Services defined in numa.toml (not persisted to user file)
config_services: std::collections::HashSet<String>,
persist_path: PathBuf,
}
impl Default for ServiceStore {
@@ -20,13 +25,18 @@ impl Default for ServiceStore {
impl ServiceStore {
pub fn new() -> Self {
let persist_path = dirs_path();
ServiceStore {
entries: HashMap::new(),
config_services: std::collections::HashSet::new(),
persist_path,
}
}
pub fn insert(&mut self, name: &str, target_port: u16) {
/// Insert a service from numa.toml config (not persisted)
pub fn insert_from_config(&mut self, name: &str, target_port: u16) {
let key = name.to_lowercase();
self.config_services.insert(key.clone());
self.entries.insert(
key.clone(),
ServiceEntry {
@@ -36,12 +46,30 @@ impl ServiceStore {
);
}
/// Insert a user-defined service (persisted to ~/.config/numa/services.json)
pub fn insert(&mut self, name: &str, target_port: u16) {
let key = name.to_lowercase();
self.entries.insert(
key.clone(),
ServiceEntry {
name: key,
target_port,
},
);
self.save();
}
pub fn lookup(&self, name: &str) -> Option<&ServiceEntry> {
self.entries.get(&name.to_lowercase())
}
pub fn remove(&mut self, name: &str) -> bool {
self.entries.remove(&name.to_lowercase()).is_some()
let key = name.to_lowercase();
let removed = self.entries.remove(&key).is_some();
if removed {
self.save();
}
removed
}
pub fn list(&self) -> Vec<&ServiceEntry> {
@@ -49,4 +77,56 @@ impl ServiceStore {
entries.sort_by(|a, b| a.name.cmp(&b.name));
entries
}
/// Load user-defined services from ~/.config/numa/services.json
pub fn load_persisted(&mut self) {
if !self.persist_path.exists() {
return;
}
match std::fs::read_to_string(&self.persist_path) {
Ok(contents) => match serde_json::from_str::<Vec<ServiceEntry>>(&contents) {
Ok(entries) => {
let count = entries.len();
for entry in entries {
let key = entry.name.to_lowercase();
// Don't overwrite config-defined services
if !self.config_services.contains(&key) {
self.entries.insert(key, entry);
}
}
if count > 0 {
info!("loaded {} persisted services from {:?}", count, self.persist_path);
}
}
Err(e) => warn!("failed to parse {:?}: {}", self.persist_path, e),
},
Err(e) => warn!("failed to read {:?}: {}", self.persist_path, e),
}
}
/// Save user-defined services (excluding config and "numa") to disk
fn save(&self) {
let user_services: Vec<&ServiceEntry> = self
.entries
.values()
.filter(|e| !self.config_services.contains(&e.name))
.collect();
if let Some(parent) = self.persist_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match serde_json::to_string_pretty(&user_services) {
Ok(json) => {
if let Err(e) = std::fs::write(&self.persist_path, json) {
warn!("failed to save services to {:?}: {}", self.persist_path, e);
}
}
Err(e) => warn!("failed to serialize services: {}", e),
}
}
}
fn dirs_path() -> PathBuf {
crate::config_dir().join("services.json")
}

View File

@@ -211,21 +211,25 @@ pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option<S
/// Saves the original DNS settings for later restoration.
pub fn install_system_dns() -> Result<(), String> {
#[cfg(target_os = "macos")]
{
install_macos()
}
let result = install_macos();
#[cfg(target_os = "linux")]
{
install_linux()
}
let result = install_linux();
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
Err("system DNS configuration not supported on this OS".to_string())
let result = Err("system DNS configuration not supported on this OS".to_string());
if result.is_ok() {
if let Err(e) = trust_ca() {
eprintln!(" warning: could not trust CA: {}", e);
eprintln!(" HTTPS proxy will work but browsers will show certificate warnings.\n");
}
}
result
}
/// Restore the original system DNS settings saved during install.
pub fn uninstall_system_dns() -> Result<(), String> {
let _ = untrust_ca();
#[cfg(target_os = "macos")]
{
uninstall_macos()
@@ -761,3 +765,82 @@ fn run_systemctl(args: &[&str]) -> Result<(), String> {
))
}
}
// --- CA trust management ---
fn trust_ca() -> Result<(), String> {
let ca_path = std::path::PathBuf::from("/usr/local/var/numa/ca.pem");
if !ca_path.exists() {
return Err("CA not generated yet — start numa first to create certificates".into());
}
#[cfg(target_os = "macos")]
{
let status = std::process::Command::new("security")
.args([
"add-trusted-cert",
"-d",
"-r",
"trustRoot",
"-k",
"/Library/Keychains/System.keychain",
])
.arg(&ca_path)
.status()
.map_err(|e| format!("security: {}", e))?;
if !status.success() {
return Err("security add-trusted-cert failed".into());
}
eprintln!(" Trusted Numa CA in system keychain");
}
#[cfg(target_os = "linux")]
{
let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt");
std::fs::copy(&ca_path, dest).map_err(|e| format!("copy CA: {}", e))?;
let status = std::process::Command::new("update-ca-certificates")
.status()
.map_err(|e| format!("update-ca-certificates: {}", e))?;
if !status.success() {
return Err("update-ca-certificates failed".into());
}
eprintln!(" Trusted Numa CA system-wide");
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
return Err("CA trust not supported on this OS".into());
}
Ok(())
}
fn untrust_ca() -> Result<(), String> {
let ca_path = std::path::PathBuf::from("/usr/local/var/numa/ca.pem");
#[cfg(target_os = "macos")]
{
if ca_path.exists() {
let _ = std::process::Command::new("security")
.args(["remove-trusted-cert", "-d"])
.arg(&ca_path)
.status();
eprintln!(" Removed Numa CA from system keychain");
}
}
#[cfg(target_os = "linux")]
{
let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt");
if dest.exists() {
let _ = std::fs::remove_file(dest);
let _ = std::process::Command::new("update-ca-certificates")
.arg("--fresh")
.status();
eprintln!(" Removed Numa CA from system trust store");
}
}
let _ = ca_path; // suppress unused warning on other platforms
Ok(())
}

125
src/tls.rs Normal file
View File

@@ -0,0 +1,125 @@
use std::path::Path;
use std::sync::Arc;
use log::{info, warn};
use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, 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
/// TLS certs use a fixed system path — both the daemon and `sudo numa install` must agree.
pub const TLS_DIR: &str = "/usr/local/var/numa";
/// 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.
pub fn build_tls_config(tld: &str, service_names: &[String]) -> crate::Result<Arc<ServerConfig>> {
let dir = std::path::PathBuf::from(TLS_DIR);
let (ca_cert, ca_key) = ensure_ca(&dir)?;
let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?;
// Ensure a crypto provider is installed (rustls needs one)
let _ = rustls::crypto::ring::default_provider().install_default();
let config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, key)?;
info!("TLS configured for {} .{} domains", service_names.len(), tld);
Ok(Arc::new(config))
}
fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
let ca_key_path = dir.join("ca.key");
let ca_cert_path = dir.join("ca.pem");
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 params = CertificateParams::from_ca_cert_pem(&cert_pem)?;
let cert = params.self_signed(&key_pair)?;
info!("loaded CA from {:?}", ca_cert_path);
return Ok((cert, key_pair));
}
// 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, "Numa Local CA");
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);
Ok((cert, key_pair))
}
/// Generate a cert with explicit SANs for each service name.
/// Always regenerated at startup (~5ms) — no disk caching needed.
fn generate_service_cert(
ca_cert: &rcgen::Certificate,
ca_key: &KeyPair,
tld: &str,
service_names: &[String],
) -> crate::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
let key_pair = KeyPair::generate()?;
let mut params = CertificateParams::default();
params
.distinguished_name
.push(DnType::CommonName, format!("Numa .{} services", tld));
// Add each service as an explicit SAN: numa.numa, peekm.numa, api.numa, etc.
let mut sans = Vec::new();
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, ca_cert, ca_key)?;
info!(
"generated TLS cert for: {}",
service_names
.iter()
.map(|n| format!("{}.{}", n, tld))
.collect::<Vec<_>>()
.join(", ")
);
let cert_der = CertificateDer::from(cert.der().to_vec());
let ca_der = CertificateDer::from(ca_cert.der().to_vec());
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der()));
Ok((vec![cert_der, ca_der], key_der))
}