Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2ed2e6aec | ||
|
|
79ecb73d87 | ||
|
|
bf5565ac26 | ||
|
|
679b346246 | ||
|
|
039254280b | ||
|
|
1b2f682026 |
76
.github/workflows/homebrew-bump.yml
vendored
Normal file
76
.github/workflows/homebrew-bump.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: Bump Homebrew Tap
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to bump (e.g. 0.10.0 or v0.10.0)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Determine version
|
||||
id: ver
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
V="${{ github.event.release.tag_name }}"
|
||||
else
|
||||
V="${{ github.event.inputs.version }}"
|
||||
fi
|
||||
V="${V#v}"
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fetch sha256 checksums from release assets
|
||||
id: shas
|
||||
env:
|
||||
V: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="https://github.com/razvandimescu/numa/releases/download/v${V}"
|
||||
for t in macos-aarch64 macos-x86_64 linux-aarch64 linux-x86_64; do
|
||||
sha=$(curl -fsSL "${base}/numa-${t}.tar.gz.sha256" | awk '{print $1}')
|
||||
if [ -z "$sha" ]; then
|
||||
echo "ERROR: failed to fetch sha256 for $t" >&2
|
||||
exit 1
|
||||
fi
|
||||
key=$(echo "$t" | tr '[:lower:]-' '[:upper:]_')
|
||||
echo "SHA_${key}=${sha}" >> "$GITHUB_ENV"
|
||||
done
|
||||
|
||||
- name: Clone homebrew-tap
|
||||
env:
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
run: |
|
||||
git clone "https://x-access-token:${HOMEBREW_TAP_GITHUB_TOKEN}@github.com/razvandimescu/homebrew-tap.git" tap
|
||||
|
||||
- name: Update formula
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
python3 scripts/update-homebrew-formula.py tap/numa.rb
|
||||
echo "--- updated numa.rb ---"
|
||||
cat tap/numa.rb
|
||||
|
||||
- name: Commit and push
|
||||
working-directory: tap
|
||||
env:
|
||||
V: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "numa.rb already at v${V}, nothing to commit"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add numa.rb
|
||||
git commit -m "chore: bump numa to v${V}"
|
||||
git push origin main
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1143,7 +1143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "numa"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "numa"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
authors = ["razvandimescu <razvan@dimescu.com>"]
|
||||
edition = "2021"
|
||||
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
||||
|
||||
@@ -69,7 +69,7 @@ DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification,
|
||||
|
||||
**DNS-over-TLS listener** (RFC 7858) — accept encrypted queries on port 853 from strict clients like iOS Private DNS, systemd-resolved, or stubby. Two modes:
|
||||
|
||||
- **Self-signed** (default) — numa generates a local CA automatically. Works on any network with zero DNS setup, but clients must manually trust the CA (on macOS/Linux add to the system trust store; on iOS install a `.mobileconfig`).
|
||||
- **Self-signed** (default) — numa generates a local CA automatically. `numa install` adds it to the system trust store on macOS, Linux (Debian/Ubuntu, Fedora/RHEL/SUSE, Arch), and Windows. On iOS, install the `.mobileconfig` from `numa setup-phone`. Firefox keeps its own NSS store and ignores the system one — trust the CA there manually if you need HTTPS for `.numa` services in Firefox.
|
||||
- **Bring-your-own cert** — point `[dot] cert_path` / `key_path` at a publicly-trusted cert (e.g., Let's Encrypt via DNS-01 challenge on a domain pointing at your numa instance). Clients connect without any trust-store setup — same UX as AdGuard Home or Cloudflare `1.1.1.1`.
|
||||
|
||||
ALPN `"dot"` is advertised and enforced in both modes; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense.
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
bind_addr = "0.0.0.0:53"
|
||||
api_port = 5380
|
||||
# api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access
|
||||
# data_dir = "/usr/local/var/numa" # where numa stores TLS CA and cert material
|
||||
# (default: /usr/local/var/numa on unix,
|
||||
# %PROGRAMDATA%\numa on windows). Override for
|
||||
# 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.
|
||||
|
||||
|
||||
57
scripts/update-homebrew-formula.py
Executable file
57
scripts/update-homebrew-formula.py
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rewrite a Homebrew formula in place: bump version, URL paths, and sha256 lines.
|
||||
|
||||
Reads the formula path from argv[1], and the following env vars:
|
||||
VERSION e.g. "0.10.0" (no leading v)
|
||||
SHA_MACOS_AARCH64
|
||||
SHA_MACOS_X86_64
|
||||
SHA_LINUX_AARCH64
|
||||
SHA_LINUX_X86_64
|
||||
|
||||
Assumptions about the formula:
|
||||
- Has `version "X.Y.Z"` somewhere
|
||||
- Has `url "...releases/download/vX.Y.Z/numa-<target>.tar.gz"` lines
|
||||
- May or may not already have `sha256 "..."` lines immediately after each url
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
formula_path = sys.argv[1]
|
||||
version = os.environ["VERSION"].lstrip("v")
|
||||
shas = {
|
||||
"macos-aarch64": os.environ["SHA_MACOS_AARCH64"],
|
||||
"macos-x86_64": os.environ["SHA_MACOS_X86_64"],
|
||||
"linux-aarch64": os.environ["SHA_LINUX_AARCH64"],
|
||||
"linux-x86_64": os.environ["SHA_LINUX_X86_64"],
|
||||
}
|
||||
|
||||
with open(formula_path) as f:
|
||||
content = f.read()
|
||||
|
||||
content = re.sub(r'version "[^"]*"', f'version "{version}"', content)
|
||||
content = re.sub(
|
||||
r"releases/download/v[\d.]+/numa-",
|
||||
f"releases/download/v{version}/numa-",
|
||||
content,
|
||||
)
|
||||
content = re.sub(r'\n[ \t]*sha256 "[^"]*"', "", content)
|
||||
|
||||
|
||||
def add_sha(match: re.Match) -> str:
|
||||
indent = match.group(1)
|
||||
target = match.group(2)
|
||||
if target not in shas:
|
||||
return match.group(0)
|
||||
return f'{match.group(0)}\n{indent}sha256 "{shas[target]}"'
|
||||
|
||||
|
||||
content = re.sub(
|
||||
r'^([ \t]+)url "[^"]*numa-([\w-]+)\.tar\.gz"',
|
||||
add_sha,
|
||||
content,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
with open(formula_path, "w") as f:
|
||||
f.write(content)
|
||||
@@ -906,7 +906,7 @@ async fn remove_route(
|
||||
}
|
||||
|
||||
async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
|
||||
let ca_path = ctx.data_dir.join("ca.pem");
|
||||
let ca_path = ctx.data_dir.join(crate::tls::CA_FILE_NAME);
|
||||
let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path))
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
|
||||
67
src/lib.rs
67
src/lib.rs
@@ -26,7 +26,10 @@ pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Shared config directory for persistent data (services.json, etc).
|
||||
/// Unix: ~/.config/numa/ (or /usr/local/var/numa/ when running as root daemon)
|
||||
/// Unix users: ~/.config/numa/
|
||||
/// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa
|
||||
/// if a pre-v0.10.1 install already lives there.
|
||||
/// macOS root daemon: /usr/local/var/numa (Homebrew prefix)
|
||||
/// Windows: %APPDATA%\numa
|
||||
pub fn config_dir() -> std::path::PathBuf {
|
||||
#[cfg(windows)]
|
||||
@@ -63,13 +66,15 @@ fn config_dir_unix() -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
// Running as root daemon (launchd/systemd) — use system-wide path
|
||||
std::path::PathBuf::from("/usr/local/var/numa")
|
||||
daemon_data_dir()
|
||||
}
|
||||
|
||||
/// Default system-wide data directory for TLS certs. Overridable via
|
||||
/// `[server] data_dir = "..."` in numa.toml — this function only provides
|
||||
/// the fallback when the config doesn't set it.
|
||||
/// Unix: /usr/local/var/numa
|
||||
/// Linux: /var/lib/numa (FHS) — falls back to /usr/local/var/numa if a
|
||||
/// pre-v0.10.1 install already has data there.
|
||||
/// macOS: /usr/local/var/numa (Homebrew prefix)
|
||||
/// Windows: %PROGRAMDATA%\numa
|
||||
pub fn data_dir() -> std::path::PathBuf {
|
||||
#[cfg(windows)]
|
||||
@@ -81,6 +86,62 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +214,18 @@ fn discover_linux() -> SystemDnsInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse resolv.conf in a single pass, extracting both the first non-loopback
|
||||
/// Yield each `nameserver` address from resolv.conf content. No filtering —
|
||||
/// callers decide what counts as a real upstream.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn iter_nameservers(content: &str) -> impl Iterator<Item = &str> {
|
||||
content.lines().filter_map(|line| {
|
||||
let mut parts = line.split_whitespace();
|
||||
(parts.next() == Some("nameserver")).then_some(())?;
|
||||
parts.next()
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse resolv.conf in a single pass, extracting the first non-loopback
|
||||
/// nameserver and all search domains.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
||||
@@ -222,19 +233,13 @@ fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return (None, Vec::new()),
|
||||
};
|
||||
let mut upstream = None;
|
||||
let upstream = iter_nameservers(&text)
|
||||
.find(|ns| !is_loopback_or_stub(ns))
|
||||
.map(str::to_string);
|
||||
let mut search_domains = Vec::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
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") {
|
||||
if line.starts_with("search") || line.starts_with("domain") {
|
||||
for domain in line.split_whitespace().skip(1) {
|
||||
search_domains.push(domain.to_string());
|
||||
}
|
||||
@@ -243,6 +248,21 @@ 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> {
|
||||
@@ -526,9 +546,19 @@ 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 interfaces = get_windows_interfaces()?;
|
||||
let mut interfaces = get_windows_interfaces()?;
|
||||
if interfaces.is_empty() {
|
||||
return Err("no active network interfaces found".to_string());
|
||||
}
|
||||
@@ -538,9 +568,30 @@ 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")
|
||||
@@ -570,7 +621,10 @@ fn install_windows() -> Result<(), String> {
|
||||
let needs_reboot = disable_dnscache()?;
|
||||
register_autostart();
|
||||
|
||||
eprintln!("\n Original DNS saved to {}", path.display());
|
||||
eprintln!();
|
||||
if !has_useful_existing {
|
||||
eprintln!(" Original DNS saved to {}", path.display());
|
||||
}
|
||||
eprintln!(" Run 'numa uninstall' to restore.\n");
|
||||
if needs_reboot {
|
||||
eprintln!(" *** Reboot required. Numa will start automatically. ***\n");
|
||||
@@ -754,27 +808,60 @@ fn get_dns_servers(service: &str) -> Result<Vec<String>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the backup map has at least one real upstream (non-loopback, non-stub).
|
||||
/// An all-loopback backup is self-referential — restoring it is a no-op.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn backup_has_real_upstream_macos(
|
||||
servers: &std::collections::HashMap<String, Vec<String>>,
|
||||
) -> bool {
|
||||
servers
|
||||
.values()
|
||||
.any(|list| list.iter().any(|s| !is_loopback_or_stub(s)))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
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 {
|
||||
@@ -795,7 +882,10 @@ fn install_macos() -> Result<(), String> {
|
||||
.status();
|
||||
}
|
||||
|
||||
eprintln!("\n Original DNS saved to {}", backup_path().display());
|
||||
eprintln!();
|
||||
if !has_useful_existing {
|
||||
eprintln!(" Original DNS saved to {}", backup_path().display());
|
||||
}
|
||||
eprintln!(" Run 'sudo numa uninstall' to restore.\n");
|
||||
|
||||
Ok(())
|
||||
@@ -990,14 +1080,23 @@ fn install_service_macos() -> Result<(), String> {
|
||||
std::fs::write(PLIST_DEST, plist)
|
||||
.map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?;
|
||||
|
||||
// Load the service first so numa is listening before DNS redirect
|
||||
// Modern launchctl API: explicitly tear down any existing in-memory
|
||||
// state, then bootstrap fresh from the on-disk plist. The deprecated
|
||||
// `load -w` returns exit 0 even when it cannot actually reload (label
|
||||
// already in launchd state), silently leaving the daemon running a
|
||||
// stale binary path after `numa install` rewrites the plist on disk —
|
||||
// which is exactly what `brew upgrade numa` does.
|
||||
let _ = std::process::Command::new("launchctl")
|
||||
.args(["bootout", "system", PLIST_DEST])
|
||||
.status();
|
||||
|
||||
let status = std::process::Command::new("launchctl")
|
||||
.args(["load", "-w", PLIST_DEST])
|
||||
.args(["bootstrap", "system", PLIST_DEST])
|
||||
.status()
|
||||
.map_err(|e| format!("failed to run launchctl: {}", e))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err("launchctl load failed".to_string());
|
||||
return Err("launchctl bootstrap failed".to_string());
|
||||
}
|
||||
|
||||
// Wait for numa to be ready before redirecting DNS
|
||||
@@ -1010,7 +1109,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(["unload", PLIST_DEST])
|
||||
.args(["bootout", "system", PLIST_DEST])
|
||||
.status();
|
||||
return Err(
|
||||
"numa service did not start (port 53 may be in use). Service unloaded.".to_string(),
|
||||
@@ -1038,22 +1137,25 @@ fn uninstall_service_macos() -> Result<(), String> {
|
||||
eprintln!(" warning: failed to restore system DNS: {}", e);
|
||||
}
|
||||
|
||||
// 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));
|
||||
// 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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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)"
|
||||
);
|
||||
// Remove plist so the service won't restart on boot
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1132,11 +1234,31 @@ fn install_linux() -> Result<(), String> {
|
||||
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
||||
}
|
||||
|
||||
// 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)),
|
||||
// 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());
|
||||
}
|
||||
|
||||
if resolv
|
||||
@@ -1278,14 +1400,86 @@ 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("ca.pem");
|
||||
let ca_path = crate::data_dir().join(crate::tls::CA_FILE_NAME);
|
||||
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",
|
||||
@@ -1295,48 +1489,23 @@ fn trust_ca() -> 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")]
|
||||
{
|
||||
// Find all Numa CA certs by hash and delete each one
|
||||
fn untrust_ca_macos() -> Result<(), String> {
|
||||
if let Ok(out) = std::process::Command::new("security")
|
||||
.args([
|
||||
"find-certificate",
|
||||
"-c",
|
||||
"Numa Local CA",
|
||||
crate::tls::CA_COMMON_NAME,
|
||||
"-a",
|
||||
"-Z",
|
||||
"/Library/Keychains/System.keychain",
|
||||
@@ -1359,21 +1528,81 @@ fn untrust_ca() -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
eprintln!(" Removed Numa CA from system keychain");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
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 system trust store");
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
let _ = ca_path; // suppress unused warning on other platforms
|
||||
#[cfg(target_os = "linux")]
|
||||
fn untrust_ca_linux() -> Result<(), String> {
|
||||
let Some(store) = detect_linux_trust_store() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let dest = std::path::Path::new(store.anchor_dir).join(store.anchor_file);
|
||||
match std::fs::remove_file(&dest) {
|
||||
Ok(()) => {
|
||||
let _ = run_refresh(store.name, store.refresh_uninstall);
|
||||
eprintln!(" Removed Numa CA from system trust store ({})", store.name);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(_) => {} // best-effort uninstall
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn run_refresh(store_name: &str, argv: &[&str]) -> Result<(), String> {
|
||||
let (cmd, args) = argv
|
||||
.split_first()
|
||||
.expect("refresh command must be non-empty");
|
||||
let status = std::process::Command::new(cmd)
|
||||
.args(args)
|
||||
.status()
|
||||
.map_err(|e| format!("{} ({}): {}", cmd, store_name, e))?;
|
||||
if !status.success() {
|
||||
return Err(format!("{} ({}) failed", cmd, store_name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn trust_ca_windows(ca_path: &std::path::Path) -> Result<(), String> {
|
||||
let status = std::process::Command::new("certutil")
|
||||
.args(["-addstore", "-f", "Root"])
|
||||
.arg(ca_path)
|
||||
.status()
|
||||
.map_err(|e| format!("certutil: {}", e))?;
|
||||
if !status.success() {
|
||||
return Err("certutil -addstore Root failed (run as Administrator?)".into());
|
||||
}
|
||||
eprintln!(" Trusted Numa CA in Windows Root store");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn untrust_ca_windows() -> Result<(), String> {
|
||||
let _ = std::process::Command::new("certutil")
|
||||
.args(["-delstore", "Root", crate::tls::CA_COMMON_NAME])
|
||||
.status();
|
||||
eprintln!(" Removed Numa CA from Windows Root store");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1432,6 +1661,82 @@ 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 = "\
|
||||
|
||||
11
src/tls.rs
11
src/tls.rs
@@ -13,6 +13,13 @@ 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 {
|
||||
@@ -67,7 +74,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.pem");
|
||||
let ca_cert_path = dir.join(CA_FILE_NAME);
|
||||
|
||||
if ca_key_path.exists() && ca_cert_path.exists() {
|
||||
let key_pem = std::fs::read_to_string(&ca_key_path)?;
|
||||
@@ -86,7 +93,7 @@ fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
|
||||
let mut params = CertificateParams::default();
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::CommonName, "Numa Local CA");
|
||||
.push(DnType::CommonName, CA_COMMON_NAME);
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
params.not_before = OffsetDateTime::now_utc();
|
||||
|
||||
123
tests/docker/install-trust.sh
Executable file
123
tests/docker/install-trust.sh
Executable file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Cross-distro CA trust contract test for issue #35.
|
||||
#
|
||||
# Runs the exact shell commands `src/system_dns.rs::trust_ca_linux` would run
|
||||
# on each Linux trust-store family (Debian, Fedora pki, Arch p11-kit), and
|
||||
# asserts the certificate ends up in (and is removed from) the system bundle.
|
||||
#
|
||||
# This is a contract test, not an integration test: it doesn't drive the Rust
|
||||
# code (that would need systemd-in-container). It verifies the assumptions in
|
||||
# `LINUX_TRUST_STORES` against the real distro behavior. If you change that
|
||||
# table in src/system_dns.rs, update the per-distro cases below to match.
|
||||
#
|
||||
# Requirements: docker, openssl (host).
|
||||
# Usage: ./tests/docker/install-trust.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
|
||||
|
||||
# Self-signed CA fixture, mounted into each container as ca.pem.
|
||||
# basicConstraints=CA:TRUE is required — without it, Debian's
|
||||
# update-ca-certificates silently skips the cert during bundle build.
|
||||
FIXTURE_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$FIXTURE_DIR"' EXIT
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
|
||||
-keyout "$FIXTURE_DIR/ca.key" \
|
||||
-out "$FIXTURE_DIR/ca.pem" \
|
||||
-subj "/CN=Numa Local CA Test $(date +%s)" \
|
||||
-addext "basicConstraints=critical,CA:TRUE" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign" >/dev/null 2>&1
|
||||
|
||||
# Distro bundles store certs differently — Debian writes raw PEM only,
|
||||
# Fedora prepends "# CN" comment headers, Arch via extract-compat is
|
||||
# raw PEM. To detect cert presence uniformly we grep for a deterministic
|
||||
# substring of the base64 body (first base64 line is unique per cert).
|
||||
CERT_TAG=$(sed -n '2p' "$FIXTURE_DIR/ca.pem")
|
||||
|
||||
PASSED=0; FAILED=0
|
||||
|
||||
run_case() {
|
||||
local distro="$1"; shift
|
||||
local image="$1"; shift
|
||||
local platform="$1"; shift
|
||||
local script="$1"
|
||||
|
||||
printf "── %s (%s) ──\n" "$distro" "$image"
|
||||
if docker run --rm \
|
||||
--platform "$platform" \
|
||||
--security-opt seccomp=unconfined \
|
||||
-e CERT_TAG="$CERT_TAG" \
|
||||
-e DEBIAN_FRONTEND=noninteractive \
|
||||
-v "$FIXTURE_DIR/ca.pem:/fixture/ca.pem:ro" \
|
||||
"$image" bash -c "$script"; then
|
||||
printf "${GREEN}✓${RESET} %s\n\n" "$distro"
|
||||
PASSED=$((PASSED + 1))
|
||||
else
|
||||
printf "${RED}✗${RESET} %s\n\n" "$distro"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Debian / Ubuntu / Mint — anchor: /usr/local/share/ca-certificates/*.crt
|
||||
run_case "debian" "debian:stable" "linux/amd64" '
|
||||
set -e
|
||||
apt-get update -qq
|
||||
apt-get install -qq -y ca-certificates >/dev/null
|
||||
install -m 0644 /fixture/ca.pem /usr/local/share/ca-certificates/numa-local-ca.crt
|
||||
update-ca-certificates >/dev/null 2>&1
|
||||
grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt
|
||||
echo " install: cert present in bundle"
|
||||
rm /usr/local/share/ca-certificates/numa-local-ca.crt
|
||||
update-ca-certificates --fresh >/dev/null 2>&1
|
||||
if grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt; then
|
||||
echo " uninstall: cert STILL present (regression)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " uninstall: cert removed from bundle"
|
||||
'
|
||||
|
||||
# Fedora / RHEL / CentOS / SUSE — anchor: /etc/pki/ca-trust/source/anchors/*.pem
|
||||
run_case "fedora" "fedora:latest" "linux/amd64" '
|
||||
set -e
|
||||
dnf install -q -y ca-certificates >/dev/null
|
||||
install -m 0644 /fixture/ca.pem /etc/pki/ca-trust/source/anchors/numa-local-ca.pem
|
||||
update-ca-trust extract
|
||||
grep -q "$CERT_TAG" /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem
|
||||
echo " install: cert present in bundle"
|
||||
rm /etc/pki/ca-trust/source/anchors/numa-local-ca.pem
|
||||
update-ca-trust extract
|
||||
if grep -q "$CERT_TAG" /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem; then
|
||||
echo " uninstall: cert STILL present (regression)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " uninstall: cert removed from bundle"
|
||||
'
|
||||
|
||||
# Arch / Manjaro — anchor: /etc/ca-certificates/trust-source/anchors/*.pem
|
||||
# archlinux:latest is x86_64-only; --platform forces emulation on Apple Silicon.
|
||||
run_case "arch" "archlinux:latest" "linux/amd64" '
|
||||
set -e
|
||||
# pacman 7+ filters syscalls in its own sandbox; disable for Rosetta/qemu emulation.
|
||||
sed -i "s/^#DisableSandboxSyscalls/DisableSandboxSyscalls/" /etc/pacman.conf
|
||||
pacman -Sy --noconfirm --needed ca-certificates p11-kit >/dev/null 2>&1
|
||||
install -m 0644 /fixture/ca.pem /etc/ca-certificates/trust-source/anchors/numa-local-ca.pem
|
||||
trust extract-compat
|
||||
grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt
|
||||
echo " install: cert present in bundle"
|
||||
rm /etc/ca-certificates/trust-source/anchors/numa-local-ca.pem
|
||||
trust extract-compat
|
||||
if grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt; then
|
||||
echo " uninstall: cert STILL present (regression)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " uninstall: cert removed from bundle"
|
||||
'
|
||||
|
||||
printf "── summary ──\n"
|
||||
printf " ${GREEN}passed${RESET}: %d\n" "$PASSED"
|
||||
printf " ${RED}failed${RESET}: %d\n" "$FAILED"
|
||||
[ "$FAILED" -eq 0 ]
|
||||
147
tests/docker/smoke-arch.sh
Executable file
147
tests/docker/smoke-arch.sh
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Arch Linux compatibility smoke test.
|
||||
#
|
||||
# Builds numa from source inside an archlinux:latest container, runs it
|
||||
# in forward mode on port 5354, and verifies a single DNS query returns
|
||||
# an A record. Validates the "Arch compatible" claim end-to-end before
|
||||
# release announcements.
|
||||
#
|
||||
# Dogfooding: the test numa forwards to the host's running numa via
|
||||
# host.docker.internal (Docker Desktop's host gateway). This avoids the
|
||||
# Docker NAT/UDP issues with public resolvers and exercises the realistic
|
||||
# numa-on-numa shape. Requires the host to be running numa on port 53.
|
||||
#
|
||||
# First run is slow (~8-12 min): image pull + pacman + cold cargo build.
|
||||
# No caching across runs.
|
||||
#
|
||||
# Requirements: docker, host running numa on 0.0.0.0:53
|
||||
# Usage: ./tests/docker/smoke-arch.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
|
||||
|
||||
# Precondition: the test numa-on-arch forwards to the host numa as its
|
||||
# upstream (dogfood pattern). Fail fast with a clear error if there is
|
||||
# no working DNS on the host, rather than letting the dig inside the
|
||||
# container time out with "deadline has elapsed".
|
||||
if ! dig @127.0.0.1 google.com A +short +time=1 +tries=1 >/dev/null 2>&1; then
|
||||
printf "${RED}error:${RESET} host numa is not answering on 127.0.0.1:53\n" >&2
|
||||
echo " This test forwards to the host numa via host.docker.internal." >&2
|
||||
echo " Start numa on the host first (sudo numa install), then rerun." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "── building + running numa on archlinux:latest ──"
|
||||
echo " (first run is slow: image pull + pacman + cold cargo build, ~8-12 min)"
|
||||
echo
|
||||
|
||||
docker run --rm \
|
||||
--platform linux/amd64 \
|
||||
--security-opt seccomp=unconfined \
|
||||
-v "$PWD:/src:ro" \
|
||||
-v numa-arch-cargo:/root/.cargo \
|
||||
-v numa-arch-target:/work/target \
|
||||
archlinux:latest bash -c '
|
||||
set -e
|
||||
|
||||
# pacman 7+ filters syscalls in its own sandbox; disable for Rosetta/qemu
|
||||
sed -i "s/^#DisableSandboxSyscalls/DisableSandboxSyscalls/" /etc/pacman.conf
|
||||
|
||||
echo "── pacman: installing build + runtime deps ──"
|
||||
pacman -Sy --noconfirm --needed rust gcc pkgconf cmake make perl bind 2>&1 | tail -3
|
||||
echo
|
||||
|
||||
# Copy source to a writable workdir, skipping target/ + .git so we
|
||||
# do not pull in the host (macOS) build artifacts.
|
||||
mkdir -p /work
|
||||
tar -C /src --exclude=./target --exclude=./.git -cf - . | tar -C /work -xf -
|
||||
cd /work
|
||||
|
||||
echo "── cargo build --release --locked ──"
|
||||
cargo build --release --locked 2>&1 | tail -5
|
||||
echo
|
||||
|
||||
# Dogfood: forward to the host numa via host.docker.internal.
|
||||
# numa parses upstream.address as a literal SocketAddr, so we resolve
|
||||
# the hostname to an IPv4 address first (force v4 — getent hosts may
|
||||
# return IPv6 first, and IPv6 addresses need bracketed addr:port form).
|
||||
HOST_IP=$(getent ahostsv4 host.docker.internal | awk "/STREAM/ {print \$1; exit}")
|
||||
if [ -z "$HOST_IP" ]; then
|
||||
echo " ✗ could not resolve host.docker.internal to IPv4 (not on Docker Desktop?)"
|
||||
exit 1
|
||||
fi
|
||||
echo "── starting numa on :5354 (forward to host numa at $HOST_IP:53) ──"
|
||||
# Intentionally NOT setting [server] data_dir — we want to exercise the
|
||||
# default code path (data_dir() → daemon_data_dir() → /var/lib/numa) so
|
||||
# the FHS-path assertion below verifies the live wiring, not just the
|
||||
# unit-tested helper.
|
||||
cat > /tmp/numa.toml <<EOF
|
||||
[server]
|
||||
bind_addr = "127.0.0.1:5354"
|
||||
api_port = 5381
|
||||
|
||||
[upstream]
|
||||
mode = "forward"
|
||||
address = "$HOST_IP"
|
||||
port = 53
|
||||
EOF
|
||||
|
||||
./target/release/numa /tmp/numa.toml > /tmp/numa.log 2>&1 &
|
||||
NUMA_PID=$!
|
||||
|
||||
# Poll for readiness — numa is ready when it answers a query
|
||||
READY=0
|
||||
for i in 1 2 3 4 5 6 7 8; do
|
||||
sleep 1
|
||||
if dig @127.0.0.1 -p 5354 google.com A +short +time=1 +tries=1 2>/dev/null \
|
||||
| grep -qE "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$"; then
|
||||
READY=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$READY" -ne 1 ]; then
|
||||
echo " ✗ numa did not return an A record after 8s"
|
||||
echo " numa log:"
|
||||
cat /tmp/numa.log
|
||||
kill $NUMA_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "── dig @127.0.0.1 -p 5354 google.com A ──"
|
||||
ANSWER=$(dig @127.0.0.1 -p 5354 google.com A +short +time=2 +tries=1)
|
||||
echo "$ANSWER" | sed "s/^/ /"
|
||||
|
||||
kill $NUMA_PID 2>/dev/null || true
|
||||
|
||||
# FHS path assertion: the default data dir on Linux must be /var/lib/numa
|
||||
# (not the legacy /usr/local/var/numa). The CA cert generated at startup
|
||||
# is the canonical proof that numa wrote to the right place.
|
||||
echo
|
||||
echo "── FHS path check ──"
|
||||
if [ -f /var/lib/numa/ca.pem ]; then
|
||||
echo " ✓ CA cert at /var/lib/numa/ca.pem (FHS path)"
|
||||
else
|
||||
echo " ✗ CA cert NOT at /var/lib/numa/ca.pem"
|
||||
echo " ls /var/lib/numa/:"
|
||||
ls -la /var/lib/numa/ 2>&1 | sed "s/^/ /"
|
||||
echo " ls /usr/local/var/numa/:"
|
||||
ls -la /usr/local/var/numa/ 2>&1 | sed "s/^/ /"
|
||||
exit 1
|
||||
fi
|
||||
if [ -e /usr/local/var/numa ]; then
|
||||
echo " ✗ legacy path /usr/local/var/numa unexpectedly exists on a fresh container"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✓ legacy path /usr/local/var/numa absent (fresh install used FHS)"
|
||||
|
||||
echo
|
||||
echo " ✓ numa built, ran, answered a forward query, and used the FHS data dir on Arch"
|
||||
'
|
||||
|
||||
echo
|
||||
printf "${GREEN}── smoke-arch passed ──${RESET}\n"
|
||||
94
tests/manual/install-trust-macos.sh
Executable file
94
tests/manual/install-trust-macos.sh
Executable file
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Manual macOS CA trust contract test.
|
||||
#
|
||||
# Mirrors src/system_dns.rs::trust_ca_macos / untrust_ca_macos by running
|
||||
# the same `security` shell commands against a fixture cert with a unique
|
||||
# CN. Safe to run alongside a production numa install:
|
||||
#
|
||||
# - Test cert CN = "Numa Local CA Test <pid-ts>", always strictly longer
|
||||
# than the production CN "Numa Local CA". `security find-certificate -c`
|
||||
# does substring matching, so the test's search for $TEST_CN can never
|
||||
# match the production cert (the search term is longer than the prod CN).
|
||||
# - All deletes use `delete-certificate -Z <hash>`, which only touches the
|
||||
# cert with that exact hash. Production and test certs have different
|
||||
# hashes by construction (different key material), so the delete cannot
|
||||
# reach the production cert even if a CN search somehow returned both.
|
||||
#
|
||||
# Mutates the System keychain (briefly). Cleans up on success or interrupt.
|
||||
# Requires sudo for `security add-trusted-cert` and `delete-certificate`.
|
||||
#
|
||||
# Usage: ./tests/manual/install-trust-macos.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$OSTYPE" != darwin* ]]; then
|
||||
echo "This test is macOS-only." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
|
||||
|
||||
# Production constant from src/tls.rs::CA_COMMON_NAME — keep in sync.
|
||||
PROD_CN="Numa Local CA"
|
||||
KEYCHAIN="/Library/Keychains/System.keychain"
|
||||
|
||||
# Notice if production numa is already installed. We proceed regardless —
|
||||
# see header for why coexistence is safe (unique CN + by-hash deletion).
|
||||
if security find-certificate -c "$PROD_CN" "$KEYCHAIN" >/dev/null 2>&1; then
|
||||
echo " note: production '$PROD_CN' detected — proceeding alongside (test cert can't touch it)"
|
||||
echo
|
||||
fi
|
||||
|
||||
# Unique CN ensures the test cert can never collide with production.
|
||||
TEST_CN="Numa Local CA Test $$-$(date +%s)"
|
||||
FIXTURE_DIR=$(mktemp -d)
|
||||
|
||||
cleanup() {
|
||||
# Best-effort: remove any test certs by hash if still present.
|
||||
if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then
|
||||
echo " cleanup: removing leftover test cert"
|
||||
security find-certificate -c "$TEST_CN" -a -Z "$KEYCHAIN" 2>/dev/null \
|
||||
| awk '/^SHA-1 hash:/ {print $NF}' \
|
||||
| while read -r hash; do
|
||||
sudo security delete-certificate -Z "$hash" "$KEYCHAIN" >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
rm -rf "$FIXTURE_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "── generating fixture CA ──"
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
|
||||
-keyout "$FIXTURE_DIR/ca.key" \
|
||||
-out "$FIXTURE_DIR/ca.pem" \
|
||||
-subj "/CN=$TEST_CN" \
|
||||
-addext "basicConstraints=critical,CA:TRUE" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign" >/dev/null 2>&1
|
||||
echo " CN: $TEST_CN"
|
||||
echo
|
||||
|
||||
echo "── trust step (mirrors trust_ca_macos) ──"
|
||||
sudo security add-trusted-cert -d -r trustRoot -k "$KEYCHAIN" "$FIXTURE_DIR/ca.pem"
|
||||
if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then
|
||||
printf " ${GREEN}✓${RESET} test cert found in keychain\n"
|
||||
else
|
||||
printf " ${RED}✗${RESET} test cert NOT found after add-trusted-cert\n"
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "── untrust step (mirrors untrust_ca_macos) ──"
|
||||
security find-certificate -c "$TEST_CN" -a -Z "$KEYCHAIN" 2>/dev/null \
|
||||
| awk '/^SHA-1 hash:/ {print $NF}' \
|
||||
| while read -r hash; do
|
||||
sudo security delete-certificate -Z "$hash" "$KEYCHAIN" >/dev/null
|
||||
done
|
||||
if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then
|
||||
printf " ${RED}✗${RESET} test cert STILL present after delete (regression)\n"
|
||||
exit 1
|
||||
fi
|
||||
printf " ${GREEN}✓${RESET} test cert removed from keychain\n"
|
||||
echo
|
||||
|
||||
printf "${GREEN}all checks passed${RESET}\n"
|
||||
Reference in New Issue
Block a user