use std::{fs, net::IpAddr, path::Path}; use anyhow::{Context, Result, ensure}; use serde::Deserialize; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { #[serde(default = "default_interval_secs")] pub interval_secs: u64, #[serde(default)] pub probe: ProbeConfig, pub telegram: Option, #[serde(rename = "group")] pub groups: Vec, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct TelegramConfig { /// The bot token is not here: it comes from TELEGRAM_BOT_TOKEN. pub chat_id: i64, } #[derive(Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ProbeConfig { /// ICMP echo requests per check; one reply is enough to count as alive. pub ping_count: u16, pub ping_timeout_ms: u64, /// Consecutive failed checks before a healthy IP is pulled from DNS. pub fail_threshold: u32, /// Consecutive successful checks before a dead IP is put back. pub rise_threshold: u32, } impl Default for ProbeConfig { fn default() -> Self { Self { ping_count: 3, ping_timeout_ms: 2000, fail_threshold: 3, rise_threshold: 2, } } } /// Which address family a group works in: members are resolved and the group name is /// managed through records of this type only. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] #[allow(clippy::upper_case_acronyms)] // Spelled as in DNS and in the config file. pub enum RecordType { #[default] A, AAAA, } impl RecordType { pub fn as_str(self) -> &'static str { match self { RecordType::A => "A", RecordType::AAAA => "AAAA", } } pub fn matches(self, ip: IpAddr) -> bool { match self { RecordType::A => ip.is_ipv4(), RecordType::AAAA => ip.is_ipv6(), } } } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Group { /// Cloudflare zone, e.g. "hoshino.app". pub zone: String, /// The aggregate record this program owns, e.g. "jp.hoshino.app". pub name: String, /// "A" (default) or "AAAA". #[serde(default)] pub record_type: RecordType, /// Hostnames in `zone` (their `record_type` records are read from Cloudflare) or literal /// addresses of the matching family. pub members: Vec, #[serde(default = "default_ttl")] pub ttl: u32, /// When set, a member must also accept a TCP connection on this port to count as alive. pub tcp_port: Option, } fn default_interval_secs() -> u64 { 30 } fn default_ttl() -> u32 { 60 } pub fn load(path: &Path) -> Result { let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; let cfg: Config = toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?; cfg.validate()?; Ok(cfg) } impl Config { fn validate(&self) -> Result<()> { ensure!(self.interval_secs > 0, "interval_secs must be > 0"); ensure!(self.probe.ping_count > 0, "probe.ping_count must be > 0"); ensure!(self.probe.fail_threshold > 0, "probe.fail_threshold must be > 0"); ensure!(self.probe.rise_threshold > 0, "probe.rise_threshold must be > 0"); ensure!(!self.groups.is_empty(), "at least one [[group]] is required"); for g in &self.groups { ensure!(in_zone(&g.name, &g.zone), "group {} is not inside zone {}", g.name, g.zone); ensure!(!g.members.is_empty(), "group {} has no members", g.name); let kind = g.record_type.as_str(); for m in &g.members { match m.parse::() { Ok(ip) => ensure!( g.record_type.matches(ip), "member {m} of {} does not fit a {kind} record", g.name ), Err(_) => ensure!( in_zone(m, &g.zone), "member {m} of {} is neither an IP address nor inside zone {}", g.name, g.zone ), } ensure!(*m != g.name, "group {} lists itself as a member", g.name); } } Ok(()) } } fn in_zone(name: &str, zone: &str) -> bool { name == zone || name.ends_with(&format!(".{zone}")) } #[cfg(test)] mod tests { use super::*; fn parse(group: &str) -> Result { let cfg: Config = toml::from_str(&format!("[[group]]\nzone = \"example.com\"\n{group}"))?; cfg.validate()?; Ok(cfg) } #[test] fn record_type_defaults_to_a() { let cfg = parse("name = \"g.example.com\"\nmembers = [\"a.example.com\", \"192.0.2.1\"]").unwrap(); assert_eq!(cfg.groups[0].record_type, RecordType::A); } #[test] fn aaaa_group_takes_v6_literals_only() { let cfg = parse("name = \"g.example.com\"\nrecord_type = \"AAAA\"\nmembers = [\"a.example.com\", \"2001:db8::1\"]"); assert_eq!(cfg.unwrap().groups[0].record_type, RecordType::AAAA); assert!(parse("name = \"g.example.com\"\nrecord_type = \"AAAA\"\nmembers = [\"192.0.2.1\"]").is_err()); assert!(parse("name = \"g.example.com\"\nmembers = [\"2001:db8::1\"]").is_err()); } }