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>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -906,7 +906,7 @@ async fn remove_route(
|
||||
}
|
||||
|
||||
async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
|
||||
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)?
|
||||
|
||||
@@ -1278,14 +1278,86 @@ 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 result = trust_ca_macos(&ca_path);
|
||||
#[cfg(target_os = "linux")]
|
||||
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());
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn untrust_ca() -> Result<(), String> {
|
||||
#[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",
|
||||
@@ -1295,48 +1367,23 @@ fn trust_ca() -> Result<(), String> {
|
||||
"-k",
|
||||
"/Library/Keychains/System.keychain",
|
||||
])
|
||||
.arg(&ca_path)
|
||||
.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")))]
|
||||
{
|
||||
Err("CA trust not supported on this OS".into())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn untrust_ca() -> Result<(), String> {
|
||||
let ca_path = crate::data_dir().join("ca.pem");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Find all Numa CA certs by hash and delete each one
|
||||
fn untrust_ca_macos() -> Result<(), String> {
|
||||
if let Ok(out) = std::process::Command::new("security")
|
||||
.args([
|
||||
"find-certificate",
|
||||
"-c",
|
||||
"Numa Local CA",
|
||||
crate::tls::CA_COMMON_NAME,
|
||||
"-a",
|
||||
"-Z",
|
||||
"/Library/Keychains/System.keychain",
|
||||
@@ -1359,21 +1406,85 @@ fn untrust_ca() -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
let _ = ca_path; // suppress unused warning on other platforms
|
||||
#[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(())
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
|
||||
|
||||
11
src/tls.rs
11
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();
|
||||
|
||||
123
tests/docker/install-trust.sh
Executable file
123
tests/docker/install-trust.sh
Executable file
@@ -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 ]
|
||||
Reference in New Issue
Block a user