Files
numa/src/tls.rs
Razvan Dimescu fab8b698d8 fix: human-readable advisories for TLS data_dir + port-53 EACCES (#48)
* fix: human-readable advisory when TLS data_dir is not writable

When numa runs as non-root on a system with a privileged default
data_dir (e.g. /usr/local/var/numa on macOS), TLS CA setup fails with
a raw "Permission denied (os error 13)" and HTTPS proxy is silently
disabled. The user sees a cryptic warning with no path forward.

Detect std::io::ErrorKind::PermissionDenied on the tls error, print a
diagnostic naming the data_dir and offering two fixes (install as
system resolver, or point data_dir at a writable path), and keep the
graceful-degradation behavior — DNS resolution and plain-HTTP proxy
continue to work without HTTPS.

All other TLS setup errors fall through to the existing log::warn!.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: port-53 advisory also handles EACCES (non-root privileged bind)

The original port-53 match arm only caught EADDRINUSE, so a fresh
non-root user on macOS/Linux hitting EACCES when trying to bind a
privileged port saw the raw OS error instead of the advisory.

Collapse the scoping helper and the advisory into a single
`try_port53_advisory(bind_addr, &io::Error) -> Option<String>` that
returns the formatted diagnostic when both the port is 53 and the
error kind is one we can speak to (AddrInUse or PermissionDenied),
and `None` otherwise. The two failure modes share one body with a
cause-sentence variant — no duplicated fix text.

Caller becomes a plain if-let: no match guard, no separate is_port_53
helper exposed on the public API. is_port_53 goes back to private.

Unit tests cover all branches: AddrInUse, PermissionDenied, non-53
bind_addr, unrelated ErrorKind, and malformed bind_addr.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: move TLS error classification into tls module

main.rs no longer downcasts a boxed error to figure out whether it's
a permission-denied case. tls::try_data_dir_advisory(&err, &dir)
encapsulates the downcast + kind match and returns Some(advisory) or
None, mirroring system_dns::try_port53_advisory. main.rs becomes a
plain if-let, symmetric with the port-53 path.

Trim the docstrings on both advisory functions: they were narrating
the implementation (errno mapping) instead of stating the contract.

Add unit tests for try_data_dir_advisory covering PermissionDenied,
other io::ErrorKind, and non-io errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 16:27:08 +03:00

237 lines
8.2 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),
}
}
/// 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<String> {
let io_err = err.downcast_ref::<std::io::Error>()?;
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 ~/.config/numa/numa.toml with:
[server]
data_dir = \"/path/you/can/write\"
",
data_dir.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<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))
}
#[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());
}
}