* feat: numa setup-phone — QR-based mobile DoT onboarding Adds a CLI subcommand that generates a one-time mobileconfig profile containing both the Numa local CA (as a com.apple.security.root payload) and the DoT DNS settings, then serves it via a temporary HTTP server and prints a scannable QR code in the terminal. Flow: 1. User runs `numa setup-phone` (no sudo needed) 2. Detects current LAN IP, reads CA from /usr/local/var/numa/ca.pem 3. Builds combined mobileconfig (CA trust + DoT) 4. Renders QR code with qrcode crate (Unicode block characters) 5. Serves the profile on port 8765, stays open until Ctrl+C 6. Counts successful downloads (multi-device households) Important caveat documented in instructions: even with the CA bundled in the profile, iOS still requires the user to manually enable trust in Settings → General → About → Certificate Trust Settings. Verified on a real iPhone. Stable PayloadIdentifiers/UUIDs ensure re-running replaces the existing profile on iOS rather than accumulating duplicates. - New module: src/setup_phone.rs (~270 lines) - New CLI subcommand: `numa setup-phone` - New dependency: qrcode = "0.14" (default-features = false) - tokio "signal" feature added for Ctrl+C handling - 3 unit tests: PEM stripping, mobileconfig generation, QR rendering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: mobile API, enriched /health, mobileconfig module Adds a persistent read-only HTTP listener (default port 8765, LAN-bound) serving a dedicated subset of Numa's API for iOS/Android companion apps and as a replacement for the one-shot server setup_phone used to spin up: GET /health — enriched JSON with version, hostname, LAN IP, SNI, DoT config, mobile API port, CA fingerprint, features (shared handler with the main API on port 5380) GET /ca.pem — public CA certificate (shared handler) GET /mobileconfig — full iOS profile (CA trust + DNS settings pinned to current LAN IP) GET /ca.mobileconfig — CA-only iOS profile (trust anchor without DNS override — for the iOS companion app's programmatic DNS flow via NEDNSSettingsManager) All routes are idempotent GETs. The mobile API never serves the state-mutating routes that live on the main API (overrides, blocking toggle, service CRUD, cache flush), so it is safe to expose on the LAN regardless of the main API's bind address. The CA private key is never served by any route. Opt-in via `[mobile] enabled = true`. Default is false so new installs do not silently expose a LAN listener after upgrading; our committed numa.toml template enables it explicitly for spike testing. New modules: - src/mobileconfig.rs — ProfileMode::{Full, CaOnly} enum with plist builder lifted from setup_phone.rs. Full and CaOnly share the CA payload UUID (same trust anchor) but have distinct top-level UUIDs so they coexist as separate installable profiles on iOS. - src/health.rs — HealthMeta cached metadata built once at startup from config + CA fingerprint (SHA-256 of the PEM via ring), and the HealthResponse JSON shape shared between the main and mobile APIs. - src/mobile_api.rs — axum Router for the persistent listener. Reuses api::health and api::serve_ca from the main API; owns the two mobileconfig handlers. Modified: - src/api.rs — health() returns the enriched HealthResponse, now pub. serve_ca is now pub so mobile_api can reuse it. - src/config.rs — MobileConfig section (enabled, port, bind_addr). - src/ctx.rs — health_meta: HealthMeta field on ServerCtx. - src/main.rs — builds HealthMeta at startup, spawns mobile API listener if enabled. - src/lan.rs — build_announcement takes &HealthMeta and writes enriched TXT records (version, api_port, proto, dot_port, ca_fp). SRV port now reports the mobile API port; peer discovery still reads TXT `services=` so this is backwards compatible. Always announces even when no .numa services are registered, so the iOS companion app can discover Numa via mDNS regardless of service state. - src/setup_phone.rs — reduced from 267 to 100 lines. The CLI is now a thin QR wrapper over the persistent /mobileconfig endpoint; the hand-rolled one-shot HTTP server (accept_loop, RUST_OK_HEADERS, RUST_NOT_FOUND, download counter) is gone. - src/dot.rs — test fixture updated with HealthMeta::test_fixture(). - numa.toml — commented [mobile] section, enabled = true for spike. Tests: 136 unit tests passing (5 new in mobileconfig, 3 new in health). cargo clippy clean. Integration sanity check: curl'd /health, /ca.pem, /mobileconfig, /ca.mobileconfig against a running numa — all return 200 with correct content types and valid response bodies. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: setup-phone probe, unknown command error, query source in dashboard - setup-phone now probes the mobile API before printing the QR code and shows an actionable error if [mobile] is not enabled - Unknown CLI subcommands print an error instead of silently attempting to start a full server - Dashboard query log shows source IP under timestamp (localhost for loopback, full IP for LAN devices) with full addr on hover Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
674 lines
18 KiB
Rust
674 lines
18 KiB
Rust
use std::collections::HashMap;
|
|
use std::net::Ipv4Addr;
|
|
use std::net::Ipv6Addr;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde::Deserialize;
|
|
|
|
use crate::question::QueryType;
|
|
use crate::record::DnsRecord;
|
|
use crate::Result;
|
|
|
|
#[derive(Deserialize, Default)]
|
|
pub struct Config {
|
|
#[serde(default)]
|
|
pub server: ServerConfig,
|
|
#[serde(default)]
|
|
pub upstream: UpstreamConfig,
|
|
#[serde(default)]
|
|
pub cache: CacheConfig,
|
|
#[serde(default)]
|
|
pub blocking: BlockingConfig,
|
|
#[serde(default)]
|
|
pub zones: Vec<ZoneRecord>,
|
|
#[serde(default)]
|
|
pub proxy: ProxyConfig,
|
|
#[serde(default)]
|
|
pub services: Vec<ServiceConfig>,
|
|
#[serde(default)]
|
|
pub lan: LanConfig,
|
|
#[serde(default)]
|
|
pub dnssec: DnssecConfig,
|
|
#[serde(default)]
|
|
pub dot: DotConfig,
|
|
#[serde(default)]
|
|
pub mobile: MobileConfig,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ServerConfig {
|
|
#[serde(default = "default_bind_addr")]
|
|
pub bind_addr: String,
|
|
#[serde(default = "default_api_port")]
|
|
pub api_port: u16,
|
|
#[serde(default = "default_api_bind_addr")]
|
|
pub api_bind_addr: String,
|
|
/// Where numa writes TLS material (CA, leaf certs, regenerated state).
|
|
/// Defaults to `crate::data_dir()` (platform-specific system path) if unset.
|
|
#[serde(default)]
|
|
pub data_dir: Option<PathBuf>,
|
|
}
|
|
|
|
impl Default for ServerConfig {
|
|
fn default() -> Self {
|
|
ServerConfig {
|
|
bind_addr: default_bind_addr(),
|
|
api_port: default_api_port(),
|
|
api_bind_addr: default_api_bind_addr(),
|
|
data_dir: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_api_bind_addr() -> String {
|
|
"127.0.0.1".to_string()
|
|
}
|
|
|
|
fn default_bind_addr() -> String {
|
|
"0.0.0.0:53".to_string()
|
|
}
|
|
|
|
pub const DEFAULT_API_PORT: u16 = 5380;
|
|
|
|
fn default_api_port() -> u16 {
|
|
DEFAULT_API_PORT
|
|
}
|
|
|
|
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Copy)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum UpstreamMode {
|
|
Auto,
|
|
#[default]
|
|
Forward,
|
|
Recursive,
|
|
}
|
|
|
|
impl UpstreamMode {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
UpstreamMode::Auto => "auto",
|
|
UpstreamMode::Forward => "forward",
|
|
UpstreamMode::Recursive => "recursive",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct UpstreamConfig {
|
|
#[serde(default)]
|
|
pub mode: UpstreamMode,
|
|
#[serde(default = "default_upstream_addr")]
|
|
pub address: String,
|
|
#[serde(default = "default_upstream_port")]
|
|
pub port: u16,
|
|
#[serde(default = "default_timeout_ms")]
|
|
pub timeout_ms: u64,
|
|
#[serde(default = "default_root_hints")]
|
|
pub root_hints: Vec<String>,
|
|
#[serde(default = "default_prime_tlds")]
|
|
pub prime_tlds: Vec<String>,
|
|
#[serde(default = "default_srtt")]
|
|
pub srtt: bool,
|
|
}
|
|
|
|
impl Default for UpstreamConfig {
|
|
fn default() -> Self {
|
|
UpstreamConfig {
|
|
mode: UpstreamMode::default(),
|
|
address: default_upstream_addr(),
|
|
port: default_upstream_port(),
|
|
timeout_ms: default_timeout_ms(),
|
|
root_hints: default_root_hints(),
|
|
prime_tlds: default_prime_tlds(),
|
|
srtt: default_srtt(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_srtt() -> bool {
|
|
default_true()
|
|
}
|
|
|
|
fn default_prime_tlds() -> Vec<String> {
|
|
vec![
|
|
// gTLDs
|
|
"com".into(),
|
|
"net".into(),
|
|
"org".into(),
|
|
"info".into(),
|
|
"io".into(),
|
|
"dev".into(),
|
|
"app".into(),
|
|
"xyz".into(),
|
|
"me".into(),
|
|
// EU + European ccTLDs
|
|
"eu".into(),
|
|
"uk".into(),
|
|
"de".into(),
|
|
"fr".into(),
|
|
"nl".into(),
|
|
"it".into(),
|
|
"es".into(),
|
|
"pl".into(),
|
|
"se".into(),
|
|
"no".into(),
|
|
"dk".into(),
|
|
"fi".into(),
|
|
"at".into(),
|
|
"be".into(),
|
|
"ie".into(),
|
|
"pt".into(),
|
|
"cz".into(),
|
|
"ro".into(),
|
|
"gr".into(),
|
|
"hu".into(),
|
|
"bg".into(),
|
|
"hr".into(),
|
|
"sk".into(),
|
|
"si".into(),
|
|
"lt".into(),
|
|
"lv".into(),
|
|
"ee".into(),
|
|
"ch".into(),
|
|
"is".into(),
|
|
// Other major ccTLDs
|
|
"co".into(),
|
|
"br".into(),
|
|
"au".into(),
|
|
"ca".into(),
|
|
"jp".into(),
|
|
]
|
|
}
|
|
|
|
fn default_root_hints() -> Vec<String> {
|
|
vec![
|
|
"198.41.0.4".into(), // a.root-servers.net
|
|
"199.9.14.201".into(), // b.root-servers.net
|
|
"192.33.4.12".into(), // c.root-servers.net
|
|
"199.7.91.13".into(), // d.root-servers.net
|
|
"192.203.230.10".into(), // e.root-servers.net
|
|
"192.5.5.241".into(), // f.root-servers.net
|
|
"192.112.36.4".into(), // g.root-servers.net
|
|
"198.97.190.53".into(), // h.root-servers.net
|
|
"192.36.148.17".into(), // i.root-servers.net
|
|
"192.58.128.30".into(), // j.root-servers.net
|
|
"193.0.14.129".into(), // k.root-servers.net
|
|
"199.7.83.42".into(), // l.root-servers.net
|
|
"202.12.27.33".into(), // m.root-servers.net
|
|
]
|
|
}
|
|
|
|
fn default_upstream_addr() -> String {
|
|
String::new() // empty = auto-detect from system resolver
|
|
}
|
|
fn default_upstream_port() -> u16 {
|
|
53
|
|
}
|
|
fn default_timeout_ms() -> u64 {
|
|
5000
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CacheConfig {
|
|
#[serde(default = "default_max_entries")]
|
|
pub max_entries: usize,
|
|
#[serde(default = "default_min_ttl")]
|
|
pub min_ttl: u32,
|
|
#[serde(default = "default_max_ttl")]
|
|
pub max_ttl: u32,
|
|
}
|
|
|
|
impl Default for CacheConfig {
|
|
fn default() -> Self {
|
|
CacheConfig {
|
|
max_entries: default_max_entries(),
|
|
min_ttl: default_min_ttl(),
|
|
max_ttl: default_max_ttl(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_max_entries() -> usize {
|
|
10000
|
|
}
|
|
fn default_min_ttl() -> u32 {
|
|
60
|
|
}
|
|
fn default_max_ttl() -> u32 {
|
|
86400
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ZoneRecord {
|
|
pub domain: String,
|
|
pub record_type: String,
|
|
pub value: String,
|
|
#[serde(default = "default_zone_ttl")]
|
|
pub ttl: u32,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct BlockingConfig {
|
|
#[serde(default = "default_blocking_enabled")]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_blocklists")]
|
|
pub lists: Vec<String>,
|
|
#[serde(default = "default_refresh_hours")]
|
|
pub refresh_hours: u64,
|
|
#[serde(default)]
|
|
pub allowlist: Vec<String>,
|
|
}
|
|
|
|
impl Default for BlockingConfig {
|
|
fn default() -> Self {
|
|
BlockingConfig {
|
|
enabled: default_blocking_enabled(),
|
|
lists: default_blocklists(),
|
|
refresh_hours: default_refresh_hours(),
|
|
allowlist: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_blocking_enabled() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_blocklists() -> Vec<String> {
|
|
vec!["https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/hosts/pro.txt".to_string()]
|
|
}
|
|
|
|
fn default_refresh_hours() -> u64 {
|
|
24
|
|
}
|
|
|
|
fn default_zone_ttl() -> u32 {
|
|
300
|
|
}
|
|
|
|
#[derive(Deserialize, Clone)]
|
|
pub struct ProxyConfig {
|
|
#[serde(default = "default_proxy_enabled")]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_proxy_port")]
|
|
pub port: u16,
|
|
#[serde(default = "default_proxy_tls_port")]
|
|
pub tls_port: u16,
|
|
#[serde(default = "default_proxy_tld")]
|
|
pub tld: String,
|
|
#[serde(default = "default_proxy_bind_addr")]
|
|
pub bind_addr: String,
|
|
}
|
|
|
|
impl Default for ProxyConfig {
|
|
fn default() -> Self {
|
|
ProxyConfig {
|
|
enabled: default_proxy_enabled(),
|
|
port: default_proxy_port(),
|
|
tls_port: default_proxy_tls_port(),
|
|
tld: default_proxy_tld(),
|
|
bind_addr: default_proxy_bind_addr(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_proxy_bind_addr() -> String {
|
|
"127.0.0.1".to_string()
|
|
}
|
|
|
|
fn default_proxy_enabled() -> bool {
|
|
true
|
|
}
|
|
fn default_proxy_port() -> u16 {
|
|
80
|
|
}
|
|
fn default_proxy_tls_port() -> u16 {
|
|
443
|
|
}
|
|
fn default_proxy_tld() -> String {
|
|
"numa".to_string()
|
|
}
|
|
|
|
#[derive(Deserialize, Clone)]
|
|
pub struct ServiceConfig {
|
|
pub name: String,
|
|
pub target_port: u16,
|
|
#[serde(default)]
|
|
pub routes: Vec<crate::service_store::RouteEntry>,
|
|
}
|
|
|
|
#[derive(Deserialize, Clone)]
|
|
pub struct LanConfig {
|
|
#[serde(default = "default_lan_enabled")]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_lan_broadcast_interval")]
|
|
pub broadcast_interval_secs: u64,
|
|
#[serde(default = "default_lan_peer_timeout")]
|
|
pub peer_timeout_secs: u64,
|
|
}
|
|
|
|
impl Default for LanConfig {
|
|
fn default() -> Self {
|
|
LanConfig {
|
|
enabled: default_lan_enabled(),
|
|
broadcast_interval_secs: default_lan_broadcast_interval(),
|
|
peer_timeout_secs: default_lan_peer_timeout(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_lan_enabled() -> bool {
|
|
false
|
|
}
|
|
fn default_lan_broadcast_interval() -> u64 {
|
|
30
|
|
}
|
|
fn default_lan_peer_timeout() -> u64 {
|
|
90
|
|
}
|
|
|
|
#[derive(Deserialize, Clone, Default)]
|
|
pub struct DnssecConfig {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default)]
|
|
pub strict: bool,
|
|
}
|
|
|
|
#[derive(Deserialize, Clone)]
|
|
pub struct DotConfig {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_dot_port")]
|
|
pub port: u16,
|
|
#[serde(default = "default_dot_bind_addr")]
|
|
pub bind_addr: String,
|
|
/// Path to TLS certificate (PEM). If None, uses self-signed CA.
|
|
#[serde(default)]
|
|
pub cert_path: Option<PathBuf>,
|
|
/// Path to TLS private key (PEM). If None, uses self-signed CA.
|
|
#[serde(default)]
|
|
pub key_path: Option<PathBuf>,
|
|
}
|
|
|
|
impl Default for DotConfig {
|
|
fn default() -> Self {
|
|
DotConfig {
|
|
enabled: false,
|
|
port: default_dot_port(),
|
|
bind_addr: default_dot_bind_addr(),
|
|
cert_path: None,
|
|
key_path: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_dot_port() -> u16 {
|
|
853
|
|
}
|
|
fn default_dot_bind_addr() -> String {
|
|
"0.0.0.0".to_string()
|
|
}
|
|
|
|
/// Configuration for the mobile API — a persistent HTTP listener that
|
|
/// serves a read-only subset of routes (`/health`, `/ca.pem`,
|
|
/// `/mobileconfig`, `/ca.mobileconfig`) on a LAN-reachable port, for
|
|
/// consumption by the iOS/Android companion apps.
|
|
///
|
|
/// Unlike the main API (port 5380, localhost-only by default, supports
|
|
/// state-mutating routes), the mobile API is safe to expose on the LAN
|
|
/// because every route is idempotent and read-only.
|
|
#[derive(Deserialize, Clone)]
|
|
pub struct MobileConfig {
|
|
/// If true, spawn the mobile API listener at startup. **Default false.**
|
|
/// Opt-in because the listener binds to the LAN by default and exposes
|
|
/// a few read-only endpoints to any device on the same network (`/health`,
|
|
/// `/ca.pem`, `/mobileconfig`, `/ca.mobileconfig`). None of those are
|
|
/// cryptographically sensitive (the CA private key is never served),
|
|
/// but users should enable this explicitly rather than have a new
|
|
/// LAN-reachable port appear after an upgrade.
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
/// Port for the mobile API. Default 8765.
|
|
#[serde(default = "default_mobile_port")]
|
|
pub port: u16,
|
|
/// Bind address for the mobile API. Default "0.0.0.0" (all interfaces)
|
|
/// so phones on the LAN can reach it. Set to "127.0.0.1" to restrict
|
|
/// to localhost — useful if you're running behind another front-end.
|
|
#[serde(default = "default_mobile_bind_addr")]
|
|
pub bind_addr: String,
|
|
}
|
|
|
|
impl Default for MobileConfig {
|
|
fn default() -> Self {
|
|
MobileConfig {
|
|
enabled: false,
|
|
port: default_mobile_port(),
|
|
bind_addr: default_mobile_bind_addr(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_mobile_port() -> u16 {
|
|
8765
|
|
}
|
|
|
|
fn default_mobile_bind_addr() -> String {
|
|
"0.0.0.0".to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn lan_disabled_by_default() {
|
|
assert!(!LanConfig::default().enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn api_binds_localhost_by_default() {
|
|
assert_eq!(ServerConfig::default().api_bind_addr, "127.0.0.1");
|
|
}
|
|
|
|
#[test]
|
|
fn proxy_binds_localhost_by_default() {
|
|
assert_eq!(ProxyConfig::default().bind_addr, "127.0.0.1");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_toml_gives_defaults() {
|
|
let config: Config = toml::from_str("").unwrap();
|
|
assert!(!config.lan.enabled);
|
|
assert_eq!(config.server.api_bind_addr, "127.0.0.1");
|
|
assert_eq!(config.proxy.bind_addr, "127.0.0.1");
|
|
assert_eq!(config.server.api_port, ServerConfig::default().api_port);
|
|
}
|
|
|
|
#[test]
|
|
fn lan_enabled_parses() {
|
|
let config: Config = toml::from_str("[lan]\nenabled = true").unwrap();
|
|
assert!(config.lan.enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn custom_bind_addrs_parse() {
|
|
let toml = r#"
|
|
[server]
|
|
api_bind_addr = "0.0.0.0"
|
|
[proxy]
|
|
bind_addr = "0.0.0.0"
|
|
"#;
|
|
let config: Config = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.server.api_bind_addr, "0.0.0.0");
|
|
assert_eq!(config.proxy.bind_addr, "0.0.0.0");
|
|
}
|
|
|
|
#[test]
|
|
fn service_routes_parse_from_toml() {
|
|
let toml = r#"
|
|
[[services]]
|
|
name = "app"
|
|
target_port = 3000
|
|
routes = [
|
|
{ path = "/api", port = 4000, strip = true },
|
|
{ path = "/static", port = 5000 },
|
|
]
|
|
"#;
|
|
let config: Config = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.services.len(), 1);
|
|
assert_eq!(config.services[0].routes.len(), 2);
|
|
assert!(config.services[0].routes[0].strip);
|
|
assert!(!config.services[0].routes[1].strip); // default false
|
|
}
|
|
}
|
|
|
|
pub struct ConfigLoad {
|
|
pub config: Config,
|
|
pub path: String,
|
|
pub found: bool,
|
|
}
|
|
|
|
fn resolve_path(path: &str) -> String {
|
|
// canonicalize gives the real absolute path for existing files;
|
|
// for non-existent files, build an absolute path manually
|
|
std::fs::canonicalize(path)
|
|
.or_else(|_| std::env::current_dir().map(|cwd| cwd.join(path)))
|
|
.unwrap_or_else(|_| Path::new(path).to_path_buf())
|
|
.to_string_lossy()
|
|
.to_string()
|
|
}
|
|
|
|
pub fn load_config(path: &str) -> Result<ConfigLoad> {
|
|
// Try the given path first, then well-known locations (for service mode where cwd is /)
|
|
let candidates: Vec<std::path::PathBuf> = {
|
|
let p = Path::new(path);
|
|
let mut v = vec![p.to_path_buf()];
|
|
if p.is_relative() {
|
|
let filename = p.file_name().unwrap_or(p.as_os_str());
|
|
v.push(crate::config_dir().join(filename));
|
|
v.push(crate::data_dir().join(filename));
|
|
}
|
|
v
|
|
};
|
|
|
|
for candidate in &candidates {
|
|
match std::fs::read_to_string(candidate) {
|
|
Ok(contents) => {
|
|
let resolved = resolve_path(&candidate.to_string_lossy());
|
|
let config: Config = toml::from_str(&contents)?;
|
|
return Ok(ConfigLoad {
|
|
config,
|
|
path: resolved,
|
|
found: true,
|
|
});
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
}
|
|
|
|
// Show config_dir candidate as the "expected" path — it's actionable
|
|
let display_path = candidates
|
|
.get(1)
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| resolve_path(path));
|
|
log::info!("config not found, using defaults (create {})", display_path);
|
|
Ok(ConfigLoad {
|
|
config: Config::default(),
|
|
path: display_path,
|
|
found: false,
|
|
})
|
|
}
|
|
|
|
pub type ZoneMap = HashMap<String, HashMap<QueryType, Vec<DnsRecord>>>;
|
|
|
|
pub fn build_zone_map(zones: &[ZoneRecord]) -> Result<ZoneMap> {
|
|
let mut map: ZoneMap = HashMap::new();
|
|
|
|
for zone in zones {
|
|
let domain = zone.domain.to_lowercase();
|
|
let (qtype, record) = match zone.record_type.to_uppercase().as_str() {
|
|
"A" => {
|
|
let addr: Ipv4Addr = zone
|
|
.value
|
|
.parse()
|
|
.map_err(|e| format!("invalid A record value '{}': {}", zone.value, e))?;
|
|
(
|
|
QueryType::A,
|
|
DnsRecord::A {
|
|
domain: domain.clone(),
|
|
addr,
|
|
ttl: zone.ttl,
|
|
},
|
|
)
|
|
}
|
|
"AAAA" => {
|
|
let addr: Ipv6Addr = zone
|
|
.value
|
|
.parse()
|
|
.map_err(|e| format!("invalid AAAA record value '{}': {}", zone.value, e))?;
|
|
(
|
|
QueryType::AAAA,
|
|
DnsRecord::AAAA {
|
|
domain: domain.clone(),
|
|
addr,
|
|
ttl: zone.ttl,
|
|
},
|
|
)
|
|
}
|
|
"CNAME" => (
|
|
QueryType::CNAME,
|
|
DnsRecord::CNAME {
|
|
domain: domain.clone(),
|
|
host: zone.value.clone(),
|
|
ttl: zone.ttl,
|
|
},
|
|
),
|
|
"NS" => (
|
|
QueryType::NS,
|
|
DnsRecord::NS {
|
|
domain: domain.clone(),
|
|
host: zone.value.clone(),
|
|
ttl: zone.ttl,
|
|
},
|
|
),
|
|
"MX" => {
|
|
let parts: Vec<&str> = zone.value.splitn(2, ' ').collect();
|
|
if parts.len() != 2 {
|
|
return Err(
|
|
format!("MX value must be 'priority host', got '{}'", zone.value).into(),
|
|
);
|
|
}
|
|
let priority: u16 = parts[0]
|
|
.parse()
|
|
.map_err(|e| format!("invalid MX priority '{}': {}", parts[0], e))?;
|
|
(
|
|
QueryType::MX,
|
|
DnsRecord::MX {
|
|
domain: domain.clone(),
|
|
priority,
|
|
host: parts[1].to_string(),
|
|
ttl: zone.ttl,
|
|
},
|
|
)
|
|
}
|
|
other => {
|
|
return Err(format!("unsupported record type '{}'", other).into());
|
|
}
|
|
};
|
|
|
|
map.entry(domain)
|
|
.or_default()
|
|
.entry(qtype)
|
|
.or_default()
|
|
.push(record);
|
|
}
|
|
|
|
Ok(map)
|
|
}
|