Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f6bdff8f8 | ||
|
|
643d6b01e1 | ||
|
|
17c8e70aa3 | ||
|
|
389ac09907 | ||
|
|
5308e9648c | ||
|
|
819614fa7d | ||
|
|
fab8b698d8 | ||
|
|
a6f23a5ddb | ||
|
|
27dfaab360 | ||
|
|
b2ed2e6aec | ||
|
|
79ecb73d87 | ||
|
|
bf5565ac26 | ||
|
|
679b346246 | ||
|
|
039254280b | ||
|
|
1b2f682026 |
19
.SRCINFO
Normal file
19
.SRCINFO
Normal file
@@ -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
|
||||||
76
.github/workflows/homebrew-bump.yml
vendored
Normal file
76
.github/workflows/homebrew-bump.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
name: Bump Homebrew Tap
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to bump (e.g. 0.10.0 or v0.10.0)'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Determine version
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "release" ]; then
|
||||||
|
V="${{ github.event.release.tag_name }}"
|
||||||
|
else
|
||||||
|
V="${{ github.event.inputs.version }}"
|
||||||
|
fi
|
||||||
|
V="${V#v}"
|
||||||
|
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Fetch sha256 checksums from release assets
|
||||||
|
id: shas
|
||||||
|
env:
|
||||||
|
V: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
base="https://github.com/razvandimescu/numa/releases/download/v${V}"
|
||||||
|
for t in macos-aarch64 macos-x86_64 linux-aarch64 linux-x86_64; do
|
||||||
|
sha=$(curl -fsSL "${base}/numa-${t}.tar.gz.sha256" | awk '{print $1}')
|
||||||
|
if [ -z "$sha" ]; then
|
||||||
|
echo "ERROR: failed to fetch sha256 for $t" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
key=$(echo "$t" | tr '[:lower:]-' '[:upper:]_')
|
||||||
|
echo "SHA_${key}=${sha}" >> "$GITHUB_ENV"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Clone homebrew-tap
|
||||||
|
env:
|
||||||
|
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git clone "https://x-access-token:${HOMEBREW_TAP_GITHUB_TOKEN}@github.com/razvandimescu/homebrew-tap.git" tap
|
||||||
|
|
||||||
|
- name: Update formula
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
python3 scripts/update-homebrew-formula.py tap/numa.rb
|
||||||
|
echo "--- updated numa.rb ---"
|
||||||
|
cat tap/numa.rb
|
||||||
|
|
||||||
|
- name: Commit and push
|
||||||
|
working-directory: tap
|
||||||
|
env:
|
||||||
|
V: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
if git diff --quiet; then
|
||||||
|
echo "numa.rb already at v${V}, nothing to commit"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add numa.rb
|
||||||
|
git commit -m "chore: bump numa to v${V}"
|
||||||
|
git push origin main
|
||||||
159
.github/workflows/publish-aur.yml
vendored
Normal file
159
.github/workflows/publish-aur.yml
vendored
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# `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/<package-name>.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.
|
||||||
|
# --noprepare skips the prepare() function, which invokes cargo and would
|
||||||
|
# otherwise require a full rust toolchain in this metadata-only container.
|
||||||
|
# pkgver() runs before prepare(), so .SRCINFO still gets the correct version.
|
||||||
|
sudo -u builduser makepkg -od --noprepare && 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"
|
||||||
|
|
||||||
|
# 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
|
||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -103,6 +103,14 @@ jobs:
|
|||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
# Use a PAT (not the default GITHUB_TOKEN) so the resulting
|
||||||
|
# `release: published` event propagates to downstream workflows
|
||||||
|
# like homebrew-bump.yml. Events triggered by GITHUB_TOKEN are
|
||||||
|
# deliberately not propagated by GitHub Actions to prevent
|
||||||
|
# infinite loops; PAT-authored events are the documented escape
|
||||||
|
# hatch. Reusing HOMEBREW_TAP_GITHUB_TOKEN (already a PAT used
|
||||||
|
# by homebrew-bump.yml itself) keeps the secret surface flat.
|
||||||
|
token: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: |
|
files: |
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
/target
|
/target
|
||||||
|
/build-dir
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
docs/
|
docs/
|
||||||
site/blog/posts/
|
site/blog/posts/
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1143,7 +1143,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.10.0"
|
version = "0.10.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.10.0"
|
version = "0.10.2"
|
||||||
authors = ["razvandimescu <razvan@dimescu.com>"]
|
authors = ["razvandimescu <razvan@dimescu.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
||||||
|
|||||||
62
PKGBUILD
Normal file
62
PKGBUILD
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Maintainer: razvandimescu <razvan@dimescu.com>
|
||||||
|
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"
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ brew install razvandimescu/tap/numa
|
|||||||
# Linux
|
# Linux
|
||||||
curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
|
curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
|
||||||
|
|
||||||
|
# Arch Linux (AUR)
|
||||||
|
yay -S numa-git
|
||||||
|
|
||||||
# Windows — download from GitHub Releases
|
# Windows — download from GitHub Releases
|
||||||
# All platforms
|
# All platforms
|
||||||
cargo install numa
|
cargo install numa
|
||||||
@@ -69,7 +72,7 @@ DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification,
|
|||||||
|
|
||||||
**DNS-over-TLS listener** (RFC 7858) — accept encrypted queries on port 853 from strict clients like iOS Private DNS, systemd-resolved, or stubby. Two modes:
|
**DNS-over-TLS listener** (RFC 7858) — accept encrypted queries on port 853 from strict clients like iOS Private DNS, systemd-resolved, or stubby. Two modes:
|
||||||
|
|
||||||
- **Self-signed** (default) — numa generates a local CA automatically. Works on any network with zero DNS setup, but clients must manually trust the CA (on macOS/Linux add to the system trust store; on iOS install a `.mobileconfig`).
|
- **Self-signed** (default) — numa generates a local CA automatically. `numa install` adds it to the system trust store on macOS, Linux (Debian/Ubuntu, Fedora/RHEL/SUSE, Arch), and Windows. On iOS, install the `.mobileconfig` from `numa setup-phone`. Firefox keeps its own NSS store and ignores the system one — trust the CA there manually if you need HTTPS for `.numa` services in Firefox.
|
||||||
- **Bring-your-own cert** — point `[dot] cert_path` / `key_path` at a publicly-trusted cert (e.g., Let's Encrypt via DNS-01 challenge on a domain pointing at your numa instance). Clients connect without any trust-store setup — same UX as AdGuard Home or Cloudflare `1.1.1.1`.
|
- **Bring-your-own cert** — point `[dot] cert_path` / `key_path` at a publicly-trusted cert (e.g., Let's Encrypt via DNS-01 challenge on a domain pointing at your numa instance). Clients connect without any trust-store setup — same UX as AdGuard Home or Cloudflare `1.1.1.1`.
|
||||||
|
|
||||||
ALPN `"dot"` is advertised and enforced in both modes; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense.
|
ALPN `"dot"` is advertised and enforced in both modes; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense.
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
bind_addr = "0.0.0.0:53"
|
bind_addr = "0.0.0.0:53"
|
||||||
api_port = 5380
|
api_port = 5380
|
||||||
# api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access
|
# api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access
|
||||||
# data_dir = "/usr/local/var/numa" # where numa stores TLS CA and cert material
|
# data_dir = "/var/lib/numa" # where numa stores TLS CA and cert material
|
||||||
# (default: /usr/local/var/numa on unix,
|
# Defaults: /var/lib/numa on linux (FHS),
|
||||||
# %PROGRAMDATA%\numa on windows). Override for
|
# /usr/local/var/numa on macos (homebrew prefix),
|
||||||
|
# %PROGRAMDATA%\numa on windows. Override for
|
||||||
# containerized deploys or tests that can't
|
# containerized deploys or tests that can't
|
||||||
# write to the system path.
|
# write to the system path.
|
||||||
|
|
||||||
|
|||||||
57
scripts/update-homebrew-formula.py
Executable file
57
scripts/update-homebrew-formula.py
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Rewrite a Homebrew formula in place: bump version, URL paths, and sha256 lines.
|
||||||
|
|
||||||
|
Reads the formula path from argv[1], and the following env vars:
|
||||||
|
VERSION e.g. "0.10.0" (no leading v)
|
||||||
|
SHA_MACOS_AARCH64
|
||||||
|
SHA_MACOS_X86_64
|
||||||
|
SHA_LINUX_AARCH64
|
||||||
|
SHA_LINUX_X86_64
|
||||||
|
|
||||||
|
Assumptions about the formula:
|
||||||
|
- Has `version "X.Y.Z"` somewhere
|
||||||
|
- Has `url "...releases/download/vX.Y.Z/numa-<target>.tar.gz"` lines
|
||||||
|
- May or may not already have `sha256 "..."` lines immediately after each url
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
formula_path = sys.argv[1]
|
||||||
|
version = os.environ["VERSION"].lstrip("v")
|
||||||
|
shas = {
|
||||||
|
"macos-aarch64": os.environ["SHA_MACOS_AARCH64"],
|
||||||
|
"macos-x86_64": os.environ["SHA_MACOS_X86_64"],
|
||||||
|
"linux-aarch64": os.environ["SHA_LINUX_AARCH64"],
|
||||||
|
"linux-x86_64": os.environ["SHA_LINUX_X86_64"],
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(formula_path) as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
content = re.sub(r'version "[^"]*"', f'version "{version}"', content)
|
||||||
|
content = re.sub(
|
||||||
|
r"releases/download/v[\d.]+/numa-",
|
||||||
|
f"releases/download/v{version}/numa-",
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
content = re.sub(r'\n[ \t]*sha256 "[^"]*"', "", content)
|
||||||
|
|
||||||
|
|
||||||
|
def add_sha(match: re.Match) -> str:
|
||||||
|
indent = match.group(1)
|
||||||
|
target = match.group(2)
|
||||||
|
if target not in shas:
|
||||||
|
return match.group(0)
|
||||||
|
return f'{match.group(0)}\n{indent}sha256 "{shas[target]}"'
|
||||||
|
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'^([ \t]+)url "[^"]*numa-([\w-]+)\.tar\.gz"',
|
||||||
|
add_sha,
|
||||||
|
content,
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(formula_path, "w") as f:
|
||||||
|
f.write(content)
|
||||||
@@ -906,7 +906,7 @@ async fn remove_route(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
|
async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
|
||||||
let ca_path = ctx.data_dir.join("ca.pem");
|
let ca_path = ctx.data_dir.join(crate::tls::CA_FILE_NAME);
|
||||||
let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path))
|
let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
|||||||
67
src/lib.rs
67
src/lib.rs
@@ -26,7 +26,10 @@ pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
|||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
/// Shared config directory for persistent data (services.json, etc).
|
/// Shared config directory for persistent data (services.json, etc).
|
||||||
/// Unix: ~/.config/numa/ (or /usr/local/var/numa/ when running as root daemon)
|
/// Unix users: ~/.config/numa/
|
||||||
|
/// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa
|
||||||
|
/// if a pre-v0.10.1 install already lives there.
|
||||||
|
/// macOS root daemon: /usr/local/var/numa (Homebrew prefix)
|
||||||
/// Windows: %APPDATA%\numa
|
/// Windows: %APPDATA%\numa
|
||||||
pub fn config_dir() -> std::path::PathBuf {
|
pub fn config_dir() -> std::path::PathBuf {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -63,13 +66,15 @@ fn config_dir_unix() -> std::path::PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Running as root daemon (launchd/systemd) — use system-wide path
|
// Running as root daemon (launchd/systemd) — use system-wide path
|
||||||
std::path::PathBuf::from("/usr/local/var/numa")
|
daemon_data_dir()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default system-wide data directory for TLS certs. Overridable via
|
/// Default system-wide data directory for TLS certs. Overridable via
|
||||||
/// `[server] data_dir = "..."` in numa.toml — this function only provides
|
/// `[server] data_dir = "..."` in numa.toml — this function only provides
|
||||||
/// the fallback when the config doesn't set it.
|
/// the fallback when the config doesn't set it.
|
||||||
/// Unix: /usr/local/var/numa
|
/// Linux: /var/lib/numa (FHS) — falls back to /usr/local/var/numa if a
|
||||||
|
/// pre-v0.10.1 install already has data there.
|
||||||
|
/// macOS: /usr/local/var/numa (Homebrew prefix)
|
||||||
/// Windows: %PROGRAMDATA%\numa
|
/// Windows: %PROGRAMDATA%\numa
|
||||||
pub fn data_dir() -> std::path::PathBuf {
|
pub fn data_dir() -> std::path::PathBuf {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -81,6 +86,62 @@ pub fn data_dir() -> std::path::PathBuf {
|
|||||||
}
|
}
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
|
daemon_data_dir()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the system-wide data directory for the running platform.
|
||||||
|
/// Honors backwards compatibility with pre-v0.10.1 installs that still
|
||||||
|
/// have their CA cert + services.json under `/usr/local/var/numa`.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn daemon_data_dir() -> std::path::PathBuf {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
std::path::PathBuf::from(resolve_linux_data_dir(
|
||||||
|
std::path::Path::new("/usr/local/var/numa").exists(),
|
||||||
|
std::path::Path::new("/var/lib/numa").exists(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
// macOS uses the Homebrew prefix convention; no FHS migration needed.
|
||||||
std::path::PathBuf::from("/usr/local/var/numa")
|
std::path::PathBuf::from("/usr/local/var/numa")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extracted as a pure function so the migration logic is unit-testable
|
||||||
|
/// without touching the real filesystem.
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
fn resolve_linux_data_dir(legacy_exists: bool, fhs_exists: bool) -> &'static str {
|
||||||
|
if legacy_exists && !fhs_exists {
|
||||||
|
"/usr/local/var/numa"
|
||||||
|
} else {
|
||||||
|
"/var/lib/numa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linux_data_dir_fresh_install_uses_fhs() {
|
||||||
|
assert_eq!(resolve_linux_data_dir(false, false), "/var/lib/numa");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linux_data_dir_upgrading_install_keeps_legacy() {
|
||||||
|
// Migration must keep legacy so the user doesn't lose their CA on upgrade.
|
||||||
|
assert_eq!(resolve_linux_data_dir(true, false), "/usr/local/var/numa");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linux_data_dir_after_migration_uses_fhs() {
|
||||||
|
assert_eq!(resolve_linux_data_dir(true, true), "/var/lib/numa");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linux_data_dir_only_fhs_uses_fhs() {
|
||||||
|
assert_eq!(resolve_linux_data_dir(false, true), "/var/lib/numa");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
19
src/main.rs
19
src/main.rs
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,8 +235,21 @@ async fn main() -> numa::Result<()> {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let socket = match UdpSocket::bind(&config.server.bind_addr).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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,60 @@ pub fn discover_system_dns() -> SystemDnsInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String> {
|
||||||
|
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";
|
||||||
|
Some(format!(
|
||||||
|
"
|
||||||
|
{o}Numa{r} — cannot bind to {bind_addr}: {title}.
|
||||||
|
|
||||||
|
{cause}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
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")]
|
||||||
fn discover_macos() -> SystemDnsInfo {
|
fn discover_macos() -> SystemDnsInfo {
|
||||||
use log::{debug, warn};
|
use log::{debug, warn};
|
||||||
@@ -174,6 +228,9 @@ fn discover_linux() -> SystemDnsInfo {
|
|||||||
let default_upstream = if let Some(ns) = upstream {
|
let default_upstream = if let Some(ns) = upstream {
|
||||||
info!("detected system upstream: {}", ns);
|
info!("detected system upstream: {}", ns);
|
||||||
Some(ns)
|
Some(ns)
|
||||||
|
} else if let Some(ns) = resolvectl_dns_server() {
|
||||||
|
info!("detected system upstream via resolvectl: {}", ns);
|
||||||
|
Some(ns)
|
||||||
} else {
|
} else {
|
||||||
// Fallback to backup from a previous `numa install`
|
// Fallback to backup from a previous `numa install`
|
||||||
let backup = {
|
let backup = {
|
||||||
@@ -214,7 +271,18 @@ fn discover_linux() -> SystemDnsInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse resolv.conf in a single pass, extracting both the first non-loopback
|
/// Yield each `nameserver` address from resolv.conf content. No filtering —
|
||||||
|
/// callers decide what counts as a real upstream.
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
fn iter_nameservers(content: &str) -> impl Iterator<Item = &str> {
|
||||||
|
content.lines().filter_map(|line| {
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
(parts.next() == Some("nameserver")).then_some(())?;
|
||||||
|
parts.next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse resolv.conf in a single pass, extracting the first non-loopback
|
||||||
/// nameserver and all search domains.
|
/// nameserver and all search domains.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
||||||
@@ -222,19 +290,13 @@ fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
|||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(_) => return (None, Vec::new()),
|
Err(_) => return (None, Vec::new()),
|
||||||
};
|
};
|
||||||
let mut upstream = None;
|
let upstream = iter_nameservers(&text)
|
||||||
|
.find(|ns| !is_loopback_or_stub(ns))
|
||||||
|
.map(str::to_string);
|
||||||
let mut search_domains = Vec::new();
|
let mut search_domains = Vec::new();
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.starts_with("nameserver") {
|
if line.starts_with("search") || line.starts_with("domain") {
|
||||||
if upstream.is_none() {
|
|
||||||
if let Some(ns) = line.split_whitespace().nth(1) {
|
|
||||||
if !is_loopback_or_stub(ns) {
|
|
||||||
upstream = Some(ns.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if line.starts_with("search") || line.starts_with("domain") {
|
|
||||||
for domain in line.split_whitespace().skip(1) {
|
for domain in line.split_whitespace().skip(1) {
|
||||||
search_domains.push(domain.to_string());
|
search_domains.push(domain.to_string());
|
||||||
}
|
}
|
||||||
@@ -243,6 +305,21 @@ fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
|||||||
(upstream, search_domains)
|
(upstream, search_domains)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if the resolv.conf *content* appears to be written by numa itself,
|
||||||
|
/// or has no real upstream — either way, it's not a safe source of truth
|
||||||
|
/// for a backup.
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
fn resolv_conf_is_numa_managed(content: &str) -> bool {
|
||||||
|
content.contains("Generated by Numa") || !resolv_conf_has_real_upstream(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the resolv.conf content has at least one non-loopback, non-stub
|
||||||
|
/// nameserver. An all-loopback resolv.conf is self-referential.
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
fn resolv_conf_has_real_upstream(content: &str) -> bool {
|
||||||
|
iter_nameservers(content).any(|ns| !is_loopback_or_stub(ns))
|
||||||
|
}
|
||||||
|
|
||||||
/// Query resolvectl for the real upstream DNS server (e.g. VPC resolver on AWS).
|
/// Query resolvectl for the real upstream DNS server (e.g. VPC resolver on AWS).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn resolvectl_dns_server() -> Option<String> {
|
fn resolvectl_dns_server() -> Option<String> {
|
||||||
@@ -526,9 +603,19 @@ fn enable_dnscache() {
|
|||||||
.status();
|
.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if the backup map has at least one real upstream (non-loopback, non-stub).
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn backup_has_real_upstream_windows(
|
||||||
|
interfaces: &std::collections::HashMap<String, WindowsInterfaceDns>,
|
||||||
|
) -> bool {
|
||||||
|
interfaces
|
||||||
|
.values()
|
||||||
|
.any(|iface| iface.servers.iter().any(|s| !is_loopback_or_stub(s)))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn install_windows() -> Result<(), String> {
|
fn install_windows() -> Result<(), String> {
|
||||||
let interfaces = get_windows_interfaces()?;
|
let mut interfaces = get_windows_interfaces()?;
|
||||||
if interfaces.is_empty() {
|
if interfaces.is_empty() {
|
||||||
return Err("no active network interfaces found".to_string());
|
return Err("no active network interfaces found".to_string());
|
||||||
}
|
}
|
||||||
@@ -538,9 +625,30 @@ fn install_windows() -> Result<(), String> {
|
|||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve an existing useful backup rather than overwriting it with
|
||||||
|
// numa-managed state (which would be self-referential after uninstall).
|
||||||
|
let existing: Option<std::collections::HashMap<String, WindowsInterfaceDns>> =
|
||||||
|
std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|json| serde_json::from_str(&json).ok());
|
||||||
|
let has_useful_existing = existing
|
||||||
|
.as_ref()
|
||||||
|
.map(backup_has_real_upstream_windows)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if has_useful_existing {
|
||||||
|
eprintln!(" Existing DNS backup preserved at {}", path.display());
|
||||||
|
} else {
|
||||||
|
// Filter loopback/stub addresses before saving so a fresh backup
|
||||||
|
// captured from already-numa-managed state isn't self-referential.
|
||||||
|
for iface in interfaces.values_mut() {
|
||||||
|
iface.servers.retain(|s| !is_loopback_or_stub(s));
|
||||||
|
}
|
||||||
let json = serde_json::to_string_pretty(&interfaces)
|
let json = serde_json::to_string_pretty(&interfaces)
|
||||||
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
||||||
std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?;
|
std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
for name in interfaces.keys() {
|
for name in interfaces.keys() {
|
||||||
let status = std::process::Command::new("netsh")
|
let status = std::process::Command::new("netsh")
|
||||||
@@ -570,7 +678,10 @@ fn install_windows() -> Result<(), String> {
|
|||||||
let needs_reboot = disable_dnscache()?;
|
let needs_reboot = disable_dnscache()?;
|
||||||
register_autostart();
|
register_autostart();
|
||||||
|
|
||||||
eprintln!("\n Original DNS saved to {}", path.display());
|
eprintln!();
|
||||||
|
if !has_useful_existing {
|
||||||
|
eprintln!(" Original DNS saved to {}", path.display());
|
||||||
|
}
|
||||||
eprintln!(" Run 'numa uninstall' to restore.\n");
|
eprintln!(" Run 'numa uninstall' to restore.\n");
|
||||||
if needs_reboot {
|
if needs_reboot {
|
||||||
eprintln!(" *** Reboot required. Numa will start automatically. ***\n");
|
eprintln!(" *** Reboot required. Numa will start automatically. ***\n");
|
||||||
@@ -754,27 +865,60 @@ fn get_dns_servers(service: &str) -> Result<Vec<String>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if the backup map has at least one real upstream (non-loopback, non-stub).
|
||||||
|
/// An all-loopback backup is self-referential — restoring it is a no-op.
|
||||||
|
#[cfg(any(target_os = "macos", test))]
|
||||||
|
fn backup_has_real_upstream_macos(
|
||||||
|
servers: &std::collections::HashMap<String, Vec<String>>,
|
||||||
|
) -> bool {
|
||||||
|
servers
|
||||||
|
.values()
|
||||||
|
.any(|list| list.iter().any(|s| !is_loopback_or_stub(s)))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn install_macos() -> Result<(), String> {
|
fn install_macos() -> Result<(), String> {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
let services = get_network_services()?;
|
let services = get_network_services()?;
|
||||||
let mut original: HashMap<String, Vec<String>> = HashMap::new();
|
|
||||||
|
|
||||||
// Save current DNS for each service
|
|
||||||
for service in &services {
|
|
||||||
let servers = get_dns_servers(service)?;
|
|
||||||
original.insert(service.clone(), servers);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save backup
|
|
||||||
let dir = numa_data_dir();
|
let dir = numa_data_dir();
|
||||||
std::fs::create_dir_all(&dir)
|
std::fs::create_dir_all(&dir)
|
||||||
.map_err(|e| format!("failed to create {}: {}", dir.display(), e))?;
|
.map_err(|e| format!("failed to create {}: {}", dir.display(), e))?;
|
||||||
|
|
||||||
|
// If a useful backup already exists (at least one non-loopback upstream),
|
||||||
|
// preserve it — overwriting would destroy the original DNS state when
|
||||||
|
// re-installing on top of a numa-managed configuration.
|
||||||
|
let existing_backup: Option<HashMap<String, Vec<String>>> =
|
||||||
|
std::fs::read_to_string(backup_path())
|
||||||
|
.ok()
|
||||||
|
.and_then(|json| serde_json::from_str(&json).ok());
|
||||||
|
let has_useful_existing = existing_backup
|
||||||
|
.as_ref()
|
||||||
|
.map(backup_has_real_upstream_macos)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if has_useful_existing {
|
||||||
|
eprintln!(
|
||||||
|
" Existing DNS backup preserved at {}",
|
||||||
|
backup_path().display()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Capture fresh, filtering out loopback and stub addresses so we
|
||||||
|
// never record a self-referential backup.
|
||||||
|
let mut original: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
for service in &services {
|
||||||
|
let servers: Vec<String> = get_dns_servers(service)?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| !is_loopback_or_stub(s))
|
||||||
|
.collect();
|
||||||
|
original.insert(service.clone(), servers);
|
||||||
|
}
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&original)
|
let json = serde_json::to_string_pretty(&original)
|
||||||
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
||||||
std::fs::write(backup_path(), json).map_err(|e| format!("failed to write backup: {}", e))?;
|
std::fs::write(backup_path(), json)
|
||||||
|
.map_err(|e| format!("failed to write backup: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
// Set DNS to 127.0.0.1 and add "numa" search domain for each service
|
// Set DNS to 127.0.0.1 and add "numa" search domain for each service
|
||||||
for service in &services {
|
for service in &services {
|
||||||
@@ -795,7 +939,10 @@ fn install_macos() -> Result<(), String> {
|
|||||||
.status();
|
.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("\n Original DNS saved to {}", backup_path().display());
|
eprintln!();
|
||||||
|
if !has_useful_existing {
|
||||||
|
eprintln!(" Original DNS saved to {}", backup_path().display());
|
||||||
|
}
|
||||||
eprintln!(" Run 'sudo numa uninstall' to restore.\n");
|
eprintln!(" Run 'sudo numa uninstall' to restore.\n");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -990,14 +1137,23 @@ fn install_service_macos() -> Result<(), String> {
|
|||||||
std::fs::write(PLIST_DEST, plist)
|
std::fs::write(PLIST_DEST, plist)
|
||||||
.map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?;
|
.map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?;
|
||||||
|
|
||||||
// Load the service first so numa is listening before DNS redirect
|
// Modern launchctl API: explicitly tear down any existing in-memory
|
||||||
|
// state, then bootstrap fresh from the on-disk plist. The deprecated
|
||||||
|
// `load -w` returns exit 0 even when it cannot actually reload (label
|
||||||
|
// already in launchd state), silently leaving the daemon running a
|
||||||
|
// stale binary path after `numa install` rewrites the plist on disk —
|
||||||
|
// which is exactly what `brew upgrade numa` does.
|
||||||
|
let _ = std::process::Command::new("launchctl")
|
||||||
|
.args(["bootout", "system", PLIST_DEST])
|
||||||
|
.status();
|
||||||
|
|
||||||
let status = std::process::Command::new("launchctl")
|
let status = std::process::Command::new("launchctl")
|
||||||
.args(["load", "-w", PLIST_DEST])
|
.args(["bootstrap", "system", PLIST_DEST])
|
||||||
.status()
|
.status()
|
||||||
.map_err(|e| format!("failed to run launchctl: {}", e))?;
|
.map_err(|e| format!("failed to run launchctl: {}", e))?;
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err("launchctl load failed".to_string());
|
return Err("launchctl bootstrap failed".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for numa to be ready before redirecting DNS
|
// Wait for numa to be ready before redirecting DNS
|
||||||
@@ -1010,7 +1166,7 @@ fn install_service_macos() -> Result<(), String> {
|
|||||||
if !api_up {
|
if !api_up {
|
||||||
// Service failed to start — don't redirect DNS to a dead endpoint
|
// Service failed to start — don't redirect DNS to a dead endpoint
|
||||||
let _ = std::process::Command::new("launchctl")
|
let _ = std::process::Command::new("launchctl")
|
||||||
.args(["unload", PLIST_DEST])
|
.args(["bootout", "system", PLIST_DEST])
|
||||||
.status();
|
.status();
|
||||||
return Err(
|
return Err(
|
||||||
"numa service did not start (port 53 may be in use). Service unloaded.".to_string(),
|
"numa service did not start (port 53 may be in use). Service unloaded.".to_string(),
|
||||||
@@ -1038,22 +1194,25 @@ fn uninstall_service_macos() -> Result<(), String> {
|
|||||||
eprintln!(" warning: failed to restore system DNS: {}", e);
|
eprintln!(" warning: failed to restore system DNS: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove plist first so service won't restart on boot even if unload fails
|
// Bootout the service from launchd's in-memory state BEFORE removing
|
||||||
if let Err(e) = std::fs::remove_file(PLIST_DEST) {
|
// the plist. The modern API needs the file path as the specifier;
|
||||||
if e.kind() != std::io::ErrorKind::NotFound {
|
// doing this in the wrong order would leave the service loaded in
|
||||||
return Err(format!("failed to remove {}: {}", PLIST_DEST, e));
|
// memory until reboot. (Deprecated `unload -w` had the same issue.)
|
||||||
|
let bootout_status = std::process::Command::new("launchctl")
|
||||||
|
.args(["bootout", "system", PLIST_DEST])
|
||||||
|
.status();
|
||||||
|
if let Ok(s) = bootout_status {
|
||||||
|
if !s.success() {
|
||||||
|
eprintln!(
|
||||||
|
" warning: launchctl bootout returned non-zero (service may not have been loaded)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unload the service
|
// Remove plist so the service won't restart on boot
|
||||||
let status = std::process::Command::new("launchctl")
|
if let Err(e) = std::fs::remove_file(PLIST_DEST) {
|
||||||
.args(["unload", "-w", PLIST_DEST])
|
if e.kind() != std::io::ErrorKind::NotFound {
|
||||||
.status();
|
return Err(format!("failed to remove {}: {}", PLIST_DEST, e));
|
||||||
if let Ok(s) = status {
|
|
||||||
if !s.success() {
|
|
||||||
eprintln!(
|
|
||||||
" warning: launchctl unload returned non-zero (service may still be running)"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1132,11 +1291,31 @@ fn install_linux() -> Result<(), String> {
|
|||||||
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back up current resolv.conf (ignore NotFound)
|
// Back up current resolv.conf, but never overwrite a useful existing
|
||||||
match std::fs::copy(resolv, &backup) {
|
// backup with a numa-managed file — that would leave uninstall with
|
||||||
Ok(_) => eprintln!(" Saved /etc/resolv.conf to {}", backup.display()),
|
// nothing to restore to.
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
let current = std::fs::read_to_string(resolv).ok();
|
||||||
Err(e) => return Err(format!("failed to backup /etc/resolv.conf: {}", e)),
|
let current_is_numa_managed = current
|
||||||
|
.as_deref()
|
||||||
|
.map(resolv_conf_is_numa_managed)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let existing_backup_is_useful = std::fs::read_to_string(&backup)
|
||||||
|
.ok()
|
||||||
|
.as_deref()
|
||||||
|
.map(resolv_conf_has_real_upstream)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if existing_backup_is_useful {
|
||||||
|
eprintln!(
|
||||||
|
" Existing resolv.conf backup preserved at {}",
|
||||||
|
backup.display()
|
||||||
|
);
|
||||||
|
} else if current_is_numa_managed {
|
||||||
|
eprintln!(" warning: /etc/resolv.conf is already numa-managed; no fresh backup written");
|
||||||
|
} else if let Some(content) = current.as_deref() {
|
||||||
|
std::fs::write(&backup, content)
|
||||||
|
.map_err(|e| format!("failed to backup /etc/resolv.conf: {}", e))?;
|
||||||
|
eprintln!(" Saved /etc/resolv.conf to {}", backup.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
if resolv
|
if resolv
|
||||||
@@ -1278,14 +1457,86 @@ fn run_systemctl(args: &[&str]) -> Result<(), String> {
|
|||||||
|
|
||||||
// --- CA trust management ---
|
// --- CA trust management ---
|
||||||
|
|
||||||
|
/// One Linux trust-store backend (Debian, Fedora pki, Arch p11-kit).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
struct LinuxTrustStore {
|
||||||
|
name: &'static str,
|
||||||
|
anchor_dir: &'static str,
|
||||||
|
anchor_file: &'static str,
|
||||||
|
refresh_install: &'static [&'static str],
|
||||||
|
refresh_uninstall: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
// If you change this table, update tests/docker/install-trust.sh to match —
|
||||||
|
// it asserts the same paths/commands against real distro images.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
const LINUX_TRUST_STORES: &[LinuxTrustStore] = &[
|
||||||
|
// Debian / Ubuntu / Mint
|
||||||
|
LinuxTrustStore {
|
||||||
|
name: "debian",
|
||||||
|
anchor_dir: "/usr/local/share/ca-certificates",
|
||||||
|
anchor_file: "numa-local-ca.crt",
|
||||||
|
refresh_install: &["update-ca-certificates"],
|
||||||
|
refresh_uninstall: &["update-ca-certificates", "--fresh"],
|
||||||
|
},
|
||||||
|
// Fedora / RHEL / CentOS / SUSE (p11-kit via update-ca-trust wrapper)
|
||||||
|
LinuxTrustStore {
|
||||||
|
name: "pki",
|
||||||
|
anchor_dir: "/etc/pki/ca-trust/source/anchors",
|
||||||
|
anchor_file: "numa-local-ca.pem",
|
||||||
|
refresh_install: &["update-ca-trust", "extract"],
|
||||||
|
refresh_uninstall: &["update-ca-trust", "extract"],
|
||||||
|
},
|
||||||
|
// Arch / Manjaro (raw p11-kit)
|
||||||
|
LinuxTrustStore {
|
||||||
|
name: "p11kit",
|
||||||
|
anchor_dir: "/etc/ca-certificates/trust-source/anchors",
|
||||||
|
anchor_file: "numa-local-ca.pem",
|
||||||
|
refresh_install: &["trust", "extract-compat"],
|
||||||
|
refresh_uninstall: &["trust", "extract-compat"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn detect_linux_trust_store() -> Option<&'static LinuxTrustStore> {
|
||||||
|
LINUX_TRUST_STORES
|
||||||
|
.iter()
|
||||||
|
.find(|s| std::path::Path::new(s.anchor_dir).is_dir())
|
||||||
|
}
|
||||||
|
|
||||||
fn trust_ca() -> Result<(), String> {
|
fn trust_ca() -> Result<(), String> {
|
||||||
let ca_path = crate::data_dir().join("ca.pem");
|
let ca_path = crate::data_dir().join(crate::tls::CA_FILE_NAME);
|
||||||
if !ca_path.exists() {
|
if !ca_path.exists() {
|
||||||
return Err("CA not generated yet — start numa first to create certificates".into());
|
return Err("CA not generated yet — start numa first to create certificates".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
let result = trust_ca_macos(&ca_path);
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let result = trust_ca_linux(&ca_path);
|
||||||
|
#[cfg(windows)]
|
||||||
|
let result = trust_ca_windows(&ca_path);
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||||
|
let result = Err::<(), String>("CA trust not supported on this OS".to_string());
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn untrust_ca() -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let result = untrust_ca_macos();
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let result = untrust_ca_linux();
|
||||||
|
#[cfg(windows)]
|
||||||
|
let result = untrust_ca_windows();
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||||
|
let result = Ok::<(), String>(());
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn trust_ca_macos(ca_path: &std::path::Path) -> Result<(), String> {
|
||||||
let status = std::process::Command::new("security")
|
let status = std::process::Command::new("security")
|
||||||
.args([
|
.args([
|
||||||
"add-trusted-cert",
|
"add-trusted-cert",
|
||||||
@@ -1295,48 +1546,23 @@ fn trust_ca() -> Result<(), String> {
|
|||||||
"-k",
|
"-k",
|
||||||
"/Library/Keychains/System.keychain",
|
"/Library/Keychains/System.keychain",
|
||||||
])
|
])
|
||||||
.arg(&ca_path)
|
.arg(ca_path)
|
||||||
.status()
|
.status()
|
||||||
.map_err(|e| format!("security: {}", e))?;
|
.map_err(|e| format!("security: {}", e))?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err("security add-trusted-cert failed".into());
|
return Err("security add-trusted-cert failed".into());
|
||||||
}
|
}
|
||||||
eprintln!(" Trusted Numa CA in system keychain");
|
eprintln!(" Trusted Numa CA in system keychain");
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt");
|
|
||||||
std::fs::copy(&ca_path, dest).map_err(|e| format!("copy CA: {}", e))?;
|
|
||||||
let status = std::process::Command::new("update-ca-certificates")
|
|
||||||
.status()
|
|
||||||
.map_err(|e| format!("update-ca-certificates: {}", e))?;
|
|
||||||
if !status.success() {
|
|
||||||
return Err("update-ca-certificates failed".into());
|
|
||||||
}
|
|
||||||
eprintln!(" Trusted Numa CA system-wide");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
|
||||||
{
|
|
||||||
Err("CA trust not supported on this OS".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn untrust_ca() -> Result<(), String> {
|
|
||||||
let ca_path = crate::data_dir().join("ca.pem");
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
fn untrust_ca_macos() -> Result<(), String> {
|
||||||
// Find all Numa CA certs by hash and delete each one
|
|
||||||
if let Ok(out) = std::process::Command::new("security")
|
if let Ok(out) = std::process::Command::new("security")
|
||||||
.args([
|
.args([
|
||||||
"find-certificate",
|
"find-certificate",
|
||||||
"-c",
|
"-c",
|
||||||
"Numa Local CA",
|
crate::tls::CA_COMMON_NAME,
|
||||||
"-a",
|
"-a",
|
||||||
"-Z",
|
"-Z",
|
||||||
"/Library/Keychains/System.keychain",
|
"/Library/Keychains/System.keychain",
|
||||||
@@ -1359,21 +1585,81 @@ fn untrust_ca() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
eprintln!(" Removed Numa CA from system keychain");
|
eprintln!(" Removed Numa CA from system keychain");
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
fn trust_ca_linux(ca_path: &std::path::Path) -> Result<(), String> {
|
||||||
let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt");
|
let store = detect_linux_trust_store().ok_or_else(|| {
|
||||||
if dest.exists() {
|
let names: Vec<&str> = LINUX_TRUST_STORES.iter().map(|s| s.name).collect();
|
||||||
let _ = std::fs::remove_file(dest);
|
format!(
|
||||||
let _ = std::process::Command::new("update-ca-certificates")
|
"no supported CA trust store found (tried: {}). \
|
||||||
.arg("--fresh")
|
Please report at https://github.com/razvandimescu/numa/issues",
|
||||||
.status();
|
names.join(", ")
|
||||||
eprintln!(" Removed Numa CA from system trust store");
|
)
|
||||||
}
|
})?;
|
||||||
|
|
||||||
|
let dest = std::path::Path::new(store.anchor_dir).join(store.anchor_file);
|
||||||
|
std::fs::copy(ca_path, &dest).map_err(|e| format!("copy CA to {}: {}", dest.display(), e))?;
|
||||||
|
|
||||||
|
run_refresh(store.name, store.refresh_install)?;
|
||||||
|
eprintln!(" Trusted Numa CA system-wide ({})", store.name);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = ca_path; // suppress unused warning on other platforms
|
#[cfg(target_os = "linux")]
|
||||||
|
fn untrust_ca_linux() -> Result<(), String> {
|
||||||
|
let Some(store) = detect_linux_trust_store() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let dest = std::path::Path::new(store.anchor_dir).join(store.anchor_file);
|
||||||
|
match std::fs::remove_file(&dest) {
|
||||||
|
Ok(()) => {
|
||||||
|
let _ = run_refresh(store.name, store.refresh_uninstall);
|
||||||
|
eprintln!(" Removed Numa CA from system trust store ({})", store.name);
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(_) => {} // best-effort uninstall
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn run_refresh(store_name: &str, argv: &[&str]) -> Result<(), String> {
|
||||||
|
let (cmd, args) = argv
|
||||||
|
.split_first()
|
||||||
|
.expect("refresh command must be non-empty");
|
||||||
|
let status = std::process::Command::new(cmd)
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("{} ({}): {}", cmd, store_name, e))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!("{} ({}) failed", cmd, store_name));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn trust_ca_windows(ca_path: &std::path::Path) -> Result<(), String> {
|
||||||
|
let status = std::process::Command::new("certutil")
|
||||||
|
.args(["-addstore", "-f", "Root"])
|
||||||
|
.arg(ca_path)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("certutil: {}", e))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err("certutil -addstore Root failed (run as Administrator?)".into());
|
||||||
|
}
|
||||||
|
eprintln!(" Trusted Numa CA in Windows Root store");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn untrust_ca_windows() -> Result<(), String> {
|
||||||
|
let _ = std::process::Command::new("certutil")
|
||||||
|
.args(["-delstore", "Root", crate::tls::CA_COMMON_NAME])
|
||||||
|
.status();
|
||||||
|
eprintln!(" Removed Numa CA from Windows Root store");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1432,6 +1718,82 @@ Wireless LAN adapter Wi-Fi:
|
|||||||
assert!(!result.contains("{{exe_path}}"));
|
assert!(!result.contains("{{exe_path}}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn macos_backup_real_upstream_detection() {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
|
||||||
|
// Empty backup → no real upstream
|
||||||
|
assert!(!backup_has_real_upstream_macos(&map));
|
||||||
|
|
||||||
|
// All-loopback backup → still no real upstream (the bug case)
|
||||||
|
map.insert("Wi-Fi".into(), vec!["127.0.0.1".into()]);
|
||||||
|
map.insert("Ethernet".into(), vec!["::1".into()]);
|
||||||
|
assert!(!backup_has_real_upstream_macos(&map));
|
||||||
|
|
||||||
|
// One real entry → useful
|
||||||
|
map.insert("Tailscale".into(), vec!["192.168.1.1".into()]);
|
||||||
|
assert!(backup_has_real_upstream_macos(&map));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn windows_backup_filters_loopback() {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut map: HashMap<String, WindowsInterfaceDns> = HashMap::new();
|
||||||
|
|
||||||
|
// Empty backup → no real upstream
|
||||||
|
assert!(!backup_has_real_upstream_windows(&map));
|
||||||
|
|
||||||
|
// All-loopback backup → still no real upstream (the bug case)
|
||||||
|
map.insert(
|
||||||
|
"Wi-Fi".into(),
|
||||||
|
WindowsInterfaceDns {
|
||||||
|
dhcp: false,
|
||||||
|
servers: vec!["127.0.0.1".into()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"Ethernet".into(),
|
||||||
|
WindowsInterfaceDns {
|
||||||
|
dhcp: false,
|
||||||
|
servers: vec!["::1".into(), "0.0.0.0".into()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(!backup_has_real_upstream_windows(&map));
|
||||||
|
|
||||||
|
// One real entry alongside loopback → useful
|
||||||
|
map.insert(
|
||||||
|
"Ethernet 2".into(),
|
||||||
|
WindowsInterfaceDns {
|
||||||
|
dhcp: false,
|
||||||
|
servers: vec!["192.168.1.1".into()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(backup_has_real_upstream_windows(&map));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolv_conf_real_upstream_detection() {
|
||||||
|
let real = "nameserver 192.168.1.1\nsearch lan\n";
|
||||||
|
assert!(resolv_conf_has_real_upstream(real));
|
||||||
|
assert!(!resolv_conf_is_numa_managed(real));
|
||||||
|
|
||||||
|
let self_ref = "nameserver 127.0.0.1\nsearch numa\n";
|
||||||
|
assert!(!resolv_conf_has_real_upstream(self_ref));
|
||||||
|
assert!(resolv_conf_is_numa_managed(self_ref));
|
||||||
|
|
||||||
|
let numa_marker =
|
||||||
|
"# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\nsearch numa\n";
|
||||||
|
assert!(resolv_conf_is_numa_managed(numa_marker));
|
||||||
|
|
||||||
|
let systemd_stub = "nameserver 127.0.0.53\noptions edns0\n";
|
||||||
|
assert!(!resolv_conf_has_real_upstream(systemd_stub));
|
||||||
|
|
||||||
|
let mixed = "nameserver 127.0.0.1\nnameserver 1.1.1.1\n";
|
||||||
|
assert!(resolv_conf_has_real_upstream(mixed));
|
||||||
|
assert!(!resolv_conf_is_numa_managed(mixed));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_ipconfig_skips_disconnected() {
|
fn parse_ipconfig_skips_disconnected() {
|
||||||
let sample = "\
|
let sample = "\
|
||||||
@@ -1448,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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
75
src/tls.rs
75
src/tls.rs
@@ -13,6 +13,13 @@ use time::{Duration, OffsetDateTime};
|
|||||||
const CA_VALIDITY_DAYS: i64 = 3650; // 10 years
|
const CA_VALIDITY_DAYS: i64 = 3650; // 10 years
|
||||||
const CERT_VALIDITY_DAYS: i64 = 365; // 1 year
|
const CERT_VALIDITY_DAYS: i64 = 365; // 1 year
|
||||||
|
|
||||||
|
/// Common Name on Numa's local CA. Referenced by trust-store helpers
|
||||||
|
/// (`security`, `certutil`) when locating the cert for removal.
|
||||||
|
pub const CA_COMMON_NAME: &str = "Numa Local CA";
|
||||||
|
|
||||||
|
/// Filename of the CA certificate inside the data dir.
|
||||||
|
pub const CA_FILE_NAME: &str = "ca.pem";
|
||||||
|
|
||||||
/// Collect all service + LAN peer names and regenerate the TLS cert.
|
/// Collect all service + LAN peer names and regenerate the TLS cert.
|
||||||
pub fn regenerate_tls(ctx: &ServerCtx) {
|
pub fn regenerate_tls(ctx: &ServerCtx) {
|
||||||
let tls = match &ctx.tls_config {
|
let tls = match &ctx.tls_config {
|
||||||
@@ -33,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.
|
||||||
@@ -67,7 +108,7 @@ pub fn build_tls_config(
|
|||||||
|
|
||||||
fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
|
fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
|
||||||
let ca_key_path = dir.join("ca.key");
|
let ca_key_path = dir.join("ca.key");
|
||||||
let ca_cert_path = dir.join("ca.pem");
|
let ca_cert_path = dir.join(CA_FILE_NAME);
|
||||||
|
|
||||||
if ca_key_path.exists() && ca_cert_path.exists() {
|
if ca_key_path.exists() && ca_cert_path.exists() {
|
||||||
let key_pem = std::fs::read_to_string(&ca_key_path)?;
|
let key_pem = std::fs::read_to_string(&ca_key_path)?;
|
||||||
@@ -86,7 +127,7 @@ fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
|
|||||||
let mut params = CertificateParams::default();
|
let mut params = CertificateParams::default();
|
||||||
params
|
params
|
||||||
.distinguished_name
|
.distinguished_name
|
||||||
.push(DnType::CommonName, "Numa Local CA");
|
.push(DnType::CommonName, CA_COMMON_NAME);
|
||||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||||
params.not_before = OffsetDateTime::now_utc();
|
params.not_before = OffsetDateTime::now_utc();
|
||||||
@@ -163,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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
123
tests/docker/install-trust.sh
Executable file
123
tests/docker/install-trust.sh
Executable file
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Cross-distro CA trust contract test for issue #35.
|
||||||
|
#
|
||||||
|
# Runs the exact shell commands `src/system_dns.rs::trust_ca_linux` would run
|
||||||
|
# on each Linux trust-store family (Debian, Fedora pki, Arch p11-kit), and
|
||||||
|
# asserts the certificate ends up in (and is removed from) the system bundle.
|
||||||
|
#
|
||||||
|
# This is a contract test, not an integration test: it doesn't drive the Rust
|
||||||
|
# code (that would need systemd-in-container). It verifies the assumptions in
|
||||||
|
# `LINUX_TRUST_STORES` against the real distro behavior. If you change that
|
||||||
|
# table in src/system_dns.rs, update the per-distro cases below to match.
|
||||||
|
#
|
||||||
|
# Requirements: docker, openssl (host).
|
||||||
|
# Usage: ./tests/docker/install-trust.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
|
||||||
|
|
||||||
|
# Self-signed CA fixture, mounted into each container as ca.pem.
|
||||||
|
# basicConstraints=CA:TRUE is required — without it, Debian's
|
||||||
|
# update-ca-certificates silently skips the cert during bundle build.
|
||||||
|
FIXTURE_DIR=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$FIXTURE_DIR"' EXIT
|
||||||
|
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
|
||||||
|
-keyout "$FIXTURE_DIR/ca.key" \
|
||||||
|
-out "$FIXTURE_DIR/ca.pem" \
|
||||||
|
-subj "/CN=Numa Local CA Test $(date +%s)" \
|
||||||
|
-addext "basicConstraints=critical,CA:TRUE" \
|
||||||
|
-addext "keyUsage=critical,keyCertSign,cRLSign" >/dev/null 2>&1
|
||||||
|
|
||||||
|
# Distro bundles store certs differently — Debian writes raw PEM only,
|
||||||
|
# Fedora prepends "# CN" comment headers, Arch via extract-compat is
|
||||||
|
# raw PEM. To detect cert presence uniformly we grep for a deterministic
|
||||||
|
# substring of the base64 body (first base64 line is unique per cert).
|
||||||
|
CERT_TAG=$(sed -n '2p' "$FIXTURE_DIR/ca.pem")
|
||||||
|
|
||||||
|
PASSED=0; FAILED=0
|
||||||
|
|
||||||
|
run_case() {
|
||||||
|
local distro="$1"; shift
|
||||||
|
local image="$1"; shift
|
||||||
|
local platform="$1"; shift
|
||||||
|
local script="$1"
|
||||||
|
|
||||||
|
printf "── %s (%s) ──\n" "$distro" "$image"
|
||||||
|
if docker run --rm \
|
||||||
|
--platform "$platform" \
|
||||||
|
--security-opt seccomp=unconfined \
|
||||||
|
-e CERT_TAG="$CERT_TAG" \
|
||||||
|
-e DEBIAN_FRONTEND=noninteractive \
|
||||||
|
-v "$FIXTURE_DIR/ca.pem:/fixture/ca.pem:ro" \
|
||||||
|
"$image" bash -c "$script"; then
|
||||||
|
printf "${GREEN}✓${RESET} %s\n\n" "$distro"
|
||||||
|
PASSED=$((PASSED + 1))
|
||||||
|
else
|
||||||
|
printf "${RED}✗${RESET} %s\n\n" "$distro"
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Debian / Ubuntu / Mint — anchor: /usr/local/share/ca-certificates/*.crt
|
||||||
|
run_case "debian" "debian:stable" "linux/amd64" '
|
||||||
|
set -e
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -qq -y ca-certificates >/dev/null
|
||||||
|
install -m 0644 /fixture/ca.pem /usr/local/share/ca-certificates/numa-local-ca.crt
|
||||||
|
update-ca-certificates >/dev/null 2>&1
|
||||||
|
grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt
|
||||||
|
echo " install: cert present in bundle"
|
||||||
|
rm /usr/local/share/ca-certificates/numa-local-ca.crt
|
||||||
|
update-ca-certificates --fresh >/dev/null 2>&1
|
||||||
|
if grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt; then
|
||||||
|
echo " uninstall: cert STILL present (regression)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " uninstall: cert removed from bundle"
|
||||||
|
'
|
||||||
|
|
||||||
|
# Fedora / RHEL / CentOS / SUSE — anchor: /etc/pki/ca-trust/source/anchors/*.pem
|
||||||
|
run_case "fedora" "fedora:latest" "linux/amd64" '
|
||||||
|
set -e
|
||||||
|
dnf install -q -y ca-certificates >/dev/null
|
||||||
|
install -m 0644 /fixture/ca.pem /etc/pki/ca-trust/source/anchors/numa-local-ca.pem
|
||||||
|
update-ca-trust extract
|
||||||
|
grep -q "$CERT_TAG" /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem
|
||||||
|
echo " install: cert present in bundle"
|
||||||
|
rm /etc/pki/ca-trust/source/anchors/numa-local-ca.pem
|
||||||
|
update-ca-trust extract
|
||||||
|
if grep -q "$CERT_TAG" /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem; then
|
||||||
|
echo " uninstall: cert STILL present (regression)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " uninstall: cert removed from bundle"
|
||||||
|
'
|
||||||
|
|
||||||
|
# Arch / Manjaro — anchor: /etc/ca-certificates/trust-source/anchors/*.pem
|
||||||
|
# archlinux:latest is x86_64-only; --platform forces emulation on Apple Silicon.
|
||||||
|
run_case "arch" "archlinux:latest" "linux/amd64" '
|
||||||
|
set -e
|
||||||
|
# pacman 7+ filters syscalls in its own sandbox; disable for Rosetta/qemu emulation.
|
||||||
|
sed -i "s/^#DisableSandboxSyscalls/DisableSandboxSyscalls/" /etc/pacman.conf
|
||||||
|
pacman -Sy --noconfirm --needed ca-certificates p11-kit >/dev/null 2>&1
|
||||||
|
install -m 0644 /fixture/ca.pem /etc/ca-certificates/trust-source/anchors/numa-local-ca.pem
|
||||||
|
trust extract-compat
|
||||||
|
grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt
|
||||||
|
echo " install: cert present in bundle"
|
||||||
|
rm /etc/ca-certificates/trust-source/anchors/numa-local-ca.pem
|
||||||
|
trust extract-compat
|
||||||
|
if grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt; then
|
||||||
|
echo " uninstall: cert STILL present (regression)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " uninstall: cert removed from bundle"
|
||||||
|
'
|
||||||
|
|
||||||
|
printf "── summary ──\n"
|
||||||
|
printf " ${GREEN}passed${RESET}: %d\n" "$PASSED"
|
||||||
|
printf " ${RED}failed${RESET}: %d\n" "$FAILED"
|
||||||
|
[ "$FAILED" -eq 0 ]
|
||||||
147
tests/docker/smoke-arch.sh
Executable file
147
tests/docker/smoke-arch.sh
Executable file
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Arch Linux compatibility smoke test.
|
||||||
|
#
|
||||||
|
# Builds numa from source inside an archlinux:latest container, runs it
|
||||||
|
# in forward mode on port 5354, and verifies a single DNS query returns
|
||||||
|
# an A record. Validates the "Arch compatible" claim end-to-end before
|
||||||
|
# release announcements.
|
||||||
|
#
|
||||||
|
# Dogfooding: the test numa forwards to the host's running numa via
|
||||||
|
# host.docker.internal (Docker Desktop's host gateway). This avoids the
|
||||||
|
# Docker NAT/UDP issues with public resolvers and exercises the realistic
|
||||||
|
# numa-on-numa shape. Requires the host to be running numa on port 53.
|
||||||
|
#
|
||||||
|
# First run is slow (~8-12 min): image pull + pacman + cold cargo build.
|
||||||
|
# No caching across runs.
|
||||||
|
#
|
||||||
|
# Requirements: docker, host running numa on 0.0.0.0:53
|
||||||
|
# Usage: ./tests/docker/smoke-arch.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
|
||||||
|
|
||||||
|
# Precondition: the test numa-on-arch forwards to the host numa as its
|
||||||
|
# upstream (dogfood pattern). Fail fast with a clear error if there is
|
||||||
|
# no working DNS on the host, rather than letting the dig inside the
|
||||||
|
# container time out with "deadline has elapsed".
|
||||||
|
if ! dig @127.0.0.1 google.com A +short +time=1 +tries=1 >/dev/null 2>&1; then
|
||||||
|
printf "${RED}error:${RESET} host numa is not answering on 127.0.0.1:53\n" >&2
|
||||||
|
echo " This test forwards to the host numa via host.docker.internal." >&2
|
||||||
|
echo " Start numa on the host first (sudo numa install), then rerun." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "── building + running numa on archlinux:latest ──"
|
||||||
|
echo " (first run is slow: image pull + pacman + cold cargo build, ~8-12 min)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--security-opt seccomp=unconfined \
|
||||||
|
-v "$PWD:/src:ro" \
|
||||||
|
-v numa-arch-cargo:/root/.cargo \
|
||||||
|
-v numa-arch-target:/work/target \
|
||||||
|
archlinux:latest bash -c '
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# pacman 7+ filters syscalls in its own sandbox; disable for Rosetta/qemu
|
||||||
|
sed -i "s/^#DisableSandboxSyscalls/DisableSandboxSyscalls/" /etc/pacman.conf
|
||||||
|
|
||||||
|
echo "── pacman: installing build + runtime deps ──"
|
||||||
|
pacman -Sy --noconfirm --needed rust gcc pkgconf cmake make perl bind 2>&1 | tail -3
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Copy source to a writable workdir, skipping target/ + .git so we
|
||||||
|
# do not pull in the host (macOS) build artifacts.
|
||||||
|
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
|
||||||
|
|
||||||
|
# Dogfood: forward to the host numa via host.docker.internal.
|
||||||
|
# numa parses upstream.address as a literal SocketAddr, so we resolve
|
||||||
|
# the hostname to an IPv4 address first (force v4 — getent hosts may
|
||||||
|
# return IPv6 first, and IPv6 addresses need bracketed addr:port form).
|
||||||
|
HOST_IP=$(getent ahostsv4 host.docker.internal | awk "/STREAM/ {print \$1; exit}")
|
||||||
|
if [ -z "$HOST_IP" ]; then
|
||||||
|
echo " ✗ could not resolve host.docker.internal to IPv4 (not on Docker Desktop?)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "── starting numa on :5354 (forward to host numa at $HOST_IP:53) ──"
|
||||||
|
# Intentionally NOT setting [server] data_dir — we want to exercise the
|
||||||
|
# default code path (data_dir() → daemon_data_dir() → /var/lib/numa) so
|
||||||
|
# the FHS-path assertion below verifies the live wiring, not just the
|
||||||
|
# unit-tested helper.
|
||||||
|
cat > /tmp/numa.toml <<EOF
|
||||||
|
[server]
|
||||||
|
bind_addr = "127.0.0.1:5354"
|
||||||
|
api_port = 5381
|
||||||
|
|
||||||
|
[upstream]
|
||||||
|
mode = "forward"
|
||||||
|
address = "$HOST_IP"
|
||||||
|
port = 53
|
||||||
|
EOF
|
||||||
|
|
||||||
|
./target/release/numa /tmp/numa.toml > /tmp/numa.log 2>&1 &
|
||||||
|
NUMA_PID=$!
|
||||||
|
|
||||||
|
# Poll for readiness — numa is ready when it answers a query
|
||||||
|
READY=0
|
||||||
|
for i in 1 2 3 4 5 6 7 8; do
|
||||||
|
sleep 1
|
||||||
|
if dig @127.0.0.1 -p 5354 google.com A +short +time=1 +tries=1 2>/dev/null \
|
||||||
|
| grep -qE "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$"; then
|
||||||
|
READY=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$READY" -ne 1 ]; then
|
||||||
|
echo " ✗ numa did not return an A record after 8s"
|
||||||
|
echo " numa log:"
|
||||||
|
cat /tmp/numa.log
|
||||||
|
kill $NUMA_PID 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "── dig @127.0.0.1 -p 5354 google.com A ──"
|
||||||
|
ANSWER=$(dig @127.0.0.1 -p 5354 google.com A +short +time=2 +tries=1)
|
||||||
|
echo "$ANSWER" | sed "s/^/ /"
|
||||||
|
|
||||||
|
kill $NUMA_PID 2>/dev/null || true
|
||||||
|
|
||||||
|
# FHS path assertion: the default data dir on Linux must be /var/lib/numa
|
||||||
|
# (not the legacy /usr/local/var/numa). The CA cert generated at startup
|
||||||
|
# is the canonical proof that numa wrote to the right place.
|
||||||
|
echo
|
||||||
|
echo "── FHS path check ──"
|
||||||
|
if [ -f /var/lib/numa/ca.pem ]; then
|
||||||
|
echo " ✓ CA cert at /var/lib/numa/ca.pem (FHS path)"
|
||||||
|
else
|
||||||
|
echo " ✗ CA cert NOT at /var/lib/numa/ca.pem"
|
||||||
|
echo " ls /var/lib/numa/:"
|
||||||
|
ls -la /var/lib/numa/ 2>&1 | sed "s/^/ /"
|
||||||
|
echo " ls /usr/local/var/numa/:"
|
||||||
|
ls -la /usr/local/var/numa/ 2>&1 | sed "s/^/ /"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -e /usr/local/var/numa ]; then
|
||||||
|
echo " ✗ legacy path /usr/local/var/numa unexpectedly exists on a fresh container"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ✓ legacy path /usr/local/var/numa absent (fresh install used FHS)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ✓ numa built, ran, answered a forward query, and used the FHS data dir on Arch"
|
||||||
|
'
|
||||||
|
|
||||||
|
echo
|
||||||
|
printf "${GREEN}── smoke-arch passed ──${RESET}\n"
|
||||||
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
|
||||||
94
tests/manual/install-trust-macos.sh
Executable file
94
tests/manual/install-trust-macos.sh
Executable file
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Manual macOS CA trust contract test.
|
||||||
|
#
|
||||||
|
# Mirrors src/system_dns.rs::trust_ca_macos / untrust_ca_macos by running
|
||||||
|
# the same `security` shell commands against a fixture cert with a unique
|
||||||
|
# CN. Safe to run alongside a production numa install:
|
||||||
|
#
|
||||||
|
# - Test cert CN = "Numa Local CA Test <pid-ts>", always strictly longer
|
||||||
|
# than the production CN "Numa Local CA". `security find-certificate -c`
|
||||||
|
# does substring matching, so the test's search for $TEST_CN can never
|
||||||
|
# match the production cert (the search term is longer than the prod CN).
|
||||||
|
# - All deletes use `delete-certificate -Z <hash>`, which only touches the
|
||||||
|
# cert with that exact hash. Production and test certs have different
|
||||||
|
# hashes by construction (different key material), so the delete cannot
|
||||||
|
# reach the production cert even if a CN search somehow returned both.
|
||||||
|
#
|
||||||
|
# Mutates the System keychain (briefly). Cleans up on success or interrupt.
|
||||||
|
# Requires sudo for `security add-trusted-cert` and `delete-certificate`.
|
||||||
|
#
|
||||||
|
# Usage: ./tests/manual/install-trust-macos.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "$OSTYPE" != darwin* ]]; then
|
||||||
|
echo "This test is macOS-only." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
|
||||||
|
|
||||||
|
# Production constant from src/tls.rs::CA_COMMON_NAME — keep in sync.
|
||||||
|
PROD_CN="Numa Local CA"
|
||||||
|
KEYCHAIN="/Library/Keychains/System.keychain"
|
||||||
|
|
||||||
|
# Notice if production numa is already installed. We proceed regardless —
|
||||||
|
# see header for why coexistence is safe (unique CN + by-hash deletion).
|
||||||
|
if security find-certificate -c "$PROD_CN" "$KEYCHAIN" >/dev/null 2>&1; then
|
||||||
|
echo " note: production '$PROD_CN' detected — proceeding alongside (test cert can't touch it)"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Unique CN ensures the test cert can never collide with production.
|
||||||
|
TEST_CN="Numa Local CA Test $$-$(date +%s)"
|
||||||
|
FIXTURE_DIR=$(mktemp -d)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
# Best-effort: remove any test certs by hash if still present.
|
||||||
|
if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then
|
||||||
|
echo " cleanup: removing leftover test cert"
|
||||||
|
security find-certificate -c "$TEST_CN" -a -Z "$KEYCHAIN" 2>/dev/null \
|
||||||
|
| awk '/^SHA-1 hash:/ {print $NF}' \
|
||||||
|
| while read -r hash; do
|
||||||
|
sudo security delete-certificate -Z "$hash" "$KEYCHAIN" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
rm -rf "$FIXTURE_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "── generating fixture CA ──"
|
||||||
|
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
|
||||||
|
-keyout "$FIXTURE_DIR/ca.key" \
|
||||||
|
-out "$FIXTURE_DIR/ca.pem" \
|
||||||
|
-subj "/CN=$TEST_CN" \
|
||||||
|
-addext "basicConstraints=critical,CA:TRUE" \
|
||||||
|
-addext "keyUsage=critical,keyCertSign,cRLSign" >/dev/null 2>&1
|
||||||
|
echo " CN: $TEST_CN"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "── trust step (mirrors trust_ca_macos) ──"
|
||||||
|
sudo security add-trusted-cert -d -r trustRoot -k "$KEYCHAIN" "$FIXTURE_DIR/ca.pem"
|
||||||
|
if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then
|
||||||
|
printf " ${GREEN}✓${RESET} test cert found in keychain\n"
|
||||||
|
else
|
||||||
|
printf " ${RED}✗${RESET} test cert NOT found after add-trusted-cert\n"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "── untrust step (mirrors untrust_ca_macos) ──"
|
||||||
|
security find-certificate -c "$TEST_CN" -a -Z "$KEYCHAIN" 2>/dev/null \
|
||||||
|
| awk '/^SHA-1 hash:/ {print $NF}' \
|
||||||
|
| while read -r hash; do
|
||||||
|
sudo security delete-certificate -Z "$hash" "$KEYCHAIN" >/dev/null
|
||||||
|
done
|
||||||
|
if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then
|
||||||
|
printf " ${RED}✗${RESET} test cert STILL present after delete (regression)\n"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf " ${GREEN}✓${RESET} test cert removed from keychain\n"
|
||||||
|
echo
|
||||||
|
|
||||||
|
printf "${GREEN}all checks passed${RESET}\n"
|
||||||
Reference in New Issue
Block a user