diff --git a/README.md b/README.md index 4c32370..5794268 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, **DNS-over-TLS listener** (RFC 7858) — accept encrypted queries on port 853 from strict clients like iOS Private DNS, systemd-resolved, or stubby. Two modes: -- **Self-signed** (default) — numa generates a local CA automatically. Works on any network with zero DNS setup, but clients must manually trust the CA (on macOS/Linux add to the system trust store; on iOS install a `.mobileconfig`). +- **Self-signed** (default) — numa generates a local CA automatically. `numa install` adds it to the system trust store on macOS, Linux (Debian/Ubuntu, Fedora/RHEL/SUSE, Arch), and Windows. On iOS, install the `.mobileconfig` from `numa setup-phone`. Firefox keeps its own NSS store and ignores the system one — trust the CA there manually if you need HTTPS for `.numa` services in Firefox. - **Bring-your-own cert** — point `[dot] cert_path` / `key_path` at a publicly-trusted cert (e.g., Let's Encrypt via DNS-01 challenge on a domain pointing at your numa instance). Clients connect without any trust-store setup — same UX as AdGuard Home or Cloudflare `1.1.1.1`. ALPN `"dot"` is advertised and enforced in both modes; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense. diff --git a/src/api.rs b/src/api.rs index 1a6b7ef..59938b4 100644 --- a/src/api.rs +++ b/src/api.rs @@ -906,7 +906,7 @@ async fn remove_route( } async fn serve_ca(State(ctx): State>) -> Result { - let ca_path = ctx.data_dir.join("ca.pem"); + let ca_path = ctx.data_dir.join(crate::tls::CA_FILE_NAME); let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path)) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? diff --git a/src/system_dns.rs b/src/system_dns.rs index 8709e0d..141e562 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1278,102 +1278,213 @@ fn run_systemctl(args: &[&str]) -> Result<(), String> { // --- CA trust management --- +/// One Linux trust-store backend (Debian, Fedora pki, Arch p11-kit). +#[cfg(target_os = "linux")] +struct LinuxTrustStore { + name: &'static str, + anchor_dir: &'static str, + anchor_file: &'static str, + refresh_install: &'static [&'static str], + refresh_uninstall: &'static [&'static str], +} + +// If you change this table, update tests/docker/install-trust.sh to match — +// it asserts the same paths/commands against real distro images. +#[cfg(target_os = "linux")] +const LINUX_TRUST_STORES: &[LinuxTrustStore] = &[ + // Debian / Ubuntu / Mint + LinuxTrustStore { + name: "debian", + anchor_dir: "/usr/local/share/ca-certificates", + anchor_file: "numa-local-ca.crt", + refresh_install: &["update-ca-certificates"], + refresh_uninstall: &["update-ca-certificates", "--fresh"], + }, + // Fedora / RHEL / CentOS / SUSE (p11-kit via update-ca-trust wrapper) + LinuxTrustStore { + name: "pki", + anchor_dir: "/etc/pki/ca-trust/source/anchors", + anchor_file: "numa-local-ca.pem", + refresh_install: &["update-ca-trust", "extract"], + refresh_uninstall: &["update-ca-trust", "extract"], + }, + // Arch / Manjaro (raw p11-kit) + LinuxTrustStore { + name: "p11kit", + anchor_dir: "/etc/ca-certificates/trust-source/anchors", + anchor_file: "numa-local-ca.pem", + refresh_install: &["trust", "extract-compat"], + refresh_uninstall: &["trust", "extract-compat"], + }, +]; + +#[cfg(target_os = "linux")] +fn detect_linux_trust_store() -> Option<&'static LinuxTrustStore> { + LINUX_TRUST_STORES + .iter() + .find(|s| std::path::Path::new(s.anchor_dir).is_dir()) +} + fn trust_ca() -> Result<(), String> { - let ca_path = crate::data_dir().join("ca.pem"); + let ca_path = crate::data_dir().join(crate::tls::CA_FILE_NAME); 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"); - } - + let result = trust_ca_macos(&ca_path); #[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"); - } + let result = trust_ca_linux(&ca_path); + #[cfg(windows)] + let result = trust_ca_windows(&ca_path); + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + let result = Err::<(), String>("CA trust not supported on this OS".to_string()); - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - Err("CA trust not supported on this OS".into()) - } - - #[cfg(any(target_os = "macos", target_os = "linux"))] - Ok(()) + result } fn untrust_ca() -> Result<(), String> { - let ca_path = crate::data_dir().join("ca.pem"); - #[cfg(target_os = "macos")] + let result = untrust_ca_macos(); + #[cfg(target_os = "linux")] + let result = untrust_ca_linux(); + #[cfg(windows)] + let result = untrust_ca_windows(); + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + let result = Ok::<(), String>(()); + + result +} + +#[cfg(target_os = "macos")] +fn trust_ca_macos(ca_path: &std::path::Path) -> Result<(), String> { + 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"); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn untrust_ca_macos() -> Result<(), String> { + if let Ok(out) = std::process::Command::new("security") + .args([ + "find-certificate", + "-c", + crate::tls::CA_COMMON_NAME, + "-a", + "-Z", + "/Library/Keychains/System.keychain", + ]) + .output() { - // Find all Numa CA certs by hash and delete each one - if let Ok(out) = std::process::Command::new("security") - .args([ - "find-certificate", - "-c", - "Numa Local CA", - "-a", - "-Z", - "/Library/Keychains/System.keychain", - ]) - .output() - { - let stdout = String::from_utf8_lossy(&out.stdout); - for line in stdout.lines() { - if let Some(hash) = line.strip_prefix("SHA-1 hash: ") { - let hash = hash.trim(); - let _ = std::process::Command::new("security") - .args([ - "delete-certificate", - "-Z", - hash, - "/Library/Keychains/System.keychain", - ]) - .output(); - } + let stdout = String::from_utf8_lossy(&out.stdout); + for line in stdout.lines() { + if let Some(hash) = line.strip_prefix("SHA-1 hash: ") { + let hash = hash.trim(); + let _ = std::process::Command::new("security") + .args([ + "delete-certificate", + "-Z", + hash, + "/Library/Keychains/System.keychain", + ]) + .output(); } } - eprintln!(" Removed Numa CA from system keychain"); } + eprintln!(" Removed Numa CA from system keychain"); + Ok(()) +} - #[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"); +#[cfg(target_os = "linux")] +fn trust_ca_linux(ca_path: &std::path::Path) -> Result<(), String> { + let store = detect_linux_trust_store().ok_or_else(|| { + let names: Vec<&str> = LINUX_TRUST_STORES.iter().map(|s| s.name).collect(); + format!( + "no supported CA trust store found (tried: {}). \ + Please report at https://github.com/razvandimescu/numa/issues", + names.join(", ") + ) + })?; + + let dest = std::path::Path::new(store.anchor_dir).join(store.anchor_file); + std::fs::copy(ca_path, &dest) + .map_err(|e| format!("copy CA to {}: {}", dest.display(), e))?; + + run_refresh(store.name, store.refresh_install)?; + eprintln!(" Trusted Numa CA system-wide ({})", store.name); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn untrust_ca_linux() -> Result<(), String> { + let Some(store) = detect_linux_trust_store() else { + return Ok(()); + }; + + let dest = std::path::Path::new(store.anchor_dir).join(store.anchor_file); + match std::fs::remove_file(&dest) { + Ok(()) => { + let _ = run_refresh(store.name, store.refresh_uninstall); + eprintln!( + " Removed Numa CA from system trust store ({})", + store.name + ); } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => {} // best-effort uninstall } + Ok(()) +} - let _ = ca_path; // suppress unused warning on other platforms +#[cfg(target_os = "linux")] +fn run_refresh(store_name: &str, argv: &[&str]) -> Result<(), String> { + let (cmd, args) = argv + .split_first() + .expect("refresh command must be non-empty"); + let status = std::process::Command::new(cmd) + .args(args) + .status() + .map_err(|e| format!("{} ({}): {}", cmd, store_name, e))?; + if !status.success() { + return Err(format!("{} ({}) failed", cmd, store_name)); + } + Ok(()) +} + +#[cfg(windows)] +fn trust_ca_windows(ca_path: &std::path::Path) -> Result<(), String> { + let status = std::process::Command::new("certutil") + .args(["-addstore", "-f", "Root"]) + .arg(ca_path) + .status() + .map_err(|e| format!("certutil: {}", e))?; + if !status.success() { + return Err("certutil -addstore Root failed (run as Administrator?)".into()); + } + eprintln!(" Trusted Numa CA in Windows Root store"); + Ok(()) +} + +#[cfg(windows)] +fn untrust_ca_windows() -> Result<(), String> { + let _ = std::process::Command::new("certutil") + .args(["-delstore", "Root", crate::tls::CA_COMMON_NAME]) + .status(); + eprintln!(" Removed Numa CA from Windows Root store"); Ok(()) } diff --git a/src/tls.rs b/src/tls.rs index c60714e..7c7620a 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -13,6 +13,13 @@ 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 { @@ -67,7 +74,7 @@ pub fn build_tls_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"); + 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)?; @@ -86,7 +93,7 @@ fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> { let mut params = CertificateParams::default(); params .distinguished_name - .push(DnType::CommonName, "Numa Local CA"); + .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(); diff --git a/tests/docker/install-trust.sh b/tests/docker/install-trust.sh new file mode 100755 index 0000000..ec6d55c --- /dev/null +++ b/tests/docker/install-trust.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# +# Cross-distro CA trust contract test for issue #35. +# +# Runs the exact shell commands `src/system_dns.rs::trust_ca_linux` would run +# on each Linux trust-store family (Debian, Fedora pki, Arch p11-kit), and +# asserts the certificate ends up in (and is removed from) the system bundle. +# +# This is a contract test, not an integration test: it doesn't drive the Rust +# code (that would need systemd-in-container). It verifies the assumptions in +# `LINUX_TRUST_STORES` against the real distro behavior. If you change that +# table in src/system_dns.rs, update the per-distro cases below to match. +# +# Requirements: docker, openssl (host). +# Usage: ./tests/docker/install-trust.sh + +set -euo pipefail + +cd "$(dirname "$0")/../.." + +GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m" + +# Self-signed CA fixture, mounted into each container as ca.pem. +# basicConstraints=CA:TRUE is required — without it, Debian's +# update-ca-certificates silently skips the cert during bundle build. +FIXTURE_DIR=$(mktemp -d) +trap 'rm -rf "$FIXTURE_DIR"' EXIT +openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -keyout "$FIXTURE_DIR/ca.key" \ + -out "$FIXTURE_DIR/ca.pem" \ + -subj "/CN=Numa Local CA Test $(date +%s)" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" >/dev/null 2>&1 + +# Distro bundles store certs differently — Debian writes raw PEM only, +# Fedora prepends "# CN" comment headers, Arch via extract-compat is +# raw PEM. To detect cert presence uniformly we grep for a deterministic +# substring of the base64 body (first base64 line is unique per cert). +CERT_TAG=$(sed -n '2p' "$FIXTURE_DIR/ca.pem") + +PASSED=0; FAILED=0 + +run_case() { + local distro="$1"; shift + local image="$1"; shift + local platform="$1"; shift + local script="$1" + + printf "── %s (%s) ──\n" "$distro" "$image" + if docker run --rm \ + --platform "$platform" \ + --security-opt seccomp=unconfined \ + -e CERT_TAG="$CERT_TAG" \ + -e DEBIAN_FRONTEND=noninteractive \ + -v "$FIXTURE_DIR/ca.pem:/fixture/ca.pem:ro" \ + "$image" bash -c "$script"; then + printf "${GREEN}✓${RESET} %s\n\n" "$distro" + PASSED=$((PASSED + 1)) + else + printf "${RED}✗${RESET} %s\n\n" "$distro" + FAILED=$((FAILED + 1)) + fi +} + +# Debian / Ubuntu / Mint — anchor: /usr/local/share/ca-certificates/*.crt +run_case "debian" "debian:stable" "linux/amd64" ' + set -e + apt-get update -qq + apt-get install -qq -y ca-certificates >/dev/null + install -m 0644 /fixture/ca.pem /usr/local/share/ca-certificates/numa-local-ca.crt + update-ca-certificates >/dev/null 2>&1 + grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt + echo " install: cert present in bundle" + rm /usr/local/share/ca-certificates/numa-local-ca.crt + update-ca-certificates --fresh >/dev/null 2>&1 + if grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt; then + echo " uninstall: cert STILL present (regression)" >&2 + exit 1 + fi + echo " uninstall: cert removed from bundle" +' + +# Fedora / RHEL / CentOS / SUSE — anchor: /etc/pki/ca-trust/source/anchors/*.pem +run_case "fedora" "fedora:latest" "linux/amd64" ' + set -e + dnf install -q -y ca-certificates >/dev/null + install -m 0644 /fixture/ca.pem /etc/pki/ca-trust/source/anchors/numa-local-ca.pem + update-ca-trust extract + grep -q "$CERT_TAG" /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem + echo " install: cert present in bundle" + rm /etc/pki/ca-trust/source/anchors/numa-local-ca.pem + update-ca-trust extract + if grep -q "$CERT_TAG" /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem; then + echo " uninstall: cert STILL present (regression)" >&2 + exit 1 + fi + echo " uninstall: cert removed from bundle" +' + +# Arch / Manjaro — anchor: /etc/ca-certificates/trust-source/anchors/*.pem +# archlinux:latest is x86_64-only; --platform forces emulation on Apple Silicon. +run_case "arch" "archlinux:latest" "linux/amd64" ' + set -e + # pacman 7+ filters syscalls in its own sandbox; disable for Rosetta/qemu emulation. + sed -i "s/^#DisableSandboxSyscalls/DisableSandboxSyscalls/" /etc/pacman.conf + pacman -Sy --noconfirm --needed ca-certificates p11-kit >/dev/null 2>&1 + install -m 0644 /fixture/ca.pem /etc/ca-certificates/trust-source/anchors/numa-local-ca.pem + trust extract-compat + grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt + echo " install: cert present in bundle" + rm /etc/ca-certificates/trust-source/anchors/numa-local-ca.pem + trust extract-compat + if grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt; then + echo " uninstall: cert STILL present (regression)" >&2 + exit 1 + fi + echo " uninstall: cert removed from bundle" +' + +printf "── summary ──\n" +printf " ${GREEN}passed${RESET}: %d\n" "$PASSED" +printf " ${RED}failed${RESET}: %d\n" "$FAILED" +[ "$FAILED" -eq 0 ]