* fix: cross-platform CA trust (Arch/Fedora + Windows) Closes #35. trust_ca_linux now detects which trust store the distro ships and runs the matching refresh command, instead of hardcoding Debian's update-ca-certificates. Detection walks a const table in priority order, picking the first whose anchor dir exists: - debian: /usr/local/share/ca-certificates (update-ca-certificates) - pki: /etc/pki/ca-trust/source/anchors (update-ca-trust extract) - p11kit: /etc/ca-certificates/trust-source/anchors (trust extract-compat) Falls back with a clear error listing every backend tried. Adds Windows support via certutil -addstore Root / -delstore Root, removing the silent CA-trust gap on numa install (previously the service installed but the trust step quietly errored, leaving every HTTPS .numa request throwing browser warnings). Refactor: trust_ca and untrust_ca are now thin dispatchers calling per-platform helpers. CA_COMMON_NAME and CA_FILE_NAME are centralized in tls.rs and reused from system_dns.rs and api.rs. untrust_ca_linux no longer pre-checks file existence (TOCTOU) and skips the refresh when no file was actually removed. Test: tests/docker/install-trust.sh runs the install/uninstall contract against debian:stable, fedora:latest, and archlinux:latest in containers, asserting the cert lands in (and is removed from) the system bundle. All three pass locally. README notes the Firefox/NSS limitation (separate trust store). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: rustfmt fixes for trust_ca_linux helpers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: macOS CA trust contract test (manual) Adds tests/manual/install-trust-macos.sh — a sudo bash script that mirrors trust_ca_macos / untrust_ca_macos against a fixture cert with a unique CN. Designed to coexist with a running production numa: - Refuses to run if a real "Numa Local CA" is already in System.keychain (fail-closed protection for dogfood installs) - Uses a unique CN ("Numa Local CA Test <pid-timestamp>") so the test cert can never collide with production - Mirrors the by-hash deletion loop from untrust_ca_macos - Trap-cleanup on success or interrupt Lives under tests/manual/ to signal "host-mutating, dev-only" — distinct from tests/docker/install-trust.sh which is hermetic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: relax bail-out in macOS trust test (safe alongside production) The bail-out was overly defensive. The test cert uses a unique CN ("Numa Local CA Test <pid-ts>") that is strictly longer than the production CN, so `security find-certificate -c $TEST_CN` cannot substring-match the production cert. All deletes are by-hash, which can only target the test cert's specific hash. Coexistence is provably safe; document the reasoning in the header comment block and replace the refusal with an informational notice. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
173 lines
6.1 KiB
Rust
173 lines
6.1 KiB
Rust
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;
|
|
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<String> = ctx.services.lock().unwrap().names().into_iter().collect();
|
|
names.extend(ctx.lan_peers.lock().unwrap().names());
|
|
let names: Vec<String> = 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),
|
|
}
|
|
}
|
|
|
|
/// 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<Vec<u8>>,
|
|
data_dir: &Path,
|
|
) -> crate::Result<Arc<ServerConfig>> {
|
|
let (ca_cert, ca_key) = ensure_ca(data_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 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<(rcgen::Certificate, 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 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, 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);
|
|
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 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, 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))
|
|
}
|