revamp zone file generation

This commit is contained in:
Jorge Aparicio 2024-02-05 18:33:04 +01:00
parent 7e9f63d85e
commit 984a05e873
11 changed files with 412 additions and 145 deletions

1
Cargo.lock generated
View File

@ -25,7 +25,6 @@ name = "dnssec-tests"
version = "0.1.0"
dependencies = [
"minijinja",
"serde",
"tempfile",
]

View File

@ -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"

View File

@ -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<Self> {
impl<'a> AuthoritativeNameServer<'a> {
pub fn start(domain: Domain<'a>, referrals: &[Referral<'a>]) -> Result<Self> {
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(())
}
}

View File

@ -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<Ipv4Addr> {
}
let ipv4_addr = str::from_utf8(&output.stdout)?.trim().to_string();
dbg!(&ipv4_addr);
Ok(ipv4_addr.parse()?)
}

View File

@ -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<Domain<'_>> {
pub fn Domain<'a>(input: impl Into<Cow<'a, str>>) -> Result<Domain<'a>> {
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<Domain<'_>> {
}
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)
}
}

View File

@ -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)
}

293
src/record.rs Normal file
View File

@ -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<Record<'a>>,
}
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<Record<'a>>) {
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<A<'a>> for Record<'a> {
fn from(v: A<'a>) -> Self {
Self::A(v)
}
}
impl<'a> From<Ns<'a>> 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<A<'static>> {
Ok(A {
domain: Domain("e.gtld-servers.net.")?,
ipv4_addr: Ipv4Addr::new(192, 12, 94, 30),
})
}
fn example_ns() -> Result<Ns<'static>> {
Ok(Ns {
domain: Domain("com.")?,
ns: Domain("e.gtld-servers.net.")?,
})
}
fn example_soa() -> Result<Soa<'static>> {
Ok(Soa {
domain: Domain(".")?,
ns: Domain("a.root-servers.net.")?,
admin: Domain("nstld.verisign-grs.com.")?,
settings: SoaSettings::default(),
})
}
}

View File

@ -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<Self> {
pub fn start(roots: &[Root]) -> Result<Self> {
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::<Vec<_>>();
let hints = root_hints(&roots);
eprintln!("{hints}");
let lines = hints.lines().collect::<Vec<_>>();
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::<Vec<_>>()
.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::<Vec<_>>()
.try_into()
.unwrap();
assert_eq!(expected_ns_name, ns_name);
assert_eq!("A", record_type);
assert_eq!(expected_ip_addr.to_string(), ip_addr);
}
}
}

View File

@ -1,4 +0,0 @@
{%- for root in roots -%}
. 3600000 NS {{ root.name }}
{{ root.name }} 3600000 A {{ root.ip_addr }}
{% endfor %}

View File

@ -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

View File

@ -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