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>
This commit was merged in pull request #48.
This commit is contained in:
Razvan Dimescu
2026-04-09 16:27:08 +03:00
committed by GitHub
parent a6f23a5ddb
commit fab8b698d8
3 changed files with 146 additions and 30 deletions

View File

@@ -223,7 +223,11 @@ async fn main() -> numa::Result<()> {
) { ) {
Ok(tls_config) => Some(ArcSwap::from(tls_config)), Ok(tls_config) => Some(ArcSwap::from(tls_config)),
Err(e) => { Err(e) => {
if let Some(advisory) = numa::tls::try_data_dir_advisory(&e, &resolved_data_dir) {
eprint!("{}", advisory);
} else {
log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e);
}
None None
} }
} }
@@ -233,17 +237,15 @@ async fn main() -> numa::Result<()> {
let socket = match UdpSocket::bind(&config.server.bind_addr).await { let socket = match UdpSocket::bind(&config.server.bind_addr).await {
Ok(s) => s, Ok(s) => s,
Err(e) Err(e) => {
if e.kind() == std::io::ErrorKind::AddrInUse if let Some(advisory) =
&& numa::system_dns::is_port_53(&config.server.bind_addr) => numa::system_dns::try_port53_advisory(&config.server.bind_addr, &e)
{ {
eprint!( eprint!("{}", advisory);
"{}",
numa::system_dns::port53_conflict_advisory(&config.server.bind_addr)
);
std::process::exit(1); std::process::exit(1);
} }
Err(e) => return Err(e.into()), return Err(e.into());
}
}; };
let ctx = Arc::new(ServerCtx { let ctx = Arc::new(ServerCtx {

View File

@@ -46,28 +46,32 @@ pub fn discover_system_dns() -> SystemDnsInfo {
} }
} }
/// True if `bind_addr` targets DNS port 53. Used to scope the port-53 /// Advisory for port-53 bind failures (EADDRINUSE or EACCES); `None`
/// conflict advisory — we only want to print the systemd-resolved / /// if not applicable so the caller can fall back to the raw error.
/// Dnscache hint when the user is actually trying to bind the DNS port. pub fn try_port53_advisory(bind_addr: &str, err: &std::io::Error) -> Option<String> {
pub fn is_port_53(bind_addr: &str) -> bool { if !is_port_53(bind_addr) {
bind_addr return None;
.parse::<SocketAddr>() }
.map(|s| s.port() == 53) let (title, cause) = match err.kind() {
.unwrap_or(false) std::io::ErrorKind::AddrInUse => (
} "port 53 is already in use",
"Another process is already bound to port 53. On Linux this is\n \
/// Human-readable diagnostic for port-53 bind conflicts. Offers two typically systemd-resolved; on Windows, the DNS Client service.",
/// concrete fixes: install Numa as the system resolver, or bind to a ),
/// non-privileged port. std::io::ErrorKind::PermissionDenied => (
pub fn port53_conflict_advisory(bind_addr: &str) -> String { "permission denied",
"Port 53 is privileged — binding it requires root on Linux/macOS\n \
or Administrator on Windows.",
),
_ => return None,
};
let o = "\x1b[1;38;2;192;98;58m"; // bold orange let o = "\x1b[1;38;2;192;98;58m"; // bold orange
let r = "\x1b[0m"; let r = "\x1b[0m";
format!( Some(format!(
" "
{o}Numa{r} — cannot bind to {bind_addr}: port 53 is already in use. {o}Numa{r} — cannot bind to {bind_addr}: {title}.
Another process is already bound to port 53. On Linux this is {cause}
typically systemd-resolved; on Windows, the DNS Client service.
Fix — pick one: Fix — pick one:
@@ -86,7 +90,14 @@ pub fn port53_conflict_advisory(bind_addr: &str) -> String {
Test with: dig @127.0.0.1 -p 5354 example.com Test with: dig @127.0.0.1 -p 5354 example.com
" "
) ))
}
fn is_port_53(bind_addr: &str) -> bool {
bind_addr
.parse::<SocketAddr>()
.map(|s| s.port() == 53)
.unwrap_or(false)
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -1796,4 +1807,43 @@ Wireless LAN adapter Wi-Fi:
assert_eq!(result.len(), 1); assert_eq!(result.len(), 1);
assert!(result.contains_key("Wi-Fi")); assert!(result.contains_key("Wi-Fi"));
} }
#[test]
fn try_port53_advisory_addr_in_use() {
let err = std::io::Error::from(std::io::ErrorKind::AddrInUse);
let msg = try_port53_advisory("0.0.0.0:53", &err).expect("should advise on port 53");
assert!(msg.contains("cannot bind to"));
assert!(msg.contains("already in use"));
assert!(msg.contains("numa install"));
assert!(msg.contains("bind_addr"));
}
#[test]
fn try_port53_advisory_permission_denied() {
let err = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
let msg = try_port53_advisory("0.0.0.0:53", &err).expect("should advise on port 53");
assert!(msg.contains("cannot bind to"));
assert!(msg.contains("permission denied"));
assert!(msg.contains("numa install"));
assert!(msg.contains("bind_addr"));
}
#[test]
fn try_port53_advisory_skips_non_53_ports() {
let err = std::io::Error::from(std::io::ErrorKind::AddrInUse);
assert!(try_port53_advisory("127.0.0.1:5354", &err).is_none());
assert!(try_port53_advisory("[::]:853", &err).is_none());
}
#[test]
fn try_port53_advisory_skips_unrelated_error_kinds() {
let err = std::io::Error::from(std::io::ErrorKind::NotFound);
assert!(try_port53_advisory("0.0.0.0:53", &err).is_none());
}
#[test]
fn try_port53_advisory_skips_malformed_bind_addr() {
let err = std::io::Error::from(std::io::ErrorKind::AddrInUse);
assert!(try_port53_advisory("not-an-address", &err).is_none());
}
} }

View File

@@ -40,6 +40,40 @@ pub fn regenerate_tls(ctx: &ServerCtx) {
} }
} }
/// 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. /// Build a TLS config with a cert covering all provided service names.
/// Wildcards under single-label TLDs (*.numa) are rejected by browsers, /// Wildcards under single-label TLDs (*.numa) are rejected by browsers,
/// so we list each service explicitly as a SAN. /// so we list each service explicitly as a SAN.
@@ -170,3 +204,33 @@ fn generate_service_cert(
Ok((vec![cert_der, ca_der], key_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());
}
}