Port-53 and TLS-data-dir advisories told users to create ~/.config/numa/numa.toml, but config_dir() routed root to /var/lib/numa/ and load_config never consulted the XDG path, so the file the user created was silently ignored. New suggested_config_path() helper prefers $HOME/.config/numa/ when HOME is set (and isn't "/" or empty), with config_dir() as lazy fallback. Used by both advisories and by load_config as an additional candidate, so the advised path is the path numa actually reads. Runtime state (services.json, TLS CA) stays in FHS — config_dir()/data_dir() are intentionally unchanged to keep continuity with the installed daemon. End-to-end replication + regression check in tests/docker/issue-81.sh: four scenarios (replication and existing-install, each against main and fix), all matching expectations.
244 lines
8.4 KiB
Rust
244 lines
8.4 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, Issuer, 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 {} with:
|
|
|
|
[server]
|
|
data_dir = \"/path/you/can/write\"
|
|
|
|
",
|
|
data_dir.display(),
|
|
crate::suggested_config_path().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_der, issuer) = ensure_ca(data_dir)?;
|
|
let (cert_chain, key) = generate_service_cert(&ca_der, &issuer, 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<(CertificateDer<'static>, Issuer<'static, 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 ca_der = rustls_pemfile::certs(&mut cert_pem.as_bytes())
|
|
.next()
|
|
.ok_or("empty CA PEM file")??;
|
|
let issuer = Issuer::from_ca_cert_der(&ca_der, key_pair)?;
|
|
info!("loaded CA from {:?}", ca_cert_path);
|
|
return Ok((ca_der, issuer));
|
|
}
|
|
|
|
// 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);
|
|
let ca_der = cert.der().clone();
|
|
let issuer = Issuer::new(params, key_pair);
|
|
Ok((ca_der, issuer))
|
|
}
|
|
|
|
/// Generate a cert with explicit SANs for each service name.
|
|
/// Always regenerated at startup (~5ms) — no disk caching needed.
|
|
fn generate_service_cert(
|
|
ca_der: &CertificateDer<'static>,
|
|
issuer: &Issuer<'_, 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, issuer)?;
|
|
|
|
info!(
|
|
"generated TLS cert for: {}",
|
|
service_names
|
|
.iter()
|
|
.map(|n| format!("{}.{}", n, tld))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
|
|
let cert_der = cert.der().clone();
|
|
let ca_cert_der = ca_der.clone();
|
|
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der()));
|
|
|
|
Ok((vec![cert_der, ca_cert_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());
|
|
}
|
|
}
|