use std::net::IpAddr; use std::path::Path; use serde::Deserialize; use thiserror::Error; #[derive(Debug, Error)] pub enum ConfigError { #[error("io error: {0}")] Io(#[from] std::io::Error), #[error("toml parse error: {0}")] Toml(#[from] toml::de::Error), } /// Top-level config covering all onis services. /// /// Load from a TOML file with [`OnisConfig::load`]. Every field has a default, /// so an empty (or missing) file produces a usable config. #[derive(Debug, Deserialize)] #[serde(default)] pub struct OnisConfig { /// Configuration for the appview service. pub appview: AppviewConfig, /// Configuration for the DNS server. pub dns: DnsConfig, /// Configuration for the verification service. pub verify: VerifyConfig, } impl OnisConfig { /// Load config from `ONIS_CONFIG` env var path, or `onis.toml` in the /// current directory. Returns defaults if the file does not exist. pub fn load() -> Result { let path = std::env::var("ONIS_CONFIG").unwrap_or_else(|_| "onis.toml".to_string()); let path = Path::new(&path); if path.exists() { let content = std::fs::read_to_string(path)?; Ok(toml::from_str(&content)?) } else { Ok(Self::default()) } } } impl Default for OnisConfig { fn default() -> Self { Self { appview: AppviewConfig::default(), dns: DnsConfig::default(), verify: VerifyConfig::default(), } } } #[derive(Debug, Deserialize)] #[serde(default)] pub struct AppviewConfig { /// Address and port for the appview HTTP server. pub bind: String, /// WebSocket URL for the TAP firehose. pub tap_url: String, /// Whether to acknowledge TAP messages. pub tap_acks: bool, /// Seconds to wait before reconnecting after a TAP connection error. pub tap_reconnect_delay: u64, /// Path to the shared zone index SQLite database. pub index_path: String, /// Directory for per-DID SQLite databases. pub db_dir: String, /// Database pool configuration. pub database: DatabaseConfig, } impl Default for AppviewConfig { fn default() -> Self { Self { bind: "0.0.0.0:3000".to_string(), tap_url: "ws://localhost:2480/channel".to_string(), tap_acks: true, tap_reconnect_delay: 5, index_path: "./data/index.db".to_string(), db_dir: "./data/dbs".to_string(), database: DatabaseConfig::default(), } } } #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct DatabaseConfig { /// Seconds to wait when the database is locked. pub busy_timeout: u64, /// Max connections for per-user database pools. pub user_max_connections: u32, /// Max connections for the shared index database pool. pub index_max_connections: u32, } impl Default for DatabaseConfig { fn default() -> Self { Self { busy_timeout: 5, user_max_connections: 5, index_max_connections: 10, } } } #[derive(Debug, Deserialize)] #[serde(default)] pub struct DnsConfig { /// URL of the appview API. pub appview_url: String, /// Address for the DNS server to listen on. pub bind: String, /// Port for the DNS server. pub port: u16, /// Seconds before a TCP connection times out. pub tcp_timeout: u64, /// Minimum TTL enforced on all DNS responses. pub ttl_floor: u32, /// Log a warning for queries slower than this (milliseconds). pub slow_query_threshold_ms: u64, /// SOA record defaults for zones without a user-published SOA. pub soa: SoaConfig, /// NS records to serve for all zones (fully qualified, trailing dot). pub ns: Vec, /// Bind address for the metrics HTTP server (e.g. "0.0.0.0:9100"). pub metrics_bind: String, } impl Default for DnsConfig { fn default() -> Self { Self { appview_url: "http://localhost:3000".to_string(), bind: "0.0.0.0".to_string(), port: 5353, tcp_timeout: 30, ttl_floor: 60, slow_query_threshold_ms: 50, soa: SoaConfig::default(), ns: vec![ "ns1.example.com.".to_string(), "ns2.example.com.".to_string(), ], metrics_bind: "0.0.0.0:9100".to_string(), } } } #[derive(Debug, Deserialize)] #[serde(default)] pub struct SoaConfig { /// SOA record TTL in seconds. pub ttl: u32, /// SOA refresh interval in seconds. pub refresh: i32, /// SOA retry interval in seconds. pub retry: i32, /// SOA expire interval in seconds. pub expire: i32, /// SOA minimum (negative cache) TTL in seconds. pub minimum: u32, /// SOA MNAME (primary nameserver, fully qualified). pub mname: String, /// SOA RNAME (admin email in DNS format, fully qualified). pub rname: String, } impl Default for SoaConfig { fn default() -> Self { Self { ttl: 3600, refresh: 3600, retry: 900, expire: 604800, minimum: 300, mname: "ns1.example.com.".to_string(), rname: "admin.example.com.".to_string(), } } } #[derive(Debug, Deserialize)] #[serde(default)] pub struct VerifyConfig { /// Onis appview to call. pub appview_url: String, /// Address to start listening on for api. pub bind: String, /// Port used for api. pub port: u16, /// Seconds between scheduled verification runs. pub check_interval: u64, /// Seconds a zone must be stale before reverification. pub recheck_interval: i64, /// Expected NS records that indicate correct delegation. pub expected_ns: Vec, /// Optional custom resolver IP addresses. pub nameservers: Vec, /// Port used when resolving against custom nameservers. pub dns_port: u16, } impl Default for VerifyConfig { fn default() -> Self { Self { appview_url: "http://localhost:3000".to_string(), bind: "0.0.0.0".to_string(), port: 3001, check_interval: 60, recheck_interval: 3600, expected_ns: vec![ "ns1.example.com".to_string(), "ns2.example.com".to_string(), ], nameservers: vec![], dns_port: 53, } } } impl VerifyConfig { /// Parse the nameservers list into IP addresses. /// Returns None if the list is empty. pub fn parse_nameservers(&self) -> Result>, std::net::AddrParseError> { if self.nameservers.is_empty() { return Ok(None); } let addrs: Result, _> = self .nameservers .iter() .map(|s| s.parse()) .collect(); addrs.map(Some) } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; #[test] fn parse_nameservers_empty_returns_none() { let config = VerifyConfig::default(); let result = config.parse_nameservers().unwrap(); assert_eq!(result, None); } #[test] fn parse_nameservers_valid_ipv4() { let config = VerifyConfig { nameservers: vec!["1.1.1.1".to_string(), "8.8.8.8".to_string()], ..Default::default() }; let result = config.parse_nameservers().unwrap().unwrap(); assert_eq!(result.len(), 2); assert_eq!(result, vec![ "1.1.1.1".parse::().unwrap(), "8.8.8.8".parse::().unwrap(), ]); } #[test] fn parse_nameservers_valid_ipv6() { let config = VerifyConfig { nameservers: vec!["2001:4860:4860::8888".to_string()], ..Default::default() }; let result = config.parse_nameservers().unwrap().unwrap(); assert_eq!(result.len(), 1); assert_eq!(result, vec!["2001:4860:4860::8888".parse::().unwrap()]); } #[test] fn parse_nameservers_mixed_v4_v6() { let config = VerifyConfig { nameservers: vec!["1.1.1.1".to_string(), "::1".to_string()], ..Default::default() }; let result = config.parse_nameservers().unwrap().unwrap(); assert_eq!(result.len(), 2); } #[test] fn parse_nameservers_invalid_returns_err() { let config = VerifyConfig { nameservers: vec!["not-an-ip".to_string()], ..Default::default() }; assert!(config.parse_nameservers().is_err()); } #[test] fn parse_nameservers_one_invalid_fails_all() { let config = VerifyConfig { nameservers: vec!["1.1.1.1".to_string(), "bad".to_string()], ..Default::default() }; assert!(config.parse_nameservers().is_err()); } }