Compare commits
1 Commits
v0.11.0
...
fix/window
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f80d1ab7f |
19
.SRCINFO
19
.SRCINFO
@@ -1,19 +0,0 @@
|
||||
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
|
||||
34
.github/dependabot.yml
vendored
34
.github/dependabot.yml
vendored
@@ -1,34 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
patterns: ["*"]
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
patterns: ["*"]
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
patterns: ["*"]
|
||||
update-types: ["minor", "patch"]
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
check-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: clippy
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
check-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: build
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: test
|
||||
run: cargo test
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: numa-windows-x86_64
|
||||
path: target/debug/numa.exe
|
||||
|
||||
77
.github/workflows/homebrew-bump.yml
vendored
77
.github/workflows/homebrew-bump.yml
vendored
@@ -1,77 +0,0 @@
|
||||
name: Bump Homebrew Tap
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to bump (e.g. 0.10.0 or v0.10.0)'
|
||||
type: string
|
||||
required: true
|
||||
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@v6
|
||||
|
||||
- name: Determine version
|
||||
id: ver
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
V="${INPUT_VERSION#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
159
.github/workflows/publish-aur.yml
vendored
@@ -1,159 +0,0 @@
|
||||
# `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
|
||||
99
.github/workflows/release.yml
vendored
99
.github/workflows/release.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
(Get-FileHash "${{ matrix.name }}.zip" -Algorithm SHA256).Hash.ToLower() + " ${{ matrix.name }}.zip" | Out-File "${{ matrix.name }}.zip.sha256" -Encoding ascii
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.name }}
|
||||
path: |
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
needs: [build, publish]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
merge-multiple: true
|
||||
|
||||
@@ -109,9 +109,92 @@ jobs:
|
||||
*.zip
|
||||
*.sha256
|
||||
|
||||
bump-homebrew:
|
||||
update-homebrew:
|
||||
needs: release
|
||||
uses: ./.github/workflows/homebrew-bump.yml
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download SHA256 files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
secrets: inherit
|
||||
merge-multiple: true
|
||||
|
||||
- name: Extract checksums
|
||||
id: sha
|
||||
run: |
|
||||
echo "macos_arm=$(awk '{print $1}' numa-macos-aarch64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
|
||||
echo "macos_x86=$(awk '{print $1}' numa-macos-x86_64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
|
||||
echo "linux_arm=$(awk '{print $1}' numa-linux-aarch64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
|
||||
echo "linux_x86=$(awk '{print $1}' numa-linux-x86_64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update Homebrew formula
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
script: |
|
||||
const version = '${{ steps.version.outputs.version }}';
|
||||
const base = `https://github.com/razvandimescu/numa/releases/download/v${version}`;
|
||||
const formula = `class Numa < Formula
|
||||
desc "Portable DNS resolver with ad blocking, .numa local service proxy, and developer overrides"
|
||||
homepage "https://github.com/razvandimescu/numa"
|
||||
license "MIT"
|
||||
version "${version}"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "${base}/numa-macos-aarch64.tar.gz"
|
||||
sha256 "${{ steps.sha.outputs.macos_arm }}"
|
||||
else
|
||||
url "${base}/numa-macos-x86_64.tar.gz"
|
||||
sha256 "${{ steps.sha.outputs.macos_x86 }}"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "${base}/numa-linux-aarch64.tar.gz"
|
||||
sha256 "${{ steps.sha.outputs.linux_arm }}"
|
||||
else
|
||||
url "${base}/numa-linux-x86_64.tar.gz"
|
||||
sha256 "${{ steps.sha.outputs.linux_x86 }}"
|
||||
end
|
||||
end
|
||||
|
||||
def install
|
||||
bin.install "numa"
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
Numa requires root to bind port 53:
|
||||
sudo numa # start the DNS server
|
||||
sudo numa install # set as system DNS
|
||||
sudo numa service start # run as persistent service
|
||||
|
||||
Dashboard: http://localhost:5380
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match "numa", shell_output("#{bin}/numa --version")
|
||||
end
|
||||
end
|
||||
`.replace(/^ /gm, '');
|
||||
|
||||
const { data: existing } = await github.rest.repos.getContent({
|
||||
owner: 'razvandimescu',
|
||||
repo: 'homebrew-tap',
|
||||
path: 'numa.rb',
|
||||
});
|
||||
|
||||
await github.rest.repos.createOrUpdateFileContents({
|
||||
owner: 'razvandimescu',
|
||||
repo: 'homebrew-tap',
|
||||
path: 'numa.rb',
|
||||
message: `numa ${version}`,
|
||||
content: Buffer.from(formula).toString('base64'),
|
||||
sha: existing.sha,
|
||||
});
|
||||
|
||||
8
.github/workflows/static.yml
vendored
8
.github/workflows/static.yml
vendored
@@ -30,18 +30,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- name: Install pandoc
|
||||
run: sudo apt-get install -y pandoc
|
||||
- name: Generate blog HTML
|
||||
run: make blog
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
uses: actions/configure-pages@v5
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
# Upload entire repository
|
||||
path: './site'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,4 @@
|
||||
/target
|
||||
/build-dir
|
||||
CLAUDE.md
|
||||
docs/
|
||||
site/blog/posts/
|
||||
|
||||
236
Cargo.lock
generated
236
Cargo.lock
generated
@@ -17,15 +17,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloca"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anes"
|
||||
version = "0.1.6"
|
||||
@@ -93,9 +84,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.1"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60"
|
||||
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
@@ -103,15 +94,15 @@ dependencies = [
|
||||
"nom",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.6.0"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -377,24 +368,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.8.2"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
|
||||
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
||||
dependencies = [
|
||||
"alloca",
|
||||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
"page_size",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"tinytemplate",
|
||||
"walkdir",
|
||||
@@ -402,9 +394,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.8.2"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
@@ -449,9 +441,9 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
version = "9.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"displaydoc",
|
||||
@@ -522,16 +514,6 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -720,6 +702,12 @@ version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -822,7 +810,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -956,6 +944,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -964,9 +963,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
version = "0.10.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
@@ -1144,7 +1143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "numa"
|
||||
version = "0.11.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
@@ -1156,15 +1155,13 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"qrcode",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"ring",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
@@ -1174,9 +1171,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
]
|
||||
@@ -1199,16 +1196,6 @@ version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "page_size"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
@@ -1313,12 +1300,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -1332,8 +1313,8 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"socket2 0.6.3",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -1354,7 +1335,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -1369,7 +1350,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"socket2 0.6.3",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -1440,9 +1421,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.7"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e"
|
||||
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
||||
dependencies = [
|
||||
"pem",
|
||||
"ring",
|
||||
@@ -1565,15 +1546,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
@@ -1673,11 +1645,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1698,16 +1670,6 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
@@ -1726,6 +1688,16 @@ version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
@@ -1779,13 +1751,33 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1875,8 +1867,7 @@ dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"socket2 0.6.3",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -1917,42 +1908,44 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
@@ -2185,22 +2178,6 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -2210,12 +2187,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
@@ -2380,9 +2351,12 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.1"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
@@ -2398,9 +2372,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"data-encoding",
|
||||
@@ -2410,7 +2384,7 @@ dependencies = [
|
||||
"oid-registry",
|
||||
"ring",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
|
||||
14
Cargo.toml
14
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "numa"
|
||||
version = "0.11.0"
|
||||
version = "0.9.1"
|
||||
authors = ["razvandimescu <razvan@dimescu.com>"]
|
||||
edition = "2021"
|
||||
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
||||
@@ -10,11 +10,11 @@ keywords = ["dns", "dns-server", "ad-blocking", "reverse-proxy", "developer-tool
|
||||
categories = ["network-programming", "development-tools"]
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "signal"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
|
||||
axum = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1.1"
|
||||
toml = "0.8"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "gzip", "http2"], default-features = false }
|
||||
@@ -22,18 +22,16 @@ hyper = { version = "1", features = ["client", "http1", "server"] }
|
||||
hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] }
|
||||
http-body-util = "0.1"
|
||||
futures = "0.3"
|
||||
socket2 = { version = "0.6", features = ["all"] }
|
||||
rcgen = { version = "0.14", features = ["pem", "x509-parser"] }
|
||||
socket2 = { version = "0.5", features = ["all"] }
|
||||
rcgen = { version = "0.13", features = ["pem", "x509-parser"] }
|
||||
time = "0.3"
|
||||
rustls = "0.23"
|
||||
tokio-rustls = "0.26"
|
||||
arc-swap = "1"
|
||||
ring = "0.17"
|
||||
rustls-pemfile = "2.2.0"
|
||||
qrcode = { version = "0.14", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http = "1"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.94-alpine AS builder
|
||||
FROM rust:1.88-alpine AS builder
|
||||
RUN apk add --no-cache musl-dev cmake make perl
|
||||
WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
@@ -11,7 +11,7 @@ COPY numa.toml com.numa.dns.plist numa.service ./
|
||||
RUN touch src/main.rs src/lib.rs
|
||||
RUN cargo build --release
|
||||
|
||||
FROM alpine:3.23
|
||||
FROM alpine:3.20
|
||||
COPY --from=builder /app/target/release/numa /usr/local/bin/numa
|
||||
EXPOSE 53/udp 80/tcp 443/tcp 853/tcp 5380/tcp
|
||||
EXPOSE 53/udp 80/tcp 443/tcp 5380/tcp
|
||||
ENTRYPOINT ["numa"]
|
||||
|
||||
62
PKGBUILD
62
PKGBUILD
@@ -1,62 +0,0 @@
|
||||
# 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"
|
||||
}
|
||||
14
README.md
14
README.md
@@ -8,7 +8,7 @@
|
||||
|
||||
A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required.
|
||||
|
||||
Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Caching, ad blocking, and local service domains out of the box. Optional recursive resolution from root nameservers with full DNSSEC chain-of-trust validation, plus a DNS-over-TLS listener for encrypted client connections (iOS Private DNS, systemd-resolved, etc.). One ~8MB binary, everything embedded.
|
||||
Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Caching, ad blocking, and local service domains out of the box. Optional recursive resolution from root nameservers with full DNSSEC chain-of-trust validation. One ~8MB binary, everything embedded.
|
||||
|
||||

|
||||
|
||||
@@ -21,9 +21,6 @@ brew install razvandimescu/tap/numa
|
||||
# Linux
|
||||
curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
|
||||
|
||||
# Arch Linux (AUR)
|
||||
yay -S numa-git
|
||||
|
||||
# Windows — download from GitHub Releases
|
||||
# All platforms
|
||||
cargo install numa
|
||||
@@ -70,13 +67,6 @@ Three resolution modes:
|
||||
|
||||
DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, DS delegation, NSEC/NSEC3 denial proofs. [Read how it works →](https://numa.rs/blog/posts/dnssec-from-scratch.html)
|
||||
|
||||
**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. `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`.
|
||||
|
||||
ALPN `"dot"` is advertised and enforced in both modes; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense.
|
||||
|
||||
## LAN Discovery
|
||||
|
||||
Run Numa on multiple machines. They find each other automatically via mDNS:
|
||||
@@ -106,7 +96,6 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
|
||||
| Ad blocking | Yes | Yes | — | 385K+ domains |
|
||||
| Web admin UI | Full | Full | — | Dashboard |
|
||||
| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native |
|
||||
| Encrypted clients (DoT listener) | Needs stunnel sidecar | Yes | Yes | Native (RFC 7858) |
|
||||
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows |
|
||||
| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New |
|
||||
|
||||
@@ -127,7 +116,6 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
|
||||
- [x] `.numa` local domains — auto TLS, path routing, WebSocket proxy
|
||||
- [x] LAN service discovery — mDNS, cross-machine DNS + proxy
|
||||
- [x] DNS-over-HTTPS — encrypted upstream
|
||||
- [x] DNS-over-TLS listener — encrypted client connections (RFC 7858, ALPN strict)
|
||||
- [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3
|
||||
- [x] SRTT-based nameserver selection
|
||||
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT
|
||||
|
||||
33
numa.toml
33
numa.toml
@@ -2,12 +2,6 @@
|
||||
bind_addr = "0.0.0.0:53"
|
||||
api_port = 5380
|
||||
# api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access
|
||||
# data_dir = "/var/lib/numa" # where numa stores TLS CA and cert material
|
||||
# Defaults: /var/lib/numa on linux (FHS),
|
||||
# /usr/local/var/numa on macos (homebrew prefix),
|
||||
# %PROGRAMDATA%\numa on windows. Override for
|
||||
# containerized deploys or tests that can't
|
||||
# write to the system path.
|
||||
|
||||
# [upstream]
|
||||
# mode = "forward" # "forward" (default) — relay to upstream
|
||||
@@ -89,35 +83,8 @@ tld = "numa"
|
||||
# enabled = false # opt-in: verify chain of trust from root KSK
|
||||
# strict = false # true = SERVFAIL on bogus signatures
|
||||
|
||||
# DNS-over-TLS listener (RFC 7858) — encrypted DNS on port 853
|
||||
# [dot]
|
||||
# enabled = false # opt-in: accept DoT queries
|
||||
# port = 853 # standard DoT port
|
||||
# bind_addr = "0.0.0.0" # IPv4 or IPv6; unspecified binds all interfaces
|
||||
# cert_path = "/etc/numa/dot.crt" # PEM cert; omit to use self-signed (proxy CA if available)
|
||||
# key_path = "/etc/numa/dot.key" # PEM private key; must be set together with cert_path
|
||||
|
||||
# LAN service discovery via mDNS (disabled by default — no network traffic unless enabled)
|
||||
# [lan]
|
||||
# enabled = true # discover other Numa instances via mDNS (_numa._tcp.local)
|
||||
# broadcast_interval_secs = 30
|
||||
# peer_timeout_secs = 90
|
||||
|
||||
# Mobile API — persistent HTTP listener serving read-only routes
|
||||
# (/health, /ca.pem, /mobileconfig, /ca.mobileconfig) on a LAN-reachable
|
||||
# port. Consumed by the iOS/Android companion apps for discovery and
|
||||
# profile fetching, and by `numa setup-phone` for QR-based onboarding.
|
||||
#
|
||||
# Opt-in because the listener binds to the LAN by default. None of the
|
||||
# exposed routes are cryptographically sensitive (no private keys, no
|
||||
# state mutations, all idempotent GETs), but enabling it does add a new
|
||||
# listener to any device on the LAN that scans port 8765.
|
||||
#
|
||||
# Safe for home LANs. Think twice before enabling on untrusted LANs
|
||||
# (office Wi-Fi, coffee shops, etc.) — an attacker on the same network
|
||||
# could run a competing Numa instance that shadows yours via mDNS and
|
||||
# trick companion apps into installing their profile instead of yours.
|
||||
[mobile]
|
||||
enabled = true # opt-in to the mobile API listener
|
||||
# port = 8765 # default; matches Discovery.swift defaultAPIPort
|
||||
# bind_addr = "0.0.0.0" # default; set to "127.0.0.1" for localhost-only
|
||||
|
||||
@@ -37,7 +37,7 @@ cargo update --workspace
|
||||
git add Cargo.toml Cargo.lock
|
||||
git commit -m "chore: bump version to $VERSION"
|
||||
git tag "$TAG"
|
||||
git push origin main "$TAG"
|
||||
git push origin main --tags
|
||||
|
||||
echo
|
||||
echo "Released $TAG — GitHub Actions will build, publish to crates.io, and create the release."
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/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)
|
||||
@@ -288,7 +288,6 @@ body {
|
||||
.path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); }
|
||||
.path-tag.BLOCKED { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); }
|
||||
.path-tag.COALESCED { background: rgba(138, 104, 158, 0.12); color: var(--violet-dim); }
|
||||
.src-tag { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.02em; }
|
||||
|
||||
/* Sidebar panels */
|
||||
.sidebar {
|
||||
@@ -788,13 +787,6 @@ function formatTime(epoch) {
|
||||
return d.toLocaleTimeString([], { hour12: false });
|
||||
}
|
||||
|
||||
function shortSrc(addr) {
|
||||
if (!addr) return '';
|
||||
const ip = addr.replace(/:\d+$/, '');
|
||||
if (ip === '127.0.0.1' || ip === '::1') return 'localhost';
|
||||
return ip;
|
||||
}
|
||||
|
||||
function formatRemaining(secs) {
|
||||
if (secs == null) return 'permanent';
|
||||
if (secs < 60) return `${secs}s left`;
|
||||
@@ -920,8 +912,8 @@ function applyLogFilter() {
|
||||
? ` <button class="btn-delete" onclick="allowDomain('${e.domain}')" title="Allow this domain" style="color:var(--emerald);font-size:0.65rem;">allow</button>`
|
||||
: '';
|
||||
return `
|
||||
<tr title="Source: ${e.src || 'unknown'}">
|
||||
<td>${formatTime(e.timestamp_epoch)}<br><span class="src-tag">${shortSrc(e.src)}</span></td>
|
||||
<tr>
|
||||
<td>${formatTime(e.timestamp_epoch)}</td>
|
||||
<td>${e.query_type}</td>
|
||||
<td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td>
|
||||
<td><span class="path-tag ${e.path}">${e.path}</span></td>
|
||||
|
||||
27
src/api.rs
27
src/api.rs
@@ -592,19 +592,8 @@ async fn flush_cache_domain(
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
/// Enriched `/health` handler shared between the main API and the mobile API.
|
||||
///
|
||||
/// Returns the cached `HealthMeta` assembled with live fields (LAN IP,
|
||||
/// uptime). Backward compatible with the previous minimal response in
|
||||
/// that `status` is still the first field and `"ok"` is still the value.
|
||||
/// The iOS companion app's `HealthInfo` Swift struct decodes the full
|
||||
/// response; any HTTP client asserting only on `"status"` keeps working.
|
||||
pub async fn health(State(ctx): State<Arc<ServerCtx>>) -> Json<crate::health::HealthResponse> {
|
||||
let lan_ip = Some(*ctx.lan_ip.lock().unwrap());
|
||||
Json(crate::health::HealthResponse::build(
|
||||
&ctx.health_meta,
|
||||
lan_ip,
|
||||
))
|
||||
async fn health() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({ "status": "ok" }))
|
||||
}
|
||||
|
||||
// --- Blocking handlers ---
|
||||
@@ -916,8 +905,12 @@ async fn remove_route(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
|
||||
let pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?;
|
||||
async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
|
||||
let ca_path = ctx.data_dir.join("ca.pem");
|
||||
let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path))
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/x-pem-file"),
|
||||
@@ -927,7 +920,7 @@ pub async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResp
|
||||
),
|
||||
(header::CACHE_CONTROL, "public, max-age=86400"),
|
||||
],
|
||||
pem.to_string(),
|
||||
bytes,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1003,8 +996,6 @@ mod tests {
|
||||
inflight: Mutex::new(std::collections::HashMap::new()),
|
||||
dnssec_enabled: false,
|
||||
dnssec_strict: false,
|
||||
health_meta: crate::health::HealthMeta::test_fixture(),
|
||||
ca_pem: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
235
src/buffer.rs
235
src/buffer.rs
@@ -84,11 +84,6 @@ impl BytePacketBuffer {
|
||||
|
||||
/// Read a qname, handling label compression (pointer jumps).
|
||||
/// Converts wire format like [3]www[6]google[3]com[0] into "www.google.com".
|
||||
///
|
||||
/// Label bytes are escaped per RFC 1035 §5.1:
|
||||
/// - literal `.` within a label → `\.`
|
||||
/// - literal `\` → `\\`
|
||||
/// - bytes outside `0x21..=0x7E` (excluding `.` and `\`) → `\DDD` (3-digit decimal)
|
||||
pub fn read_qname(&mut self, outstr: &mut String) -> Result<()> {
|
||||
let mut pos = self.pos();
|
||||
let mut jumped = false;
|
||||
@@ -126,18 +121,7 @@ impl BytePacketBuffer {
|
||||
|
||||
let str_buffer = self.get_range(pos, len as usize)?;
|
||||
for &b in str_buffer {
|
||||
let c = b.to_ascii_lowercase();
|
||||
match c {
|
||||
b'.' => outstr.push_str("\\."),
|
||||
b'\\' => outstr.push_str("\\\\"),
|
||||
0x21..=0x7E => outstr.push(c as char),
|
||||
_ => {
|
||||
outstr.push('\\');
|
||||
outstr.push((b'0' + c / 100) as char);
|
||||
outstr.push((b'0' + (c / 10) % 10) as char);
|
||||
outstr.push((b'0' + c % 10) as char);
|
||||
}
|
||||
}
|
||||
outstr.push(b.to_ascii_lowercase() as char);
|
||||
}
|
||||
|
||||
delim = ".";
|
||||
@@ -179,68 +163,24 @@ impl BytePacketBuffer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a qname in wire format, parsing RFC 1035 §5.1 text escapes.
|
||||
/// See `read_qname` for the escape grammar.
|
||||
pub fn write_qname(&mut self, qname: &str) -> Result<()> {
|
||||
if qname.is_empty() || qname == "." {
|
||||
self.write_u8(0)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let bytes = qname.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let len_pos = self.pos;
|
||||
self.write_u8(0)?; // placeholder length byte, backpatched below
|
||||
let body_start = self.pos;
|
||||
|
||||
while i < bytes.len() && bytes[i] != b'.' {
|
||||
let b = bytes[i];
|
||||
if b == b'\\' {
|
||||
i += 1;
|
||||
let c1 = *bytes.get(i).ok_or("trailing backslash in qname")?;
|
||||
if c1.is_ascii_digit() {
|
||||
let c2 = *bytes
|
||||
.get(i + 1)
|
||||
.ok_or("invalid \\DDD escape: expected 3 digits")?;
|
||||
let c3 = *bytes
|
||||
.get(i + 2)
|
||||
.ok_or("invalid \\DDD escape: expected 3 digits")?;
|
||||
if !c2.is_ascii_digit() || !c3.is_ascii_digit() {
|
||||
return Err("invalid \\DDD escape: expected 3 digits".into());
|
||||
for label in qname.split('.') {
|
||||
let len = label.len();
|
||||
if len == 0 {
|
||||
continue; // skip empty labels from trailing dot
|
||||
}
|
||||
let val =
|
||||
(c1 - b'0') as u16 * 100 + (c2 - b'0') as u16 * 10 + (c3 - b'0') as u16;
|
||||
if val > 255 {
|
||||
return Err(format!("\\DDD escape out of range: {}", val).into());
|
||||
}
|
||||
self.write_u8(val as u8)?;
|
||||
i += 3;
|
||||
} else {
|
||||
// \. \\ and any other \X → literal next byte
|
||||
self.write_u8(c1)?;
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
self.write_u8(b)?;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if self.pos - body_start > 0x3f {
|
||||
if len > 0x3f {
|
||||
return Err("Single label exceeds 63 characters of length".into());
|
||||
}
|
||||
}
|
||||
|
||||
let label_len = self.pos - body_start;
|
||||
if label_len == 0 && i < bytes.len() {
|
||||
// Empty label from leading/consecutive dots — roll back the placeholder.
|
||||
self.pos = len_pos;
|
||||
} else {
|
||||
self.set(len_pos, label_len as u8)?;
|
||||
}
|
||||
|
||||
if i < bytes.len() && bytes[i] == b'.' {
|
||||
i += 1;
|
||||
self.write_u8(len as u8)?;
|
||||
for b in label.as_bytes() {
|
||||
self.write_u8(*b)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,160 +212,3 @@ impl BytePacketBuffer {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn roundtrip(wire: &[u8]) -> String {
|
||||
let mut buf = BytePacketBuffer::from_bytes(wire);
|
||||
let mut out = String::new();
|
||||
buf.read_qname(&mut out).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn write_then_read(text: &str) -> String {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
buf.write_qname(text).unwrap();
|
||||
let wire_end = buf.pos();
|
||||
buf.seek(0).unwrap();
|
||||
let mut out = String::new();
|
||||
buf.read_qname(&mut out).unwrap();
|
||||
assert_eq!(
|
||||
buf.pos(),
|
||||
wire_end,
|
||||
"reader should consume exactly what writer wrote"
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_plain_domain() {
|
||||
// [3]www[6]google[3]com[0]
|
||||
let wire = b"\x03www\x06google\x03com\x00";
|
||||
assert_eq!(roundtrip(wire), "www.google.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_label_with_literal_dot_is_escaped() {
|
||||
// fanf2's example: [8]exa.mple[3]com[0] — two labels, first contains 0x2E
|
||||
let wire = b"\x08exa.mple\x03com\x00";
|
||||
assert_eq!(roundtrip(wire), "exa\\.mple.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_label_with_backslash_is_escaped() {
|
||||
// [4]a\bc[3]com[0]
|
||||
let wire = b"\x04a\\bc\x03com\x00";
|
||||
assert_eq!(roundtrip(wire), "a\\\\bc.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_label_with_nonprintable_byte_uses_decimal_escape() {
|
||||
// [4]\x00foo[3]com[0] — null byte at label start
|
||||
let wire = b"\x04\x00foo\x03com\x00";
|
||||
assert_eq!(roundtrip(wire), "\\000foo.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_label_with_space_uses_decimal_escape() {
|
||||
// Space (0x20) is outside 0x21..=0x7E, so it must be decimal-escaped.
|
||||
let wire = b"\x05a b c\x00";
|
||||
assert_eq!(roundtrip(wire), "a\\032b\\032c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_plain_domain() {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
buf.write_qname("www.google.com").unwrap();
|
||||
assert_eq!(&buf.buf[..buf.pos], b"\x03www\x06google\x03com\x00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_escaped_dot_does_not_split_label() {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
buf.write_qname("exa\\.mple.com").unwrap();
|
||||
assert_eq!(&buf.buf[..buf.pos], b"\x08exa.mple\x03com\x00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_escaped_backslash() {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
buf.write_qname("a\\\\bc.com").unwrap();
|
||||
assert_eq!(&buf.buf[..buf.pos], b"\x04a\\bc\x03com\x00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_decimal_escape_yields_raw_byte() {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
buf.write_qname("\\000foo.com").unwrap();
|
||||
assert_eq!(&buf.buf[..buf.pos], b"\x04\x00foo\x03com\x00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_skips_empty_labels() {
|
||||
// Leading dot — first (empty) label is rolled back.
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
buf.write_qname(".foo.com").unwrap();
|
||||
assert_eq!(&buf.buf[..buf.pos], b"\x03foo\x03com\x00");
|
||||
|
||||
// Consecutive dots — middle empty label is rolled back.
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
buf.write_qname("foo..com").unwrap();
|
||||
assert_eq!(&buf.buf[..buf.pos], b"\x03foo\x03com\x00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_out_of_range_decimal_escape() {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
assert!(buf.write_qname("\\999foo.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_trailing_backslash() {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
assert!(buf.write_qname("foo\\").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_short_decimal_escape() {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
assert!(buf.write_qname("\\1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_label_over_63_bytes() {
|
||||
// 64 bytes exceeds the wire-format label cap.
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
assert!(buf.write_qname(&"a".repeat(64)).is_err());
|
||||
|
||||
// 63 bytes is the maximum permitted label length.
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
assert!(buf.write_qname(&"a".repeat(63)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_preserves_dot_in_label() {
|
||||
assert_eq!(write_then_read("exa\\.mple.com"), "exa\\.mple.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_preserves_backslash_in_label() {
|
||||
assert_eq!(write_then_read("a\\\\b.com"), "a\\\\b.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_preserves_nonprintable_byte() {
|
||||
assert_eq!(write_then_read("\\000foo.com"), "\\000foo.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_name_empty_and_dot_both_produce_single_zero() {
|
||||
let mut a = BytePacketBuffer::new();
|
||||
a.write_qname("").unwrap();
|
||||
let mut b = BytePacketBuffer::new();
|
||||
b.write_qname(".").unwrap();
|
||||
assert_eq!(&a.buf[..a.pos], b"\x00");
|
||||
assert_eq!(&b.buf[..b.pos], b"\x00");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::Ipv6Addr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -29,10 +29,6 @@ pub struct Config {
|
||||
pub lan: LanConfig,
|
||||
#[serde(default)]
|
||||
pub dnssec: DnssecConfig,
|
||||
#[serde(default)]
|
||||
pub dot: DotConfig,
|
||||
#[serde(default)]
|
||||
pub mobile: MobileConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -43,10 +39,6 @@ pub struct ServerConfig {
|
||||
pub api_port: u16,
|
||||
#[serde(default = "default_api_bind_addr")]
|
||||
pub api_bind_addr: String,
|
||||
/// Where numa writes TLS material (CA, leaf certs, regenerated state).
|
||||
/// Defaults to `crate::data_dir()` (platform-specific system path) if unset.
|
||||
#[serde(default)]
|
||||
pub data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
@@ -55,7 +47,6 @@ impl Default for ServerConfig {
|
||||
bind_addr: default_bind_addr(),
|
||||
api_port: default_api_port(),
|
||||
api_bind_addr: default_api_bind_addr(),
|
||||
data_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,88 +370,6 @@ pub struct DnssecConfig {
|
||||
pub strict: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct DotConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_dot_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_dot_bind_addr")]
|
||||
pub bind_addr: String,
|
||||
/// Path to TLS certificate (PEM). If None, uses self-signed CA.
|
||||
#[serde(default)]
|
||||
pub cert_path: Option<PathBuf>,
|
||||
/// Path to TLS private key (PEM). If None, uses self-signed CA.
|
||||
#[serde(default)]
|
||||
pub key_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for DotConfig {
|
||||
fn default() -> Self {
|
||||
DotConfig {
|
||||
enabled: false,
|
||||
port: default_dot_port(),
|
||||
bind_addr: default_dot_bind_addr(),
|
||||
cert_path: None,
|
||||
key_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_dot_port() -> u16 {
|
||||
853
|
||||
}
|
||||
fn default_dot_bind_addr() -> String {
|
||||
"0.0.0.0".to_string()
|
||||
}
|
||||
|
||||
/// Configuration for the mobile API — a persistent HTTP listener that
|
||||
/// serves a read-only subset of routes (`/health`, `/ca.pem`,
|
||||
/// `/mobileconfig`, `/ca.mobileconfig`) on a LAN-reachable port, for
|
||||
/// consumption by the iOS/Android companion apps.
|
||||
///
|
||||
/// Unlike the main API (port 5380, localhost-only by default, supports
|
||||
/// state-mutating routes), the mobile API is safe to expose on the LAN
|
||||
/// because every route is idempotent and read-only.
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct MobileConfig {
|
||||
/// If true, spawn the mobile API listener at startup. **Default false.**
|
||||
/// Opt-in because the listener binds to the LAN by default and exposes
|
||||
/// a few read-only endpoints to any device on the same network (`/health`,
|
||||
/// `/ca.pem`, `/mobileconfig`, `/ca.mobileconfig`). None of those are
|
||||
/// cryptographically sensitive (the CA private key is never served),
|
||||
/// but users should enable this explicitly rather than have a new
|
||||
/// LAN-reachable port appear after an upgrade.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Port for the mobile API. Default 8765.
|
||||
#[serde(default = "default_mobile_port")]
|
||||
pub port: u16,
|
||||
/// Bind address for the mobile API. Default "0.0.0.0" (all interfaces)
|
||||
/// so phones on the LAN can reach it. Set to "127.0.0.1" to restrict
|
||||
/// to localhost — useful if you're running behind another front-end.
|
||||
#[serde(default = "default_mobile_bind_addr")]
|
||||
pub bind_addr: String,
|
||||
}
|
||||
|
||||
impl Default for MobileConfig {
|
||||
fn default() -> Self {
|
||||
MobileConfig {
|
||||
enabled: false,
|
||||
port: default_mobile_port(),
|
||||
bind_addr: default_mobile_bind_addr(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_mobile_port() -> u16 {
|
||||
8765
|
||||
}
|
||||
|
||||
fn default_mobile_bind_addr() -> String {
|
||||
"0.0.0.0".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
67
src/ctx.rs
67
src/ctx.rs
@@ -18,7 +18,6 @@ use crate::cache::{DnsCache, DnssecStatus};
|
||||
use crate::config::{UpstreamMode, ZoneMap};
|
||||
use crate::forward::{forward_query, Upstream};
|
||||
use crate::header::ResultCode;
|
||||
use crate::health::HealthMeta;
|
||||
use crate::lan::PeerStore;
|
||||
use crate::override_store::OverrideStore;
|
||||
use crate::packet::DnsPacket;
|
||||
@@ -61,32 +60,26 @@ pub struct ServerCtx {
|
||||
pub inflight: Mutex<InflightMap>,
|
||||
pub dnssec_enabled: bool,
|
||||
pub dnssec_strict: bool,
|
||||
/// Cached health metadata (version, hostname, DoT config, CA
|
||||
/// fingerprint, features). Shared between the main and mobile
|
||||
/// API `/health` handlers. Built once at startup in `main.rs`.
|
||||
pub health_meta: HealthMeta,
|
||||
/// CA certificate in PEM form, cached at startup. `None` if no
|
||||
/// TLS-using feature is enabled and the CA hasn't been generated.
|
||||
/// Used by `/ca.pem`, `/mobileconfig`, and `/ca.mobileconfig`
|
||||
/// handlers to avoid per-request disk I/O on the hot path.
|
||||
pub ca_pem: Option<String>,
|
||||
}
|
||||
|
||||
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,
|
||||
/// cache, upstream, DNSSEC) and returns the serialized response in a buffer.
|
||||
/// Callers use `.filled()` to get the response bytes without heap allocation.
|
||||
/// Callers are responsible for parsing the incoming buffer into a `DnsPacket`
|
||||
/// (and logging parse errors) before calling this function.
|
||||
pub async fn resolve_query(
|
||||
query: DnsPacket,
|
||||
pub async fn handle_query(
|
||||
mut buffer: BytePacketBuffer,
|
||||
src_addr: SocketAddr,
|
||||
ctx: &ServerCtx,
|
||||
) -> crate::Result<BytePacketBuffer> {
|
||||
) -> crate::Result<()> {
|
||||
let start = Instant::now();
|
||||
|
||||
let query = match DnsPacket::from_buffer(&mut buffer) {
|
||||
Ok(packet) => packet,
|
||||
Err(e) => {
|
||||
warn!("{} | PARSE ERROR | {}", src_addr, e);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let (qname, qtype) = match query.questions.first() {
|
||||
Some(q) => (q.name.clone(), q.qtype),
|
||||
None => return Err("empty question section".into()),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream
|
||||
@@ -313,17 +306,17 @@ pub async fn resolve_query(
|
||||
response.resources.len(),
|
||||
);
|
||||
|
||||
// Serialize response
|
||||
// TODO: TC bit is UDP-specific; DoT connections could carry up to 65535 bytes.
|
||||
// Once BytePacketBuffer supports larger buffers, skip truncation for TCP/TLS.
|
||||
let mut resp_buffer = BytePacketBuffer::new();
|
||||
if response.write(&mut resp_buffer).is_err() {
|
||||
// Response too large — set TC bit and send header + question only
|
||||
// Response too large for UDP — set TC bit and send header + question only
|
||||
debug!("response too large, setting TC bit for {}", qname);
|
||||
let mut tc_response = DnsPacket::response_from(&query, response.header.rescode);
|
||||
tc_response.header.truncated_message = true;
|
||||
resp_buffer = BytePacketBuffer::new();
|
||||
tc_response.write(&mut resp_buffer)?;
|
||||
let mut tc_buffer = BytePacketBuffer::new();
|
||||
tc_response.write(&mut tc_buffer)?;
|
||||
ctx.socket.send_to(tc_buffer.filled(), src_addr).await?;
|
||||
} else {
|
||||
ctx.socket.send_to(resp_buffer.filled(), src_addr).await?;
|
||||
}
|
||||
|
||||
// Record stats and query log
|
||||
@@ -346,30 +339,6 @@ pub async fn resolve_query(
|
||||
dnssec,
|
||||
});
|
||||
|
||||
Ok(resp_buffer)
|
||||
}
|
||||
|
||||
/// Handle a DNS query received over UDP. Thin wrapper around resolve_query.
|
||||
pub async fn handle_query(
|
||||
mut buffer: BytePacketBuffer,
|
||||
src_addr: SocketAddr,
|
||||
ctx: &ServerCtx,
|
||||
) -> crate::Result<()> {
|
||||
let query = match DnsPacket::from_buffer(&mut buffer) {
|
||||
Ok(packet) => packet,
|
||||
Err(e) => {
|
||||
warn!("{} | PARSE ERROR | {}", src_addr, e);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
match resolve_query(query, src_addr, ctx).await {
|
||||
Ok(resp_buffer) => {
|
||||
ctx.socket.send_to(resp_buffer.filled(), src_addr).await?;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{} | RESOLVE ERROR | {}", src_addr, e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ use log::{debug, trace};
|
||||
use ring::digest;
|
||||
use ring::signature;
|
||||
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::cache::{DnsCache, DnssecStatus};
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::question::QueryType;
|
||||
@@ -721,29 +720,22 @@ pub fn verify_ds(ds: &DnsRecord, dnskey: &DnsRecord, owner: &str) -> bool {
|
||||
|
||||
// -- Canonical wire format --
|
||||
|
||||
/// Encode a DNS name in canonical wire form per RFC 4034 §6.2:
|
||||
/// uncompressed, with ASCII letters lowercased.
|
||||
///
|
||||
/// Lowercasing happens *after* escape resolution because `\065` yields
|
||||
/// `'A'`, which canonical form must convert to `'a'`.
|
||||
pub fn name_to_wire(name: &str) -> Vec<u8> {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
buf.write_qname(name)
|
||||
.expect("name_to_wire: input must parse as a valid DNS name");
|
||||
let mut wire = buf.filled().to_vec();
|
||||
|
||||
let mut i = 0;
|
||||
while i < wire.len() {
|
||||
let label_len = wire[i] as usize;
|
||||
if label_len == 0 {
|
||||
break;
|
||||
let mut wire = Vec::with_capacity(name.len() + 2);
|
||||
if name == "." || name.is_empty() {
|
||||
wire.push(0);
|
||||
return wire;
|
||||
}
|
||||
i += 1;
|
||||
let end = i + label_len;
|
||||
wire[i..end].make_ascii_lowercase();
|
||||
i = end;
|
||||
for label in name.split('.') {
|
||||
if label.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
wire.push(label.len() as u8);
|
||||
for &b in label.as_bytes() {
|
||||
wire.push(b.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
wire.push(0);
|
||||
wire
|
||||
}
|
||||
|
||||
@@ -1483,23 +1475,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_to_wire_escaped_dot_in_label_is_not_a_separator() {
|
||||
// `exa\.mple.com` is two labels: `exa.mple` (8 bytes including the 0x2E) and `com`.
|
||||
let wire = name_to_wire("exa\\.mple.com");
|
||||
assert_eq!(
|
||||
wire,
|
||||
vec![8, b'e', b'x', b'a', b'.', b'm', b'p', b'l', b'e', 3, b'c', b'o', b'm', 0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_to_wire_decimal_escape_is_lowercased() {
|
||||
// \065 = 'A', must become 'a' in canonical form.
|
||||
let wire = name_to_wire("\\065bc.com");
|
||||
assert_eq!(wire, vec![3, b'a', b'b', b'c', 3, b'c', b'o', b'm', 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_zone_cases() {
|
||||
assert_eq!(parent_zone("example.com"), "com");
|
||||
|
||||
544
src/dot.rs
544
src/dot.rs
@@ -1,544 +0,0 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use log::{debug, error, info, warn};
|
||||
use rustls::ServerConfig;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::config::DotConfig;
|
||||
use crate::ctx::{resolve_query, ServerCtx};
|
||||
use crate::header::ResultCode;
|
||||
use crate::packet::DnsPacket;
|
||||
|
||||
const MAX_CONNECTIONS: usize = 512;
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
// Matches BytePacketBuffer::BUF_SIZE — RFC 7858 allows up to 65535 but our
|
||||
// buffer would silently truncate anything larger.
|
||||
const MAX_MSG_LEN: usize = 4096;
|
||||
|
||||
fn dot_alpn() -> Vec<Vec<u8>> {
|
||||
vec![b"dot".to_vec()]
|
||||
}
|
||||
|
||||
/// Build a TLS ServerConfig for DoT from user-provided cert/key PEM files.
|
||||
fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result<Arc<ServerConfig>> {
|
||||
// rustls needs a CryptoProvider installed before ServerConfig::builder().
|
||||
// The proxy's build_tls_config also does this; we repeat it here because
|
||||
// running DoT with user-provided certs while the proxy is disabled would
|
||||
// otherwise panic on first handshake (no default provider).
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let cert_pem = std::fs::read(cert_path)?;
|
||||
let key_pem = std::fs::read(key_path)?;
|
||||
|
||||
let certs: Vec<_> = rustls_pemfile::certs(&mut &cert_pem[..]).collect::<Result<_, _>>()?;
|
||||
let key = rustls_pemfile::private_key(&mut &key_pem[..])?
|
||||
.ok_or("no private key found in key file")?;
|
||||
|
||||
let mut config = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)?;
|
||||
config.alpn_protocols = dot_alpn();
|
||||
|
||||
Ok(Arc::new(config))
|
||||
}
|
||||
|
||||
/// Build a self-signed DoT TLS config. Can't reuse `ctx.tls_config` (the
|
||||
/// proxy's shared config) because DoT needs its own ALPN advertisement.
|
||||
///
|
||||
/// Pass `proxy_tld` itself as a service name so the cert gets an explicit
|
||||
/// `{tld}.{tld}` SAN (e.g. "numa.numa") matching the ServerName that
|
||||
/// setup-phone's mobileconfig sends as SNI. The `*.{tld}` wildcard alone
|
||||
/// is rejected by strict TLS clients under single-label TLDs (per the
|
||||
/// note in tls.rs::generate_service_cert).
|
||||
fn self_signed_tls(ctx: &ServerCtx) -> Option<Arc<ServerConfig>> {
|
||||
let service_names = [ctx.proxy_tld.clone()];
|
||||
match crate::tls::build_tls_config(&ctx.proxy_tld, &service_names, dot_alpn(), &ctx.data_dir) {
|
||||
Ok(cfg) => Some(cfg),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"DoT: failed to generate self-signed TLS: {} — DoT disabled",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the DNS-over-TLS listener (RFC 7858).
|
||||
pub async fn start_dot(ctx: Arc<ServerCtx>, config: &DotConfig) {
|
||||
let tls_config = match (&config.cert_path, &config.key_path) {
|
||||
(Some(cert), Some(key)) => match load_tls_config(cert, key) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
warn!("DoT: failed to load TLS cert/key: {} — DoT disabled", e);
|
||||
return;
|
||||
}
|
||||
},
|
||||
_ => match self_signed_tls(&ctx) {
|
||||
Some(cfg) => cfg,
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
|
||||
let bind_addr: IpAddr = config
|
||||
.bind_addr
|
||||
.parse()
|
||||
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
|
||||
let addr = SocketAddr::new(bind_addr, config.port);
|
||||
let listener = match TcpListener::bind(addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
warn!("DoT: could not bind {} ({}) — DoT disabled", addr, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
info!("DoT listening on {}", addr);
|
||||
|
||||
accept_loop(listener, TlsAcceptor::from(tls_config), ctx).await;
|
||||
}
|
||||
|
||||
async fn accept_loop(listener: TcpListener, acceptor: TlsAcceptor, ctx: Arc<ServerCtx>) {
|
||||
let semaphore = Arc::new(Semaphore::new(MAX_CONNECTIONS));
|
||||
|
||||
loop {
|
||||
let (tcp_stream, remote_addr) = match listener.accept().await {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
error!("DoT: TCP accept error: {}", e);
|
||||
// Back off to avoid tight-looping on persistent failures (e.g. fd exhaustion).
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let permit = match semaphore.clone().try_acquire_owned() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
debug!("DoT: connection limit reached, rejecting {}", remote_addr);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let acceptor = acceptor.clone();
|
||||
let ctx = Arc::clone(&ctx);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit; // held until task exits
|
||||
|
||||
let tls_stream =
|
||||
match tokio::time::timeout(HANDSHAKE_TIMEOUT, acceptor.accept(tcp_stream)).await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => {
|
||||
debug!("DoT: TLS handshake failed from {}: {}", remote_addr, e);
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
debug!("DoT: TLS handshake timeout from {}", remote_addr);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
handle_dot_connection(tls_stream, remote_addr, &ctx).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a single persistent DoT connection (RFC 7858).
|
||||
/// Reads length-prefixed DNS queries until EOF, idle timeout, or error.
|
||||
async fn handle_dot_connection<S>(mut stream: S, remote_addr: SocketAddr, ctx: &ServerCtx)
|
||||
where
|
||||
S: AsyncReadExt + AsyncWriteExt + Unpin,
|
||||
{
|
||||
loop {
|
||||
// Read 2-byte length prefix (RFC 1035 §4.2.2) with idle timeout
|
||||
let mut len_buf = [0u8; 2];
|
||||
let Ok(Ok(_)) = tokio::time::timeout(IDLE_TIMEOUT, stream.read_exact(&mut len_buf)).await
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let msg_len = u16::from_be_bytes(len_buf) as usize;
|
||||
if msg_len > MAX_MSG_LEN {
|
||||
debug!("DoT: oversized message {} from {}", msg_len, remote_addr);
|
||||
break;
|
||||
}
|
||||
|
||||
let mut buffer = BytePacketBuffer::new();
|
||||
let Ok(Ok(_)) =
|
||||
tokio::time::timeout(IDLE_TIMEOUT, stream.read_exact(&mut buffer.buf[..msg_len])).await
|
||||
else {
|
||||
break;
|
||||
};
|
||||
|
||||
// Parse query up-front so we can echo its question section in SERVFAIL
|
||||
// responses when resolve_query fails.
|
||||
let query = match DnsPacket::from_buffer(&mut buffer) {
|
||||
Ok(q) => q,
|
||||
Err(e) => {
|
||||
warn!("{} | PARSE ERROR | {}", remote_addr, e);
|
||||
// BytePacketBuffer is zero-initialized, so buf[0..2] reads as 0x0000
|
||||
// for sub-2-byte messages — harmless FORMERR with id=0.
|
||||
let query_id = u16::from_be_bytes([buffer.buf[0], buffer.buf[1]]);
|
||||
let mut resp = DnsPacket::new();
|
||||
resp.header.id = query_id;
|
||||
resp.header.response = true;
|
||||
resp.header.rescode = ResultCode::FORMERR;
|
||||
if send_response(&mut stream, &resp, remote_addr)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match resolve_query(query.clone(), remote_addr, ctx).await {
|
||||
Ok(resp_buffer) => {
|
||||
if write_framed(&mut stream, resp_buffer.filled())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{} | RESOLVE ERROR | {}", remote_addr, e);
|
||||
// SERVFAIL that echoes the original question section.
|
||||
let resp = DnsPacket::response_from(&query, ResultCode::SERVFAIL);
|
||||
if send_response(&mut stream, &resp, remote_addr)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a DNS response and send it framed. Logs serialization failures
|
||||
/// and returns Err so the caller can tear down the connection.
|
||||
async fn send_response<S>(
|
||||
stream: &mut S,
|
||||
resp: &DnsPacket,
|
||||
remote_addr: SocketAddr,
|
||||
) -> std::io::Result<()>
|
||||
where
|
||||
S: AsyncWriteExt + Unpin,
|
||||
{
|
||||
let mut out_buf = BytePacketBuffer::new();
|
||||
if resp.write(&mut out_buf).is_err() {
|
||||
debug!(
|
||||
"DoT: failed to serialize {:?} response for {}",
|
||||
resp.header.rescode, remote_addr
|
||||
);
|
||||
return Err(std::io::Error::other("serialize failed"));
|
||||
}
|
||||
write_framed(stream, out_buf.filled()).await
|
||||
}
|
||||
|
||||
/// Write a DNS message with its 2-byte length prefix, coalesced into one syscall.
|
||||
/// Bounded by WRITE_TIMEOUT so a stalled reader can't indefinitely hold a worker.
|
||||
async fn write_framed<S>(stream: &mut S, msg: &[u8]) -> std::io::Result<()>
|
||||
where
|
||||
S: AsyncWriteExt + Unpin,
|
||||
{
|
||||
let mut out = Vec::with_capacity(2 + msg.len());
|
||||
out.extend_from_slice(&(msg.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(msg);
|
||||
match tokio::time::timeout(WRITE_TIMEOUT, async {
|
||||
stream.write_all(&out).await?;
|
||||
stream.flush().await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(std::io::Error::other("write timeout")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, RwLock};
|
||||
|
||||
use rcgen::{CertificateParams, DnType, KeyPair};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::header::ResultCode;
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::question::QueryType;
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
/// Generate a self-signed DoT server config and return its leaf cert DER
|
||||
/// so callers can build matching client configs with arbitrary ALPN.
|
||||
fn test_tls_configs() -> (Arc<ServerConfig>, CertificateDer<'static>) {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
// Mirror production self_signed_tls SAN shape: *.numa wildcard plus
|
||||
// explicit numa.numa apex (the ServerName setup-phone uses as SNI).
|
||||
let key_pair = KeyPair::generate().unwrap();
|
||||
let mut params = CertificateParams::default();
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::CommonName, "Numa .numa services");
|
||||
params.subject_alt_names = vec![
|
||||
rcgen::SanType::DnsName("*.numa".try_into().unwrap()),
|
||||
rcgen::SanType::DnsName("numa.numa".try_into().unwrap()),
|
||||
];
|
||||
let cert = params.self_signed(&key_pair).unwrap();
|
||||
|
||||
let cert_der = CertificateDer::from(cert.der().to_vec());
|
||||
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der()));
|
||||
|
||||
let mut server_config = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![cert_der.clone()], key_der)
|
||||
.unwrap();
|
||||
server_config.alpn_protocols = dot_alpn();
|
||||
|
||||
(Arc::new(server_config), cert_der)
|
||||
}
|
||||
|
||||
/// Build a TLS client config that trusts `cert_der` and advertises the
|
||||
/// given ALPN protocols. Used by tests to vary ALPN per test case.
|
||||
fn dot_client(
|
||||
cert_der: &CertificateDer<'static>,
|
||||
alpn: Vec<Vec<u8>>,
|
||||
) -> Arc<rustls::ClientConfig> {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.add(cert_der.clone()).unwrap();
|
||||
let mut config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
config.alpn_protocols = alpn;
|
||||
Arc::new(config)
|
||||
}
|
||||
|
||||
/// Spin up a DoT listener with a test TLS config. Returns the bind addr
|
||||
/// and the leaf cert DER so callers can build clients with arbitrary ALPN.
|
||||
/// The upstream is pointed at a bound-but-unresponsive UDP socket we own, so
|
||||
/// any query that escapes to the upstream path times out deterministically
|
||||
/// (SERVFAIL) regardless of what the host has running on port 53.
|
||||
async fn spawn_dot_server() -> (SocketAddr, CertificateDer<'static>) {
|
||||
let (server_tls, cert_der) = test_tls_configs();
|
||||
|
||||
let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
// Bind an unresponsive upstream and leak it so it lives for the test duration.
|
||||
let blackhole = Box::leak(Box::new(std::net::UdpSocket::bind("127.0.0.1:0").unwrap()));
|
||||
let upstream_addr = blackhole.local_addr().unwrap();
|
||||
let ctx = Arc::new(ServerCtx {
|
||||
socket,
|
||||
zone_map: {
|
||||
let mut m = HashMap::new();
|
||||
let mut inner = HashMap::new();
|
||||
inner.insert(
|
||||
QueryType::A,
|
||||
vec![DnsRecord::A {
|
||||
domain: "dot-test.example".to_string(),
|
||||
addr: std::net::Ipv4Addr::new(10, 0, 0, 1),
|
||||
ttl: 300,
|
||||
}],
|
||||
);
|
||||
m.insert("dot-test.example".to_string(), inner);
|
||||
m
|
||||
},
|
||||
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
|
||||
stats: Mutex::new(crate::stats::ServerStats::new()),
|
||||
overrides: RwLock::new(crate::override_store::OverrideStore::new()),
|
||||
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
|
||||
query_log: Mutex::new(crate::query_log::QueryLog::new(100)),
|
||||
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
||||
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
||||
forwarding_rules: Vec::new(),
|
||||
upstream: Mutex::new(crate::forward::Upstream::Udp(upstream_addr)),
|
||||
upstream_auto: false,
|
||||
upstream_port: 53,
|
||||
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
|
||||
timeout: Duration::from_millis(200),
|
||||
proxy_tld: "numa".to_string(),
|
||||
proxy_tld_suffix: ".numa".to_string(),
|
||||
lan_enabled: false,
|
||||
config_path: String::new(),
|
||||
config_found: false,
|
||||
config_dir: std::path::PathBuf::from("/tmp"),
|
||||
data_dir: std::path::PathBuf::from("/tmp"),
|
||||
tls_config: Some(arc_swap::ArcSwap::from(server_tls)),
|
||||
upstream_mode: crate::config::UpstreamMode::Forward,
|
||||
root_hints: Vec::new(),
|
||||
srtt: RwLock::new(crate::srtt::SrttCache::new(true)),
|
||||
inflight: Mutex::new(HashMap::new()),
|
||||
dnssec_enabled: false,
|
||||
dnssec_strict: false,
|
||||
health_meta: crate::health::HealthMeta::test_fixture(),
|
||||
ca_pem: None,
|
||||
});
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let tls_config = Arc::clone(&*ctx.tls_config.as_ref().unwrap().load());
|
||||
let acceptor = TlsAcceptor::from(tls_config);
|
||||
|
||||
tokio::spawn(accept_loop(listener, acceptor, ctx));
|
||||
|
||||
(addr, cert_der)
|
||||
}
|
||||
|
||||
/// Open a TLS connection to the DoT server and return the stream.
|
||||
/// Uses SNI "numa.numa" to mirror what setup-phone's mobileconfig sends.
|
||||
async fn dot_connect(
|
||||
addr: SocketAddr,
|
||||
client_config: &Arc<rustls::ClientConfig>,
|
||||
) -> tokio_rustls::client::TlsStream<tokio::net::TcpStream> {
|
||||
let connector = tokio_rustls::TlsConnector::from(Arc::clone(client_config));
|
||||
let tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
connector
|
||||
.connect(ServerName::try_from("numa.numa").unwrap(), tcp)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Send a DNS query over a DoT stream and read the response.
|
||||
async fn dot_exchange(
|
||||
stream: &mut tokio_rustls::client::TlsStream<tokio::net::TcpStream>,
|
||||
query: &DnsPacket,
|
||||
) -> DnsPacket {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
query.write(&mut buf).unwrap();
|
||||
let msg = buf.filled();
|
||||
|
||||
let mut out = Vec::with_capacity(2 + msg.len());
|
||||
out.extend_from_slice(&(msg.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(msg);
|
||||
stream.write_all(&out).await.unwrap();
|
||||
|
||||
let mut len_buf = [0u8; 2];
|
||||
stream.read_exact(&mut len_buf).await.unwrap();
|
||||
let resp_len = u16::from_be_bytes(len_buf) as usize;
|
||||
|
||||
let mut data = vec![0u8; resp_len];
|
||||
stream.read_exact(&mut data).await.unwrap();
|
||||
|
||||
let mut resp_buf = BytePacketBuffer::from_bytes(&data);
|
||||
DnsPacket::from_buffer(&mut resp_buf).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dot_resolves_local_zone() {
|
||||
let (addr, cert_der) = spawn_dot_server().await;
|
||||
let client_config = dot_client(&cert_der, dot_alpn());
|
||||
let mut stream = dot_connect(addr, &client_config).await;
|
||||
|
||||
let query = DnsPacket::query(0x1234, "dot-test.example", QueryType::A);
|
||||
let resp = dot_exchange(&mut stream, &query).await;
|
||||
|
||||
assert_eq!(resp.header.id, 0x1234);
|
||||
assert!(resp.header.response);
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
assert_eq!(resp.answers.len(), 1);
|
||||
match &resp.answers[0] {
|
||||
DnsRecord::A { domain, addr, ttl } => {
|
||||
assert_eq!(domain, "dot-test.example");
|
||||
assert_eq!(*addr, std::net::Ipv4Addr::new(10, 0, 0, 1));
|
||||
assert_eq!(*ttl, 300);
|
||||
}
|
||||
other => panic!("expected A record, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dot_multiple_queries_on_persistent_connection() {
|
||||
let (addr, cert_der) = spawn_dot_server().await;
|
||||
let client_config = dot_client(&cert_der, dot_alpn());
|
||||
let mut stream = dot_connect(addr, &client_config).await;
|
||||
|
||||
for i in 0..3u16 {
|
||||
let query = DnsPacket::query(0xA000 + i, "dot-test.example", QueryType::A);
|
||||
let resp = dot_exchange(&mut stream, &query).await;
|
||||
assert_eq!(resp.header.id, 0xA000 + i);
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
assert_eq!(resp.answers.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dot_nxdomain_for_unknown() {
|
||||
let (addr, cert_der) = spawn_dot_server().await;
|
||||
let client_config = dot_client(&cert_der, dot_alpn());
|
||||
let mut stream = dot_connect(addr, &client_config).await;
|
||||
|
||||
let query = DnsPacket::query(0xBEEF, "nonexistent.test", QueryType::A);
|
||||
let resp = dot_exchange(&mut stream, &query).await;
|
||||
|
||||
assert_eq!(resp.header.id, 0xBEEF);
|
||||
assert!(resp.header.response);
|
||||
// Query goes to the blackhole upstream which never replies → SERVFAIL.
|
||||
// The SERVFAIL response echoes the question section.
|
||||
assert_eq!(resp.header.rescode, ResultCode::SERVFAIL);
|
||||
assert_eq!(resp.questions.len(), 1);
|
||||
assert_eq!(resp.questions[0].name, "nonexistent.test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dot_negotiates_alpn() {
|
||||
let (addr, cert_der) = spawn_dot_server().await;
|
||||
let client_config = dot_client(&cert_der, dot_alpn());
|
||||
let stream = dot_connect(addr, &client_config).await;
|
||||
let (_io, conn) = stream.get_ref();
|
||||
assert_eq!(conn.alpn_protocol(), Some(&b"dot"[..]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dot_rejects_non_dot_alpn() {
|
||||
// Cross-protocol confusion defense: a client that only offers "h2"
|
||||
// (e.g. an HTTP/2 client mistakenly hitting :853) must not complete
|
||||
// a TLS handshake with the DoT server. Verifies the rustls server
|
||||
// sends `no_application_protocol` rather than silently negotiating.
|
||||
let (addr, cert_der) = spawn_dot_server().await;
|
||||
let client_config = dot_client(&cert_der, vec![b"h2".to_vec()]);
|
||||
let connector = tokio_rustls::TlsConnector::from(client_config);
|
||||
let tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
let result = connector
|
||||
.connect(ServerName::try_from("numa.numa").unwrap(), tcp)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"DoT server must reject ALPN that doesn't include \"dot\""
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dot_concurrent_connections() {
|
||||
let (addr, cert_der) = spawn_dot_server().await;
|
||||
let client_config = dot_client(&cert_der, dot_alpn());
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for i in 0..5u16 {
|
||||
let cfg = Arc::clone(&client_config);
|
||||
handles.push(tokio::spawn(async move {
|
||||
let mut stream = dot_connect(addr, &cfg).await;
|
||||
let query = DnsPacket::query(0xC000 + i, "dot-test.example", QueryType::A);
|
||||
let resp = dot_exchange(&mut stream, &query).await;
|
||||
assert_eq!(resp.header.id, 0xC000 + i);
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
assert_eq!(resp.answers.len(), 1);
|
||||
}));
|
||||
}
|
||||
|
||||
for h in handles {
|
||||
h.await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
254
src/health.rs
254
src/health.rs
@@ -1,254 +0,0 @@
|
||||
//! Health metadata and `/health` response shape, shared between the main
|
||||
//! HTTP API and the mobile API.
|
||||
//!
|
||||
//! The static fields (version, hostname, DoT config, CA fingerprint,
|
||||
//! feature list) are computed once at startup and stored in [`HealthMeta`]
|
||||
//! on `ServerCtx`. Per-request fields (uptime, LAN IP) are computed live.
|
||||
//! Both handlers call [`HealthResponse::build`] to assemble the JSON
|
||||
//! response from `HealthMeta` + live inputs.
|
||||
//!
|
||||
//! JSON schema is documented in `docs/implementation/ios-companion-app.md`
|
||||
//! §4.2. The iOS companion app's `HealthInfo` struct is the canonical
|
||||
//! consumer; any change to this response must keep that struct decoding
|
||||
//! cleanly (all consumed fields are optional on the Swift side, but
|
||||
//! `lan_ip` is load-bearing for the pipeline).
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
use ring::digest::{digest, SHA256};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Immutable health metadata cached on `ServerCtx`. Built once at startup
|
||||
/// from config + file-system state (CA cert).
|
||||
#[derive(Clone)]
|
||||
pub struct HealthMeta {
|
||||
pub version: &'static str,
|
||||
pub hostname: String,
|
||||
pub sni: String,
|
||||
pub dot_enabled: bool,
|
||||
pub dot_port: u16,
|
||||
pub api_port: u16,
|
||||
pub ca_fingerprint_sha256: Option<String>,
|
||||
pub features: Vec<String>,
|
||||
pub started_at: Instant,
|
||||
}
|
||||
|
||||
impl HealthMeta {
|
||||
/// Minimal `HealthMeta` for unit tests that construct a `ServerCtx`
|
||||
/// without needing the real startup flow (CA file reads, hostname
|
||||
/// detection, etc.). Deterministic values so test JSON assertions
|
||||
/// stay stable.
|
||||
#[cfg(test)]
|
||||
pub fn test_fixture() -> Self {
|
||||
HealthMeta {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
hostname: "test-host".to_string(),
|
||||
sni: "numa.numa".to_string(),
|
||||
dot_enabled: false,
|
||||
dot_port: 853,
|
||||
api_port: 8765,
|
||||
ca_fingerprint_sha256: None,
|
||||
features: vec![],
|
||||
started_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a new HealthMeta from config + startup-time environment.
|
||||
/// Call once at server boot; the returned value is cheap to clone
|
||||
/// (small number of short strings) and lives on `ServerCtx`.
|
||||
///
|
||||
/// The argument count is deliberate — each flag corresponds to a
|
||||
/// specific config value and is clearly named at the call site.
|
||||
/// Collapsing into a struct hides nothing meaningful for a one-call
|
||||
/// initializer.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build(
|
||||
data_dir: &Path,
|
||||
dot_enabled: bool,
|
||||
dot_port: u16,
|
||||
api_port: u16,
|
||||
dnssec_enabled: bool,
|
||||
recursive_enabled: bool,
|
||||
mdns_enabled: bool,
|
||||
blocking_enabled: bool,
|
||||
) -> Self {
|
||||
let ca_path = data_dir.join("ca.pem");
|
||||
let ca_fingerprint_sha256 = compute_ca_fingerprint(&ca_path);
|
||||
|
||||
let mut features = Vec::new();
|
||||
if dot_enabled {
|
||||
features.push("dot".to_string());
|
||||
}
|
||||
if recursive_enabled {
|
||||
features.push("recursive".to_string());
|
||||
}
|
||||
if blocking_enabled {
|
||||
features.push("blocking".to_string());
|
||||
}
|
||||
if mdns_enabled {
|
||||
features.push("mdns".to_string());
|
||||
}
|
||||
if dnssec_enabled {
|
||||
features.push("dnssec".to_string());
|
||||
}
|
||||
|
||||
HealthMeta {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
hostname: crate::hostname(),
|
||||
sni: "numa.numa".to_string(),
|
||||
dot_enabled,
|
||||
dot_port,
|
||||
api_port,
|
||||
ca_fingerprint_sha256,
|
||||
features,
|
||||
started_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON response shape returned by `GET /health` on both main and mobile APIs.
|
||||
///
|
||||
/// Fields are organized to match the iOS companion app's
|
||||
/// `HealthInfo` Swift struct — see `ios-companion-app.md` §4.2.
|
||||
#[derive(Serialize)]
|
||||
pub struct HealthResponse {
|
||||
pub status: &'static str,
|
||||
pub version: &'static str,
|
||||
pub uptime_secs: u64,
|
||||
pub hostname: String,
|
||||
pub lan_ip: Option<String>,
|
||||
pub sni: String,
|
||||
pub dot: DotBlock,
|
||||
pub api: ApiBlock,
|
||||
pub ca: CaBlock,
|
||||
pub features: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DotBlock {
|
||||
pub enabled: bool,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ApiBlock {
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CaBlock {
|
||||
pub present: bool,
|
||||
pub fingerprint_sha256: Option<String>,
|
||||
}
|
||||
|
||||
impl HealthResponse {
|
||||
/// Assemble a fresh `HealthResponse` from the cached metadata and
|
||||
/// the current LAN IP (which may change across network transitions).
|
||||
/// Pass `None` for `lan_ip` if detection fails — the response still
|
||||
/// returns 200 OK, just without the LAN address.
|
||||
pub fn build(meta: &HealthMeta, lan_ip: Option<Ipv4Addr>) -> Self {
|
||||
HealthResponse {
|
||||
status: "ok",
|
||||
version: meta.version,
|
||||
uptime_secs: meta.started_at.elapsed().as_secs(),
|
||||
hostname: meta.hostname.clone(),
|
||||
lan_ip: lan_ip.map(|ip| ip.to_string()),
|
||||
sni: meta.sni.clone(),
|
||||
dot: DotBlock {
|
||||
enabled: meta.dot_enabled,
|
||||
port: if meta.dot_enabled {
|
||||
Some(meta.dot_port)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
api: ApiBlock {
|
||||
port: meta.api_port,
|
||||
},
|
||||
ca: CaBlock {
|
||||
present: meta.ca_fingerprint_sha256.is_some(),
|
||||
fingerprint_sha256: meta.ca_fingerprint_sha256.clone(),
|
||||
},
|
||||
features: meta.features.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the CA cert at `ca_path` and return its SHA-256 fingerprint as a
|
||||
/// lowercase hex string, or None if the file doesn't exist or can't be read.
|
||||
///
|
||||
/// Hashes the raw PEM bytes for simplicity. A more canonical SPKI-based
|
||||
/// fingerprint would require parsing the PEM → DER → extracting
|
||||
/// SubjectPublicKeyInfo, which adds complexity without meaningful benefit
|
||||
/// for our use case (the iOS app uses the fingerprint only for display
|
||||
/// and to detect rotation).
|
||||
fn compute_ca_fingerprint(ca_path: &Path) -> Option<String> {
|
||||
let pem = std::fs::read(ca_path).ok()?;
|
||||
let hash = digest(&SHA256, &pem);
|
||||
let hex: String = hash.as_ref().iter().map(|b| format!("{:02x}", b)).collect();
|
||||
Some(hex)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn health_response_contains_required_fields() {
|
||||
let meta = HealthMeta {
|
||||
version: "0.10.0",
|
||||
hostname: "test-host".to_string(),
|
||||
sni: "numa.numa".to_string(),
|
||||
dot_enabled: true,
|
||||
dot_port: 853,
|
||||
api_port: 8765,
|
||||
ca_fingerprint_sha256: Some("abcd1234".to_string()),
|
||||
features: vec!["dot".to_string(), "dnssec".to_string()],
|
||||
started_at: Instant::now(),
|
||||
};
|
||||
|
||||
let response = HealthResponse::build(&meta, Some(Ipv4Addr::new(192, 168, 1, 50)));
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
|
||||
assert!(json.contains("\"status\":\"ok\""));
|
||||
assert!(json.contains("\"version\":\"0.10.0\""));
|
||||
assert!(json.contains("\"hostname\":\"test-host\""));
|
||||
assert!(json.contains("\"lan_ip\":\"192.168.1.50\""));
|
||||
assert!(json.contains("\"sni\":\"numa.numa\""));
|
||||
assert!(json.contains("\"port\":853"));
|
||||
assert!(json.contains("\"port\":8765"));
|
||||
assert!(json.contains("\"fingerprint_sha256\":\"abcd1234\""));
|
||||
assert!(json.contains("\"features\":[\"dot\",\"dnssec\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_response_omits_dot_port_when_disabled() {
|
||||
let meta = HealthMeta {
|
||||
version: "0.10.0",
|
||||
hostname: "t".to_string(),
|
||||
sni: "numa.numa".to_string(),
|
||||
dot_enabled: false,
|
||||
dot_port: 853,
|
||||
api_port: 8765,
|
||||
ca_fingerprint_sha256: None,
|
||||
features: vec![],
|
||||
started_at: Instant::now(),
|
||||
};
|
||||
|
||||
let response = HealthResponse::build(&meta, None);
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
|
||||
assert!(json.contains("\"enabled\":false"));
|
||||
assert!(json.contains("\"dot\":{\"enabled\":false,\"port\":null}"));
|
||||
assert!(json.contains("\"present\":false"));
|
||||
assert!(json.contains("\"lan_ip\":null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ca_fingerprint_returns_none_for_missing_file() {
|
||||
let fp = compute_ca_fingerprint(Path::new("/nonexistent/ca.pem"));
|
||||
assert!(fp.is_none());
|
||||
}
|
||||
}
|
||||
84
src/lan.rs
84
src/lan.rs
@@ -9,7 +9,6 @@ use crate::buffer::BytePacketBuffer;
|
||||
use crate::config::LanConfig;
|
||||
use crate::ctx::ServerCtx;
|
||||
use crate::header::DnsHeader;
|
||||
use crate::health::HealthMeta;
|
||||
use crate::question::{DnsQuestion, QueryType};
|
||||
|
||||
// --- Constants ---
|
||||
@@ -19,18 +18,6 @@ const MDNS_PORT: u16 = 5353;
|
||||
const SERVICE_TYPE: &str = "_numa._tcp.local";
|
||||
const MDNS_TTL: u32 = 120;
|
||||
|
||||
// TXT record key prefixes (including the trailing `=`). Shared between
|
||||
// the sender (`build_announcement`) and the receiver (`parse_mdns_response`)
|
||||
// to prevent drift — both sides match on the same literal, not on two
|
||||
// independent string constants that could diverge.
|
||||
const TXT_SERVICES: &str = "services=";
|
||||
const TXT_ID: &str = "id=";
|
||||
const TXT_VERSION: &str = "version=";
|
||||
const TXT_API_PORT: &str = "api_port=";
|
||||
const TXT_PROTO: &str = "proto=";
|
||||
const TXT_DOT_PORT: &str = "dot_port=";
|
||||
const TXT_CA_FP: &str = "ca_fp=";
|
||||
|
||||
// --- Peer Store ---
|
||||
|
||||
pub struct PeerStore {
|
||||
@@ -110,16 +97,14 @@ pub fn detect_lan_ip() -> Option<Ipv4Addr> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Short hostname for mDNS instance names (`<short>._numa._tcp.local`).
|
||||
/// Truncates at the first `.` so `macbook-pro.local` becomes `macbook-pro`.
|
||||
/// Uses the shared `crate::hostname()` helper as the source.
|
||||
fn get_hostname() -> String {
|
||||
crate::hostname()
|
||||
.split('.')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("numa")
|
||||
.to_string()
|
||||
std::process::Command::new("hostname")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|h| h.trim().split('.').next().unwrap_or("numa").to_string())
|
||||
.filter(|h| !h.is_empty())
|
||||
.unwrap_or_else(|| "numa".to_string())
|
||||
}
|
||||
|
||||
/// Generate a per-process instance ID for self-filtering on multi-instance hosts
|
||||
@@ -183,22 +168,13 @@ pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
|
||||
.map(|e| (e.name.clone(), e.target_port))
|
||||
.collect()
|
||||
};
|
||||
// Note: we always announce ourselves, even when the
|
||||
// services list is empty. The announcement still carries
|
||||
// the mobile API port + version + CA fingerprint in TXT,
|
||||
// which is what the iOS companion app browses for via
|
||||
// NWBrowser on `_numa._tcp.local`. Other Numa peers
|
||||
// receive these empty-services announcements too and
|
||||
// correctly ignore them in parse_mdns_response (the
|
||||
// receiver only processes when services is non-empty).
|
||||
if services.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let current_ip = *sender_ctx.lan_ip.lock().unwrap();
|
||||
if let Ok(pkt) = build_announcement(
|
||||
&sender_hostname,
|
||||
current_ip,
|
||||
&services,
|
||||
&sender_instance_id,
|
||||
&sender_ctx.health_meta,
|
||||
) {
|
||||
if let Ok(pkt) =
|
||||
build_announcement(&sender_hostname, current_ip, &services, &sender_instance_id)
|
||||
{
|
||||
let _ = sender_socket.send_to(pkt.filled(), dest).await;
|
||||
}
|
||||
}
|
||||
@@ -264,7 +240,6 @@ fn build_announcement(
|
||||
ip: Ipv4Addr,
|
||||
services: &[(String, u16)],
|
||||
inst_id: &str,
|
||||
meta: &HealthMeta,
|
||||
) -> crate::Result<BytePacketBuffer> {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
let instance_name = format!("{}._numa._tcp.local", hostname);
|
||||
@@ -285,11 +260,7 @@ fn build_announcement(
|
||||
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
|
||||
|
||||
// SRV: <instance>._numa._tcp.local → <hostname>.local
|
||||
// Port = mobile API port, which is what the iOS companion app resolves
|
||||
// the SRV record for. Legacy Numa peers don't read the SRV port (see
|
||||
// parse_mdns_response — it only uses TXT services= for peer discovery),
|
||||
// so changing the SRV port from "first service's port" to the mobile
|
||||
// API port is backwards compatible.
|
||||
// Port in SRV is informational; actual service ports are in TXT
|
||||
write_record_header(
|
||||
&mut buf,
|
||||
&instance_name,
|
||||
@@ -302,13 +273,11 @@ fn build_announcement(
|
||||
let rdata_start = buf.pos();
|
||||
buf.write_u16(0)?; // priority
|
||||
buf.write_u16(0)?; // weight
|
||||
buf.write_u16(meta.api_port)?; // mobile API port, for iOS companion app
|
||||
buf.write_u16(services.first().map(|(_, p)| *p).unwrap_or(0))?; // first service port for SRV display
|
||||
buf.write_qname(&host_local)?;
|
||||
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
|
||||
|
||||
// TXT: legacy peer-discovery entries (services, id) + enriched entries
|
||||
// for the iOS companion app (version, api_port, proto, dot_port, ca_fp).
|
||||
// All in one TXT RRset per mDNS convention.
|
||||
// TXT: services + instance ID for self-filtering
|
||||
write_record_header(
|
||||
&mut buf,
|
||||
&instance_name,
|
||||
@@ -324,21 +293,8 @@ fn build_announcement(
|
||||
.map(|(name, port)| format!("{}:{}", name, port))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
// Legacy peer-discovery entries (consumed by parse_mdns_response)
|
||||
write_txt_string(&mut buf, &format!("{}{}", TXT_SERVICES, svc_str))?;
|
||||
write_txt_string(&mut buf, &format!("{}{}", TXT_ID, inst_id))?;
|
||||
// Enriched entries (consumed by the iOS/Android companion apps)
|
||||
write_txt_string(&mut buf, &format!("{}{}", TXT_VERSION, meta.version))?;
|
||||
write_txt_string(&mut buf, &format!("{}{}", TXT_API_PORT, meta.api_port))?;
|
||||
if meta.dot_enabled {
|
||||
write_txt_string(&mut buf, &format!("{}dot", TXT_PROTO))?;
|
||||
write_txt_string(&mut buf, &format!("{}{}", TXT_DOT_PORT, meta.dot_port))?;
|
||||
} else {
|
||||
write_txt_string(&mut buf, &format!("{}plain", TXT_PROTO))?;
|
||||
}
|
||||
if let Some(fp) = &meta.ca_fingerprint_sha256 {
|
||||
write_txt_string(&mut buf, &format!("{}{}", TXT_CA_FP, fp))?;
|
||||
}
|
||||
write_txt_string(&mut buf, &format!("services={}", svc_str))?;
|
||||
write_txt_string(&mut buf, &format!("id={}", inst_id))?;
|
||||
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
|
||||
|
||||
// A: <hostname>.local → IP
|
||||
@@ -452,7 +408,7 @@ fn parse_mdns_response(data: &[u8]) -> Option<MdnsAnnouncement> {
|
||||
break;
|
||||
}
|
||||
if let Ok(txt) = std::str::from_utf8(&data[pos..pos + txt_len]) {
|
||||
if let Some(val) = txt.strip_prefix(TXT_SERVICES) {
|
||||
if let Some(val) = txt.strip_prefix("services=") {
|
||||
let svcs: Vec<(String, u16)> = val
|
||||
.split(',')
|
||||
.filter_map(|s| {
|
||||
@@ -465,7 +421,7 @@ fn parse_mdns_response(data: &[u8]) -> Option<MdnsAnnouncement> {
|
||||
if !svcs.is_empty() {
|
||||
txt_services = Some(svcs);
|
||||
}
|
||||
} else if let Some(id) = txt.strip_prefix(TXT_ID) {
|
||||
} else if let Some(id) = txt.strip_prefix("id=") {
|
||||
peer_instance_id = Some(id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
90
src/lib.rs
90
src/lib.rs
@@ -5,13 +5,9 @@ pub mod cache;
|
||||
pub mod config;
|
||||
pub mod ctx;
|
||||
pub mod dnssec;
|
||||
pub mod dot;
|
||||
pub mod forward;
|
||||
pub mod header;
|
||||
pub mod health;
|
||||
pub mod lan;
|
||||
pub mod mobile_api;
|
||||
pub mod mobileconfig;
|
||||
pub mod override_store;
|
||||
pub mod packet;
|
||||
pub mod proxy;
|
||||
@@ -20,7 +16,6 @@ pub mod question;
|
||||
pub mod record;
|
||||
pub mod recursive;
|
||||
pub mod service_store;
|
||||
pub mod setup_phone;
|
||||
pub mod srtt;
|
||||
pub mod stats;
|
||||
pub mod system_dns;
|
||||
@@ -29,25 +24,8 @@ pub mod tls;
|
||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Detect the machine hostname via the `hostname` command. Returns the
|
||||
/// full hostname (e.g., `macbook-pro.local`), or `"numa"` if the command
|
||||
/// fails. Call sites that need the short form (e.g., mDNS instance
|
||||
/// names) should truncate at the first `.`.
|
||||
pub fn hostname() -> String {
|
||||
std::process::Command::new("hostname")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|h| h.trim().to_string())
|
||||
.filter(|h| !h.is_empty())
|
||||
.unwrap_or_else(|| "numa".to_string())
|
||||
}
|
||||
|
||||
/// Shared config directory for persistent data (services.json, etc).
|
||||
/// 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)
|
||||
/// Unix: ~/.config/numa/ (or /usr/local/var/numa/ when running as root daemon)
|
||||
/// Windows: %APPDATA%\numa
|
||||
pub fn config_dir() -> std::path::PathBuf {
|
||||
#[cfg(windows)]
|
||||
@@ -84,15 +62,11 @@ fn config_dir_unix() -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
// Running as root daemon (launchd/systemd) — use system-wide path
|
||||
daemon_data_dir()
|
||||
std::path::PathBuf::from("/usr/local/var/numa")
|
||||
}
|
||||
|
||||
/// Default system-wide data directory for TLS certs. Overridable via
|
||||
/// `[server] data_dir = "..."` in numa.toml — this function only provides
|
||||
/// the fallback when the config doesn't set it.
|
||||
/// 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)
|
||||
/// System-wide data directory for TLS certs.
|
||||
/// Unix: /usr/local/var/numa
|
||||
/// Windows: %PROGRAMDATA%\numa
|
||||
pub fn data_dir() -> std::path::PathBuf {
|
||||
#[cfg(windows)]
|
||||
@@ -104,62 +78,6 @@ pub fn data_dir() -> std::path::PathBuf {
|
||||
}
|
||||
#[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")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
|
||||
98
src/main.rs
98
src/main.rs
@@ -54,9 +54,6 @@ async fn main() -> numa::Result<()> {
|
||||
}
|
||||
};
|
||||
}
|
||||
"setup-phone" => {
|
||||
return numa::setup_phone::run().await.map_err(|e| e.into());
|
||||
}
|
||||
"lan" => {
|
||||
let sub = std::env::args().nth(2).unwrap_or_default();
|
||||
let config_path = std::env::args()
|
||||
@@ -88,27 +85,12 @@ async fn main() -> numa::Result<()> {
|
||||
eprintln!(" service status Check if the service is running");
|
||||
eprintln!(" lan on Enable LAN service discovery (mDNS)");
|
||||
eprintln!(" lan off Disable LAN service discovery");
|
||||
eprintln!(" setup-phone Generate a QR code to install Numa DoT on a phone");
|
||||
eprintln!(" help Show this help");
|
||||
eprintln!();
|
||||
eprintln!("Config path defaults to numa.toml");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
if !arg1.is_empty()
|
||||
&& arg1 != "run"
|
||||
&& !arg1.contains('/')
|
||||
&& !arg1.contains('\\')
|
||||
&& !arg1.ends_with(".toml")
|
||||
{
|
||||
eprintln!(
|
||||
"\x1b[1;38;2;192;98;58mNuma\x1b[0m — unknown command: \x1b[1m{}\x1b[0m\n",
|
||||
arg1
|
||||
);
|
||||
eprintln!("Run \x1b[1mnuma help\x1b[0m for a list of commands.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let config_path = if arg1.is_empty() || arg1 == "run" {
|
||||
@@ -222,30 +204,13 @@ async fn main() -> numa::Result<()> {
|
||||
|
||||
let forwarding_rules = system_dns.forwarding_rules;
|
||||
|
||||
// Resolve data_dir from config, falling back to the platform default.
|
||||
// Used for TLS CA storage below and stored on ServerCtx for runtime use.
|
||||
let resolved_data_dir = config
|
||||
.server
|
||||
.data_dir
|
||||
.clone()
|
||||
.unwrap_or_else(numa::data_dir);
|
||||
|
||||
// Build initial TLS config before ServerCtx (so ArcSwap is ready at construction)
|
||||
let initial_tls = if config.proxy.enabled && config.proxy.tls_port > 0 {
|
||||
let service_names = service_store.names();
|
||||
match numa::tls::build_tls_config(
|
||||
&config.proxy.tld,
|
||||
&service_names,
|
||||
Vec::new(),
|
||||
&resolved_data_dir,
|
||||
) {
|
||||
match numa::tls::build_tls_config(&config.proxy.tld, &service_names) {
|
||||
Ok(tls_config) => Some(ArcSwap::from(tls_config)),
|
||||
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);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -253,34 +218,8 @@ async fn main() -> numa::Result<()> {
|
||||
None
|
||||
};
|
||||
|
||||
let health_meta = numa::health::HealthMeta::build(
|
||||
&resolved_data_dir,
|
||||
config.dot.enabled,
|
||||
config.dot.port,
|
||||
config.mobile.port,
|
||||
config.dnssec.enabled,
|
||||
resolved_mode == numa::config::UpstreamMode::Recursive,
|
||||
config.lan.enabled,
|
||||
config.blocking.enabled,
|
||||
);
|
||||
|
||||
let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok();
|
||||
|
||||
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 {
|
||||
socket,
|
||||
socket: UdpSocket::bind(&config.server.bind_addr).await?,
|
||||
zone_map: build_zone_map(&config.zones)?,
|
||||
cache: RwLock::new(DnsCache::new(
|
||||
config.cache.max_entries,
|
||||
@@ -309,7 +248,7 @@ async fn main() -> numa::Result<()> {
|
||||
config_path: resolved_config_path,
|
||||
config_found,
|
||||
config_dir: numa::config_dir(),
|
||||
data_dir: resolved_data_dir,
|
||||
data_dir: numa::data_dir(),
|
||||
tls_config: initial_tls,
|
||||
upstream_mode: resolved_mode,
|
||||
root_hints,
|
||||
@@ -317,8 +256,6 @@ async fn main() -> numa::Result<()> {
|
||||
inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
dnssec_enabled: config.dnssec.enabled,
|
||||
dnssec_strict: config.dnssec.strict,
|
||||
health_meta,
|
||||
ca_pem,
|
||||
});
|
||||
|
||||
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
||||
@@ -433,9 +370,6 @@ async fn main() -> numa::Result<()> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if config.dot.enabled {
|
||||
row("DoT", g, &format!("tls://:{}", config.dot.port));
|
||||
}
|
||||
if config.lan.enabled {
|
||||
row("LAN", g, "mDNS (_numa._tcp.local)");
|
||||
}
|
||||
@@ -502,21 +436,6 @@ async fn main() -> numa::Result<()> {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
// Spawn Mobile API listener (read-only subset for iOS/Android companion
|
||||
// apps, LAN-bound by default so phones can reach it). Only idempotent
|
||||
// GETs; no state-mutating routes are exposed here regardless of
|
||||
// the main API's bind address.
|
||||
if config.mobile.enabled {
|
||||
let mobile_ctx = Arc::clone(&ctx);
|
||||
let mobile_bind = config.mobile.bind_addr.clone();
|
||||
let mobile_port = config.mobile.port;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = numa::mobile_api::start(mobile_ctx, mobile_bind, mobile_port).await {
|
||||
log::warn!("Mobile API listener failed: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let proxy_bind: std::net::Ipv4Addr = config
|
||||
.proxy
|
||||
.bind_addr
|
||||
@@ -558,15 +477,6 @@ async fn main() -> numa::Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn DNS-over-TLS listener (RFC 7858)
|
||||
if config.dot.enabled {
|
||||
let dot_ctx = Arc::clone(&ctx);
|
||||
let dot_config = config.dot.clone();
|
||||
tokio::spawn(async move {
|
||||
numa::dot::start_dot(dot_ctx, &dot_config).await;
|
||||
});
|
||||
}
|
||||
|
||||
// UDP DNS listener
|
||||
#[allow(clippy::infinite_loop)]
|
||||
loop {
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
//! Mobile API — persistent HTTP listener for iOS/Android companion apps.
|
||||
//!
|
||||
//! Read-only subset of Numa's HTTP surface served on a separate port
|
||||
//! (default 8765) bound to the LAN. Unlike the main API on port 5380
|
||||
//! (which defaults to `127.0.0.1` and serves mutating routes like
|
||||
//! `DELETE /services/{name}` or `PUT /blocking/toggle`), this listener
|
||||
//! is safe to expose on the LAN because every route is idempotent and
|
||||
//! read-only.
|
||||
//!
|
||||
//! Routes (all GET):
|
||||
//!
|
||||
//! - `/health` — enriched status + metadata, shares the handler with the
|
||||
//! main API via `crate::api::health`
|
||||
//! - `/ca.pem` — Numa local CA in PEM form, shares the handler with the
|
||||
//! main API via `crate::api::serve_ca`
|
||||
//! - `/mobileconfig` — combined CA + DNS settings profile (Full mode)
|
||||
//! - `/ca.mobileconfig` — CA-only trust profile (no DNS override)
|
||||
//!
|
||||
//! The mobile API does NOT include the mutating routes (overrides, cache
|
||||
//! flush, blocking toggle, service CRUD, etc.). Even if a user sets
|
||||
//! `api_bind_addr` to `0.0.0.0` for the main API, those routes stay on
|
||||
//! port 5380; the mobile API on port 8765 never serves them. This is the
|
||||
//! primary security boundary: anything exposed to the LAN is read-only.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use log::info;
|
||||
|
||||
use crate::ctx::ServerCtx;
|
||||
use crate::mobileconfig::{build_mobileconfig, ProfileMode};
|
||||
|
||||
/// Content-Disposition for the full CA + DNS profile download.
|
||||
const FULL_PROFILE_DISPOSITION: &str = "attachment; filename=\"numa.mobileconfig\"";
|
||||
|
||||
/// Content-Disposition for the CA-only profile download.
|
||||
const CA_ONLY_PROFILE_DISPOSITION: &str = "attachment; filename=\"numa-ca.mobileconfig\"";
|
||||
|
||||
/// Build the axum router for the mobile API.
|
||||
///
|
||||
/// Shares handler functions with the main API where possible (`health`,
|
||||
/// `serve_ca`) so the response shapes are identical across both ports.
|
||||
pub fn router(ctx: Arc<ServerCtx>) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(crate::api::health))
|
||||
.route("/ca.pem", get(crate::api::serve_ca))
|
||||
.route("/mobileconfig", get(serve_full_mobileconfig))
|
||||
.route("/ca.mobileconfig", get(serve_ca_only_mobileconfig))
|
||||
.with_state(ctx)
|
||||
}
|
||||
|
||||
/// Start the mobile API listener on `bind_addr:port`. Runs until the
|
||||
/// caller cancels the spawned task. Logs the URL on successful bind.
|
||||
pub async fn start(ctx: Arc<ServerCtx>, bind_addr: String, port: u16) -> crate::Result<()> {
|
||||
let addr: std::net::SocketAddr = format!("{}:{}", bind_addr, port).parse()?;
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
|
||||
info!("Mobile API listening on http://{}", addr);
|
||||
|
||||
let app = router(ctx);
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serve the full mobileconfig profile (CA + DNS settings), with the
|
||||
/// DNS payload pointing at the current LAN IP. Each request reads the
|
||||
/// fresh LAN IP from `ctx.lan_ip` so the profile always reflects the
|
||||
/// laptop's current network state.
|
||||
async fn serve_full_mobileconfig(
|
||||
State(ctx): State<Arc<ServerCtx>>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let ca_pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?;
|
||||
let lan_ip: Ipv4Addr = *ctx.lan_ip.lock().unwrap();
|
||||
let profile = build_mobileconfig(ProfileMode::Full { lan_ip }, ca_pem);
|
||||
Ok(profile_response(profile, FULL_PROFILE_DISPOSITION))
|
||||
}
|
||||
|
||||
/// Serve the CA-only mobileconfig profile. Trusts the Numa local CA but
|
||||
/// does NOT change the device's DNS settings. Used by the iOS companion
|
||||
/// app's DoT mode, where the app configures DNS via `NEDNSSettingsManager`
|
||||
/// and only needs the system trust store to accept Numa's self-signed cert.
|
||||
async fn serve_ca_only_mobileconfig(
|
||||
State(ctx): State<Arc<ServerCtx>>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let ca_pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?;
|
||||
let profile = build_mobileconfig(ProfileMode::CaOnly, ca_pem);
|
||||
Ok(profile_response(profile, CA_ONLY_PROFILE_DISPOSITION))
|
||||
}
|
||||
|
||||
/// Shared response constructor for both mobileconfig variants.
|
||||
/// Identical headers; only the Content-Disposition filename differs.
|
||||
fn profile_response(profile: String, disposition: &'static str) -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/x-apple-aspen-config"),
|
||||
(header::CONTENT_DISPOSITION, disposition),
|
||||
(header::CACHE_CONTROL, "no-store"),
|
||||
],
|
||||
profile,
|
||||
)
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
//! Apple `.mobileconfig` profile generator.
|
||||
//!
|
||||
//! Builds iOS Configuration Profiles that Numa serves to phones for one-tap
|
||||
//! CA trust and DNS-over-TLS setup. The plist structure is hand-rendered
|
||||
//! via `format!` — no plist crate dependency, deterministic output, small
|
||||
//! binary footprint.
|
||||
//!
|
||||
//! Two modes:
|
||||
//!
|
||||
//! - [`ProfileMode::Full`]: CA trust payload + DNS settings payload pointing
|
||||
//! at a specific LAN IP over DoT. This is what `numa setup-phone` has
|
||||
//! always produced — the user scans a QR, installs this profile, and the
|
||||
//! phone is configured for DoT through Numa in a single step (after the
|
||||
//! iOS Certificate Trust Settings toggle, which is a separate system
|
||||
//! gate we can't bypass).
|
||||
//!
|
||||
//! - [`ProfileMode::CaOnly`]: CA trust payload only, no DNS settings. Used
|
||||
//! by the future iOS companion app flow where `NEDNSSettingsManager`
|
||||
//! configures DNS programmatically and we only need the system trust
|
||||
//! store to accept Numa's DoT cert. Installing this profile does NOT
|
||||
//! change the user's DNS at all.
|
||||
//!
|
||||
//! Payload identifiers and UUIDs are fixed (not randomized) so iOS replaces
|
||||
//! the existing profile on re-install rather than accumulating duplicates.
|
||||
//! The `Full` and `CaOnly` profiles have distinct top-level UUIDs so they
|
||||
//! can coexist as separate installed profiles, but they share the same CA
|
||||
//! payload UUID since the CA itself is the same trust anchor in both.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// Top-level UUID and PayloadIdentifier for the full profile (CA + DNS).
|
||||
/// Changing this breaks in-place replacement on existing iOS installs.
|
||||
const FULL_PROFILE_UUID: &str = "F1E2D3C4-B5A6-7890-1234-567890ABCDEF";
|
||||
const FULL_PROFILE_ID: &str = "com.numa.dns.profile";
|
||||
|
||||
/// Top-level UUID and PayloadIdentifier for the CA-only profile.
|
||||
/// Distinct from `FULL_PROFILE_UUID` so a user can install one, the other,
|
||||
/// or both without the latest install silently replacing a different mode.
|
||||
const CA_ONLY_PROFILE_UUID: &str = "F2E3D4C5-B6A7-8901-2345-67890ABCDEF0";
|
||||
const CA_ONLY_PROFILE_ID: &str = "com.numa.dns.ca.profile";
|
||||
|
||||
/// CA trust payload UUID. Same in both modes — iOS will see "the same CA
|
||||
/// trust anchor" regardless of which wrapping profile contains it.
|
||||
const CA_PAYLOAD_UUID: &str = "B2C3D4E5-F6A7-8901-BCDE-F12345678901";
|
||||
const CA_PAYLOAD_ID: &str = "com.numa.dns.ca";
|
||||
|
||||
/// DNS settings payload UUID (Full mode only).
|
||||
const DNS_PAYLOAD_UUID: &str = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890";
|
||||
const DNS_PAYLOAD_ID: &str = "com.numa.dns.dot";
|
||||
|
||||
/// Profile mode determines which payloads are included in the generated
|
||||
/// `.mobileconfig`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProfileMode {
|
||||
/// Full profile: CA trust anchor + managed DNS settings payload
|
||||
/// pointing at the given LAN IP over DoT. This is what the classic
|
||||
/// `numa setup-phone` QR flow serves.
|
||||
Full { lan_ip: Ipv4Addr },
|
||||
|
||||
/// CA-only profile: just the trust anchor, no DNS settings. For use
|
||||
/// with the iOS companion app which manages DNS programmatically via
|
||||
/// `NEDNSSettingsManager` and only needs the system trust store to
|
||||
/// accept Numa's self-signed DoT cert.
|
||||
CaOnly,
|
||||
}
|
||||
|
||||
/// Build a full `.mobileconfig` profile as an XML plist string.
|
||||
pub fn build_mobileconfig(mode: ProfileMode, ca_pem: &str) -> String {
|
||||
let ca_payload = build_ca_payload(ca_pem);
|
||||
|
||||
match mode {
|
||||
ProfileMode::Full { lan_ip } => {
|
||||
let dns_payload = build_dns_payload(lan_ip);
|
||||
let payloads = format!("{}\n{}", ca_payload, dns_payload);
|
||||
let description = format!(
|
||||
"Trusts the Numa local CA and routes DNS queries to Numa over DoT on your local network ({lan_ip})"
|
||||
);
|
||||
wrap_plist(
|
||||
&payloads,
|
||||
FULL_PROFILE_UUID,
|
||||
FULL_PROFILE_ID,
|
||||
&description,
|
||||
"Numa DNS",
|
||||
)
|
||||
}
|
||||
ProfileMode::CaOnly => wrap_plist(
|
||||
&ca_payload,
|
||||
CA_ONLY_PROFILE_UUID,
|
||||
CA_ONLY_PROFILE_ID,
|
||||
"Trusts the Numa local Certificate Authority. Does not change your DNS settings.",
|
||||
"Numa CA",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the PEM header/footer and newlines from a CA cert, leaving raw
|
||||
/// base64 for embedding in a plist `<data>` block.
|
||||
fn pem_to_base64(pem: &str) -> String {
|
||||
pem.lines()
|
||||
.filter(|line| !line.starts_with("-----"))
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
/// Wrap the base64 CA cert at 52 chars per line for plist readability
|
||||
/// (matches Apple convention in hand-written profiles).
|
||||
fn chunk_base64(base64: &str) -> String {
|
||||
base64
|
||||
.chars()
|
||||
.collect::<Vec<_>>()
|
||||
.chunks(52)
|
||||
.map(|chunk| format!("\t\t\t{}", chunk.iter().collect::<String>()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Render the `com.apple.security.root` payload dict containing the CA cert.
|
||||
fn build_ca_payload(ca_pem: &str) -> String {
|
||||
let ca_wrapped = chunk_base64(&pem_to_base64(ca_pem));
|
||||
format!(
|
||||
r#" <dict>
|
||||
<key>PayloadCertificateFileName</key>
|
||||
<string>numa-ca.pem</string>
|
||||
<key>PayloadContent</key>
|
||||
<data>
|
||||
{ca}
|
||||
</data>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Numa local Certificate Authority — required for DoT trust</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Numa Local CA</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>{ca_id}</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.security.root</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>{ca_uuid}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>"#,
|
||||
ca = ca_wrapped,
|
||||
ca_id = CA_PAYLOAD_ID,
|
||||
ca_uuid = CA_PAYLOAD_UUID,
|
||||
)
|
||||
}
|
||||
|
||||
/// Render the `com.apple.dnsSettings.managed` payload dict for Full mode.
|
||||
/// Pins the device to Numa as its system resolver over DoT with
|
||||
/// `ServerName = "numa.numa"` (must match the DoT cert SAN).
|
||||
fn build_dns_payload(lan_ip: Ipv4Addr) -> String {
|
||||
format!(
|
||||
r#" <dict>
|
||||
<key>DNSSettings</key>
|
||||
<dict>
|
||||
<key>DNSProtocol</key>
|
||||
<string>TLS</string>
|
||||
<key>ServerAddresses</key>
|
||||
<array>
|
||||
<string>{ip}</string>
|
||||
</array>
|
||||
<key>ServerName</key>
|
||||
<string>numa.numa</string>
|
||||
</dict>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Routes all DNS queries through Numa over DNS-over-TLS</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Numa DNS-over-TLS</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>{dns_id}</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.dnsSettings.managed</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>{dns_uuid}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>"#,
|
||||
ip = lan_ip,
|
||||
dns_id = DNS_PAYLOAD_ID,
|
||||
dns_uuid = DNS_PAYLOAD_UUID,
|
||||
)
|
||||
}
|
||||
|
||||
/// Wrap one or more payload dicts in the top-level plist structure
|
||||
/// with Configuration type, PayloadContent array, and profile metadata.
|
||||
fn wrap_plist(
|
||||
payloads: &str,
|
||||
top_uuid: &str,
|
||||
top_id: &str,
|
||||
description: &str,
|
||||
display_name: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
{payloads}
|
||||
</array>
|
||||
<key>PayloadDescription</key>
|
||||
<string>{description}</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>{display_name}</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>{top_id}</string>
|
||||
<key>PayloadRemovalDisallowed</key>
|
||||
<false/>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>{top_uuid}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
"#,
|
||||
payloads = payloads,
|
||||
description = description,
|
||||
display_name = display_name,
|
||||
top_id = top_id,
|
||||
top_uuid = top_uuid,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE_PEM: &str =
|
||||
"-----BEGIN CERTIFICATE-----\nMIIBkDCCATagAwIBAgIUTEST\n-----END CERTIFICATE-----\n";
|
||||
|
||||
#[test]
|
||||
fn pem_to_base64_strips_headers() {
|
||||
let pem = "-----BEGIN CERTIFICATE-----\nABCDEF\nGHIJKL\n-----END CERTIFICATE-----\n";
|
||||
assert_eq!(pem_to_base64(pem), "ABCDEFGHIJKL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_profile_contains_ip_and_ca() {
|
||||
let config = build_mobileconfig(
|
||||
ProfileMode::Full {
|
||||
lan_ip: Ipv4Addr::new(192, 168, 1, 100),
|
||||
},
|
||||
SAMPLE_PEM,
|
||||
);
|
||||
assert!(config.contains("192.168.1.100"));
|
||||
assert!(config.contains("MIIBkDCCATagAwIBAgIUTEST"));
|
||||
assert!(config.contains("com.apple.security.root"));
|
||||
assert!(config.contains("com.apple.dnsSettings.managed"));
|
||||
assert!(config.contains("DNSProtocol"));
|
||||
assert!(config.contains(FULL_PROFILE_UUID));
|
||||
assert!(config.contains(FULL_PROFILE_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ca_only_profile_contains_ca_but_not_dns() {
|
||||
let config = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM);
|
||||
assert!(config.contains("MIIBkDCCATagAwIBAgIUTEST"));
|
||||
assert!(config.contains("com.apple.security.root"));
|
||||
assert!(!config.contains("com.apple.dnsSettings.managed"));
|
||||
assert!(!config.contains("DNSProtocol"));
|
||||
assert!(!config.contains("ServerAddresses"));
|
||||
assert!(config.contains(CA_ONLY_PROFILE_UUID));
|
||||
assert!(config.contains(CA_ONLY_PROFILE_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_and_ca_only_have_distinct_top_uuids() {
|
||||
let full = build_mobileconfig(
|
||||
ProfileMode::Full {
|
||||
lan_ip: Ipv4Addr::new(10, 0, 0, 1),
|
||||
},
|
||||
SAMPLE_PEM,
|
||||
);
|
||||
let ca_only = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM);
|
||||
assert!(full.contains(FULL_PROFILE_UUID));
|
||||
assert!(!full.contains(CA_ONLY_PROFILE_UUID));
|
||||
assert!(ca_only.contains(CA_ONLY_PROFILE_UUID));
|
||||
assert!(!ca_only.contains(FULL_PROFILE_UUID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_modes_share_ca_payload_uuid() {
|
||||
let full = build_mobileconfig(
|
||||
ProfileMode::Full {
|
||||
lan_ip: Ipv4Addr::new(10, 0, 0, 1),
|
||||
},
|
||||
SAMPLE_PEM,
|
||||
);
|
||||
let ca_only = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM);
|
||||
assert!(full.contains(CA_PAYLOAD_UUID));
|
||||
assert!(ca_only.contains(CA_PAYLOAD_UUID));
|
||||
}
|
||||
}
|
||||
@@ -870,25 +870,14 @@ mod tests {
|
||||
};
|
||||
let handler = handler.clone();
|
||||
tokio::spawn(async move {
|
||||
let timeout = std::time::Duration::from_secs(5);
|
||||
// Read length-prefixed DNS query
|
||||
let mut len_buf = [0u8; 2];
|
||||
if tokio::time::timeout(timeout, stream.read_exact(&mut len_buf))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.ok())
|
||||
.is_none()
|
||||
{
|
||||
if stream.read_exact(&mut len_buf).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let len = u16::from_be_bytes(len_buf) as usize;
|
||||
let mut data = vec![0u8; len];
|
||||
if tokio::time::timeout(timeout, stream.read_exact(&mut data))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.ok())
|
||||
.is_none()
|
||||
{
|
||||
if stream.read_exact(&mut data).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
//! `numa setup-phone` CLI — thin QR wrapper over the persistent mobile API.
|
||||
//!
|
||||
//! Before the mobile API existed, this command spawned its own one-shot
|
||||
//! HTTP server on port 8765 to serve a freshly-generated mobileconfig
|
||||
//! for a single download. That role now belongs to
|
||||
//! [`crate::mobile_api`], which runs persistently alongside the main
|
||||
//! API and serves `/mobileconfig` at the same port whenever Numa is
|
||||
//! running.
|
||||
//!
|
||||
//! This command is now a thin terminal-side wrapper:
|
||||
//!
|
||||
//! 1. Detect the current LAN IP
|
||||
//! 2. Render a terminal QR code pointing at
|
||||
//! `http://<lan_ip>:8765/mobileconfig`
|
||||
//! 3. Print install instructions and exit
|
||||
//!
|
||||
//! The user scans the QR, iOS fetches the profile from the mobile API
|
||||
//! (which is always up as long as `numa` is running), installs, and the
|
||||
//! user walks through Settings → Certificate Trust Settings to enable
|
||||
//! trust.
|
||||
//!
|
||||
//! Numa must be running for the profile download to succeed; if the
|
||||
//! mobile API is not listening on port 8765, the download will fail
|
||||
//! and the user will see Safari's "Cannot Connect to Server" error.
|
||||
//! The CLI prints a reminder about this at the bottom of the output.
|
||||
|
||||
use qrcode::render::unicode;
|
||||
use qrcode::QrCode;
|
||||
|
||||
/// Default port where the persistent mobile API serves `/mobileconfig`.
|
||||
/// Matches `MobileConfig::default().port` in `config.rs`. If the user
|
||||
/// has overridden `[mobile] port = N` in `numa.toml`, they'll need to
|
||||
/// adjust the URL manually — this CLI uses the default without parsing
|
||||
/// `numa.toml`.
|
||||
const SETUP_PORT: u16 = 8765;
|
||||
|
||||
fn render_qr(url: &str) -> Result<String, String> {
|
||||
let code = QrCode::new(url).map_err(|e| format!("failed to encode QR: {}", e))?;
|
||||
Ok(code
|
||||
.render::<unicode::Dense1x2>()
|
||||
.dark_color(unicode::Dense1x2::Light)
|
||||
.light_color(unicode::Dense1x2::Dark)
|
||||
.build())
|
||||
}
|
||||
|
||||
/// Run the `numa setup-phone` flow.
|
||||
pub async fn run() -> Result<(), String> {
|
||||
let lan_ip = crate::lan::detect_lan_ip()
|
||||
.ok_or("could not detect LAN IP — are you connected to a network?")?;
|
||||
|
||||
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], SETUP_PORT));
|
||||
let api_reachable = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(500),
|
||||
tokio::net::TcpStream::connect(addr),
|
||||
)
|
||||
.await
|
||||
.map(|r| r.is_ok())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !api_reachable {
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" \x1b[1;38;2;192;98;58mNuma\x1b[0m — mobile API is not reachable on port {}.",
|
||||
SETUP_PORT
|
||||
);
|
||||
eprintln!();
|
||||
eprintln!(" The phone won't be able to download the profile until the mobile");
|
||||
eprintln!(" API is running. Add this to your numa.toml and restart Numa:");
|
||||
eprintln!();
|
||||
eprintln!(" [mobile]");
|
||||
eprintln!(" enabled = true");
|
||||
eprintln!();
|
||||
return Err("mobile API not running".into());
|
||||
}
|
||||
|
||||
let url = format!("http://{}:{}/mobileconfig", lan_ip, SETUP_PORT);
|
||||
let qr = render_qr(&url)?;
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" \x1b[1;38;2;192;98;58mNuma Phone Setup\x1b[0m");
|
||||
eprintln!();
|
||||
eprintln!(" Profile URL: \x1b[36m{}\x1b[0m", url);
|
||||
eprintln!();
|
||||
for line in qr.lines() {
|
||||
eprintln!(" {}", line);
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!(" \x1b[1mOn your iPhone:\x1b[0m");
|
||||
eprintln!(" 1. Open Camera, point at the QR code, tap the yellow banner");
|
||||
eprintln!(" 2. Allow the download when Safari asks");
|
||||
eprintln!(" 3. Open Settings — tap \"Profile Downloaded\" near the top");
|
||||
eprintln!(" (or: Settings → General → VPN & Device Management → Numa DNS)");
|
||||
eprintln!(" 4. Tap Install (top right), enter passcode, Install again");
|
||||
eprintln!(" 5. \x1b[1mSettings → General → About → Certificate Trust Settings\x1b[0m");
|
||||
eprintln!(" Toggle ON \"Numa Local CA\" — required for DoT to work");
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" \x1b[33mNote:\x1b[0m profile uses your laptop's current IP ({}). If your",
|
||||
lan_ip
|
||||
);
|
||||
eprintln!(" laptop changes networks, re-scan this QR — iOS will replace the");
|
||||
eprintln!(" existing profile automatically (fixed UUID).");
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" \x1b[90mThe profile is served by Numa's persistent mobile API on port {}.\x1b[0m",
|
||||
SETUP_PORT
|
||||
);
|
||||
eprintln!(" \x1b[90mMake sure `numa` is running before scanning. If it's not,\x1b[0m");
|
||||
eprintln!(" \x1b[90mstart it with `sudo numa install` or run it interactively.\x1b[0m");
|
||||
eprintln!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn render_qr_produces_unicode() {
|
||||
let qr = render_qr("http://192.168.1.9:8765/mobileconfig").unwrap();
|
||||
assert!(!qr.is_empty());
|
||||
// Dense1x2 uses these block characters
|
||||
assert!(qr.chars().any(|c| matches!(c, '█' | '▀' | '▄' | ' ')));
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,6 @@ use std::net::SocketAddr;
|
||||
|
||||
use log::info;
|
||||
|
||||
fn print_recursive_hint() {
|
||||
let is_recursive = crate::config::load_config("numa.toml")
|
||||
.map(|c| c.config.upstream.mode == crate::config::UpstreamMode::Recursive)
|
||||
.unwrap_or(false);
|
||||
if !is_recursive {
|
||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
||||
eprintln!(" [upstream]");
|
||||
eprintln!(" mode = \"recursive\"\n");
|
||||
}
|
||||
}
|
||||
|
||||
fn is_loopback_or_stub(addr: &str) -> bool {
|
||||
matches!(addr, "127.0.0.1" | "127.0.0.53" | "0.0.0.0" | "::1" | "")
|
||||
}
|
||||
@@ -57,60 +46,6 @@ 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")]
|
||||
fn discover_macos() -> SystemDnsInfo {
|
||||
use log::{debug, warn};
|
||||
@@ -239,9 +174,6 @@ fn discover_linux() -> SystemDnsInfo {
|
||||
let default_upstream = if let Some(ns) = upstream {
|
||||
info!("detected system upstream: {}", ns);
|
||||
Some(ns)
|
||||
} else if let Some(ns) = resolvectl_dns_server() {
|
||||
info!("detected system upstream via resolvectl: {}", ns);
|
||||
Some(ns)
|
||||
} else {
|
||||
// Fallback to backup from a previous `numa install`
|
||||
let backup = {
|
||||
@@ -282,18 +214,7 @@ fn discover_linux() -> SystemDnsInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Parse resolv.conf in a single pass, extracting both the first non-loopback
|
||||
/// nameserver and all search domains.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
||||
@@ -301,13 +222,19 @@ fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return (None, Vec::new()),
|
||||
};
|
||||
let upstream = iter_nameservers(&text)
|
||||
.find(|ns| !is_loopback_or_stub(ns))
|
||||
.map(str::to_string);
|
||||
let mut upstream = None;
|
||||
let mut search_domains = Vec::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("search") || line.starts_with("domain") {
|
||||
if line.starts_with("nameserver") {
|
||||
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) {
|
||||
search_domains.push(domain.to_string());
|
||||
}
|
||||
@@ -316,21 +243,6 @@ fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
||||
(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).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn resolvectl_dns_server() -> Option<String> {
|
||||
@@ -614,19 +526,9 @@ fn enable_dnscache() {
|
||||
.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)]
|
||||
fn install_windows() -> Result<(), String> {
|
||||
let mut interfaces = get_windows_interfaces()?;
|
||||
let interfaces = get_windows_interfaces()?;
|
||||
if interfaces.is_empty() {
|
||||
return Err("no active network interfaces found".to_string());
|
||||
}
|
||||
@@ -636,30 +538,9 @@ fn install_windows() -> Result<(), String> {
|
||||
std::fs::create_dir_all(parent)
|
||||
.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)
|
||||
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
||||
std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?;
|
||||
}
|
||||
|
||||
for name in interfaces.keys() {
|
||||
let status = std::process::Command::new("netsh")
|
||||
@@ -689,17 +570,16 @@ fn install_windows() -> Result<(), String> {
|
||||
let needs_reboot = disable_dnscache()?;
|
||||
register_autostart();
|
||||
|
||||
eprintln!();
|
||||
if !has_useful_existing {
|
||||
eprintln!(" Original DNS saved to {}", path.display());
|
||||
}
|
||||
eprintln!("\n Original DNS saved to {}", path.display());
|
||||
eprintln!(" Run 'numa uninstall' to restore.\n");
|
||||
if needs_reboot {
|
||||
eprintln!(" *** Reboot required. Numa will start automatically. ***\n");
|
||||
} else {
|
||||
eprintln!(" Numa will start automatically on next boot.\n");
|
||||
}
|
||||
print_recursive_hint();
|
||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
||||
eprintln!(" [upstream]");
|
||||
eprintln!(" mode = \"recursive\"\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -874,60 +754,27 @@ 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")]
|
||||
fn install_macos() -> Result<(), String> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
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();
|
||||
std::fs::create_dir_all(&dir)
|
||||
.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)
|
||||
.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
|
||||
for service in &services {
|
||||
@@ -948,10 +795,7 @@ fn install_macos() -> Result<(), String> {
|
||||
.status();
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
if !has_useful_existing {
|
||||
eprintln!(" Original DNS saved to {}", backup_path().display());
|
||||
}
|
||||
eprintln!("\n Original DNS saved to {}", backup_path().display());
|
||||
eprintln!(" Run 'sudo numa uninstall' to restore.\n");
|
||||
|
||||
Ok(())
|
||||
@@ -1146,23 +990,14 @@ fn install_service_macos() -> Result<(), String> {
|
||||
std::fs::write(PLIST_DEST, plist)
|
||||
.map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?;
|
||||
|
||||
// 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();
|
||||
|
||||
// Load the service first so numa is listening before DNS redirect
|
||||
let status = std::process::Command::new("launchctl")
|
||||
.args(["bootstrap", "system", PLIST_DEST])
|
||||
.args(["load", "-w", PLIST_DEST])
|
||||
.status()
|
||||
.map_err(|e| format!("failed to run launchctl: {}", e))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err("launchctl bootstrap failed".to_string());
|
||||
return Err("launchctl load failed".to_string());
|
||||
}
|
||||
|
||||
// Wait for numa to be ready before redirecting DNS
|
||||
@@ -1175,7 +1010,7 @@ fn install_service_macos() -> Result<(), String> {
|
||||
if !api_up {
|
||||
// Service failed to start — don't redirect DNS to a dead endpoint
|
||||
let _ = std::process::Command::new("launchctl")
|
||||
.args(["bootout", "system", PLIST_DEST])
|
||||
.args(["unload", PLIST_DEST])
|
||||
.status();
|
||||
return Err(
|
||||
"numa service did not start (port 53 may be in use). Service unloaded.".to_string(),
|
||||
@@ -1190,7 +1025,9 @@ fn install_service_macos() -> Result<(), String> {
|
||||
eprintln!(" Numa will auto-start on boot and restart if killed.");
|
||||
eprintln!(" Logs: /usr/local/var/log/numa.log");
|
||||
eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n");
|
||||
print_recursive_hint();
|
||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
||||
eprintln!(" [upstream]");
|
||||
eprintln!(" mode = \"recursive\"\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1201,28 +1038,25 @@ fn uninstall_service_macos() -> Result<(), String> {
|
||||
eprintln!(" warning: failed to restore system DNS: {}", e);
|
||||
}
|
||||
|
||||
// Bootout the service from launchd's in-memory state BEFORE removing
|
||||
// the plist. The modern API needs the file path as the specifier;
|
||||
// doing this in the wrong order would leave the service loaded in
|
||||
// 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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove plist so the service won't restart on boot
|
||||
// Remove plist first so service won't restart on boot even if unload fails
|
||||
if let Err(e) = std::fs::remove_file(PLIST_DEST) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
return Err(format!("failed to remove {}: {}", PLIST_DEST, e));
|
||||
}
|
||||
}
|
||||
|
||||
// Unload the service
|
||||
let status = std::process::Command::new("launchctl")
|
||||
.args(["unload", "-w", PLIST_DEST])
|
||||
.status();
|
||||
if let Ok(s) = status {
|
||||
if !s.success() {
|
||||
eprintln!(
|
||||
" warning: launchctl unload returned non-zero (service may still be running)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(" Service uninstalled. Numa will no longer auto-start.\n");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1298,31 +1132,11 @@ fn install_linux() -> Result<(), String> {
|
||||
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
||||
}
|
||||
|
||||
// Back up current resolv.conf, but never overwrite a useful existing
|
||||
// backup with a numa-managed file — that would leave uninstall with
|
||||
// nothing to restore to.
|
||||
let current = std::fs::read_to_string(resolv).ok();
|
||||
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());
|
||||
// Back up current resolv.conf (ignore NotFound)
|
||||
match std::fs::copy(resolv, &backup) {
|
||||
Ok(_) => eprintln!(" Saved /etc/resolv.conf to {}", backup.display()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("failed to backup /etc/resolv.conf: {}", e)),
|
||||
}
|
||||
|
||||
if resolv
|
||||
@@ -1395,7 +1209,9 @@ fn install_service_linux() -> Result<(), String> {
|
||||
eprintln!(" Numa will auto-start on boot and restart if killed.");
|
||||
eprintln!(" Logs: journalctl -u numa -f");
|
||||
eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n");
|
||||
print_recursive_hint();
|
||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
||||
eprintln!(" [upstream]");
|
||||
eprintln!(" mode = \"recursive\"\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1462,86 +1278,14 @@ fn run_systemctl(args: &[&str]) -> Result<(), String> {
|
||||
|
||||
// --- 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> {
|
||||
let ca_path = crate::data_dir().join(crate::tls::CA_FILE_NAME);
|
||||
let ca_path = crate::data_dir().join("ca.pem");
|
||||
if !ca_path.exists() {
|
||||
return Err("CA not generated yet — start numa first to create certificates".into());
|
||||
}
|
||||
|
||||
#[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")
|
||||
.args([
|
||||
"add-trusted-cert",
|
||||
@@ -1551,23 +1295,48 @@ fn trust_ca_macos(ca_path: &std::path::Path) -> Result<(), String> {
|
||||
"-k",
|
||||
"/Library/Keychains/System.keychain",
|
||||
])
|
||||
.arg(ca_path)
|
||||
.arg(&ca_path)
|
||||
.status()
|
||||
.map_err(|e| format!("security: {}", e))?;
|
||||
if !status.success() {
|
||||
return Err("security add-trusted-cert failed".into());
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
fn untrust_ca() -> Result<(), String> {
|
||||
let ca_path = crate::data_dir().join("ca.pem");
|
||||
|
||||
#[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")
|
||||
.args([
|
||||
"find-certificate",
|
||||
"-c",
|
||||
crate::tls::CA_COMMON_NAME,
|
||||
"Numa Local CA",
|
||||
"-a",
|
||||
"-Z",
|
||||
"/Library/Keychains/System.keychain",
|
||||
@@ -1590,81 +1359,21 @@ fn untrust_ca_macos() -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
eprintln!(" Removed Numa CA from system keychain");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn trust_ca_linux(ca_path: &std::path::Path) -> Result<(), String> {
|
||||
let store = detect_linux_trust_store().ok_or_else(|| {
|
||||
let names: Vec<&str> = LINUX_TRUST_STORES.iter().map(|s| s.name).collect();
|
||||
format!(
|
||||
"no supported CA trust store found (tried: {}). \
|
||||
Please report at https://github.com/razvandimescu/numa/issues",
|
||||
names.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
#[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])
|
||||
{
|
||||
let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt");
|
||||
if dest.exists() {
|
||||
let _ = std::fs::remove_file(dest);
|
||||
let _ = std::process::Command::new("update-ca-certificates")
|
||||
.arg("--fresh")
|
||||
.status();
|
||||
eprintln!(" Removed Numa CA from Windows Root store");
|
||||
eprintln!(" Removed Numa CA from system trust store");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = ca_path; // suppress unused warning on other platforms
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1723,82 +1432,6 @@ Wireless LAN adapter Wi-Fi:
|
||||
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]
|
||||
fn parse_ipconfig_skips_disconnected() {
|
||||
let sample = "\
|
||||
@@ -1815,43 +1448,4 @@ Wireless LAN adapter Wi-Fi:
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result.contains_key("Wi-Fi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_port53_advisory_addr_in_use() {
|
||||
let err = std::io::Error::from(std::io::ErrorKind::AddrInUse);
|
||||
let msg = try_port53_advisory("0.0.0.0:53", &err).expect("should advise on port 53");
|
||||
assert!(msg.contains("cannot bind to"));
|
||||
assert!(msg.contains("already in use"));
|
||||
assert!(msg.contains("numa install"));
|
||||
assert!(msg.contains("bind_addr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_port53_advisory_permission_denied() {
|
||||
let err = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
|
||||
let msg = try_port53_advisory("0.0.0.0:53", &err).expect("should advise on port 53");
|
||||
assert!(msg.contains("cannot bind to"));
|
||||
assert!(msg.contains("permission denied"));
|
||||
assert!(msg.contains("numa install"));
|
||||
assert!(msg.contains("bind_addr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_port53_advisory_skips_non_53_ports() {
|
||||
let err = std::io::Error::from(std::io::ErrorKind::AddrInUse);
|
||||
assert!(try_port53_advisory("127.0.0.1:5354", &err).is_none());
|
||||
assert!(try_port53_advisory("[::]:853", &err).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_port53_advisory_skips_unrelated_error_kinds() {
|
||||
let err = std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||
assert!(try_port53_advisory("0.0.0.0:53", &err).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_port53_advisory_skips_malformed_bind_addr() {
|
||||
let err = std::io::Error::from(std::io::ErrorKind::AddrInUse);
|
||||
assert!(try_port53_advisory("not-an-address", &err).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
126
src/tls.rs
126
src/tls.rs
@@ -5,9 +5,7 @@ use std::sync::Arc;
|
||||
use log::{info, warn};
|
||||
|
||||
use crate::ctx::ServerCtx;
|
||||
use rcgen::{
|
||||
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
|
||||
};
|
||||
use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
use rustls::ServerConfig;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
@@ -15,13 +13,6 @@ use time::{Duration, OffsetDateTime};
|
||||
const CA_VALIDITY_DAYS: i64 = 3650; // 10 years
|
||||
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.
|
||||
pub fn regenerate_tls(ctx: &ServerCtx) {
|
||||
let tls = match &ctx.tls_config {
|
||||
@@ -33,7 +24,7 @@ pub fn regenerate_tls(ctx: &ServerCtx) {
|
||||
names.extend(ctx.lan_peers.lock().unwrap().names());
|
||||
let names: Vec<String> = names.into_iter().collect();
|
||||
|
||||
match build_tls_config(&ctx.proxy_tld, &names, Vec::new(), &ctx.data_dir) {
|
||||
match build_tls_config(&ctx.proxy_tld, &names) {
|
||||
Ok(new_config) => {
|
||||
tls.store(new_config);
|
||||
info!("TLS cert regenerated for {} services", names.len());
|
||||
@@ -42,63 +33,20 @@ 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.
|
||||
/// Wildcards under single-label TLDs (*.numa) are rejected by browsers,
|
||||
/// so we list each service explicitly as a SAN.
|
||||
/// `alpn` is advertised in the TLS ServerHello — pass empty for the proxy
|
||||
/// (which accepts any ALPN), or `[b"dot"]` for DoT (RFC 7858 §3.2).
|
||||
/// `data_dir` is where the CA material is stored — taken from
|
||||
/// `[server] data_dir` in numa.toml (defaults to `crate::data_dir()`).
|
||||
pub fn build_tls_config(
|
||||
tld: &str,
|
||||
service_names: &[String],
|
||||
alpn: Vec<Vec<u8>>,
|
||||
data_dir: &Path,
|
||||
) -> crate::Result<Arc<ServerConfig>> {
|
||||
let (ca_der, issuer) = ensure_ca(data_dir)?;
|
||||
let (cert_chain, key) = generate_service_cert(&ca_der, &issuer, tld, service_names)?;
|
||||
pub fn build_tls_config(tld: &str, service_names: &[String]) -> crate::Result<Arc<ServerConfig>> {
|
||||
let dir = crate::data_dir();
|
||||
let (ca_cert, ca_key) = ensure_ca(&dir)?;
|
||||
let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?;
|
||||
|
||||
// Ensure a crypto provider is installed (rustls needs one)
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let mut config = ServerConfig::builder()
|
||||
let config = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(cert_chain, key)?;
|
||||
config.alpn_protocols = alpn;
|
||||
|
||||
info!(
|
||||
"TLS configured for {} .{} domains",
|
||||
@@ -108,20 +56,18 @@ pub fn build_tls_config(
|
||||
Ok(Arc::new(config))
|
||||
}
|
||||
|
||||
fn ensure_ca(dir: &Path) -> crate::Result<(CertificateDer<'static>, Issuer<'static, KeyPair>)> {
|
||||
fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
|
||||
let ca_key_path = dir.join("ca.key");
|
||||
let ca_cert_path = dir.join(CA_FILE_NAME);
|
||||
let ca_cert_path = dir.join("ca.pem");
|
||||
|
||||
if ca_key_path.exists() && ca_cert_path.exists() {
|
||||
let key_pem = std::fs::read_to_string(&ca_key_path)?;
|
||||
let cert_pem = std::fs::read_to_string(&ca_cert_path)?;
|
||||
let key_pair = KeyPair::from_pem(&key_pem)?;
|
||||
let ca_der = rustls_pemfile::certs(&mut cert_pem.as_bytes())
|
||||
.next()
|
||||
.ok_or("empty CA PEM file")??;
|
||||
let issuer = Issuer::from_ca_cert_der(&ca_der, key_pair)?;
|
||||
let params = CertificateParams::from_ca_cert_pem(&cert_pem)?;
|
||||
let cert = params.self_signed(&key_pair)?;
|
||||
info!("loaded CA from {:?}", ca_cert_path);
|
||||
return Ok((ca_der, issuer));
|
||||
return Ok((cert, key_pair));
|
||||
}
|
||||
|
||||
// Generate new CA
|
||||
@@ -131,7 +77,7 @@ fn ensure_ca(dir: &Path) -> crate::Result<(CertificateDer<'static>, Issuer<'stat
|
||||
let mut params = CertificateParams::default();
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::CommonName, CA_COMMON_NAME);
|
||||
.push(DnType::CommonName, "Numa Local CA");
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
@@ -149,16 +95,14 @@ fn ensure_ca(dir: &Path) -> crate::Result<(CertificateDer<'static>, Issuer<'stat
|
||||
}
|
||||
|
||||
info!("generated CA at {:?}", ca_cert_path);
|
||||
let ca_der = cert.der().clone();
|
||||
let issuer = Issuer::new(params, key_pair);
|
||||
Ok((ca_der, issuer))
|
||||
Ok((cert, key_pair))
|
||||
}
|
||||
|
||||
/// Generate a cert with explicit SANs for each service name.
|
||||
/// Always regenerated at startup (~5ms) — no disk caching needed.
|
||||
fn generate_service_cert(
|
||||
ca_der: &CertificateDer<'static>,
|
||||
issuer: &Issuer<'_, KeyPair>,
|
||||
ca_cert: &rcgen::Certificate,
|
||||
ca_key: &KeyPair,
|
||||
tld: &str,
|
||||
service_names: &[String],
|
||||
) -> crate::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
|
||||
@@ -193,7 +137,7 @@ fn generate_service_cert(
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
params.not_after = OffsetDateTime::now_utc() + Duration::days(CERT_VALIDITY_DAYS);
|
||||
|
||||
let cert = params.signed_by(&key_pair, issuer)?;
|
||||
let cert = params.signed_by(&key_pair, ca_cert, ca_key)?;
|
||||
|
||||
info!(
|
||||
"generated TLS cert for: {}",
|
||||
@@ -204,39 +148,9 @@ fn generate_service_cert(
|
||||
.join(", ")
|
||||
);
|
||||
|
||||
let cert_der = cert.der().clone();
|
||||
let ca_cert_der = ca_der.clone();
|
||||
let cert_der = CertificateDer::from(cert.der().to_vec());
|
||||
let ca_der = CertificateDer::from(ca_cert.der().to_vec());
|
||||
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der()));
|
||||
|
||||
Ok((vec![cert_der, ca_cert_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());
|
||||
}
|
||||
Ok((vec![cert_der, ca_der], key_der))
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/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 ]
|
||||
@@ -1,147 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/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
|
||||
@@ -404,241 +404,6 @@ check "Cache flushed" \
|
||||
|
||||
kill "$NUMA_PID" 2>/dev/null || true
|
||||
wait "$NUMA_PID" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# ---- Suite 5: DNS-over-TLS (RFC 7858) ----
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════╗"
|
||||
echo "║ Suite 5: DNS-over-TLS (RFC 7858) ║"
|
||||
echo "╚══════════════════════════════════════════╝"
|
||||
|
||||
if ! command -v kdig >/dev/null 2>&1; then
|
||||
printf " ${DIM}skipped — install 'knot' for kdig${RESET}\n"
|
||||
elif ! command -v openssl >/dev/null 2>&1; then
|
||||
printf " ${DIM}skipped — openssl not found${RESET}\n"
|
||||
else
|
||||
DOT_PORT=8853
|
||||
DOT_CERT=/tmp/numa-integration-dot.crt
|
||||
DOT_KEY=/tmp/numa-integration-dot.key
|
||||
|
||||
# Generate a test cert mirroring production self_signed_tls SAN shape
|
||||
# (*.numa wildcard + explicit numa.numa apex).
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
|
||||
-keyout "$DOT_KEY" -out "$DOT_CERT" \
|
||||
-subj "/CN=Numa .numa services" \
|
||||
-addext "subjectAltName=DNS:*.numa,DNS:numa.numa" \
|
||||
>/dev/null 2>&1
|
||||
|
||||
# Suite 5 uses a local zone so it's upstream-independent — the point is
|
||||
# to exercise the DoT transport layer (handshake, ALPN, framing,
|
||||
# persistent connections), not re-test recursive resolution.
|
||||
cat > "$CONFIG" << CONF
|
||||
[server]
|
||||
bind_addr = "127.0.0.1:$PORT"
|
||||
api_port = $API_PORT
|
||||
|
||||
[upstream]
|
||||
mode = "forward"
|
||||
address = "127.0.0.1"
|
||||
port = 65535
|
||||
|
||||
[cache]
|
||||
max_entries = 10000
|
||||
|
||||
[blocking]
|
||||
enabled = false
|
||||
|
||||
[proxy]
|
||||
enabled = false
|
||||
|
||||
[dot]
|
||||
enabled = true
|
||||
port = $DOT_PORT
|
||||
bind_addr = "127.0.0.1"
|
||||
cert_path = "$DOT_CERT"
|
||||
key_path = "$DOT_KEY"
|
||||
|
||||
[[zones]]
|
||||
domain = "dot-test.example"
|
||||
record_type = "A"
|
||||
value = "10.0.0.1"
|
||||
ttl = 60
|
||||
CONF
|
||||
|
||||
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
|
||||
NUMA_PID=$!
|
||||
sleep 4
|
||||
|
||||
if ! kill -0 "$NUMA_PID" 2>/dev/null; then
|
||||
FAILED=$((FAILED + 1))
|
||||
printf " ${RED}✗${RESET} DoT startup\n"
|
||||
printf " ${DIM}%s${RESET}\n" "$(tail -5 "$LOG")"
|
||||
else
|
||||
echo ""
|
||||
echo "=== Listener ==="
|
||||
|
||||
check "DoT bound on 127.0.0.1:$DOT_PORT" \
|
||||
"DoT listening on 127.0.0.1:$DOT_PORT" \
|
||||
"$(grep 'DoT listening' "$LOG")"
|
||||
|
||||
KDIG="kdig @127.0.0.1 -p $DOT_PORT +tls +tls-ca=$DOT_CERT +tls-hostname=numa.numa +time=5 +retry=0"
|
||||
|
||||
echo ""
|
||||
echo "=== Queries over DoT ==="
|
||||
|
||||
check "DoT local zone A record" \
|
||||
"10.0.0.1" \
|
||||
"$($KDIG +short dot-test.example A 2>/dev/null)"
|
||||
|
||||
# +keepopen reuses one TLS connection for multiple queries — tests
|
||||
# persistent connection handling. kdig applies options left-to-right,
|
||||
# so +short and +keepopen must come before the query specs.
|
||||
check "DoT persistent connection (3 queries, 1 handshake)" \
|
||||
"10.0.0.1" \
|
||||
"$($KDIG +keepopen +short dot-test.example A dot-test.example A dot-test.example A 2>/dev/null | head -1)"
|
||||
|
||||
echo ""
|
||||
echo "=== ALPN ==="
|
||||
|
||||
# Positive case: client offers "dot", server picks it.
|
||||
ALPN_OK=$(echo "" | openssl s_client -connect "127.0.0.1:$DOT_PORT" \
|
||||
-servername numa.numa -alpn dot -CAfile "$DOT_CERT" 2>&1 </dev/null || true)
|
||||
check "DoT negotiates ALPN \"dot\"" \
|
||||
"ALPN protocol: dot" \
|
||||
"$ALPN_OK"
|
||||
|
||||
# Negative case: client offers only "h2", server must reject the
|
||||
# handshake with no_application_protocol alert (cross-protocol
|
||||
# confusion defense, RFC 7858bis §3.2).
|
||||
if echo "" | openssl s_client -connect "127.0.0.1:$DOT_PORT" \
|
||||
-servername numa.numa -alpn h2 -CAfile "$DOT_CERT" \
|
||||
</dev/null >/dev/null 2>&1; then
|
||||
ALPN_MISMATCH="handshake unexpectedly succeeded"
|
||||
else
|
||||
ALPN_MISMATCH="rejected"
|
||||
fi
|
||||
check "DoT rejects non-dot ALPN" \
|
||||
"rejected" \
|
||||
"$ALPN_MISMATCH"
|
||||
fi
|
||||
|
||||
kill "$NUMA_PID" 2>/dev/null || true
|
||||
wait "$NUMA_PID" 2>/dev/null || true
|
||||
rm -f "$DOT_CERT" "$DOT_KEY"
|
||||
fi
|
||||
sleep 1
|
||||
|
||||
# ---- Suite 6: Proxy + DoT coexistence ----
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════╗"
|
||||
echo "║ Suite 6: Proxy + DoT Coexistence ║"
|
||||
echo "╚══════════════════════════════════════════╝"
|
||||
|
||||
if ! command -v kdig >/dev/null 2>&1 || ! command -v openssl >/dev/null 2>&1; then
|
||||
printf " ${DIM}skipped — needs kdig + openssl${RESET}\n"
|
||||
else
|
||||
DOT_PORT=8853
|
||||
PROXY_HTTP_PORT=8080
|
||||
PROXY_HTTPS_PORT=8443
|
||||
NUMA_DATA=/tmp/numa-integration-data
|
||||
|
||||
# Fresh data dir so we generate a fresh CA for this suite. Path is set
|
||||
# via [server] data_dir in the TOML below, not an env var — numa treats
|
||||
# its config file as the single source of truth for all knobs.
|
||||
rm -rf "$NUMA_DATA"
|
||||
mkdir -p "$NUMA_DATA"
|
||||
|
||||
cat > "$CONFIG" << CONF
|
||||
[server]
|
||||
bind_addr = "127.0.0.1:$PORT"
|
||||
api_port = $API_PORT
|
||||
data_dir = "$NUMA_DATA"
|
||||
|
||||
[upstream]
|
||||
mode = "forward"
|
||||
address = "127.0.0.1"
|
||||
port = 65535
|
||||
|
||||
[cache]
|
||||
max_entries = 10000
|
||||
|
||||
[blocking]
|
||||
enabled = false
|
||||
|
||||
[proxy]
|
||||
enabled = true
|
||||
port = $PROXY_HTTP_PORT
|
||||
tls_port = $PROXY_HTTPS_PORT
|
||||
tld = "numa"
|
||||
bind_addr = "127.0.0.1"
|
||||
|
||||
[dot]
|
||||
enabled = true
|
||||
port = $DOT_PORT
|
||||
bind_addr = "127.0.0.1"
|
||||
|
||||
[[zones]]
|
||||
domain = "dot-test.example"
|
||||
record_type = "A"
|
||||
value = "10.0.0.1"
|
||||
ttl = 60
|
||||
CONF
|
||||
|
||||
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
|
||||
NUMA_PID=$!
|
||||
sleep 4
|
||||
|
||||
if ! kill -0 "$NUMA_PID" 2>/dev/null; then
|
||||
FAILED=$((FAILED + 1))
|
||||
printf " ${RED}✗${RESET} Startup with proxy + DoT\n"
|
||||
printf " ${DIM}%s${RESET}\n" "$(tail -5 "$LOG")"
|
||||
else
|
||||
echo ""
|
||||
echo "=== Both listeners ==="
|
||||
|
||||
check "DoT listener bound" \
|
||||
"DoT listening on 127.0.0.1:$DOT_PORT" \
|
||||
"$(grep 'DoT listening' "$LOG")"
|
||||
|
||||
check "HTTPS proxy listener bound" \
|
||||
"HTTPS proxy listening on 127.0.0.1:$PROXY_HTTPS_PORT" \
|
||||
"$(grep 'HTTPS proxy listening' "$LOG")"
|
||||
|
||||
PANIC_COUNT=$(grep -c 'panicked' "$LOG" 2>/dev/null || echo 0)
|
||||
check "No startup panics in log" \
|
||||
"^0$" \
|
||||
"$PANIC_COUNT"
|
||||
|
||||
echo ""
|
||||
echo "=== DoT works with proxy enabled ==="
|
||||
|
||||
# Proxy's build_tls_config runs first and creates the CA in
|
||||
# $NUMA_DATA_DIR. DoT self_signed_tls then loads the same CA and
|
||||
# issues its own leaf cert. One CA trusts both listeners.
|
||||
CA="$NUMA_DATA/ca.pem"
|
||||
KDIG="kdig @127.0.0.1 -p $DOT_PORT +tls +tls-ca=$CA +tls-hostname=numa.numa +time=5 +retry=0"
|
||||
|
||||
check "DoT local zone A (with proxy on)" \
|
||||
"10.0.0.1" \
|
||||
"$($KDIG +short dot-test.example A 2>/dev/null)"
|
||||
|
||||
echo ""
|
||||
echo "=== Proxy TLS works with DoT enabled ==="
|
||||
|
||||
# Proxy cert has SAN numa.numa (auto-added "numa" service). A
|
||||
# successful handshake validates that the proxy's separate
|
||||
# ServerConfig wasn't disturbed by DoT's own cert generation.
|
||||
PROXY_TLS=$(echo "" | openssl s_client -connect "127.0.0.1:$PROXY_HTTPS_PORT" \
|
||||
-servername numa.numa -CAfile "$CA" 2>&1 </dev/null || true)
|
||||
check "Proxy HTTPS TLS handshake succeeds" \
|
||||
"Verify return code: 0 (ok)" \
|
||||
"$PROXY_TLS"
|
||||
fi
|
||||
|
||||
kill "$NUMA_PID" 2>/dev/null || true
|
||||
wait "$NUMA_PID" 2>/dev/null || true
|
||||
rm -rf "$NUMA_DATA"
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/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