From a6f23a5ddbeecc5da0d003caa6e33114572f12cd Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 9 Apr 2026 15:03:58 +0300 Subject: [PATCH 1/4] fix: advisory + exit(1) when port 53 is already in use (#45) (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Opus 4.6 --- src/main.rs | 17 ++++- src/system_dns.rs | 43 +++++++++++ tests/docker/smoke-port53.sh | 138 +++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100755 tests/docker/smoke-port53.sh diff --git a/src/main.rs b/src/main.rs index af0fb3a..20f0dba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -231,8 +231,23 @@ async fn main() -> numa::Result<()> { 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 { - socket: UdpSocket::bind(&config.server.bind_addr).await?, + socket, zone_map: build_zone_map(&config.zones)?, cache: RwLock::new(DnsCache::new( config.cache.max_entries, diff --git a/src/system_dns.rs b/src/system_dns.rs index b24b3ad..fcb17fa 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -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::() + .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")] fn discover_macos() -> SystemDnsInfo { use log::{debug, warn}; diff --git a/tests/docker/smoke-port53.sh b/tests/docker/smoke-port53.sh new file mode 100755 index 0000000..d5c67ae --- /dev/null +++ b/tests/docker/smoke-port53.sh @@ -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:-}" +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 From fab8b698d812ab78f63a9448f1dd02c687fdc416 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 9 Apr 2026 16:27:08 +0300 Subject: [PATCH 2/4] fix: human-readable advisories for TLS data_dir + port-53 EACCES (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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` 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 * 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 --------- Co-authored-by: Claude Opus 4.6 --- src/main.rs | 24 +++++++------ src/system_dns.rs | 88 +++++++++++++++++++++++++++++++++++++---------- src/tls.rs | 64 ++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 30 deletions(-) diff --git a/src/main.rs b/src/main.rs index 20f0dba..b335016 100644 --- a/src/main.rs +++ b/src/main.rs @@ -223,7 +223,11 @@ async fn main() -> numa::Result<()> { ) { Ok(tls_config) => Some(ArcSwap::from(tls_config)), Err(e) => { - log::warn!("TLS setup failed, HTTPS proxy disabled: {}", 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); + } None } } @@ -233,17 +237,15 @@ async fn main() -> numa::Result<()> { 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) => { + if let Some(advisory) = + numa::system_dns::try_port53_advisory(&config.server.bind_addr, &e) + { + eprint!("{}", advisory); + std::process::exit(1); + } + return Err(e.into()); } - Err(e) => return Err(e.into()), }; let ctx = Arc::new(ServerCtx { diff --git a/src/system_dns.rs b/src/system_dns.rs index fcb17fa..f77d820 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -46,28 +46,32 @@ 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::() - .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 { +/// Advisory for port-53 bind failures (EADDRINUSE or EACCES); `None` +/// if not applicable so the caller can fall back to the raw error. +pub fn try_port53_advisory(bind_addr: &str, err: &std::io::Error) -> Option { + if !is_port_53(bind_addr) { + return None; + } + let (title, cause) = match err.kind() { + std::io::ErrorKind::AddrInUse => ( + "port 53 is already in use", + "Another process is already bound to port 53. On Linux this is\n \ + 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 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 - typically systemd-resolved; on Windows, the DNS Client service. + {cause} 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 " - ) + )) +} + +fn is_port_53(bind_addr: &str) -> bool { + bind_addr + .parse::() + .map(|s| s.port() == 53) + .unwrap_or(false) } #[cfg(target_os = "macos")] @@ -1796,4 +1807,43 @@ Wireless LAN adapter Wi-Fi: assert_eq!(result.len(), 1); 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()); + } } diff --git a/src/tls.rs b/src/tls.rs index 7c7620a..7ba96b6 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -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 { + let io_err = err.downcast_ref::()?; + 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. /// Wildcards under single-label TLDs (*.numa) are rejected by browsers, /// 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)) } + +#[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()); + } +} From 819614fa7dba39c207999225def38b4f3387dc81 Mon Sep 17 00:00:00 2001 From: Casey Labs <4674433+CaseyLabs@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:22:38 -0700 Subject: [PATCH 3/4] [Feature] Add GitHub Action Workflow for Arch Linux AUR Package publishing (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature: add GitHub Actions workflow for publishing Arch Linux AUR package * Fix issues in Arch Linux AUR publishing process * Add patch to fix default Arch Linux binary path location issues * fix: PKGBUILD compatibility with numa v0.10.1, fix QEMU action SHA pin Three small bug fixes that make this PR mergeable end-to-end against current main, without changing the package design (still numa-git, still pushed on every main commit, still tracking HEAD via pkgver()): 1. Simplified prepare() — drop the obsolete sed patching for /usr/local/bin/numa. That literal only appears in a comment in current main; the actual binary path is determined at runtime via std::env::current_exe(). Additionally, numa v0.10.1 ships PR #43 which makes numa FHS-compliant on Linux out of the box (/var/lib/numa for data dir), so no source patching is needed at all on Arch. 2. Fixed package() sed for the systemd unit. The previous sed targeted "ExecStart=/usr/local/bin/numa" but numa.service actually uses "{{exe_path}}" as a templating placeholder that's substituted at runtime by replace_exe_path() when `numa install` runs. The sed silently did nothing, and the AUR-installed unit file would have a literal "{{exe_path}}" that systemd cannot start. Fixed sed: sed 's|{{exe_path}}|/usr/bin/numa /etc/numa.toml|g' \ numa.service > numa.service.patched 3. Fixed broken docker/setup-qemu-action SHA pin in publish-aur.yml. The pinned SHA 6882732593b27c7f95a044d559b586a46371a68e doesn't exist as a commit in upstream docker/setup-qemu-action. Verified v3.0.0 SHA is 68827325e0b33c7199eb31dd4e31fbe9023e06e3. Without this fix the aarch64 validate job would fail to load the action at workflow start. Also refreshed the stale pkgver placeholder in PKGBUILD and .SRCINFO from 0.9.1.r0.g1234abc to 0.10.1.r0.g0000000 — purely cosmetic since pkgver() auto-overrides on every makepkg run, but at least the in-VC value reflects the current era. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: make AUR packaging x86_64-only and stabilize local validation Turns out Arch Linux doesn't officially support aarch64 architecture, so we will drop if from this AUR build process. Changes: - drop aarch64 from PKGBUILD, .SRCINFO, and AUR validation workflow - keep AUR process aligned with official Arch Linux x86_64 support - install rust directly in CI to avoid Arch cargo provider prompts - fetch sources before running cargo audit and audit inside the fetched repo - disable makepkg LTO for this package to avoid Arch packaging link failures - mark /etc/numa.toml as a backup file - Add local AUR build scratch directory exclusion to .gitignore * Add temporary AUR test workflow * Update github actions checkout workflow version * remove temporary AUR test workflow * fix: correct AUR SSH host key fingerprint The previously pinned ed25519 key was truncated (52 chars) and did not match the actual aur.archlinux.org host key. Verified via ssh-keyscan. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Razvan Dimescu Co-authored-by: Claude Opus 4.6 (1M context) --- .SRCINFO | 19 ++++ .github/workflows/publish-aur.yml | 150 ++++++++++++++++++++++++++++++ .gitignore | 1 + PKGBUILD | 62 ++++++++++++ README.md | 3 + 5 files changed, 235 insertions(+) create mode 100644 .SRCINFO create mode 100644 .github/workflows/publish-aur.yml create mode 100644 PKGBUILD diff --git a/.SRCINFO b/.SRCINFO new file mode 100644 index 0000000..66d3218 --- /dev/null +++ b/.SRCINFO @@ -0,0 +1,19 @@ +pkgbase = numa-git + pkgdesc = Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS + pkgver = 0.10.1.r0.g0000000 + pkgrel = 1 + url = https://github.com/razvandimescu/numa + arch = x86_64 + license = MIT + options = !lto + makedepends = cargo + makedepends = git + depends = gcc-libs + depends = glibc + provides = numa + conflicts = numa + backup = etc/numa.toml + source = numa::git+https://github.com/razvandimescu/numa.git + sha256sums = SKIP + +pkgname = numa-git diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml new file mode 100644 index 0000000..03831c9 --- /dev/null +++ b/.github/workflows/publish-aur.yml @@ -0,0 +1,150 @@ +# `publish-aur.yml` - Arch Linux AUR Package Workflow +# -------------------- +# This workflow automates the validation and publishing of the 'numa-git' package to the +# Arch User Repository (AUR). The AUR is a community-driven repository for Arch Linux users. +# +# Workflow Overview: +# 1. Validate: Builds and tests the package for Arch Linux x86_64 using a clean +# Arch Linux container. +# 2. Audit: Checks Rust dependencies for known security vulnerabilities using +# 'cargo-audit'. +# 3. Publish: If on the 'main' branch, it pushes the updated PKGBUILD and +# .SRCINFO to the AUR. +# +# Security Best Practices: +# - SHA Pinning: All GitHub Actions are pinned to a full-length commit SHA (e.g., v6.0.2 @ SHA) +# to ensure the code is immutable and protects against supply-chain attacks where a tag +# might be maliciously moved to a compromised commit. +# - SSH Hygiene: Uses ssh-agent to keep the private key in memory rather than on disk. +# - Audit: Runs 'cargo audit' to prevent publishing known vulnerable dependencies. + +name: Publish - Arch Linux AUR Package + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + # The 'validate' job ensures that the PKGBUILD is correct and the software builds/tests + # successfully on Arch Linux before we attempt to publish it. + validate: + name: Validate PKGBUILD (${{ matrix.arch }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + arch: [x86_64] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Build and Test Package + timeout-minutes: 60 + env: + AUR_PKGNAME: ${{ secrets.AUR_PACKAGE_NAME }} + run: | + # We use a temporary directory to avoid Docker permission issues with the workspace. + mkdir -p build-dir + cp PKGBUILD build-dir/ + + docker run --rm -v $PWD/build-dir:/pkg -w /pkg archlinux:latest /bin/bash -c " + # ARCH LINUX SECURITY REQUIREMENT: + # 'makepkg' (the tool that builds Arch packages) refuses to run as root for safety. + # We must create a standard user and give them sudo access. + + # Install build-time dependencies. + # 'base-devel' includes essential tools like gcc, make, and binutils. + # Install 'rust' directly to avoid the interactive virtual-package + # prompt for 'cargo' on current Arch images. + pacman -Syu --noconfirm --needed base-devel rust git sudo cargo-audit + + useradd -m builduser + chown -R builduser:builduser /pkg + + # Allow the build user to install dependencies during the build process. + echo 'builduser ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builduser + + # Fetch the source tree first so pkgver() and cargo-audit have a + # real Cargo.lock to inspect. + sudo -u builduser makepkg -o --nobuild --nocheck --nodeps --noprepare + + # SECURITY AUDIT: + # Fail early if any dependencies have known security vulnerabilities. + sudo -u builduser sh -lc 'cd /pkg/src/numa && cargo audit' + + # BUILD & TEST: + # 'makepkg -s' will: + # 1. Download source files (cloning this repo) + # 2. Run prepare(), build(), and check() (running cargo test) + # 3. Create the final .pkg.tar.zst package + sudo -u builduser makepkg -s --noconfirm + " + + # The 'publish' job updates the AUR repository with our latest PKGBUILD and .SRCINFO. + publish: + name: Publish to AUR + needs: validate + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Securely configure SSH for AUR access. + - name: Configure SSH + run: | + mkdir -p ~/.ssh + # Official AUR Ed25519 fingerprint (prevents Man-in-the-Middle attacks). + echo "aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN" >> ~/.ssh/known_hosts + + # Use ssh-agent to keep the private key in memory rather than writing it to disk. + eval $(ssh-agent -s) + echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add - + + # Export the agent socket so subsequent 'git' commands can use it. + echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> $GITHUB_ENV + echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> $GITHUB_ENV + + - name: Push to AUR + env: + AUR_PKGNAME: ${{ secrets.AUR_PACKAGE_NAME }} + AUR_EMAIL: ${{ secrets.AUR_EMAIL }} + AUR_USER: ${{ secrets.AUR_USERNAME }} + run: | + # AUR repos are managed via Git. Each package has its own repo at: + # ssh://aur@aur.archlinux.org/.git + git clone ssh://aur@aur.archlinux.org/$AUR_PKGNAME.git aur-repo + + cp PKGBUILD aur-repo/ + cd aur-repo + + # METADATA GENERATION: + # '.SRCINFO' is a machine-readable version of the PKGBUILD. + # We must run this as a non-root user ('builduser') inside the container. + docker run --rm -v $(pwd):/pkg archlinux:latest /bin/bash -c " + pacman -Syu --noconfirm --needed binutils git sudo + useradd -m builduser + chown -R builduser:builduser /pkg + cd /pkg + sudo -u builduser git config --global --add safe.directory '*' + # "makepkg -od" fetches the source first so pkgver() can calculate the version. + sudo -u builduser makepkg -od && sudo -u builduser makepkg --printsrcinfo > .SRCINFO + " + + # Set the commit identity using secrets for security and auditability. + git config user.name "$AUR_USER" + git config user.email "$AUR_EMAIL" + + # Stage and commit both the human-readable PKGBUILD and machine-readable .SRCINFO. + git add PKGBUILD .SRCINFO + + if ! git diff --cached --quiet; then + git commit -m "chore: update PKGBUILD to ${{ github.sha }}" + git push origin master + else + echo "No changes to commit (metadata and PKGBUILD are already up-to-date)." + fi diff --git a/.gitignore b/.gitignore index 9dcba3d..1c510fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/build-dir CLAUDE.md docs/ site/blog/posts/ diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..b3e3f6b --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,62 @@ +# Maintainer: razvandimescu +pkgname=numa-git +_pkgname=numa +pkgver=0.10.1.r0.g0000000 # Placeholder — pkgver() rewrites this on each makepkg run +pkgrel=1 +pkgdesc="Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS" +arch=('x86_64') +url="https://github.com/razvandimescu/numa" +license=('MIT') +options=('!lto') +depends=('gcc-libs' 'glibc') +makedepends=('cargo' 'git') +provides=("$_pkgname") +conflicts=("$_pkgname") +backup=('etc/numa.toml') +source=("$_pkgname::git+$url.git") +sha256sums=('SKIP') + +pkgver() { + cd "$srcdir/$_pkgname" + ( set -o pipefail + git describe --long --tags 2>/dev/null | sed 's/\([^-]*-g\)/r\1/;s/-/./g' || + printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" + ) | sed 's/^v//' +} + +prepare() { + cd "$srcdir/$_pkgname" + # numa v0.10.1+ uses FHS-compliant paths on Linux by default + # (/var/lib/numa for data, journalctl for logs), so no source + # patching is needed. The earlier sed targeted /usr/local/bin/numa, + # which only appears in a comment in current main. + export RUSTUP_TOOLCHAIN=stable + cargo fetch --locked +} + +build() { + cd "$srcdir/$_pkgname" + export RUSTUP_TOOLCHAIN=stable + cargo build --frozen --release +} + +check() { + cd "$srcdir/$_pkgname" + export RUSTUP_TOOLCHAIN=stable + cargo test --frozen +} + +package() { + cd "$srcdir/$_pkgname" + install -Dm755 "target/release/$_pkgname" "$pkgdir/usr/bin/$_pkgname" + + # numa.service uses {{exe_path}} as a placeholder substituted by + # `numa install` at runtime via replace_exe_path(). For an AUR + # package install (no `numa install` step), we substitute it + # statically here so systemd gets a real ExecStart path. + sed 's|{{exe_path}}|/usr/bin/numa /etc/numa.toml|g' numa.service > numa.service.patched + install -Dm644 "numa.service.patched" "$pkgdir/usr/lib/systemd/system/numa.service" + + install -Dm644 "numa.toml" "$pkgdir/etc/numa.toml" + install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/README.md b/README.md index 5794268..79dcff8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ brew install razvandimescu/tap/numa # Linux curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh +# Arch Linux (AUR) +yay -S numa-git + # Windows — download from GitHub Releases # All platforms cargo install numa From 5308e9648cca820b5cbf92ce5142f3e5df980840 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 9 Apr 2026 18:24:30 +0300 Subject: [PATCH 4/4] fix(ci): reclaim aur-repo ownership after docker chown (#49) The 'Push to AUR' step failed on run 24195384571 with: error: could not lock config file .git/config: Permission denied Inside the docker block we 'chown -R builduser:builduser /pkg', which propagates through the bind mount and transfers ownership of aur-repo/ (including .git/) to the container's builduser UID. When control returns to the runner user, 'git config user.name' can no longer write .git/config and the step exits 255. Chown the directory back to the runner's UID/GID before resuming host-side git operations. Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/publish-aur.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index 03831c9..f36783d 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -135,6 +135,12 @@ jobs: sudo -u builduser makepkg -od && sudo -u builduser makepkg --printsrcinfo > .SRCINFO " + # Reclaim ownership: the in-container 'chown -R builduser:builduser /pkg' + # propagates through the bind mount, leaving .git/ owned by the container's + # builduser UID. Without this, subsequent 'git config' on the host fails with + # "could not lock config file .git/config: Permission denied". + sudo chown -R "$(id -u):$(id -g)" . + # Set the commit identity using secrets for security and auditability. git config user.name "$AUR_USER" git config user.email "$AUR_EMAIL"