From 984a05e873d6b7c4c0c4b128b627faca83ddbea6 Mon Sep 17 00:00:00 2001 From: Jorge Aparicio Date: Mon, 5 Feb 2024 18:33:04 +0100 Subject: [PATCH] revamp zone file generation --- Cargo.lock | 1 - Cargo.toml | 3 - src/authoritative_name_server.rs | 88 ++++++---- src/container.rs | 2 - src/domain.rs | 35 +++- src/lib.rs | 8 + src/record.rs | 293 +++++++++++++++++++++++++++++++ src/recursive_resolver.rs | 99 +++-------- src/templates/root.hints.jinja | 4 - src/templates/root.zone.jinja | 12 -- src/templates/tld.zone.jinja | 12 -- 11 files changed, 412 insertions(+), 145 deletions(-) create mode 100644 src/record.rs delete mode 100644 src/templates/root.hints.jinja delete mode 100644 src/templates/root.zone.jinja delete mode 100644 src/templates/tld.zone.jinja diff --git a/Cargo.lock b/Cargo.lock index 4ae8393f..9b6bb82d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,7 +25,6 @@ name = "dnssec-tests" version = "0.1.0" dependencies = [ "minijinja", - "serde", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index 03007d0b..50ccdc0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,6 @@ version = "0.1.0" edition = "2021" license = "MIT or Apache 2.0" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] minijinja = "1.0.12" -serde = { version = "1.0.196", features = ["derive"] } tempfile = "3.9.0" diff --git a/src/authoritative_name_server.rs b/src/authoritative_name_server.rs index 61b1d5b3..bde2b3b5 100644 --- a/src/authoritative_name_server.rs +++ b/src/authoritative_name_server.rs @@ -1,15 +1,18 @@ use std::net::Ipv4Addr; use std::process::Child; -use crate::{container::Container, Domain, Result, CHMOD_RW_EVERYONE}; +use crate::container::Container; +use crate::record::{self, Referral, SoaSettings, Zone}; +use crate::{Domain, Result, CHMOD_RW_EVERYONE}; -pub struct AuthoritativeNameServer { +pub struct AuthoritativeNameServer<'a> { child: Child, container: Container, + zone: Zone<'a>, } -impl AuthoritativeNameServer { - pub fn start(domain: Domain) -> Result { +impl<'a> AuthoritativeNameServer<'a> { + pub fn start(domain: Domain<'a>, referrals: &[Referral<'a>]) -> Result { let container = Container::run()?; // for PID file @@ -17,46 +20,48 @@ impl AuthoritativeNameServer { container.status_ok(&["mkdir", "-p", "/etc/nsd/zones"])?; let zone_path = "/etc/nsd/zones/main.zone"; - container.cp("/etc/nsd/nsd.conf", &nsd_conf(domain), CHMOD_RW_EVERYONE)?; + container.cp("/etc/nsd/nsd.conf", &nsd_conf(&domain), CHMOD_RW_EVERYONE)?; - let zone_file_contents = if domain.is_root() { - root_zone() - } else { - tld_zone(domain) + let ns_count = crate::nameserver_count(); + let ns = Domain(format!("primary.ns{ns_count}.com."))?; + let soa = record::Soa { + domain: domain.clone(), + ns, + admin: Domain(format!("admin.ns{ns_count}.com."))?, + settings: SoaSettings::default(), }; + let mut zone = Zone::new(domain, soa); + for referral in referrals { + zone.referral(referral) + } - container.cp(zone_path, &zone_file_contents, CHMOD_RW_EVERYONE)?; + container.cp(zone_path, &zone.to_string(), CHMOD_RW_EVERYONE)?; let child = container.spawn(&["nsd", "-d"])?; - Ok(Self { child, container }) + Ok(Self { + child, + container, + zone, + }) } pub fn ipv4_addr(&self) -> Ipv4Addr { self.container.ipv4_addr() } + + pub fn nameserver(&self) -> &Domain<'a> { + &self.zone.soa.ns + } } -impl Drop for AuthoritativeNameServer { +impl Drop for AuthoritativeNameServer<'_> { fn drop(&mut self) { let _ = self.child.kill(); } } -fn tld_zone(domain: Domain) -> String { - assert!(!domain.is_root()); - - minijinja::render!( - include_str!("templates/tld.zone.jinja"), - tld => domain.as_str() - ) -} - -fn root_zone() -> String { - minijinja::render!(include_str!("templates/root.zone.jinja"),) -} - -fn nsd_conf(domain: Domain) -> String { +fn nsd_conf(domain: &Domain) -> String { minijinja::render!( include_str!("templates/nsd.conf.jinja"), domain => domain.as_str() @@ -68,8 +73,8 @@ mod tests { use super::*; #[test] - fn tld_setup() -> Result<()> { - let tld_ns = AuthoritativeNameServer::start(Domain("com.")?)?; + fn tld_ns() -> Result<()> { + let tld_ns = AuthoritativeNameServer::start(Domain("com.")?, &[])?; let ip_addr = tld_ns.ipv4_addr(); let client = Container::run()?; @@ -83,8 +88,8 @@ mod tests { } #[test] - fn root_setup() -> Result<()> { - let root_ns = AuthoritativeNameServer::start(Domain::ROOT)?; + fn root_ns() -> Result<()> { + let root_ns = AuthoritativeNameServer::start(Domain::ROOT, &[])?; let ip_addr = root_ns.ipv4_addr(); let client = Container::run()?; @@ -96,4 +101,27 @@ mod tests { Ok(()) } + + #[test] + fn root_ns_with_referral() -> Result<()> { + let expected_ip_addr = Ipv4Addr::new(172, 17, 200, 1); + let root_ns = AuthoritativeNameServer::start( + Domain::ROOT, + &[Referral { + domain: Domain("com.")?, + ipv4_addr: expected_ip_addr, + ns: Domain("primary.tld-server.com.")?, + }], + )?; + let ip_addr = root_ns.ipv4_addr(); + + let client = Container::run()?; + let output = client.output(&["dig", &format!("@{ip_addr}"), "NS", "com."])?; + + assert!(output.status.success()); + eprintln!("{}", output.stdout); + assert!(output.stdout.contains("status: NOERROR")); + + Ok(()) + } } diff --git a/src/container.rs b/src/container.rs index 2c49a247..e7f12759 100644 --- a/src/container.rs +++ b/src/container.rs @@ -59,7 +59,6 @@ impl Container { let output: Output = checked_output(&mut command)?.try_into()?; let id = output.stdout; - dbg!(&id); let ipv4_addr = get_ipv4_addr(&id)?; @@ -183,7 +182,6 @@ fn get_ipv4_addr(container_id: &str) -> Result { } let ipv4_addr = str::from_utf8(&output.stdout)?.trim().to_string(); - dbg!(&ipv4_addr); Ok(ipv4_addr.parse()?) } diff --git a/src/domain.rs b/src/domain.rs index 2d05689e..922347cd 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -1,13 +1,17 @@ +use core::fmt; +use std::borrow::Cow; + use crate::Result; -#[derive(Clone, Copy)] +#[derive(Clone)] pub struct Domain<'a> { - inner: &'a str, + inner: Cow<'a, str>, } // TODO likely needs further validation #[allow(non_snake_case)] -pub fn Domain(input: &str) -> Result> { +pub fn Domain<'a>(input: impl Into>) -> Result> { + let input = input.into(); if !input.ends_with('.') { return Err("domain must end with a `.`".into()); } @@ -20,13 +24,32 @@ pub fn Domain(input: &str) -> Result> { } impl<'a> Domain<'a> { - pub const ROOT: Domain<'static> = Domain { inner: "." }; + pub const ROOT: Domain<'static> = Domain { + inner: Cow::Borrowed("."), + }; pub fn is_root(&self) -> bool { self.inner == "." } - pub fn as_str(&self) -> &'a str { - self.inner + pub fn as_str(&self) -> &str { + &self.inner + } + + pub fn into_owned(self) -> Domain<'static> { + let owned = match self.inner { + Cow::Borrowed(borrowed) => borrowed.to_string(), + Cow::Owned(owned) => owned, + }; + + Domain { + inner: Cow::Owned(owned), + } + } +} + +impl fmt::Display for Domain<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.inner) } } diff --git a/src/lib.rs b/src/lib.rs index 4b74a65f..82c2a386 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +use std::sync::atomic::{self, AtomicUsize}; + pub use crate::authoritative_name_server::AuthoritativeNameServer; pub use crate::domain::Domain; pub use crate::recursive_resolver::RecursiveResolver; @@ -10,4 +12,10 @@ const CHMOD_RW_EVERYONE: &str = "666"; mod authoritative_name_server; pub mod container; mod domain; +pub mod record; mod recursive_resolver; + +fn nameserver_count() -> usize { + static COUNT: AtomicUsize = AtomicUsize::new(0); + COUNT.fetch_add(1, atomic::Ordering::Relaxed) +} diff --git a/src/record.rs b/src/record.rs new file mode 100644 index 00000000..aaafbb9d --- /dev/null +++ b/src/record.rs @@ -0,0 +1,293 @@ +//! DNS records in BIND syntax +//! +//! Note that the `@` syntax is not used to avoid relying on the order of the records + +use core::fmt; +use std::net::Ipv4Addr; + +use crate::Domain; + +pub struct Zone<'a> { + pub origin: Domain<'a>, + pub ttl: u32, + pub soa: Soa<'a>, + pub records: Vec>, +} + +impl<'a> Zone<'a> { + /// Convenience constructor that uses "reasonable" defaults + pub fn new(origin: Domain<'a>, soa: Soa<'a>) -> Self { + Self { + origin, + ttl: 1800, + soa, + records: Vec::new(), + } + } + + /// Appends a record + pub fn record(&mut self, record: impl Into>) { + self.records.push(record.into()) + } + + /// Appends a NS + A record pair + pub fn referral(&mut self, referral: &Referral<'a>) { + let Referral { + domain, + ipv4_addr, + ns, + } = referral; + + self.record(Ns { + domain: domain.clone(), + ns: ns.clone(), + }); + self.record(A { + domain: domain.clone(), + ipv4_addr: *ipv4_addr, + }); + } +} + +impl fmt::Display for Zone<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + origin, + ttl, + soa, + records, + } = self; + + writeln!(f, "$ORIGIN {origin}")?; + writeln!(f, "$TTL {ttl}")?; + writeln!(f, "{soa}")?; + + for record in records { + writeln!(f, "{record}")?; + } + + Ok(()) + } +} + +pub struct Referral<'a> { + pub domain: Domain<'a>, + pub ipv4_addr: Ipv4Addr, + pub ns: Domain<'a>, +} + +pub struct Root<'a> { + pub ipv4_addr: Ipv4Addr, + pub ns: Domain<'a>, + pub ttl: u32, +} + +impl<'a> Root<'a> { + /// Convenience constructor that uses "reasonable" defaults + pub fn new(ns: Domain<'a>, ipv4_addr: Ipv4Addr) -> Self { + Self { + ipv4_addr, + ns, + ttl: 3600000, // 1000 hours + } + } +} + +impl fmt::Display for Root<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { ipv4_addr, ns, ttl } = self; + + writeln!(f, ".\t{ttl}\tNS\t{ns}")?; + write!(f, "{ns}\t{ttl}\tA\t{ipv4_addr}") + } +} + +pub enum Record<'a> { + A(A<'a>), + Ns(Ns<'a>), +} + +impl<'a> From> for Record<'a> { + fn from(v: A<'a>) -> Self { + Self::A(v) + } +} + +impl<'a> From> for Record<'a> { + fn from(v: Ns<'a>) -> Self { + Self::Ns(v) + } +} + +impl fmt::Display for Record<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Record::A(a) => a.fmt(f), + Record::Ns(ns) => ns.fmt(f), + } + } +} + +pub struct A<'a> { + pub domain: Domain<'a>, + pub ipv4_addr: Ipv4Addr, +} + +impl fmt::Display for A<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { domain, ipv4_addr } = self; + + write!(f, "{domain}\tIN\tA\t{ipv4_addr}") + } +} + +pub struct Ns<'a> { + pub domain: Domain<'a>, + pub ns: Domain<'a>, +} + +impl fmt::Display for Ns<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { domain, ns } = self; + + write!(f, "{domain}\tIN\tNS\t{ns}") + } +} + +pub struct Soa<'a> { + pub domain: Domain<'a>, + pub ns: Domain<'a>, + pub admin: Domain<'a>, + pub settings: SoaSettings, +} + +impl fmt::Display for Soa<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + domain, + ns, + admin, + settings, + } = self; + + write!(f, "{domain}\tIN\tSOA\t{ns}\t{admin}\t{settings}") + } +} + +pub struct SoaSettings { + pub serial: u32, + pub refresh: u32, + pub retry: u32, + pub expire: u32, + pub minimum: u32, +} + +impl Default for SoaSettings { + fn default() -> Self { + Self { + serial: 2024010101, + refresh: 1800, // 30 minutes + retry: 900, // 15 minutes + expire: 604800, // 1 week + minimum: 86400, // 1 day + } + } +} + +impl fmt::Display for SoaSettings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + serial, + refresh, + retry, + expire, + minimum, + } = self; + + write!(f, "( {serial} {refresh} {retry} {expire} {minimum} )") + } +} + +#[cfg(test)] +mod tests { + use crate::Result; + + use super::*; + + #[test] + fn a_to_string() -> Result<()> { + let expected = "e.gtld-servers.net. IN A 192.12.94.30"; + let a = example_a()?; + assert_eq!(expected, a.to_string()); + + Ok(()) + } + + #[test] + fn ns_to_string() -> Result<()> { + let expected = "com. IN NS e.gtld-servers.net."; + let ns = example_ns()?; + assert_eq!(expected, ns.to_string()); + + Ok(()) + } + + #[test] + fn root_to_string() -> Result<()> { + let expected = ". 3600000 NS a.root-servers.net. +a.root-servers.net. 3600000 A 198.41.0.4"; + let root = Root::new(Domain("a.root-servers.net.")?, Ipv4Addr::new(198, 41, 0, 4)); + assert_eq!(expected, root.to_string()); + Ok(()) + } + + #[test] + fn soa_to_string() -> Result<()> { + let expected = + ". IN SOA a.root-servers.net. nstld.verisign-grs.com. ( 2024010101 1800 900 604800 86400 )"; + let soa = example_soa()?; + assert_eq!(expected, soa.to_string()); + + Ok(()) + } + + #[test] + fn zone_file_to_string() -> Result<()> { + let expected = "$ORIGIN . +$TTL 1800 +. IN SOA a.root-servers.net. nstld.verisign-grs.com. ( 2024010101 1800 900 604800 86400 ) +com. IN NS e.gtld-servers.net. +e.gtld-servers.net. IN A 192.12.94.30 +"; + let mut zone = Zone::new(Domain::ROOT, example_soa()?); + zone.record(example_ns()?); + zone.record(example_a()?); + + assert_eq!(expected, zone.to_string()); + + Ok(()) + } + + fn example_a() -> Result> { + Ok(A { + domain: Domain("e.gtld-servers.net.")?, + ipv4_addr: Ipv4Addr::new(192, 12, 94, 30), + }) + } + + fn example_ns() -> Result> { + Ok(Ns { + domain: Domain("com.")?, + ns: Domain("e.gtld-servers.net.")?, + }) + } + + fn example_soa() -> Result> { + Ok(Soa { + domain: Domain(".")?, + ns: Domain("a.root-servers.net.")?, + admin: Domain("nstld.verisign-grs.com.")?, + settings: SoaSettings::default(), + }) + } +} diff --git a/src/recursive_resolver.rs b/src/recursive_resolver.rs index 14efbc47..ed823bc5 100644 --- a/src/recursive_resolver.rs +++ b/src/recursive_resolver.rs @@ -1,9 +1,9 @@ +use core::fmt::Write; use std::net::Ipv4Addr; use std::process::Child; -use serde::Serialize; - use crate::container::Container; +use crate::record::Root; use crate::{Result, CHMOD_RW_EVERYONE}; pub struct RecursiveResolver { @@ -11,28 +11,16 @@ pub struct RecursiveResolver { child: Child, } -#[derive(Serialize)] -pub struct RootServer { - name: String, - ip_addr: Ipv4Addr, -} - -fn root_hints(roots: &[RootServer]) -> String { - minijinja::render!( - include_str!("templates/root.hints.jinja"), - roots => roots - ) -} - impl RecursiveResolver { - pub fn start(root_servers: &[RootServer]) -> Result { + pub fn start(roots: &[Root]) -> Result { let container = Container::run()?; - container.cp( - "/etc/unbound/root.hints", - &root_hints(root_servers), - CHMOD_RW_EVERYONE, - )?; + let mut hints = String::new(); + for root in roots { + writeln!(hints, "{root}").unwrap(); + } + + container.cp("/etc/unbound/root.hints", &hints, CHMOD_RW_EVERYONE)?; let child = container.spawn(&["unbound", "-d"])?; @@ -52,18 +40,24 @@ impl Drop for RecursiveResolver { #[cfg(test)] mod tests { - use crate::{AuthoritativeNameServer, Domain}; + use crate::{record::Referral, AuthoritativeNameServer, Domain}; use super::*; #[test] - #[ignore = "FIXME"] fn can_resolve() -> Result<()> { - let root_ns = AuthoritativeNameServer::start(Domain::ROOT)?; - let roots = &[RootServer { - name: "my.root-server.com".to_string(), - ip_addr: root_ns.ipv4_addr(), - }]; + let tld_ns = AuthoritativeNameServer::start(Domain("com.")?, &[])?; + + let root_ns = AuthoritativeNameServer::start( + Domain::ROOT, + &[Referral { + domain: Domain("com.")?, + ipv4_addr: tld_ns.ipv4_addr(), + ns: tld_ns.nameserver().clone(), + }], + )?; + + let roots = &[Root::new(root_ns.nameserver().clone(), root_ns.ipv4_addr())]; let resolver = RecursiveResolver::start(roots)?; let resolver_ip_addr = resolver.ipv4_addr(); @@ -71,56 +65,11 @@ mod tests { let output = container.output(&["dig", &format!("@{}", resolver_ip_addr), "example.com"])?; + eprintln!("{}", output.stdout); + assert!(output.status.success()); assert!(output.stdout.contains("status: NOERROR")); Ok(()) } - - #[test] - fn root_hints_template_works() { - let expected = [ - ("a.root-server.com", Ipv4Addr::new(172, 17, 0, 1)), - ("b.root-server.com", Ipv4Addr::new(172, 17, 0, 2)), - ]; - - let roots = expected - .iter() - .map(|(ns_name, ip_addr)| RootServer { - name: ns_name.to_string(), - ip_addr: *ip_addr, - }) - .collect::>(); - - let hints = root_hints(&roots); - - eprintln!("{hints}"); - let lines = hints.lines().collect::>(); - - for (lines, (expected_ns_name, expected_ip_addr)) in lines.chunks(2).zip(expected) { - let [ns_record, a_record] = lines.try_into().unwrap(); - - // block to avoid shadowing - { - let [domain, _ttl, record_type, ns_name] = ns_record - .split_whitespace() - .collect::>() - .try_into() - .unwrap(); - - assert_eq!(".", domain); - assert_eq!("NS", record_type); - assert_eq!(expected_ns_name, ns_name); - } - - let [ns_name, _ttl, record_type, ip_addr] = a_record - .split_whitespace() - .collect::>() - .try_into() - .unwrap(); - assert_eq!(expected_ns_name, ns_name); - assert_eq!("A", record_type); - assert_eq!(expected_ip_addr.to_string(), ip_addr); - } - } } diff --git a/src/templates/root.hints.jinja b/src/templates/root.hints.jinja deleted file mode 100644 index 180fe2c5..00000000 --- a/src/templates/root.hints.jinja +++ /dev/null @@ -1,4 +0,0 @@ -{%- for root in roots -%} -. 3600000 NS {{ root.name }} -{{ root.name }} 3600000 A {{ root.ip_addr }} -{% endfor %} diff --git a/src/templates/root.zone.jinja b/src/templates/root.zone.jinja deleted file mode 100644 index e5712ffd..00000000 --- a/src/templates/root.zone.jinja +++ /dev/null @@ -1,12 +0,0 @@ -$ORIGIN . -$TTL 1800 -@ IN SOA primary.root-server.com admin.root-server.com ( - 2014010100 ; Serial - 10800 ; Refresh (3 hours) - 900 ; Retry (15 minutes) - 604800 ; Expire (1 week) - 86400 ; Minimum (1 day) - ) -@ IN NS primary.root-server.com - -; TODO referral diff --git a/src/templates/tld.zone.jinja b/src/templates/tld.zone.jinja deleted file mode 100644 index f16feb2a..00000000 --- a/src/templates/tld.zone.jinja +++ /dev/null @@ -1,12 +0,0 @@ -$ORIGIN {{ tld }} -$TTL 1800 -@ IN SOA primary.tld-server.{{ tld }} admin.tld-server.{{ tld }} ( - 2014010100 ; Serial - 10800 ; Refresh (3 hours) - 900 ; Retry (15 minutes) - 604800 ; Expire (1 week) - 86400 ; Minimum (1 day) - ) -@ IN NS primary.tld-server.{{ tld }} - -; intentionally blank