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>
This commit is contained in:
Razvan Dimescu
2026-04-09 15:26:29 +03:00
parent e2917a73f9
commit 10024161aa
2 changed files with 80 additions and 29 deletions

View File

@@ -241,17 +241,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);
"{}", std::process::exit(1);
numa::system_dns::port53_conflict_advisory(&config.server.bind_addr) }
); return Err(e.into());
std::process::exit(1);
} }
Err(e) => return Err(e.into()),
}; };
let ctx = Arc::new(ServerCtx { let ctx = Arc::new(ServerCtx {

View File

@@ -46,28 +46,35 @@ pub fn discover_system_dns() -> SystemDnsInfo {
} }
} }
/// True if `bind_addr` targets DNS port 53. Used to scope the port-53 /// Diagnostic advisory for port-53 bind failures. Returns `Some(msg)`
/// conflict advisory — we only want to print the systemd-resolved / /// when `bind_addr` targets port 53 and `err` is a kind we can advise
/// Dnscache hint when the user is actually trying to bind the DNS port. /// on (EADDRINUSE — another process holds it; EACCES — non-root on a
pub fn is_port_53(bind_addr: &str) -> bool { /// privileged port). Returns `None` for non-53 targets or unrelated
bind_addr /// error kinds, so the caller can fall back to the raw error.
.parse::<SocketAddr>() pub fn try_port53_advisory(bind_addr: &str, err: &std::io::Error) -> Option<String> {
.map(|s| s.port() == 53) if !is_port_53(bind_addr) {
.unwrap_or(false) return None;
} }
let (title, cause) = match err.kind() {
/// Human-readable diagnostic for port-53 bind conflicts. Offers two std::io::ErrorKind::AddrInUse => (
/// concrete fixes: install Numa as the system resolver, or bind to a "port 53 is already in use",
/// non-privileged port. "Another process is already bound to port 53. On Linux this is\n \
pub fn port53_conflict_advisory(bind_addr: &str) -> String { typically systemd-resolved; on Windows, the DNS Client service.",
),
std::io::ErrorKind::PermissionDenied => (
"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 +93,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 +1810,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());
}
} }