1 Commits

Author SHA1 Message Date
Razvan Dimescu
2f80d1ab7f ci: auto-update Homebrew tap on release
After creating a GitHub release, the new update-homebrew job:
- Extracts SHA256 checksums from build artifacts
- Generates an updated numa.rb formula with correct version and hashes
- Pushes it to razvandimescu/homebrew-tap via the GitHub API

Requires HOMEBREW_TAP_TOKEN secret (PAT with repo scope on homebrew-tap).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:56:34 +03:00
21 changed files with 257 additions and 1959 deletions

View File

@@ -1,76 +0,0 @@
name: Bump Homebrew Tap
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: 'Version to bump (e.g. 0.10.0 or v0.10.0)'
required: true
permissions:
contents: read
jobs:
bump:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Determine version
id: ver
run: |
if [ "${{ github.event_name }}" = "release" ]; then
V="${{ github.event.release.tag_name }}"
else
V="${{ github.event.inputs.version }}"
fi
V="${V#v}"
echo "version=$V" >> "$GITHUB_OUTPUT"
- name: Fetch sha256 checksums from release assets
id: shas
env:
V: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
base="https://github.com/razvandimescu/numa/releases/download/v${V}"
for t in macos-aarch64 macos-x86_64 linux-aarch64 linux-x86_64; do
sha=$(curl -fsSL "${base}/numa-${t}.tar.gz.sha256" | awk '{print $1}')
if [ -z "$sha" ]; then
echo "ERROR: failed to fetch sha256 for $t" >&2
exit 1
fi
key=$(echo "$t" | tr '[:lower:]-' '[:upper:]_')
echo "SHA_${key}=${sha}" >> "$GITHUB_ENV"
done
- name: Clone homebrew-tap
env:
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
run: |
git clone "https://x-access-token:${HOMEBREW_TAP_GITHUB_TOKEN}@github.com/razvandimescu/homebrew-tap.git" tap
- name: Update formula
env:
VERSION: ${{ steps.ver.outputs.version }}
run: |
python3 scripts/update-homebrew-formula.py tap/numa.rb
echo "--- updated numa.rb ---"
cat tap/numa.rb
- name: Commit and push
working-directory: tap
env:
V: ${{ steps.ver.outputs.version }}
run: |
if git diff --quiet; then
echo "numa.rb already at v${V}, nothing to commit"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add numa.rb
git commit -m "chore: bump numa to v${V}"
git push origin main

View File

@@ -108,3 +108,93 @@ jobs:
*.tar.gz
*.zip
*.sha256
update-homebrew:
needs: release
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:
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,
});

12
Cargo.lock generated
View File

@@ -1143,7 +1143,7 @@ dependencies = [
[[package]]
name = "numa"
version = "0.10.1"
version = "0.9.1"
dependencies = [
"arc-swap",
"axum",
@@ -1159,7 +1159,6 @@ dependencies = [
"reqwest",
"ring",
"rustls",
"rustls-pemfile",
"serde",
"serde_json",
"socket2 0.5.10",
@@ -1547,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"

View File

@@ -1,6 +1,6 @@
[package]
name = "numa"
version = "0.10.1"
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"
@@ -29,7 +29,6 @@ rustls = "0.23"
tokio-rustls = "0.26"
arc-swap = "1"
ring = "0.17"
rustls-pemfile = "2.2.0"
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

View File

@@ -13,5 +13,5 @@ RUN cargo build --release
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"]

View File

@@ -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.
![Numa dashboard](assets/hero-demo.gif)
@@ -67,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:
@@ -103,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 |
@@ -124,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

View File

@@ -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,14 +83,6 @@ 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)

View File

@@ -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)

View File

@@ -906,7 +906,7 @@ async fn remove_route(
}
async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
let ca_path = ctx.data_dir.join(crate::tls::CA_FILE_NAME);
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)?

View File

@@ -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,8 +29,6 @@ pub struct Config {
pub lan: LanConfig,
#[serde(default)]
pub dnssec: DnssecConfig,
#[serde(default)]
pub dot: DotConfig,
}
#[derive(Deserialize)]
@@ -41,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 {
@@ -53,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,
}
}
}
@@ -377,41 +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()
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -62,21 +62,24 @@ pub struct ServerCtx {
pub dnssec_strict: bool,
}
/// 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
@@ -303,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
@@ -336,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(())
}

View File

@@ -1,542 +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,
});
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();
}
}
}

View File

@@ -5,7 +5,6 @@ pub mod cache;
pub mod config;
pub mod ctx;
pub mod dnssec;
pub mod dot;
pub mod forward;
pub mod header;
pub mod lan;
@@ -26,10 +25,7 @@ pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Result<T> = std::result::Result<T, Error>;
/// 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)]
@@ -66,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)]
@@ -86,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");
}
}

View File

@@ -204,23 +204,10 @@ 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) => {
log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e);
@@ -261,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,
@@ -383,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)");
}
@@ -493,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 {

View File

@@ -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;
}

View File

@@ -214,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>) {
@@ -233,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());
}
@@ -248,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> {
@@ -546,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());
}
@@ -568,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")
@@ -621,10 +570,7 @@ 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");
@@ -808,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 {
@@ -882,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(())
@@ -1080,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
@@ -1109,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(),
@@ -1137,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(())
}
@@ -1234,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
@@ -1400,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",
@@ -1489,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",
@@ -1528,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(())
}
@@ -1661,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 = "\

View File

@@ -13,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 {
@@ -31,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());
@@ -43,26 +36,17 @@ pub fn regenerate_tls(ctx: &ServerCtx) {
/// 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_cert, ca_key) = ensure_ca(data_dir)?;
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",
@@ -74,7 +58,7 @@ pub fn build_tls_config(
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)?;
@@ -93,7 +77,7 @@ fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
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();

View File

@@ -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 ]

View File

@@ -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"

View File

@@ -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 ""

View File

@@ -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"