* fix: advisory + exit(1) when port 53 is already in use (#45) Detect AddrInUse on bind, print a human-readable diagnostic explaining systemd-resolved / Dnscache as the likely cause and offer two concrete fixes (sudo numa install, or bind_addr on a non-privileged port), then exit(1) instead of surfacing a raw OS error. Adds tests/docker/smoke-port53.sh: end-to-end Docker test that pre-binds port 53 with a Python UDP socket and asserts the advisory + exit code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: collapse port53 advisory to single flat path The per-platform cause sentences were cosmetic — they didn't change the user's actions (install, or bind_addr on a non-privileged port), but they introduced duplicated "another process..." strings, a dead-from-CI branch (is_systemd_resolved_active() == true is never reached by any test), and a pub visibility bump on is_systemd_resolved_active for a single caller. Replace with one flat format! whose cause line mentions both systemd-resolved and the Windows DNS Client inline. The existing smoke test now exercises 100% of the function. is_systemd_resolved_active reverts to private. 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 #47.
This commit is contained in:
17
src/main.rs
17
src/main.rs
@@ -231,8 +231,23 @@ async fn main() -> numa::Result<()> {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let socket = match UdpSocket::bind(&config.server.bind_addr).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e)
|
||||||
|
if e.kind() == std::io::ErrorKind::AddrInUse
|
||||||
|
&& numa::system_dns::is_port_53(&config.server.bind_addr) =>
|
||||||
|
{
|
||||||
|
eprint!(
|
||||||
|
"{}",
|
||||||
|
numa::system_dns::port53_conflict_advisory(&config.server.bind_addr)
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
let ctx = Arc::new(ServerCtx {
|
let ctx = Arc::new(ServerCtx {
|
||||||
socket: UdpSocket::bind(&config.server.bind_addr).await?,
|
socket,
|
||||||
zone_map: build_zone_map(&config.zones)?,
|
zone_map: build_zone_map(&config.zones)?,
|
||||||
cache: RwLock::new(DnsCache::new(
|
cache: RwLock::new(DnsCache::new(
|
||||||
config.cache.max_entries,
|
config.cache.max_entries,
|
||||||
|
|||||||
@@ -46,6 +46,49 @@ pub fn discover_system_dns() -> SystemDnsInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if `bind_addr` targets DNS port 53. Used to scope the port-53
|
||||||
|
/// conflict advisory — we only want to print the systemd-resolved /
|
||||||
|
/// Dnscache hint when the user is actually trying to bind the DNS port.
|
||||||
|
pub fn is_port_53(bind_addr: &str) -> bool {
|
||||||
|
bind_addr
|
||||||
|
.parse::<SocketAddr>()
|
||||||
|
.map(|s| s.port() == 53)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable diagnostic for port-53 bind conflicts. Offers two
|
||||||
|
/// concrete fixes: install Numa as the system resolver, or bind to a
|
||||||
|
/// non-privileged port.
|
||||||
|
pub fn port53_conflict_advisory(bind_addr: &str) -> String {
|
||||||
|
let o = "\x1b[1;38;2;192;98;58m"; // bold orange
|
||||||
|
let r = "\x1b[0m";
|
||||||
|
format!(
|
||||||
|
"
|
||||||
|
{o}Numa{r} — cannot bind to {bind_addr}: port 53 is already in use.
|
||||||
|
|
||||||
|
Another process is already bound to port 53. On Linux this is
|
||||||
|
typically systemd-resolved; on Windows, the DNS Client service.
|
||||||
|
|
||||||
|
Fix — pick one:
|
||||||
|
|
||||||
|
1. Install Numa as the system resolver (frees port 53):
|
||||||
|
|
||||||
|
sudo numa install (on Windows, run as Administrator)
|
||||||
|
|
||||||
|
2. Run on a non-privileged port for testing.
|
||||||
|
Create ~/.config/numa/numa.toml with:
|
||||||
|
|
||||||
|
[server]
|
||||||
|
bind_addr = \"127.0.0.1:5354\"
|
||||||
|
api_port = 5380
|
||||||
|
|
||||||
|
Then run: numa
|
||||||
|
Test with: dig @127.0.0.1 -p 5354 example.com
|
||||||
|
|
||||||
|
"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn discover_macos() -> SystemDnsInfo {
|
fn discover_macos() -> SystemDnsInfo {
|
||||||
use log::{debug, warn};
|
use log::{debug, warn};
|
||||||
|
|||||||
138
tests/docker/smoke-port53.sh
Executable file
138
tests/docker/smoke-port53.sh
Executable file
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Port-53 conflict advisory integration test.
|
||||||
|
#
|
||||||
|
# Builds numa from source inside a debian:bookworm container, pre-binds
|
||||||
|
# port 53 with a UDP socket, then runs numa bare (default bind_addr
|
||||||
|
# 0.0.0.0:53). Verifies:
|
||||||
|
# - process exits with code 1
|
||||||
|
# - stderr contains the advisory ("cannot bind to")
|
||||||
|
# - stderr contains both fix suggestions ("numa install", "bind_addr")
|
||||||
|
#
|
||||||
|
# This is the end-to-end test for the fix in:
|
||||||
|
# src/main.rs — AddrInUse match arm → eprint advisory + process::exit(1)
|
||||||
|
#
|
||||||
|
# No systemd-resolved needed — the conflict is simulated by a Python
|
||||||
|
# UDP socket held open before numa starts.
|
||||||
|
#
|
||||||
|
# Requirements: docker
|
||||||
|
# Usage: ./tests/docker/smoke-port53.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
|
||||||
|
|
||||||
|
pass() { printf " ${GREEN}✓${RESET} %s\n" "$1"; }
|
||||||
|
fail() { printf " ${RED}✗${RESET} %s\n" "$1"; printf " %s\n" "$2"; FAILED=$((FAILED+1)); }
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
echo "── smoke-port53: building + testing numa on debian:bookworm ──"
|
||||||
|
echo " (first run is slow: image pull + cold cargo build, ~5-8 min)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
OUTPUT=$(docker run --rm \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
-v "$PWD:/src:ro" \
|
||||||
|
-v numa-port53-cargo:/root/.cargo \
|
||||||
|
-v numa-port53-target:/work/target \
|
||||||
|
debian:bookworm bash -c '
|
||||||
|
set -e
|
||||||
|
|
||||||
|
apt-get update -qq && apt-get install -y -qq curl build-essential python3 2>&1 | tail -3
|
||||||
|
|
||||||
|
# Install rustup if not already in the cargo cache volume
|
||||||
|
if ! command -v cargo &>/dev/null; then
|
||||||
|
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --quiet
|
||||||
|
fi
|
||||||
|
. "$HOME/.cargo/env"
|
||||||
|
|
||||||
|
# Copy source to a writable workdir
|
||||||
|
mkdir -p /work
|
||||||
|
tar -C /src --exclude=./target --exclude=./.git -cf - . | tar -C /work -xf -
|
||||||
|
cd /work
|
||||||
|
|
||||||
|
echo "── cargo build --release --locked ──"
|
||||||
|
cargo build --release --locked 2>&1 | tail -5
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Write the holder script to a file to avoid quoting hell.
|
||||||
|
# Holds port 53 until killed — no sleep race.
|
||||||
|
cat > /tmp/hold53.py << '"'"'PYEOF'"'"'
|
||||||
|
import socket, signal
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
|
||||||
|
s.bind(("", 53))
|
||||||
|
signal.pause()
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
python3 /tmp/hold53.py &
|
||||||
|
HOLDER_PID=$!
|
||||||
|
|
||||||
|
# Verify the holder is actually up before proceeding
|
||||||
|
sleep 0.3
|
||||||
|
if ! kill -0 $HOLDER_PID 2>/dev/null; then
|
||||||
|
echo "holder_failed=1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "── running numa with port 53 already bound ──"
|
||||||
|
# timeout 5: guards against numa not exiting (advisory not fired, bug present)
|
||||||
|
# Capture stderr to a file so the exit code is not clobbered by || or $()
|
||||||
|
set +e
|
||||||
|
timeout 5 ./target/release/numa > /tmp/numa-stderr.txt 2>&1
|
||||||
|
EXIT_CODE=$?
|
||||||
|
set -e
|
||||||
|
STDERR=$(cat /tmp/numa-stderr.txt)
|
||||||
|
|
||||||
|
kill $HOLDER_PID 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "exit_code=$EXIT_CODE"
|
||||||
|
printf "%s" "$STDERR" | sed "s/^/ numa: /"
|
||||||
|
' 2>&1)
|
||||||
|
|
||||||
|
echo "$OUTPUT"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "── assertions ──"
|
||||||
|
|
||||||
|
if echo "$OUTPUT" | grep -q "holder_failed=1"; then
|
||||||
|
echo " SETUP FAILED: could not pre-bind port 53 inside container"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
EXIT_CODE=$(echo "$OUTPUT" | grep '^exit_code=' | cut -d= -f2)
|
||||||
|
|
||||||
|
if [ "${EXIT_CODE:-}" = "1" ]; then
|
||||||
|
pass "exits with code 1"
|
||||||
|
else
|
||||||
|
fail "exits with code 1" "got: exit_code=${EXIT_CODE:-<missing>}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$OUTPUT" | grep -q "cannot bind to"; then
|
||||||
|
pass "advisory printed to stderr"
|
||||||
|
else
|
||||||
|
fail "advisory printed to stderr" "stderr did not contain 'cannot bind to'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$OUTPUT" | grep -q "numa install"; then
|
||||||
|
pass "advisory offers 'sudo numa install'"
|
||||||
|
else
|
||||||
|
fail "advisory offers 'sudo numa install'" "not found in output"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$OUTPUT" | grep -q "bind_addr"; then
|
||||||
|
pass "advisory offers non-privileged port alternative"
|
||||||
|
else
|
||||||
|
fail "advisory offers non-privileged port alternative" "'bind_addr' not found in output"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FAILED" -eq 0 ]; then
|
||||||
|
printf "${GREEN}── smoke-port53 passed ──${RESET}\n"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
printf "${RED}── smoke-port53 failed ($FAILED assertion(s)) ──${RESET}\n"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user