fix: human-readable advisories for TLS data_dir + port-53 EACCES #48

Merged
razvandimescu merged 3 commits from fix/tls-permission-advisory into main 2026-04-09 21:27:08 +08:00
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());
}
}