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]]
|
[[package]]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.10.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.10.0"
|
version = "0.10.1"
|
||||||
authors = ["razvandimescu <razvan@dimescu.com>"]
|
authors = ["razvandimescu <razvan@dimescu.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
||||||
|
|||||||
@@ -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:
|
**DNS-over-TLS listener** (RFC 7858) — accept encrypted queries on port 853 from strict clients like iOS Private DNS, systemd-resolved, or stubby. Two modes:
|
||||||
|
|
||||||
- **Self-signed** (default) — numa generates a local CA automatically. Works on any network with zero DNS setup, but clients must manually trust the CA (on macOS/Linux add to the system trust store; on iOS install a `.mobileconfig`).
|
- **Self-signed** (default) — numa generates a local CA automatically. `numa install` adds it to the system trust store on macOS, Linux (Debian/Ubuntu, Fedora/RHEL/SUSE, Arch), and Windows. On iOS, install the `.mobileconfig` from `numa setup-phone`. Firefox keeps its own NSS store and ignores the system one — trust the CA there manually if you need HTTPS for `.numa` services in Firefox.
|
||||||
- **Bring-your-own cert** — point `[dot] cert_path` / `key_path` at a publicly-trusted cert (e.g., Let's Encrypt via DNS-01 challenge on a domain pointing at your numa instance). Clients connect without any trust-store setup — same UX as AdGuard Home or Cloudflare `1.1.1.1`.
|
- **Bring-your-own cert** — point `[dot] cert_path` / `key_path` at a publicly-trusted cert (e.g., Let's Encrypt via DNS-01 challenge on a domain pointing at your numa instance). Clients connect without any trust-store setup — same UX as AdGuard Home or Cloudflare `1.1.1.1`.
|
||||||
|
|
||||||
ALPN `"dot"` is advertised and enforced in both modes; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense.
|
ALPN `"dot"` is advertised and enforced in both modes; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense.
|
||||||
|
|||||||
11
numa.toml
11
numa.toml
@@ -2,11 +2,12 @@
|
|||||||
bind_addr = "0.0.0.0:53"
|
bind_addr = "0.0.0.0:53"
|
||||||
api_port = 5380
|
api_port = 5380
|
||||||
# api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access
|
# api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access
|
||||||
# data_dir = "/usr/local/var/numa" # where numa stores TLS CA and cert material
|
# data_dir = "/var/lib/numa" # where numa stores TLS CA and cert material
|
||||||
# (default: /usr/local/var/numa on unix,
|
# Defaults: /var/lib/numa on linux (FHS),
|
||||||
# %PROGRAMDATA%\numa on windows). Override for
|
# /usr/local/var/numa on macos (homebrew prefix),
|
||||||
# containerized deploys or tests that can't
|
# %PROGRAMDATA%\numa on windows. Override for
|
||||||
# write to the system path.
|
# containerized deploys or tests that can't
|
||||||
|
# write to the system path.
|
||||||
|
|
||||||
# [upstream]
|
# [upstream]
|
||||||
# mode = "forward" # "forward" (default) — relay to upstream
|
# mode = "forward" # "forward" (default) — relay to upstream
|
||||||
|
|||||||
57
scripts/update-homebrew-formula.py
Executable file
57
scripts/update-homebrew-formula.py
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Rewrite a Homebrew formula in place: bump version, URL paths, and sha256 lines.
|
||||||
|
|
||||||
|
Reads the formula path from argv[1], and the following env vars:
|
||||||
|
VERSION e.g. "0.10.0" (no leading v)
|
||||||
|
SHA_MACOS_AARCH64
|
||||||
|
SHA_MACOS_X86_64
|
||||||
|
SHA_LINUX_AARCH64
|
||||||
|
SHA_LINUX_X86_64
|
||||||
|
|
||||||
|
Assumptions about the formula:
|
||||||
|
- Has `version "X.Y.Z"` somewhere
|
||||||
|
- Has `url "...releases/download/vX.Y.Z/numa-<target>.tar.gz"` lines
|
||||||
|
- May or may not already have `sha256 "..."` lines immediately after each url
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
formula_path = sys.argv[1]
|
||||||
|
version = os.environ["VERSION"].lstrip("v")
|
||||||
|
shas = {
|
||||||
|
"macos-aarch64": os.environ["SHA_MACOS_AARCH64"],
|
||||||
|
"macos-x86_64": os.environ["SHA_MACOS_X86_64"],
|
||||||
|
"linux-aarch64": os.environ["SHA_LINUX_AARCH64"],
|
||||||
|
"linux-x86_64": os.environ["SHA_LINUX_X86_64"],
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(formula_path) as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
content = re.sub(r'version "[^"]*"', f'version "{version}"', content)
|
||||||
|
content = re.sub(
|
||||||
|
r"releases/download/v[\d.]+/numa-",
|
||||||
|
f"releases/download/v{version}/numa-",
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
content = re.sub(r'\n[ \t]*sha256 "[^"]*"', "", content)
|
||||||
|
|
||||||
|
|
||||||
|
def add_sha(match: re.Match) -> str:
|
||||||
|
indent = match.group(1)
|
||||||
|
target = match.group(2)
|
||||||
|
if target not in shas:
|
||||||
|
return match.group(0)
|
||||||
|
return f'{match.group(0)}\n{indent}sha256 "{shas[target]}"'
|
||||||
|
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'^([ \t]+)url "[^"]*numa-([\w-]+)\.tar\.gz"',
|
||||||
|
add_sha,
|
||||||
|
content,
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(formula_path, "w") as f:
|
||||||
|
f.write(content)
|
||||||
@@ -906,7 +906,7 @@ async fn remove_route(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
|
async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
|
||||||
let ca_path = ctx.data_dir.join("ca.pem");
|
let ca_path = ctx.data_dir.join(crate::tls::CA_FILE_NAME);
|
||||||
let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path))
|
let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
|||||||
67
src/lib.rs
67
src/lib.rs
@@ -26,7 +26,10 @@ pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
|||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
/// Shared config directory for persistent data (services.json, etc).
|
/// Shared config directory for persistent data (services.json, etc).
|
||||||
/// Unix: ~/.config/numa/ (or /usr/local/var/numa/ when running as root daemon)
|
/// Unix users: ~/.config/numa/
|
||||||
|
/// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa
|
||||||
|
/// if a pre-v0.10.1 install already lives there.
|
||||||
|
/// macOS root daemon: /usr/local/var/numa (Homebrew prefix)
|
||||||
/// Windows: %APPDATA%\numa
|
/// Windows: %APPDATA%\numa
|
||||||
pub fn config_dir() -> std::path::PathBuf {
|
pub fn config_dir() -> std::path::PathBuf {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -63,13 +66,15 @@ fn config_dir_unix() -> std::path::PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Running as root daemon (launchd/systemd) — use system-wide path
|
// Running as root daemon (launchd/systemd) — use system-wide path
|
||||||
std::path::PathBuf::from("/usr/local/var/numa")
|
daemon_data_dir()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default system-wide data directory for TLS certs. Overridable via
|
/// Default system-wide data directory for TLS certs. Overridable via
|
||||||
/// `[server] data_dir = "..."` in numa.toml — this function only provides
|
/// `[server] data_dir = "..."` in numa.toml — this function only provides
|
||||||
/// the fallback when the config doesn't set it.
|
/// the fallback when the config doesn't set it.
|
||||||
/// Unix: /usr/local/var/numa
|
/// Linux: /var/lib/numa (FHS) — falls back to /usr/local/var/numa if a
|
||||||
|
/// pre-v0.10.1 install already has data there.
|
||||||
|
/// macOS: /usr/local/var/numa (Homebrew prefix)
|
||||||
/// Windows: %PROGRAMDATA%\numa
|
/// Windows: %PROGRAMDATA%\numa
|
||||||
pub fn data_dir() -> std::path::PathBuf {
|
pub fn data_dir() -> std::path::PathBuf {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -81,6 +86,62 @@ pub fn data_dir() -> std::path::PathBuf {
|
|||||||
}
|
}
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
|
daemon_data_dir()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the system-wide data directory for the running platform.
|
||||||
|
/// Honors backwards compatibility with pre-v0.10.1 installs that still
|
||||||
|
/// have their CA cert + services.json under `/usr/local/var/numa`.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn daemon_data_dir() -> std::path::PathBuf {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
std::path::PathBuf::from(resolve_linux_data_dir(
|
||||||
|
std::path::Path::new("/usr/local/var/numa").exists(),
|
||||||
|
std::path::Path::new("/var/lib/numa").exists(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
// macOS uses the Homebrew prefix convention; no FHS migration needed.
|
||||||
std::path::PathBuf::from("/usr/local/var/numa")
|
std::path::PathBuf::from("/usr/local/var/numa")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extracted as a pure function so the migration logic is unit-testable
|
||||||
|
/// without touching the real filesystem.
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
fn resolve_linux_data_dir(legacy_exists: bool, fhs_exists: bool) -> &'static str {
|
||||||
|
if legacy_exists && !fhs_exists {
|
||||||
|
"/usr/local/var/numa"
|
||||||
|
} else {
|
||||||
|
"/var/lib/numa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linux_data_dir_fresh_install_uses_fhs() {
|
||||||
|
assert_eq!(resolve_linux_data_dir(false, false), "/var/lib/numa");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linux_data_dir_upgrading_install_keeps_legacy() {
|
||||||
|
// Migration must keep legacy so the user doesn't lose their CA on upgrade.
|
||||||
|
assert_eq!(resolve_linux_data_dir(true, false), "/usr/local/var/numa");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linux_data_dir_after_migration_uses_fhs() {
|
||||||
|
assert_eq!(resolve_linux_data_dir(true, true), "/var/lib/numa");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linux_data_dir_only_fhs_uses_fhs() {
|
||||||
|
assert_eq!(resolve_linux_data_dir(false, true), "/var/lib/numa");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
/// nameserver and all search domains.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
||||||
@@ -222,19 +233,13 @@ fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
|||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(_) => return (None, Vec::new()),
|
Err(_) => return (None, Vec::new()),
|
||||||
};
|
};
|
||||||
let mut upstream = None;
|
let upstream = iter_nameservers(&text)
|
||||||
|
.find(|ns| !is_loopback_or_stub(ns))
|
||||||
|
.map(str::to_string);
|
||||||
let mut search_domains = Vec::new();
|
let mut search_domains = Vec::new();
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.starts_with("nameserver") {
|
if line.starts_with("search") || line.starts_with("domain") {
|
||||||
if upstream.is_none() {
|
|
||||||
if let Some(ns) = line.split_whitespace().nth(1) {
|
|
||||||
if !is_loopback_or_stub(ns) {
|
|
||||||
upstream = Some(ns.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if line.starts_with("search") || line.starts_with("domain") {
|
|
||||||
for domain in line.split_whitespace().skip(1) {
|
for domain in line.split_whitespace().skip(1) {
|
||||||
search_domains.push(domain.to_string());
|
search_domains.push(domain.to_string());
|
||||||
}
|
}
|
||||||
@@ -243,6 +248,21 @@ fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
|||||||
(upstream, search_domains)
|
(upstream, search_domains)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if the resolv.conf *content* appears to be written by numa itself,
|
||||||
|
/// or has no real upstream — either way, it's not a safe source of truth
|
||||||
|
/// for a backup.
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
fn resolv_conf_is_numa_managed(content: &str) -> bool {
|
||||||
|
content.contains("Generated by Numa") || !resolv_conf_has_real_upstream(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the resolv.conf content has at least one non-loopback, non-stub
|
||||||
|
/// nameserver. An all-loopback resolv.conf is self-referential.
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
fn resolv_conf_has_real_upstream(content: &str) -> bool {
|
||||||
|
iter_nameservers(content).any(|ns| !is_loopback_or_stub(ns))
|
||||||
|
}
|
||||||
|
|
||||||
/// Query resolvectl for the real upstream DNS server (e.g. VPC resolver on AWS).
|
/// Query resolvectl for the real upstream DNS server (e.g. VPC resolver on AWS).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn resolvectl_dns_server() -> Option<String> {
|
fn resolvectl_dns_server() -> Option<String> {
|
||||||
@@ -526,9 +546,19 @@ fn enable_dnscache() {
|
|||||||
.status();
|
.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if the backup map has at least one real upstream (non-loopback, non-stub).
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn backup_has_real_upstream_windows(
|
||||||
|
interfaces: &std::collections::HashMap<String, WindowsInterfaceDns>,
|
||||||
|
) -> bool {
|
||||||
|
interfaces
|
||||||
|
.values()
|
||||||
|
.any(|iface| iface.servers.iter().any(|s| !is_loopback_or_stub(s)))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn install_windows() -> Result<(), String> {
|
fn install_windows() -> Result<(), String> {
|
||||||
let interfaces = get_windows_interfaces()?;
|
let mut interfaces = get_windows_interfaces()?;
|
||||||
if interfaces.is_empty() {
|
if interfaces.is_empty() {
|
||||||
return Err("no active network interfaces found".to_string());
|
return Err("no active network interfaces found".to_string());
|
||||||
}
|
}
|
||||||
@@ -538,9 +568,30 @@ fn install_windows() -> Result<(), String> {
|
|||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
||||||
}
|
}
|
||||||
let json = serde_json::to_string_pretty(&interfaces)
|
|
||||||
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
// Preserve an existing useful backup rather than overwriting it with
|
||||||
std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?;
|
// 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() {
|
for name in interfaces.keys() {
|
||||||
let status = std::process::Command::new("netsh")
|
let status = std::process::Command::new("netsh")
|
||||||
@@ -570,7 +621,10 @@ fn install_windows() -> Result<(), String> {
|
|||||||
let needs_reboot = disable_dnscache()?;
|
let needs_reboot = disable_dnscache()?;
|
||||||
register_autostart();
|
register_autostart();
|
||||||
|
|
||||||
eprintln!("\n Original DNS saved to {}", path.display());
|
eprintln!();
|
||||||
|
if !has_useful_existing {
|
||||||
|
eprintln!(" Original DNS saved to {}", path.display());
|
||||||
|
}
|
||||||
eprintln!(" Run 'numa uninstall' to restore.\n");
|
eprintln!(" Run 'numa uninstall' to restore.\n");
|
||||||
if needs_reboot {
|
if needs_reboot {
|
||||||
eprintln!(" *** Reboot required. Numa will start automatically. ***\n");
|
eprintln!(" *** Reboot required. Numa will start automatically. ***\n");
|
||||||
@@ -754,27 +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")]
|
#[cfg(target_os = "macos")]
|
||||||
fn install_macos() -> Result<(), String> {
|
fn install_macos() -> Result<(), String> {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
let services = get_network_services()?;
|
let services = get_network_services()?;
|
||||||
let mut original: HashMap<String, Vec<String>> = HashMap::new();
|
|
||||||
|
|
||||||
// Save current DNS for each service
|
|
||||||
for service in &services {
|
|
||||||
let servers = get_dns_servers(service)?;
|
|
||||||
original.insert(service.clone(), servers);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save backup
|
|
||||||
let dir = numa_data_dir();
|
let dir = numa_data_dir();
|
||||||
std::fs::create_dir_all(&dir)
|
std::fs::create_dir_all(&dir)
|
||||||
.map_err(|e| format!("failed to create {}: {}", dir.display(), e))?;
|
.map_err(|e| format!("failed to create {}: {}", dir.display(), e))?;
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&original)
|
// If a useful backup already exists (at least one non-loopback upstream),
|
||||||
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
// preserve it — overwriting would destroy the original DNS state when
|
||||||
std::fs::write(backup_path(), json).map_err(|e| format!("failed to write backup: {}", e))?;
|
// 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))?;
|
||||||
|
}
|
||||||
|
|
||||||
// Set DNS to 127.0.0.1 and add "numa" search domain for each service
|
// Set DNS to 127.0.0.1 and add "numa" search domain for each service
|
||||||
for service in &services {
|
for service in &services {
|
||||||
@@ -795,7 +882,10 @@ fn install_macos() -> Result<(), String> {
|
|||||||
.status();
|
.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("\n Original DNS saved to {}", backup_path().display());
|
eprintln!();
|
||||||
|
if !has_useful_existing {
|
||||||
|
eprintln!(" Original DNS saved to {}", backup_path().display());
|
||||||
|
}
|
||||||
eprintln!(" Run 'sudo numa uninstall' to restore.\n");
|
eprintln!(" Run 'sudo numa uninstall' to restore.\n");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -990,14 +1080,23 @@ fn install_service_macos() -> Result<(), String> {
|
|||||||
std::fs::write(PLIST_DEST, plist)
|
std::fs::write(PLIST_DEST, plist)
|
||||||
.map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?;
|
.map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?;
|
||||||
|
|
||||||
// Load the service first so numa is listening before DNS redirect
|
// Modern launchctl API: explicitly tear down any existing in-memory
|
||||||
|
// state, then bootstrap fresh from the on-disk plist. The deprecated
|
||||||
|
// `load -w` returns exit 0 even when it cannot actually reload (label
|
||||||
|
// already in launchd state), silently leaving the daemon running a
|
||||||
|
// stale binary path after `numa install` rewrites the plist on disk —
|
||||||
|
// which is exactly what `brew upgrade numa` does.
|
||||||
|
let _ = std::process::Command::new("launchctl")
|
||||||
|
.args(["bootout", "system", PLIST_DEST])
|
||||||
|
.status();
|
||||||
|
|
||||||
let status = std::process::Command::new("launchctl")
|
let status = std::process::Command::new("launchctl")
|
||||||
.args(["load", "-w", PLIST_DEST])
|
.args(["bootstrap", "system", PLIST_DEST])
|
||||||
.status()
|
.status()
|
||||||
.map_err(|e| format!("failed to run launchctl: {}", e))?;
|
.map_err(|e| format!("failed to run launchctl: {}", e))?;
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err("launchctl load failed".to_string());
|
return Err("launchctl bootstrap failed".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for numa to be ready before redirecting DNS
|
// Wait for numa to be ready before redirecting DNS
|
||||||
@@ -1010,7 +1109,7 @@ fn install_service_macos() -> Result<(), String> {
|
|||||||
if !api_up {
|
if !api_up {
|
||||||
// Service failed to start — don't redirect DNS to a dead endpoint
|
// Service failed to start — don't redirect DNS to a dead endpoint
|
||||||
let _ = std::process::Command::new("launchctl")
|
let _ = std::process::Command::new("launchctl")
|
||||||
.args(["unload", PLIST_DEST])
|
.args(["bootout", "system", PLIST_DEST])
|
||||||
.status();
|
.status();
|
||||||
return Err(
|
return Err(
|
||||||
"numa service did not start (port 53 may be in use). Service unloaded.".to_string(),
|
"numa service did not start (port 53 may be in use). Service unloaded.".to_string(),
|
||||||
@@ -1038,22 +1137,25 @@ fn uninstall_service_macos() -> Result<(), String> {
|
|||||||
eprintln!(" warning: failed to restore system DNS: {}", e);
|
eprintln!(" warning: failed to restore system DNS: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove plist first so service won't restart on boot even if unload fails
|
// Bootout the service from launchd's in-memory state BEFORE removing
|
||||||
if let Err(e) = std::fs::remove_file(PLIST_DEST) {
|
// the plist. The modern API needs the file path as the specifier;
|
||||||
if e.kind() != std::io::ErrorKind::NotFound {
|
// doing this in the wrong order would leave the service loaded in
|
||||||
return Err(format!("failed to remove {}: {}", PLIST_DEST, e));
|
// memory until reboot. (Deprecated `unload -w` had the same issue.)
|
||||||
|
let bootout_status = std::process::Command::new("launchctl")
|
||||||
|
.args(["bootout", "system", PLIST_DEST])
|
||||||
|
.status();
|
||||||
|
if let Ok(s) = bootout_status {
|
||||||
|
if !s.success() {
|
||||||
|
eprintln!(
|
||||||
|
" warning: launchctl bootout returned non-zero (service may not have been loaded)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unload the service
|
// Remove plist so the service won't restart on boot
|
||||||
let status = std::process::Command::new("launchctl")
|
if let Err(e) = std::fs::remove_file(PLIST_DEST) {
|
||||||
.args(["unload", "-w", PLIST_DEST])
|
if e.kind() != std::io::ErrorKind::NotFound {
|
||||||
.status();
|
return Err(format!("failed to remove {}: {}", PLIST_DEST, e));
|
||||||
if let Ok(s) = status {
|
|
||||||
if !s.success() {
|
|
||||||
eprintln!(
|
|
||||||
" warning: launchctl unload returned non-zero (service may still be running)"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1132,11 +1234,31 @@ fn install_linux() -> Result<(), String> {
|
|||||||
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back up current resolv.conf (ignore NotFound)
|
// Back up current resolv.conf, but never overwrite a useful existing
|
||||||
match std::fs::copy(resolv, &backup) {
|
// backup with a numa-managed file — that would leave uninstall with
|
||||||
Ok(_) => eprintln!(" Saved /etc/resolv.conf to {}", backup.display()),
|
// nothing to restore to.
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
let current = std::fs::read_to_string(resolv).ok();
|
||||||
Err(e) => return Err(format!("failed to backup /etc/resolv.conf: {}", e)),
|
let current_is_numa_managed = current
|
||||||
|
.as_deref()
|
||||||
|
.map(resolv_conf_is_numa_managed)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let existing_backup_is_useful = std::fs::read_to_string(&backup)
|
||||||
|
.ok()
|
||||||
|
.as_deref()
|
||||||
|
.map(resolv_conf_has_real_upstream)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if existing_backup_is_useful {
|
||||||
|
eprintln!(
|
||||||
|
" Existing resolv.conf backup preserved at {}",
|
||||||
|
backup.display()
|
||||||
|
);
|
||||||
|
} else if current_is_numa_managed {
|
||||||
|
eprintln!(" warning: /etc/resolv.conf is already numa-managed; no fresh backup written");
|
||||||
|
} else if let Some(content) = current.as_deref() {
|
||||||
|
std::fs::write(&backup, content)
|
||||||
|
.map_err(|e| format!("failed to backup /etc/resolv.conf: {}", e))?;
|
||||||
|
eprintln!(" Saved /etc/resolv.conf to {}", backup.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
if resolv
|
if resolv
|
||||||
@@ -1278,102 +1400,209 @@ fn run_systemctl(args: &[&str]) -> Result<(), String> {
|
|||||||
|
|
||||||
// --- CA trust management ---
|
// --- CA trust management ---
|
||||||
|
|
||||||
|
/// One Linux trust-store backend (Debian, Fedora pki, Arch p11-kit).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
struct LinuxTrustStore {
|
||||||
|
name: &'static str,
|
||||||
|
anchor_dir: &'static str,
|
||||||
|
anchor_file: &'static str,
|
||||||
|
refresh_install: &'static [&'static str],
|
||||||
|
refresh_uninstall: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
// If you change this table, update tests/docker/install-trust.sh to match —
|
||||||
|
// it asserts the same paths/commands against real distro images.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
const LINUX_TRUST_STORES: &[LinuxTrustStore] = &[
|
||||||
|
// Debian / Ubuntu / Mint
|
||||||
|
LinuxTrustStore {
|
||||||
|
name: "debian",
|
||||||
|
anchor_dir: "/usr/local/share/ca-certificates",
|
||||||
|
anchor_file: "numa-local-ca.crt",
|
||||||
|
refresh_install: &["update-ca-certificates"],
|
||||||
|
refresh_uninstall: &["update-ca-certificates", "--fresh"],
|
||||||
|
},
|
||||||
|
// Fedora / RHEL / CentOS / SUSE (p11-kit via update-ca-trust wrapper)
|
||||||
|
LinuxTrustStore {
|
||||||
|
name: "pki",
|
||||||
|
anchor_dir: "/etc/pki/ca-trust/source/anchors",
|
||||||
|
anchor_file: "numa-local-ca.pem",
|
||||||
|
refresh_install: &["update-ca-trust", "extract"],
|
||||||
|
refresh_uninstall: &["update-ca-trust", "extract"],
|
||||||
|
},
|
||||||
|
// Arch / Manjaro (raw p11-kit)
|
||||||
|
LinuxTrustStore {
|
||||||
|
name: "p11kit",
|
||||||
|
anchor_dir: "/etc/ca-certificates/trust-source/anchors",
|
||||||
|
anchor_file: "numa-local-ca.pem",
|
||||||
|
refresh_install: &["trust", "extract-compat"],
|
||||||
|
refresh_uninstall: &["trust", "extract-compat"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn detect_linux_trust_store() -> Option<&'static LinuxTrustStore> {
|
||||||
|
LINUX_TRUST_STORES
|
||||||
|
.iter()
|
||||||
|
.find(|s| std::path::Path::new(s.anchor_dir).is_dir())
|
||||||
|
}
|
||||||
|
|
||||||
fn trust_ca() -> Result<(), String> {
|
fn trust_ca() -> Result<(), String> {
|
||||||
let ca_path = crate::data_dir().join("ca.pem");
|
let ca_path = crate::data_dir().join(crate::tls::CA_FILE_NAME);
|
||||||
if !ca_path.exists() {
|
if !ca_path.exists() {
|
||||||
return Err("CA not generated yet — start numa first to create certificates".into());
|
return Err("CA not generated yet — start numa first to create certificates".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
let result = trust_ca_macos(&ca_path);
|
||||||
let status = std::process::Command::new("security")
|
|
||||||
.args([
|
|
||||||
"add-trusted-cert",
|
|
||||||
"-d",
|
|
||||||
"-r",
|
|
||||||
"trustRoot",
|
|
||||||
"-k",
|
|
||||||
"/Library/Keychains/System.keychain",
|
|
||||||
])
|
|
||||||
.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")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
let result = trust_ca_linux(&ca_path);
|
||||||
let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt");
|
#[cfg(windows)]
|
||||||
std::fs::copy(&ca_path, dest).map_err(|e| format!("copy CA: {}", e))?;
|
let result = trust_ca_windows(&ca_path);
|
||||||
let status = std::process::Command::new("update-ca-certificates")
|
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||||
.status()
|
let result = Err::<(), String>("CA trust not supported on this OS".to_string());
|
||||||
.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")))]
|
result
|
||||||
{
|
|
||||||
Err("CA trust not supported on this OS".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn untrust_ca() -> Result<(), String> {
|
fn untrust_ca() -> Result<(), String> {
|
||||||
let ca_path = crate::data_dir().join("ca.pem");
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[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",
|
||||||
|
"-d",
|
||||||
|
"-r",
|
||||||
|
"trustRoot",
|
||||||
|
"-k",
|
||||||
|
"/Library/Keychains/System.keychain",
|
||||||
|
])
|
||||||
|
.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");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn untrust_ca_macos() -> Result<(), String> {
|
||||||
|
if let Ok(out) = std::process::Command::new("security")
|
||||||
|
.args([
|
||||||
|
"find-certificate",
|
||||||
|
"-c",
|
||||||
|
crate::tls::CA_COMMON_NAME,
|
||||||
|
"-a",
|
||||||
|
"-Z",
|
||||||
|
"/Library/Keychains/System.keychain",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
{
|
{
|
||||||
// Find all Numa CA certs by hash and delete each one
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
if let Ok(out) = std::process::Command::new("security")
|
for line in stdout.lines() {
|
||||||
.args([
|
if let Some(hash) = line.strip_prefix("SHA-1 hash: ") {
|
||||||
"find-certificate",
|
let hash = hash.trim();
|
||||||
"-c",
|
let _ = std::process::Command::new("security")
|
||||||
"Numa Local CA",
|
.args([
|
||||||
"-a",
|
"delete-certificate",
|
||||||
"-Z",
|
"-Z",
|
||||||
"/Library/Keychains/System.keychain",
|
hash,
|
||||||
])
|
"/Library/Keychains/System.keychain",
|
||||||
.output()
|
])
|
||||||
{
|
.output();
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
||||||
for line in stdout.lines() {
|
|
||||||
if let Some(hash) = line.strip_prefix("SHA-1 hash: ") {
|
|
||||||
let hash = hash.trim();
|
|
||||||
let _ = std::process::Command::new("security")
|
|
||||||
.args([
|
|
||||||
"delete-certificate",
|
|
||||||
"-Z",
|
|
||||||
hash,
|
|
||||||
"/Library/Keychains/System.keychain",
|
|
||||||
])
|
|
||||||
.output();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
eprintln!(" Removed Numa CA from system keychain");
|
|
||||||
}
|
}
|
||||||
|
eprintln!(" Removed Numa CA from system keychain");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
fn trust_ca_linux(ca_path: &std::path::Path) -> Result<(), String> {
|
||||||
let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt");
|
let store = detect_linux_trust_store().ok_or_else(|| {
|
||||||
if dest.exists() {
|
let names: Vec<&str> = LINUX_TRUST_STORES.iter().map(|s| s.name).collect();
|
||||||
let _ = std::fs::remove_file(dest);
|
format!(
|
||||||
let _ = std::process::Command::new("update-ca-certificates")
|
"no supported CA trust store found (tried: {}). \
|
||||||
.arg("--fresh")
|
Please report at https://github.com/razvandimescu/numa/issues",
|
||||||
.status();
|
names.join(", ")
|
||||||
eprintln!(" Removed Numa CA from system trust store");
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let dest = std::path::Path::new(store.anchor_dir).join(store.anchor_file);
|
||||||
|
std::fs::copy(ca_path, &dest).map_err(|e| format!("copy CA to {}: {}", dest.display(), e))?;
|
||||||
|
|
||||||
|
run_refresh(store.name, store.refresh_install)?;
|
||||||
|
eprintln!(" Trusted Numa CA system-wide ({})", store.name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(())
|
||||||
|
}
|
||||||
|
|
||||||
let _ = ca_path; // suppress unused warning on other platforms
|
#[cfg(target_os = "linux")]
|
||||||
|
fn run_refresh(store_name: &str, argv: &[&str]) -> Result<(), String> {
|
||||||
|
let (cmd, args) = argv
|
||||||
|
.split_first()
|
||||||
|
.expect("refresh command must be non-empty");
|
||||||
|
let status = std::process::Command::new(cmd)
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("{} ({}): {}", cmd, store_name, e))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!("{} ({}) failed", cmd, store_name));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn trust_ca_windows(ca_path: &std::path::Path) -> Result<(), String> {
|
||||||
|
let status = std::process::Command::new("certutil")
|
||||||
|
.args(["-addstore", "-f", "Root"])
|
||||||
|
.arg(ca_path)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("certutil: {}", e))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err("certutil -addstore Root failed (run as Administrator?)".into());
|
||||||
|
}
|
||||||
|
eprintln!(" Trusted Numa CA in Windows Root store");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn untrust_ca_windows() -> Result<(), String> {
|
||||||
|
let _ = std::process::Command::new("certutil")
|
||||||
|
.args(["-delstore", "Root", crate::tls::CA_COMMON_NAME])
|
||||||
|
.status();
|
||||||
|
eprintln!(" Removed Numa CA from Windows Root store");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1432,6 +1661,82 @@ Wireless LAN adapter Wi-Fi:
|
|||||||
assert!(!result.contains("{{exe_path}}"));
|
assert!(!result.contains("{{exe_path}}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn macos_backup_real_upstream_detection() {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
|
||||||
|
// Empty backup → no real upstream
|
||||||
|
assert!(!backup_has_real_upstream_macos(&map));
|
||||||
|
|
||||||
|
// All-loopback backup → still no real upstream (the bug case)
|
||||||
|
map.insert("Wi-Fi".into(), vec!["127.0.0.1".into()]);
|
||||||
|
map.insert("Ethernet".into(), vec!["::1".into()]);
|
||||||
|
assert!(!backup_has_real_upstream_macos(&map));
|
||||||
|
|
||||||
|
// One real entry → useful
|
||||||
|
map.insert("Tailscale".into(), vec!["192.168.1.1".into()]);
|
||||||
|
assert!(backup_has_real_upstream_macos(&map));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn windows_backup_filters_loopback() {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut map: HashMap<String, WindowsInterfaceDns> = HashMap::new();
|
||||||
|
|
||||||
|
// Empty backup → no real upstream
|
||||||
|
assert!(!backup_has_real_upstream_windows(&map));
|
||||||
|
|
||||||
|
// All-loopback backup → still no real upstream (the bug case)
|
||||||
|
map.insert(
|
||||||
|
"Wi-Fi".into(),
|
||||||
|
WindowsInterfaceDns {
|
||||||
|
dhcp: false,
|
||||||
|
servers: vec!["127.0.0.1".into()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"Ethernet".into(),
|
||||||
|
WindowsInterfaceDns {
|
||||||
|
dhcp: false,
|
||||||
|
servers: vec!["::1".into(), "0.0.0.0".into()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(!backup_has_real_upstream_windows(&map));
|
||||||
|
|
||||||
|
// One real entry alongside loopback → useful
|
||||||
|
map.insert(
|
||||||
|
"Ethernet 2".into(),
|
||||||
|
WindowsInterfaceDns {
|
||||||
|
dhcp: false,
|
||||||
|
servers: vec!["192.168.1.1".into()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(backup_has_real_upstream_windows(&map));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolv_conf_real_upstream_detection() {
|
||||||
|
let real = "nameserver 192.168.1.1\nsearch lan\n";
|
||||||
|
assert!(resolv_conf_has_real_upstream(real));
|
||||||
|
assert!(!resolv_conf_is_numa_managed(real));
|
||||||
|
|
||||||
|
let self_ref = "nameserver 127.0.0.1\nsearch numa\n";
|
||||||
|
assert!(!resolv_conf_has_real_upstream(self_ref));
|
||||||
|
assert!(resolv_conf_is_numa_managed(self_ref));
|
||||||
|
|
||||||
|
let numa_marker =
|
||||||
|
"# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\nsearch numa\n";
|
||||||
|
assert!(resolv_conf_is_numa_managed(numa_marker));
|
||||||
|
|
||||||
|
let systemd_stub = "nameserver 127.0.0.53\noptions edns0\n";
|
||||||
|
assert!(!resolv_conf_has_real_upstream(systemd_stub));
|
||||||
|
|
||||||
|
let mixed = "nameserver 127.0.0.1\nnameserver 1.1.1.1\n";
|
||||||
|
assert!(resolv_conf_has_real_upstream(mixed));
|
||||||
|
assert!(!resolv_conf_is_numa_managed(mixed));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_ipconfig_skips_disconnected() {
|
fn parse_ipconfig_skips_disconnected() {
|
||||||
let sample = "\
|
let sample = "\
|
||||||
|
|||||||
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 CA_VALIDITY_DAYS: i64 = 3650; // 10 years
|
||||||
const CERT_VALIDITY_DAYS: i64 = 365; // 1 year
|
const CERT_VALIDITY_DAYS: i64 = 365; // 1 year
|
||||||
|
|
||||||
|
/// Common Name on Numa's local CA. Referenced by trust-store helpers
|
||||||
|
/// (`security`, `certutil`) when locating the cert for removal.
|
||||||
|
pub const CA_COMMON_NAME: &str = "Numa Local CA";
|
||||||
|
|
||||||
|
/// Filename of the CA certificate inside the data dir.
|
||||||
|
pub const CA_FILE_NAME: &str = "ca.pem";
|
||||||
|
|
||||||
/// Collect all service + LAN peer names and regenerate the TLS cert.
|
/// Collect all service + LAN peer names and regenerate the TLS cert.
|
||||||
pub fn regenerate_tls(ctx: &ServerCtx) {
|
pub fn regenerate_tls(ctx: &ServerCtx) {
|
||||||
let tls = match &ctx.tls_config {
|
let tls = match &ctx.tls_config {
|
||||||
@@ -67,7 +74,7 @@ pub fn build_tls_config(
|
|||||||
|
|
||||||
fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
|
fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
|
||||||
let ca_key_path = dir.join("ca.key");
|
let ca_key_path = dir.join("ca.key");
|
||||||
let ca_cert_path = dir.join("ca.pem");
|
let ca_cert_path = dir.join(CA_FILE_NAME);
|
||||||
|
|
||||||
if ca_key_path.exists() && ca_cert_path.exists() {
|
if ca_key_path.exists() && ca_cert_path.exists() {
|
||||||
let key_pem = std::fs::read_to_string(&ca_key_path)?;
|
let key_pem = std::fs::read_to_string(&ca_key_path)?;
|
||||||
@@ -86,7 +93,7 @@ fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
|
|||||||
let mut params = CertificateParams::default();
|
let mut params = CertificateParams::default();
|
||||||
params
|
params
|
||||||
.distinguished_name
|
.distinguished_name
|
||||||
.push(DnType::CommonName, "Numa Local CA");
|
.push(DnType::CommonName, CA_COMMON_NAME);
|
||||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||||
params.not_before = OffsetDateTime::now_utc();
|
params.not_before = OffsetDateTime::now_utc();
|
||||||
|
|||||||
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