diff --git a/Cargo.lock b/Cargo.lock index 426b416d..9b6bb82d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,221 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + [[package]] name = "dnssec-tests" version = "0.1.0" +dependencies = [ + "minijinja", + "tempfile", +] + +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + +[[package]] +name = "minijinja" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe0ff215195a22884d867b547c70a0c4815cbbcc70991f281dca604b20d10ce" +dependencies = [ + "serde", +] + +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "rustix" +version = "0.38.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949" +dependencies = [ + "bitflags 2.4.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" diff --git a/Cargo.toml b/Cargo.toml index 2b8c6086..72d5349d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,9 @@ 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" +tempfile = "3.9.0" + +[lib] +doctest = false diff --git a/README.md b/README.md index 08c23578..8e320a06 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ remote-control: control-enable: no zone: - name: . + name: main zonefile: /etc/nsd/zones/main.zone ``` @@ -91,11 +91,11 @@ zone: $ORIGIN com. $TTL 1800 @ IN SOA primary.tld-server.com. admin.tld-server.com. ( - 2014080301 - 3600 - 900 - 1209600 - 1800 + 2014010100 ; Serial + 10800 ; Refresh (3 hours) + 900 ; Retry (15 minutes) + 604800 ; Expire (1 week) + 86400 ; Minimum (1 day) ) @ IN NS primary.tld-server.com. ``` diff --git a/docker/client.Dockerfile b/docker/client.Dockerfile deleted file mode 100644 index e05b7249..00000000 --- a/docker/client.Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM ubuntu:22.04 - -RUN apt-get update && \ - apt-get install -y dnsutils iputils-ping tshark vim \ No newline at end of file diff --git a/docker/files/etc/unbound/root.hints b/docker/files/etc/unbound/root.hints deleted file mode 100644 index 5516fe71..00000000 --- a/docker/files/etc/unbound/root.hints +++ /dev/null @@ -1,2 +0,0 @@ -. 3600000 NS primary.root-server.com. -primary.root-server.com. 3600000 A 172.17.0.2 diff --git a/docker/nsd.Dockerfile b/docker/nsd.Dockerfile deleted file mode 100644 index d9441e5a..00000000 --- a/docker/nsd.Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM ubuntu:22.04 - -RUN apt-get update && \ - apt-get install -y nsd iputils-ping tshark vim \ No newline at end of file diff --git a/docker/unbound.Dockerfile b/docker/unbound.Dockerfile index 78c14dcb..ce8e2a62 100644 --- a/docker/unbound.Dockerfile +++ b/docker/unbound.Dockerfile @@ -1,7 +1,4 @@ FROM ubuntu:22.04 RUN apt-get update && \ - apt-get install -y unbound iputils-ping tshark vim - -COPY ./files/etc/unbound/unbound.conf /etc/unbound/unbound.conf -COPY ./files/etc/unbound/root.hints /etc/unbound/root.hints + apt-get install -y dnsutils unbound nsd iputils-ping tshark vim ldnsutils diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 00000000..1902ac7d --- /dev/null +++ b/src/client.rs @@ -0,0 +1,277 @@ +use core::str::FromStr; +use std::net::Ipv4Addr; + +use crate::container::Container; +use crate::record::{Record, RecordType}; +use crate::{Error, Result, FQDN}; + +pub struct Client { + inner: Container, +} + +impl Client { + pub fn new() -> Result { + Ok(Self { + inner: Container::run()?, + }) + } + + // FIXME this needs to use the same trust anchor as `RecursiveResolver` or validation will fail + pub fn delv( + &self, + server: Ipv4Addr, + record_type: RecordType, + fqdn: &FQDN<'_>, + ) -> Result { + self.inner.stdout(&[ + "delv", + "+mtrace", + &format!("@{server}"), + record_type.as_str(), + fqdn.as_str(), + ]) + } + + pub fn dig( + &self, + recurse: Recurse, + dnssec: Dnssec, + server: Ipv4Addr, + record_type: RecordType, + fqdn: &FQDN<'_>, + ) -> Result { + let output = self.inner.stdout(&[ + "dig", + recurse.as_str(), + dnssec.as_str(), + &format!("@{server}"), + record_type.as_str(), + fqdn.as_str(), + ])?; + + output.parse() + } +} + +#[derive(Clone, Copy)] +pub enum Dnssec { + Yes, + No, +} + +impl Dnssec { + fn as_str(&self) -> &'static str { + match self { + Self::Yes => "+dnssec", + Self::No => "+nodnssec", + } + } +} + +#[derive(Clone, Copy)] +pub enum Recurse { + Yes, + No, +} + +impl Recurse { + fn as_str(&self) -> &'static str { + match self { + Self::Yes => "+recurse", + Self::No => "+norecurse", + } + } +} + +#[derive(Debug)] +pub struct DigOutput { + pub flags: DigFlags, + pub status: DigStatus, + pub answer: Vec, + // TODO(if needed) other sections +} + +impl FromStr for DigOutput { + type Err = Error; + + fn from_str(input: &str) -> Result { + const FLAGS_PREFIX: &str = ";; flags: "; + const STATUS_PREFIX: &str = ";; ->>HEADER<<- opcode: QUERY, status: "; + const ANSWER_HEADER: &str = ";; ANSWER SECTION:"; + + fn not_found(prefix: &str) -> String { + format!("`{prefix}` line was not found") + } + + fn more_than_once(prefix: &str) -> String { + format!("`{prefix}` line was found more than once") + } + + fn missing(prefix: &str, delimiter: &str) -> String { + format!("`{prefix}` line is missing a {delimiter}") + } + + let mut flags = None; + let mut status = None; + let mut answer = None; + + let mut lines = input.lines(); + while let Some(line) = lines.next() { + if let Some(unprefixed) = line.strip_prefix(FLAGS_PREFIX) { + let (flags_text, _rest) = unprefixed + .split_once(';') + .ok_or_else(|| missing(FLAGS_PREFIX, "semicolon (;)"))?; + + if flags.is_some() { + return Err(more_than_once(FLAGS_PREFIX).into()); + } + + flags = Some(flags_text.parse()?); + } else if let Some(unprefixed) = line.strip_prefix(STATUS_PREFIX) { + let (status_text, _rest) = unprefixed + .split_once(',') + .ok_or_else(|| missing(STATUS_PREFIX, "comma (,)"))?; + + if status.is_some() { + return Err(more_than_once(STATUS_PREFIX).into()); + } + + status = Some(status_text.parse()?); + } else if line.starts_with(ANSWER_HEADER) { + if answer.is_some() { + return Err(more_than_once(ANSWER_HEADER).into()); + } + + let mut records = vec![]; + for line in lines.by_ref() { + if line.is_empty() { + break; + } + + records.push(line.parse()?); + } + + answer = Some(records); + } + } + + Ok(Self { + flags: flags.ok_or_else(|| not_found(FLAGS_PREFIX))?, + status: status.ok_or_else(|| not_found(STATUS_PREFIX))?, + answer: answer.unwrap_or_default(), + }) + } +} + +#[derive(Debug, Default, PartialEq)] +pub struct DigFlags { + pub qr: bool, + pub recursion_desired: bool, + pub recursion_available: bool, + pub authoritative_answer: bool, + pub authenticated_data: bool, +} + +impl FromStr for DigFlags { + type Err = Error; + + fn from_str(input: &str) -> std::prelude::v1::Result { + let mut qr = false; + let mut recursion_desired = false; + let mut recursion_available = false; + let mut authoritative_answer = false; + let mut authenticated_data = false; + + for flag in input.split_whitespace() { + match flag { + "qr" => qr = true, + "rd" => recursion_desired = true, + "ra" => recursion_available = true, + "aa" => authoritative_answer = true, + "ad" => authenticated_data = true, + _ => return Err(format!("unknown flag: {flag}").into()), + } + } + + Ok(Self { + qr, + recursion_desired, + recursion_available, + authoritative_answer, + authenticated_data, + }) + } +} + +#[allow(clippy::upper_case_acronyms)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum DigStatus { + NOERROR, + NXDOMAIN, + REFUSED, +} + +impl DigStatus { + #[must_use] + pub fn is_noerror(&self) -> bool { + matches!(self, Self::NOERROR) + } +} + +impl FromStr for DigStatus { + type Err = Error; + + fn from_str(input: &str) -> Result { + let status = match input { + "NXDOMAIN" => Self::NXDOMAIN, + "NOERROR" => Self::NOERROR, + "REFUSED" => Self::REFUSED, + _ => return Err(format!("unknown status: {input}").into()), + }; + + Ok(status) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dig_nxdomain() -> Result<()> { + // $ dig nonexistent.domain. + let input = " +; <<>> DiG 9.18.18-0ubuntu0.22.04.1-Ubuntu <<>> nonexistent.domain. +;; global options: +cmd +;; Got answer: +;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 45583 +;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1 + +;; OPT PSEUDOSECTION: +; EDNS: version: 0, flags:; udp: 1232 +;; QUESTION SECTION: +;nonexistent.domain. IN A + +;; Query time: 3 msec +;; SERVER: 192.168.1.1#53(192.168.1.1) (UDP) +;; WHEN: Tue Feb 06 15:00:12 UTC 2024 +;; MSG SIZE rcvd: 47 +"; + + let output: DigOutput = input.parse()?; + + assert_eq!(DigStatus::NXDOMAIN, output.status); + assert_eq!( + DigFlags { + qr: true, + recursion_desired: true, + recursion_available: true, + ..DigFlags::default() + }, + output.flags + ); + assert!(output.answer.is_empty()); + + Ok(()) + } +} diff --git a/src/container.rs b/src/container.rs new file mode 100644 index 00000000..6690bed5 --- /dev/null +++ b/src/container.rs @@ -0,0 +1,257 @@ +use core::str; +use std::fs; +use std::net::Ipv4Addr; +use std::path::Path; +use std::process::{self, Child, ExitStatus}; +use std::process::{Command, Stdio}; +use std::sync::atomic::AtomicUsize; +use std::sync::{atomic, Once}; + +use tempfile::NamedTempFile; + +use crate::{Error, Result}; + +pub struct Container { + name: String, + id: String, + // TODO probably also want the IPv6 address + ipv4_addr: Ipv4Addr, +} + +impl Container { + /// Starts the container in a "parked" state + pub fn run() -> Result { + static ONCE: Once = Once::new(); + static COUNT: AtomicUsize = AtomicUsize::new(0); + + // TODO configurable: hickory; bind + let binary = "unbound"; + let image_tag = format!("dnssec-tests-{binary}"); + + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let dockerfile_path = manifest_dir + .join("docker") + .join(format!("{binary}.Dockerfile")); + let docker_dir_path = manifest_dir.join("docker"); + + let mut command = Command::new("docker"); + command + .args(["build", "-t"]) + .arg(&image_tag) + .arg("-f") + .arg(dockerfile_path) + .arg(docker_dir_path); + + ONCE.call_once(|| { + let status = command.status().unwrap(); + assert!(status.success()); + }); + + let mut command = Command::new("docker"); + let pid = process::id(); + let count = COUNT.fetch_add(1, atomic::Ordering::Relaxed); + let name = format!("{binary}-{pid}-{count}"); + command + .args(["run", "--rm", "--detach", "--name", &name]) + .arg("-it") + .arg(image_tag) + .args(["sleep", "infinity"]); + + let output: Output = checked_output(&mut command)?.try_into()?; + let id = output.stdout; + + let ipv4_addr = get_ipv4_addr(&id)?; + + Ok(Self { + id, + name, + ipv4_addr, + }) + } + + pub fn cp(&self, path_in_container: &str, file_contents: &str) -> Result<()> { + const CHMOD_RW_EVERYONE: &str = "666"; + + let mut temp_file = NamedTempFile::new()?; + fs::write(&mut temp_file, file_contents)?; + + let src_path = temp_file.path().display().to_string(); + let dest_path = format!("{}:{path_in_container}", self.id); + + let mut command = Command::new("docker"); + command.args(["cp", &src_path, &dest_path]); + checked_output(&mut command)?; + + self.status_ok(&["chmod", CHMOD_RW_EVERYONE, path_in_container])?; + + Ok(()) + } + + /// Similar to `std::process::Command::output` but runs `command_and_args` in the container + pub fn output(&self, command_and_args: &[&str]) -> Result { + let mut command = Command::new("docker"); + command + .args(["exec", "-t", &self.id]) + .args(command_and_args); + + command.output()?.try_into() + } + + /// Similar to `Self::output` but checks `command_and_args` ran successfully and only + /// returns the stdout + pub fn stdout(&self, command_and_args: &[&str]) -> Result { + let output = self.output(command_and_args)?; + + if output.status.success() { + Ok(output.stdout) + } else { + Err(format!("[{}] `{command_and_args:?}` failed", self.name).into()) + } + } + + /// Similar to `std::process::Command::status` but runs `command_and_args` in the container + pub fn status(&self, command_and_args: &[&str]) -> Result { + let mut command = Command::new("docker"); + command + .args(["exec", "-t", &self.id]) + .args(command_and_args); + + Ok(command.status()?) + } + + /// Like `Self::status` but checks that `command_and_args` executed successfully + pub fn status_ok(&self, command_and_args: &[&str]) -> Result<()> { + let status = self.status(command_and_args)?; + + if status.success() { + Ok(()) + } else { + Err(format!("[{}] `{command_and_args:?}` failed", self.name).into()) + } + } + + pub fn spawn(&self, cmd: &[&str]) -> Result { + let mut command = Command::new("docker"); + command.args(["exec", "-t", &self.id]).args(cmd); + + Ok(command.spawn()?) + } + + pub fn ipv4_addr(&self) -> Ipv4Addr { + self.ipv4_addr + } +} + +#[derive(Debug)] +pub struct Output { + pub status: ExitStatus, + pub stderr: String, + pub stdout: String, +} + +impl TryFrom for Output { + type Error = Error; + + fn try_from(output: process::Output) -> Result { + let mut stderr = String::from_utf8(output.stderr)?; + while stderr.ends_with(|c| matches!(c, '\n' | '\r')) { + stderr.pop(); + } + + let mut stdout = String::from_utf8(output.stdout)?; + while stdout.ends_with(|c| matches!(c, '\n' | '\r')) { + stdout.pop(); + } + + Ok(Self { + status: output.status, + stderr, + stdout, + }) + } +} + +fn checked_output(command: &mut Command) -> Result { + let output = command.output()?; + if output.status.success() { + Ok(output) + } else { + Err(format!("`{command:?}` failed").into()) + } +} + +fn get_ipv4_addr(container_id: &str) -> Result { + let mut command = Command::new("docker"); + command + .args([ + "inspect", + "-f", + "{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}", + ]) + .arg(container_id); + + let output = command.output()?; + if !output.status.success() { + return Err(format!("`{command:?}` failed").into()); + } + + let ipv4_addr = str::from_utf8(&output.stdout)?.trim().to_string(); + + Ok(ipv4_addr.parse()?) +} + +// ensure the container gets deleted +impl Drop for Container { + fn drop(&mut self) { + // running this to completion would block the current thread for several seconds so just + // fire and forget + let _ = Command::new("docker") + .args(["rm", "-f", &self.id]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_works() -> Result<()> { + let container = Container::run()?; + + let output = container.output(&["true"])?; + assert!(output.status.success()); + + Ok(()) + } + + #[test] + fn ipv4_addr_works() -> Result<()> { + let container = Container::run()?; + let ipv4_addr = container.ipv4_addr(); + + let output = container.output(&["ping", "-c1", &format!("{ipv4_addr}")])?; + assert!(output.status.success()); + + Ok(()) + } + + #[test] + fn cp_works() -> Result<()> { + let container = Container::run()?; + + let path = "/tmp/somefile"; + let contents = "hello"; + container.cp(path, contents)?; + + let output = container.output(&["cat", path])?; + dbg!(&output); + + assert!(output.status.success()); + assert_eq!(contents, output.stdout); + + Ok(()) + } +} diff --git a/src/fqdn.rs b/src/fqdn.rs new file mode 100644 index 00000000..18e08a8b --- /dev/null +++ b/src/fqdn.rs @@ -0,0 +1,74 @@ +use core::fmt; +use core::str::FromStr; +use std::borrow::Cow; + +use crate::{Error, Result}; + +#[derive(Clone, PartialEq)] +pub struct FQDN<'a> { + inner: Cow<'a, str>, +} + +// TODO likely needs further validation +#[allow(non_snake_case)] +pub fn FQDN<'a>(input: impl Into>) -> Result> { + let input = input.into(); + if !input.ends_with('.') { + return Err("FQDN must end with a `.`".into()); + } + + if input != "." && input.starts_with('.') { + return Err("non-root FQDN cannot start with a `.`".into()); + } + + Ok(FQDN { inner: input }) +} + +impl<'a> FQDN<'a> { + pub const ROOT: FQDN<'static> = FQDN { + inner: Cow::Borrowed("."), + }; + + pub const COM: FQDN<'static> = FQDN { + inner: Cow::Borrowed("com."), + }; + + pub fn is_root(&self) -> bool { + self.inner == "." + } + + pub fn as_str(&self) -> &str { + &self.inner + } + + pub fn into_owned(self) -> FQDN<'static> { + let owned = match self.inner { + Cow::Borrowed(borrowed) => borrowed.to_string(), + Cow::Owned(owned) => owned, + }; + + FQDN { + inner: Cow::Owned(owned), + } + } +} + +impl FromStr for FQDN<'static> { + type Err = Error; + + fn from_str(input: &str) -> Result { + Ok(FQDN(input)?.into_owned()) + } +} + +impl fmt::Debug for FQDN<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +impl fmt::Display for FQDN<'_> { + 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 7d12d9af..60f55948 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,13 @@ -pub fn add(left: usize, right: usize) -> usize { - left + right -} +pub use crate::fqdn::FQDN; +pub use crate::recursive_resolver::RecursiveResolver; -#[cfg(test)] -mod tests { - use super::*; +pub type Error = Box; +pub type Result = core::result::Result; - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub mod client; +mod container; +mod fqdn; +pub mod name_server; +pub mod record; +mod recursive_resolver; +pub mod zone_file; diff --git a/src/name_server.rs b/src/name_server.rs new file mode 100644 index 00000000..c6c8fb6e --- /dev/null +++ b/src/name_server.rs @@ -0,0 +1,355 @@ +use core::sync::atomic::{self, AtomicUsize}; +use std::net::Ipv4Addr; +use std::process::Child; + +use crate::container::Container; +use crate::zone_file::{self, SoaSettings, ZoneFile, DNSKEY, DS}; +use crate::{Result, FQDN}; + +pub struct NameServer<'a, State> { + container: Container, + zone_file: ZoneFile<'a>, + state: State, +} + +impl<'a> NameServer<'a, Stopped> { + /// Spins up a primary name server that has authority over the given `zone` + /// + /// The initial state of the server is the "Stopped" state where it won't answer any query. + /// + /// The FQDN of the name server will have the form `primary{count}.nameservers.com.` where + /// `{count}` is a (process-wide) unique, monotonically increasing integer + /// + /// The zone file will contain these records + /// + /// - one SOA record, with the primary name server field set to this name server's FQDN + /// - one NS record, with this name server's FQDN set as the only available name server for + /// the zone + pub fn new(zone: FQDN<'a>) -> Result { + let ns_count = ns_count(); + let nameserver = primary_ns(ns_count); + + let soa = zone_file::SOA { + zone: zone.clone(), + nameserver: nameserver.clone(), + admin: admin_ns(ns_count), + settings: SoaSettings::default(), + }; + let mut zone_file = ZoneFile::new(zone.clone(), soa); + + zone_file.entry(zone_file::NS { + zone, + nameserver: nameserver.clone(), + }); + + Ok(Self { + container: Container::run()?, + zone_file, + state: Stopped, + }) + } + + /// Adds a NS + A record pair to the zone file + pub fn referral( + &mut self, + zone: FQDN<'a>, + nameserver: FQDN<'a>, + ipv4_addr: Ipv4Addr, + ) -> &mut Self { + self.zone_file.referral(zone, nameserver, ipv4_addr); + self + } + + /// Adds an A record pair to the zone file + pub fn a(&mut self, fqdn: FQDN<'a>, ipv4_addr: Ipv4Addr) -> &mut Self { + self.zone_file.entry(zone_file::A { fqdn, ipv4_addr }); + self + } + + /// Adds a DS record to the zone file + pub fn ds(&mut self, ds: DS) -> &mut Self { + self.zone_file.entry(ds); + self + } + + /// Freezes and signs the name server's zone file + pub fn sign(self) -> Result> { + // TODO do we want to make these settings configurable? + const ZSK_BITS: usize = 1024; + const KSK_BITS: usize = 2048; + const ALGORITHM: &str = "RSASHA1-NSEC3-SHA1"; + + let Self { + container, + zone_file, + state: _, + } = self; + + container.status_ok(&["mkdir", "-p", ZONES_DIR])?; + container.cp("/etc/nsd/zones/main.zone", &zone_file.to_string())?; + + let zone = &zone_file.origin; + + let zsk_keygen = + format!("cd {ZONES_DIR} && ldns-keygen -a {ALGORITHM} -b {ZSK_BITS} {zone}"); + let zsk_filename = container.stdout(&["sh", "-c", &zsk_keygen])?; + let zsk_path = format!("{ZONES_DIR}/{zsk_filename}.key"); + let zsk: DNSKEY = container.stdout(&["cat", &zsk_path])?.parse()?; + + let ksk_keygen = + format!("cd {ZONES_DIR} && ldns-keygen -k -a {ALGORITHM} -b {KSK_BITS} {zone}"); + let ksk_filename = container.stdout(&["sh", "-c", &ksk_keygen])?; + let ksk_path = format!("{ZONES_DIR}/{ksk_filename}.key"); + let ksk: DNSKEY = container.stdout(&["cat", &ksk_path])?.parse()?; + + // -n = use NSEC3 instead of NSEC + // -p = set the opt-out flag on all nsec3 rrs + let signzone = format!( + "cd {ZONES_DIR} && ldns-signzone -n -p {ZONE_FILENAME} {zsk_filename} {ksk_filename}" + ); + container.status_ok(&["sh", "-c", &signzone])?; + + // TODO do we want to make the hashing algorithm configurable? + // -2 = use SHA256 for the DS hash + let key2ds = format!("cd {ZONES_DIR} && ldns-key2ds -n -2 {ZONE_FILENAME}.signed"); + let ds: DS = container.stdout(&["sh", "-c", &key2ds])?.parse()?; + + // we have an in-memory representation of the zone file so we just delete the on-disk version + let zone_file_path = zone_file_path(); + container.status_ok(&["mv", &format!("{zone_file_path}.signed"), &zone_file_path])?; + + let signed_zone_file = container.stdout(&["cat", &zone_file_path])?; + + Ok(NameServer { + container, + zone_file, + state: Signed { + ds, + ksk, + signed_zone_file, + zsk, + }, + }) + } + + /// Moves the server to the "Start" state where it can answer client queries + pub fn start(self) -> Result> { + let Self { + container, + zone_file, + state: _, + } = self; + + // for PID file + container.status_ok(&["mkdir", "-p", "/run/nsd/"])?; + + container.cp("/etc/nsd/nsd.conf", &nsd_conf(&zone_file.origin))?; + + container.status_ok(&["mkdir", "-p", ZONES_DIR])?; + container.cp(&zone_file_path(), &zone_file.to_string())?; + + let child = container.spawn(&["nsd", "-d"])?; + + Ok(NameServer { + container, + zone_file, + state: Running { child }, + }) + } +} + +const ZONES_DIR: &str = "/etc/nsd/zones"; +const ZONE_FILENAME: &str = "main.zone"; + +fn zone_file_path() -> String { + format!("{ZONES_DIR}/{ZONE_FILENAME}") +} + +fn ns_count() -> usize { + static COUNT: AtomicUsize = AtomicUsize::new(0); + COUNT.fetch_add(1, atomic::Ordering::Relaxed) +} + +impl<'a> NameServer<'a, Signed> { + /// Moves the server to the "Start" state where it can answer client queries + pub fn start(self) -> Result> { + let Self { + container, + zone_file, + state: _, + } = self; + + // for PID file + container.status_ok(&["mkdir", "-p", "/run/nsd/"])?; + + container.cp("/etc/nsd/nsd.conf", &nsd_conf(&zone_file.origin))?; + + let child = container.spawn(&["nsd", "-d"])?; + + Ok(NameServer { + container, + zone_file, + state: Running { child }, + }) + } + + pub fn key_signing_key(&self) -> &DNSKEY { + &self.state.ksk + } + + pub fn zone_signing_key(&self) -> &DNSKEY { + &self.state.zsk + } + + pub fn signed_zone_file(&self) -> &str { + &self.state.signed_zone_file + } + + pub fn ds(&self) -> &DS { + &self.state.ds + } +} + +impl<'a, S> NameServer<'a, S> { + pub fn ipv4_addr(&self) -> Ipv4Addr { + self.container.ipv4_addr() + } + + /// Zone file BEFORE signing + pub fn zone_file(&self) -> &ZoneFile<'a> { + &self.zone_file + } + + pub fn zone(&self) -> &FQDN<'a> { + &self.zone_file.origin + } + + pub fn fqdn(&self) -> &FQDN<'a> { + &self.zone_file.soa.nameserver + } +} + +pub struct Stopped; + +pub struct Signed { + ds: DS, + zsk: DNSKEY, + ksk: DNSKEY, + signed_zone_file: String, +} + +pub struct Running { + child: Child, +} + +impl Drop for Running { + fn drop(&mut self) { + let _ = self.child.kill(); + } +} + +fn primary_ns(ns_count: usize) -> FQDN<'static> { + FQDN(format!("primary{ns_count}.nameservers.com.")).unwrap() +} + +fn admin_ns(ns_count: usize) -> FQDN<'static> { + FQDN(format!("admin{ns_count}.nameservers.com.")).unwrap() +} + +fn nsd_conf(fqdn: &FQDN) -> String { + minijinja::render!( + include_str!("templates/nsd.conf.jinja"), + fqdn => fqdn.as_str() + ) +} + +#[cfg(test)] +mod tests { + use crate::client::{Client, Dnssec, Recurse}; + use crate::record::RecordType; + + use super::*; + + #[test] + fn simplest() -> Result<()> { + let tld_ns = NameServer::new(FQDN::COM)?.start()?; + let ip_addr = tld_ns.ipv4_addr(); + + let client = Client::new()?; + let output = client.dig( + Recurse::No, + Dnssec::No, + ip_addr, + RecordType::SOA, + &FQDN::COM, + )?; + + assert!(output.status.is_noerror()); + + Ok(()) + } + + #[test] + fn with_referral() -> Result<()> { + let expected_ip_addr = Ipv4Addr::new(172, 17, 200, 1); + let mut root_ns = NameServer::new(FQDN::ROOT)?; + root_ns.referral( + FQDN::COM, + FQDN("primary.tld-server.com.")?, + expected_ip_addr, + ); + let root_ns = root_ns.start()?; + + eprintln!("root.zone:\n{}", root_ns.zone_file()); + + let ipv4_addr = root_ns.ipv4_addr(); + + let client = Client::new()?; + let output = client.dig( + Recurse::No, + Dnssec::No, + ipv4_addr, + RecordType::NS, + &FQDN::COM, + )?; + + assert!(output.status.is_noerror()); + + Ok(()) + } + + #[test] + fn signed() -> Result<()> { + let ns = NameServer::new(FQDN::ROOT)?.sign()?; + + eprintln!("KSK:\n{}", ns.key_signing_key()); + eprintln!("ZSK:\n{}", ns.zone_signing_key()); + eprintln!("root.zone.signed:\n{}", ns.signed_zone_file()); + + let tld_ns = ns.start()?; + + let ns_addr = tld_ns.ipv4_addr(); + + let client = Client::new()?; + let output = client.dig( + Recurse::No, + Dnssec::Yes, + ns_addr, + RecordType::SOA, + &FQDN::ROOT, + )?; + + assert!(output.status.is_noerror()); + + let [soa, rrsig] = output + .answer + .try_into() + .expect("two records in answer section"); + + assert!(soa.is_soa()); + let rrsig = rrsig.try_into_rrsig().unwrap(); + assert_eq!(RecordType::SOA, rrsig.type_covered); + + Ok(()) + } +} diff --git a/src/record.rs b/src/record.rs new file mode 100644 index 00000000..0f6885ac --- /dev/null +++ b/src/record.rs @@ -0,0 +1,296 @@ +//! Text representation of DNS records + +use core::array; +use core::result::Result as CoreResult; +use core::str::FromStr; +use std::net::Ipv4Addr; + +use crate::{Error, Result, FQDN}; + +#[allow(clippy::upper_case_acronyms)] +#[derive(Debug, PartialEq)] +pub enum RecordType { + A, + NS, + SOA, +} + +impl RecordType { + pub fn as_str(&self) -> &'static str { + match self { + RecordType::A => "A", + RecordType::SOA => "SOA", + RecordType::NS => "NS", + } + } +} + +impl FromStr for RecordType { + type Err = Error; + + fn from_str(input: &str) -> CoreResult { + let record_type = match input { + "A" => Self::A, + "SOA" => Self::SOA, + "NS" => Self::NS, + _ => return Err(format!("unknown record type: {input}").into()), + }; + + Ok(record_type) + } +} + +#[derive(Debug)] +#[allow(clippy::upper_case_acronyms)] +pub enum Record { + A(A), + RRSIG(RRSIG), + SOA(SOA), +} + +impl Record { + pub fn try_into_a(self) -> CoreResult { + if let Self::A(v) = self { + Ok(v) + } else { + Err(self) + } + } + + pub fn try_into_rrsig(self) -> CoreResult { + if let Self::RRSIG(v) = self { + Ok(v) + } else { + Err(self) + } + } + + pub fn is_soa(&self) -> bool { + matches!(self, Self::SOA(..)) + } +} + +impl FromStr for Record { + type Err = Error; + + fn from_str(input: &str) -> Result { + let record_type = input + .split_whitespace() + .nth(3) + .ok_or("record is missing the type column")?; + + let record = match record_type { + "A" => Record::A(input.parse()?), + "NS" => todo!(), + "RRSIG" => Record::RRSIG(input.parse()?), + "SOA" => Record::SOA(input.parse()?), + _ => return Err(format!("unknown record type: {record_type}").into()), + }; + + Ok(record) + } +} + +#[derive(Debug)] +pub struct A { + pub fqdn: FQDN<'static>, + pub ttl: u32, + pub ipv4_addr: Ipv4Addr, +} + +impl FromStr for A { + type Err = Error; + + fn from_str(input: &str) -> Result { + let mut columns = input.split_whitespace(); + + let [Some(fqdn), Some(ttl), Some(class), Some(record_type), Some(ipv4_addr), None] = + array::from_fn(|_| columns.next()) + else { + return Err("expected 5 columns".into()); + }; + + let expected = "A"; + if record_type != expected { + return Err( + format!("tried to parse `{record_type}` record as an {expected} record").into(), + ); + } + + if class != "IN" { + return Err(format!("unknown class: {class}").into()); + } + + Ok(Self { + fqdn: fqdn.parse()?, + ttl: ttl.parse()?, + ipv4_addr: ipv4_addr.parse()?, + }) + } +} + +#[allow(clippy::upper_case_acronyms)] +#[derive(Debug)] +pub struct RRSIG { + pub fqdn: FQDN<'static>, + pub ttl: u32, + pub type_covered: RecordType, + pub algorithm: u32, + pub labels: u32, + pub original_ttl: u32, + pub signature_expiration: u64, + pub signature_inception: u64, + pub key_tag: u32, + pub signer_name: FQDN<'static>, + /// base64 encoded + pub signature: String, +} + +impl FromStr for RRSIG { + type Err = Error; + + fn from_str(input: &str) -> CoreResult { + let mut columns = input.split_whitespace(); + + let [Some(fqdn), Some(ttl), Some(class), Some(record_type), Some(type_covered), Some(algorithm), Some(labels), Some(original_ttl), Some(signature_expiration), Some(signature_inception), Some(key_tag), Some(signer_name)] = + array::from_fn(|_| columns.next()) + else { + return Err("expected at least 12 columns".into()); + }; + + let expected = "RRSIG"; + if record_type != expected { + return Err( + format!("tried to parse `{record_type}` record as a {expected} record").into(), + ); + } + + if class != "IN" { + return Err(format!("unknown class: {class}").into()); + } + + let mut signature = String::new(); + for column in columns { + signature.push_str(column); + } + + Ok(Self { + fqdn: fqdn.parse()?, + ttl: ttl.parse()?, + type_covered: type_covered.parse()?, + algorithm: algorithm.parse()?, + labels: labels.parse()?, + original_ttl: original_ttl.parse()?, + signature_expiration: signature_expiration.parse()?, + signature_inception: signature_inception.parse()?, + key_tag: key_tag.parse()?, + signer_name: signer_name.parse()?, + signature, + }) + } +} + +#[allow(clippy::upper_case_acronyms)] +#[derive(Debug)] +pub struct SOA { + pub zone: FQDN<'static>, + pub ttl: u32, + pub nameserver: FQDN<'static>, + pub admin: FQDN<'static>, + pub serial: u32, + pub refresh: u32, + pub retry: u32, + pub expire: u32, + pub minimum: u32, +} + +impl FromStr for SOA { + type Err = Error; + + fn from_str(input: &str) -> Result { + let mut columns = input.split_whitespace(); + + let [Some(zone), Some(ttl), Some(class), Some(record_type), Some(nameserver), Some(admin), Some(serial), Some(refresh), Some(retry), Some(expire), Some(minimum), None] = + array::from_fn(|_| columns.next()) + else { + return Err("expected 11 columns".into()); + }; + + if record_type != "SOA" { + return Err(format!("tried to parse `{record_type}` record as a SOA record").into()); + } + + if class != "IN" { + return Err(format!("unknown class: {class}").into()); + } + + Ok(Self { + zone: zone.parse()?, + ttl: ttl.parse()?, + nameserver: nameserver.parse()?, + admin: admin.parse()?, + serial: serial.parse()?, + refresh: refresh.parse()?, + retry: retry.parse()?, + expire: expire.parse()?, + minimum: minimum.parse()?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_parse_a_record() -> Result<()> { + let input = "a.root-servers.net. 3600000 IN A 198.41.0.4"; + let a: A = input.parse()?; + + assert_eq!("a.root-servers.net.", a.fqdn.as_str()); + assert_eq!(3600000, a.ttl); + assert_eq!(Ipv4Addr::new(198, 41, 0, 4), a.ipv4_addr); + + Ok(()) + } + + #[test] + fn can_parse_soa_record() -> Result<()> { + let input = ". 15633 IN SOA a.root-servers.net. nstld.verisign-grs.com. 2024020501 1800 900 604800 86400"; + + let soa: SOA = input.parse()?; + + assert_eq!(".", soa.zone.as_str()); + assert_eq!(15633, soa.ttl); + assert_eq!("a.root-servers.net.", soa.nameserver.as_str()); + assert_eq!("nstld.verisign-grs.com.", soa.admin.as_str()); + assert_eq!(2024020501, soa.serial); + assert_eq!(1800, soa.refresh); + assert_eq!(900, soa.retry); + assert_eq!(604800, soa.expire); + assert_eq!(86400, soa.minimum); + + Ok(()) + } + + #[test] + fn can_parse_rrsig_record() -> Result<()> { + let input = ". 1800 IN RRSIG SOA 7 0 1800 20240306132701 20240207132701 11264 . wXpRU4elJPGYm2kgVVsIwGf1IkYJcQ3UE4mwmItWdxj0XWSWY07MO4Ll DMJgsE0u64Q/345Ck7+aQ904uLebwCvpFnsmkyCxk82XIAfHN9FiwzSy qoR/zZEvBONaej3vrvsqPwh8q/pvypLft9647HcFdwY0juzZsbrAaDAX 8WY="; + + let rrsig: RRSIG = input.parse()?; + + assert_eq!(FQDN::ROOT, rrsig.fqdn); + assert_eq!(1800, rrsig.ttl); + assert_eq!(RecordType::SOA, rrsig.type_covered); + assert_eq!(7, rrsig.algorithm); + assert_eq!(0, rrsig.labels); + assert_eq!(20240306132701, rrsig.signature_expiration); + assert_eq!(20240207132701, rrsig.signature_inception); + assert_eq!(11264, rrsig.key_tag); + assert_eq!(FQDN::ROOT, rrsig.signer_name); + let expected = "wXpRU4elJPGYm2kgVVsIwGf1IkYJcQ3UE4mwmItWdxj0XWSWY07MO4LlDMJgsE0u64Q/345Ck7+aQ904uLebwCvpFnsmkyCxk82XIAfHN9FiwzSyqoR/zZEvBONaej3vrvsqPwh8q/pvypLft9647HcFdwY0juzZsbrAaDAX8WY="; + assert_eq!(expected, rrsig.signature); + + Ok(()) + } +} diff --git a/src/recursive_resolver.rs b/src/recursive_resolver.rs new file mode 100644 index 00000000..5e369e69 --- /dev/null +++ b/src/recursive_resolver.rs @@ -0,0 +1,236 @@ +use core::fmt::Write; +use std::net::Ipv4Addr; +use std::process::Child; + +use crate::container::Container; +use crate::zone_file::{Root, DNSKEY}; +use crate::Result; + +pub struct RecursiveResolver { + container: Container, + child: Child, +} + +impl RecursiveResolver { + pub fn start(roots: &[Root], trust_anchors: &[DNSKEY]) -> Result { + const TRUST_ANCHOR_FILE: &str = "/etc/trusted-key.key"; + + let container = Container::run()?; + + let mut hints = String::new(); + for root in roots { + writeln!(hints, "{root}").unwrap(); + } + + container.cp("/etc/unbound/root.hints", &hints)?; + + let use_dnssec = !trust_anchors.is_empty(); + container.cp("/etc/unbound/unbound.conf", &unbound_conf(use_dnssec))?; + + if use_dnssec { + let trust_anchor = trust_anchors.iter().fold(String::new(), |mut buf, ds| { + writeln!(buf, "{ds}").expect("infallible"); + buf + }); + + container.cp(TRUST_ANCHOR_FILE, &trust_anchor)?; + } + + let child = container.spawn(&["unbound", "-d"])?; + + Ok(Self { child, container }) + } + + pub fn ipv4_addr(&self) -> Ipv4Addr { + self.container.ipv4_addr() + } +} + +impl Drop for RecursiveResolver { + fn drop(&mut self) { + let _ = self.child.kill(); + } +} + +fn unbound_conf(use_dnssec: bool) -> String { + minijinja::render!(include_str!("templates/unbound.conf.jinja"), use_dnssec => use_dnssec) +} + +#[cfg(test)] +mod tests { + + use crate::{ + client::{Client, Dnssec, Recurse}, + name_server::NameServer, + record::RecordType, + FQDN, + }; + + use super::*; + + #[test] + fn can_resolve() -> Result<()> { + let expected_ipv4_addr = Ipv4Addr::new(1, 2, 3, 4); + let needle = FQDN("example.nameservers.com.")?; + + let mut root_ns = NameServer::new(FQDN::ROOT)?; + let mut com_ns = NameServer::new(FQDN::COM)?; + + let mut nameservers_ns = NameServer::new(FQDN("nameservers.com.")?)?; + nameservers_ns + .a(root_ns.fqdn().clone(), root_ns.ipv4_addr()) + .a(com_ns.fqdn().clone(), com_ns.ipv4_addr()) + .a(needle.clone(), expected_ipv4_addr); + let nameservers_ns = nameservers_ns.start()?; + + eprintln!("nameservers.com.zone:\n{}", nameservers_ns.zone_file()); + + com_ns.referral( + nameservers_ns.zone().clone(), + nameservers_ns.fqdn().clone(), + nameservers_ns.ipv4_addr(), + ); + let com_ns = com_ns.start()?; + + eprintln!("com.zone:\n{}", com_ns.zone_file()); + + root_ns.referral(FQDN::COM, com_ns.fqdn().clone(), com_ns.ipv4_addr()); + let root_ns = root_ns.start()?; + + eprintln!("root.zone:\n{}", root_ns.zone_file()); + + let roots = &[Root::new(root_ns.fqdn().clone(), root_ns.ipv4_addr())]; + let resolver = RecursiveResolver::start(roots, &[])?; + let resolver_ip_addr = resolver.ipv4_addr(); + + let client = Client::new()?; + let output = client.dig( + Recurse::Yes, + Dnssec::No, + resolver_ip_addr, + RecordType::A, + &needle, + )?; + + assert!(output.status.is_noerror()); + + let [answer] = output.answer.try_into().unwrap(); + let a = answer.try_into_a().unwrap(); + + assert_eq!(needle, a.fqdn); + assert_eq!(expected_ipv4_addr, a.ipv4_addr); + + Ok(()) + } + + // no DS records are involved; this is a single-link chain of trust + #[test] + fn can_validate_without_delegation() -> Result<()> { + let mut ns = NameServer::new(FQDN::ROOT)?; + ns.a(ns.fqdn().clone(), ns.ipv4_addr()); + let ns = ns.sign()?; + + let root_ksk = ns.key_signing_key().clone(); + let root_zsk = ns.zone_signing_key().clone(); + + eprintln!("root.zone.signed:\n{}", ns.signed_zone_file()); + + let ns = ns.start()?; + + eprintln!("root.zone:\n{}", ns.zone_file()); + + let roots = &[Root::new(ns.fqdn().clone(), ns.ipv4_addr())]; + + let trust_anchor = [root_ksk.clone(), root_zsk.clone()]; + let resolver = RecursiveResolver::start(roots, &trust_anchor)?; + let resolver_addr = resolver.ipv4_addr(); + + let client = Client::new()?; + let output = client.dig( + Recurse::Yes, + Dnssec::Yes, + resolver_addr, + RecordType::SOA, + &FQDN::ROOT, + )?; + + assert!(output.status.is_noerror()); + assert!(output.flags.authenticated_data); + + Ok(()) + } + + #[test] + fn can_validate_with_delegation() -> Result<()> { + let expected_ipv4_addr = Ipv4Addr::new(1, 2, 3, 4); + let needle = FQDN("example.nameservers.com.")?; + + let mut root_ns = NameServer::new(FQDN::ROOT)?; + let mut com_ns = NameServer::new(FQDN::COM)?; + + let mut nameservers_ns = NameServer::new(FQDN("nameservers.com.")?)?; + nameservers_ns + .a(root_ns.fqdn().clone(), root_ns.ipv4_addr()) + .a(com_ns.fqdn().clone(), com_ns.ipv4_addr()) + .a(needle.clone(), expected_ipv4_addr); + let nameservers_ns = nameservers_ns.sign()?; + let nameservers_ds = nameservers_ns.ds().clone(); + let nameservers_ns = nameservers_ns.start()?; + + eprintln!("nameservers.com.zone:\n{}", nameservers_ns.zone_file()); + + com_ns + .referral( + nameservers_ns.zone().clone(), + nameservers_ns.fqdn().clone(), + nameservers_ns.ipv4_addr(), + ) + .ds(nameservers_ds); + let com_ns = com_ns.sign()?; + let com_ds = com_ns.ds().clone(); + let com_ns = com_ns.start()?; + + eprintln!("com.zone:\n{}", com_ns.zone_file()); + + root_ns + .referral(FQDN::COM, com_ns.fqdn().clone(), com_ns.ipv4_addr()) + .ds(com_ds); + let root_ns = root_ns.sign()?; + let root_ksk = root_ns.key_signing_key().clone(); + let root_zsk = root_ns.zone_signing_key().clone(); + + eprintln!("root.zone.signed:\n{}", root_ns.signed_zone_file()); + + let root_ns = root_ns.start()?; + + eprintln!("root.zone:\n{}", root_ns.zone_file()); + + let roots = &[Root::new(root_ns.fqdn().clone(), root_ns.ipv4_addr())]; + + let resolver = RecursiveResolver::start(roots, &[root_ksk.clone(), root_zsk.clone()])?; + let resolver_ip_addr = resolver.ipv4_addr(); + + let client = Client::new()?; + let output = client.dig( + Recurse::Yes, + Dnssec::Yes, + resolver_ip_addr, + RecordType::A, + &needle, + )?; + + drop(resolver); + + assert!(output.status.is_noerror()); + + assert!(output.flags.authenticated_data); + + let [a, _rrsig] = output.answer.try_into().unwrap(); + let a = a.try_into_a().unwrap(); + + assert_eq!(needle, a.fqdn); + assert_eq!(expected_ipv4_addr, a.ipv4_addr); + + Ok(()) + } +} diff --git a/src/templates/nsd.conf.jinja b/src/templates/nsd.conf.jinja new file mode 100644 index 00000000..2a7541af --- /dev/null +++ b/src/templates/nsd.conf.jinja @@ -0,0 +1,6 @@ +remote-control: + control-enable: no + +zone: + name: {{ fqdn }} + zonefile: /etc/nsd/zones/main.zone diff --git a/docker/files/etc/unbound/unbound.conf b/src/templates/unbound.conf.jinja similarity index 71% rename from docker/files/etc/unbound/unbound.conf rename to src/templates/unbound.conf.jinja index ad446203..fe74a6cf 100644 --- a/docker/files/etc/unbound/unbound.conf +++ b/src/templates/unbound.conf.jinja @@ -4,6 +4,9 @@ server: interface: 0.0.0.0 access-control: 172.17.0.0/16 allow root-hints: /etc/unbound/root.hints +{% if use_dnssec %} + trust-anchor-file: /etc/trusted-key.key +{% endif %} remote-control: control-enable: no diff --git a/src/zone_file.rs b/src/zone_file.rs new file mode 100644 index 00000000..71a7b9c6 --- /dev/null +++ b/src/zone_file.rs @@ -0,0 +1,493 @@ +//! BIND-style zone file +//! +//! Note that +//! - the `@` syntax is not used to avoid relying on the order of the entries +//! - relative domain names are not used; all domain names must be in fully-qualified form + +use core::{array, fmt}; +use std::net::Ipv4Addr; +use std::str::FromStr; + +use crate::{Error, FQDN}; + +pub struct ZoneFile<'a> { + pub origin: FQDN<'a>, + pub ttl: u32, + pub soa: SOA<'a>, + pub entries: Vec>, +} + +impl<'a> ZoneFile<'a> { + /// Convenience constructor that uses "reasonable" defaults + pub fn new(origin: FQDN<'a>, soa: SOA<'a>) -> Self { + Self { + origin, + ttl: 1800, + soa, + entries: Vec::new(), + } + } + + /// Appends an entry + pub fn entry(&mut self, entry: impl Into>) { + self.entries.push(entry.into()) + } + + /// Appends a NS + A entry pair + pub fn referral(&mut self, zone: FQDN<'a>, nameserver: FQDN<'a>, ipv4_addr: Ipv4Addr) { + self.entry(NS { + zone: zone.clone(), + nameserver: nameserver.clone(), + }); + self.entry(A { + fqdn: nameserver, + ipv4_addr, + }); + } +} + +impl fmt::Display for ZoneFile<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + origin, + ttl, + soa, + entries, + } = self; + + writeln!(f, "$ORIGIN {origin}")?; + writeln!(f, "$TTL {ttl}")?; + writeln!(f, "{soa}")?; + + for entry in entries { + writeln!(f, "{entry}")?; + } + + Ok(()) + } +} + +pub struct Root<'a> { + pub ipv4_addr: Ipv4Addr, + pub ns: FQDN<'a>, + pub ttl: u32, +} + +impl<'a> Root<'a> { + /// Convenience constructor that uses "reasonable" defaults + pub fn new(ns: FQDN<'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 Entry<'a> { + A(A<'a>), + DNSKEY(DNSKEY), + DS(DS), + NS(NS<'a>), +} + +impl<'a> From for Entry<'a> { + fn from(v: DS) -> Self { + Self::DS(v) + } +} + +impl<'a> From> for Entry<'a> { + fn from(v: A<'a>) -> Self { + Self::A(v) + } +} + +impl<'a> From> for Entry<'a> { + fn from(v: NS<'a>) -> Self { + Self::NS(v) + } +} + +impl fmt::Display for Entry<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Entry::A(a) => a.fmt(f), + Entry::DNSKEY(dnskey) => dnskey.fmt(f), + Entry::DS(ds) => ds.fmt(f), + Entry::NS(ns) => ns.fmt(f), + } + } +} + +#[derive(Clone)] +pub struct A<'a> { + pub fqdn: FQDN<'a>, + pub ipv4_addr: Ipv4Addr, +} + +impl fmt::Display for A<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { fqdn, ipv4_addr } = self; + + write!(f, "{fqdn}\tIN\tA\t{ipv4_addr}") + } +} + +// integer types chosen based on bit sizes in section 2.1 of RFC4034 +#[derive(Clone, Debug)] +pub struct DNSKEY { + zone: FQDN<'static>, + flags: u16, + protocol: u8, + algorithm: u8, + public_key: String, + + // extra information in `+multiline` format and `ldns-keygen`'s output + bits: u16, + key_tag: u16, +} + +impl DNSKEY { + pub fn bits(&self) -> u16 { + self.bits + } + + pub fn key_tag(&self) -> u16 { + self.key_tag + } +} + +impl FromStr for DNSKEY { + type Err = Error; + + fn from_str(input: &str) -> Result { + let (before, after) = input.split_once(';').ok_or("comment was not found")?; + let mut columns = before.split_whitespace(); + + let [Some(zone), Some(class), Some(record_type), Some(flags), Some(protocol), Some(algorithm), Some(public_key), None] = + array::from_fn(|_| columns.next()) + else { + return Err("expected 7 columns".into()); + }; + + if record_type != "DNSKEY" { + return Err(format!("tried to parse `{record_type}` record as a DNSKEY record").into()); + } + + if class != "IN" { + return Err(format!("unknown class: {class}").into()); + } + + // {id = 24975 (zsk), size = 1024b} + let error = "invalid comment syntax"; + let (id_expr, size_expr) = after.split_once(',').ok_or(error)?; + + // {id = 24975 (zsk) + let (id_lhs, id_rhs) = id_expr.split_once('=').ok_or(error)?; + if id_lhs.trim() != "{id" { + return Err(error.into()); + } + + // 24975 (zsk) + let (key_tag, _key_type) = id_rhs.trim().split_once(' ').ok_or(error)?; + + // size = 1024b} + let (size_lhs, size_rhs) = size_expr.split_once('=').ok_or(error)?; + if size_lhs.trim() != "size" { + return Err(error.into()); + } + let bits = size_rhs.trim().strip_suffix("b}").ok_or(error)?.parse()?; + + Ok(Self { + zone: zone.parse()?, + flags: flags.parse()?, + protocol: protocol.parse()?, + algorithm: algorithm.parse()?, + public_key: public_key.to_string(), + + key_tag: key_tag.parse()?, + bits, + }) + } +} + +impl fmt::Display for DNSKEY { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + zone, + flags, + protocol, + algorithm, + public_key, + bits: _, + key_tag: _, + } = self; + + write!( + f, + "{zone}\tIN\tDNSKEY\t{flags}\t{protocol}\t{algorithm}\t{public_key}" + ) + } +} + +#[derive(Clone)] +pub struct DS { + zone: FQDN<'static>, + _ttl: u32, + key_tag: u16, + algorithm: u8, + digest_type: u8, + digest: String, +} + +impl FromStr for DS { + type Err = Error; + + fn from_str(input: &str) -> Result { + let mut columns = input.split_whitespace(); + + let [Some(zone), Some(ttl), Some(class), Some(record_type), Some(key_tag), Some(algorithm), Some(digest_type), Some(digest), None] = + array::from_fn(|_| columns.next()) + else { + return Err("expected 8 columns".into()); + }; + + let expected = "DS"; + if record_type != expected { + return Err( + format!("tried to parse `{record_type}` entry as a {expected} entry").into(), + ); + } + + if class != "IN" { + return Err(format!("unknown class: {class}").into()); + } + + Ok(Self { + zone: zone.parse()?, + _ttl: ttl.parse()?, + key_tag: key_tag.parse()?, + algorithm: algorithm.parse()?, + digest_type: digest_type.parse()?, + digest: digest.to_string(), + }) + } +} + +/// NOTE does NOT include the TTL field +impl fmt::Display for DS { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + zone, + _ttl, + key_tag, + algorithm, + digest_type, + digest, + } = self; + + write!( + f, + "{zone}\tIN\tDS\t{key_tag}\t{algorithm}\t{digest_type}\t{digest}" + ) + } +} + +pub struct NS<'a> { + pub zone: FQDN<'a>, + pub nameserver: FQDN<'a>, +} + +impl fmt::Display for NS<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + zone, + nameserver: ns, + } = self; + + write!(f, "{zone}\tIN\tNS\t{ns}") + } +} + +pub struct SOA<'a> { + pub zone: FQDN<'a>, + pub nameserver: FQDN<'a>, + pub admin: FQDN<'a>, + pub settings: SoaSettings, +} + +impl fmt::Display for SOA<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + zone, + nameserver: ns, + admin, + settings, + } = self; + + write!(f, "{zone}\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(FQDN("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 = ZoneFile::new(FQDN::ROOT, example_soa()?); + zone.entry(example_ns()?); + zone.entry(example_a()?); + + assert_eq!(expected, zone.to_string()); + + Ok(()) + } + + // not quite roundtrip because we drop the TTL field when doing `to_string` + #[test] + fn ds_roundtrip() -> Result<()> { + let input = + ". 1800 IN DS 31153 7 2 7846338aaacde9cc9518f1f450082adc015a207c45a1e69d6e660e6836f4ef3b"; + let ds: DS = input.parse()?; + let output = ds.to_string(); + + let expected = + ". IN DS 31153 7 2 7846338aaacde9cc9518f1f450082adc015a207c45a1e69d6e660e6836f4ef3b"; + assert_eq!(expected, output); + + Ok(()) + } + + #[test] + fn dnskey_roundtrip() -> Result<()> { + let input = "example.com. IN DNSKEY 256 3 7 AwEAAdIpMlio4GJas7GbIZ9xRpzpB2pf4SxBJcsquN/0yNBPGNE2rzcFykqMAKmLwypk1/1q/EdHVa4tQ5RlK0w09CRhgSXfCaph+yLNJKpiPyuVcXKl2k0RnO4p835sgVEUIvx8qGTDo7c7DA9UBje+/3ViFKqVhOBaWyT6gHAmNVpb ;{id = 24975 (zsk), size = 1024b}"; + + let dnskey: DNSKEY = input.parse()?; + + assert_eq!(256, dnskey.flags); + assert_eq!(3, dnskey.protocol); + assert_eq!(7, dnskey.algorithm); + let expected = "AwEAAdIpMlio4GJas7GbIZ9xRpzpB2pf4SxBJcsquN/0yNBPGNE2rzcFykqMAKmLwypk1/1q/EdHVa4tQ5RlK0w09CRhgSXfCaph+yLNJKpiPyuVcXKl2k0RnO4p835sgVEUIvx8qGTDo7c7DA9UBje+/3ViFKqVhOBaWyT6gHAmNVpb"; + assert_eq!(expected, dnskey.public_key); + assert_eq!(1024, dnskey.bits()); + assert_eq!(24975, dnskey.key_tag()); + + let output = dnskey.to_string(); + assert!(input.starts_with(&output)); + + Ok(()) + } + + fn example_a() -> Result> { + Ok(A { + fqdn: FQDN("e.gtld-servers.net.")?, + ipv4_addr: Ipv4Addr::new(192, 12, 94, 30), + }) + } + + fn example_ns() -> Result> { + Ok(NS { + zone: FQDN::COM, + nameserver: FQDN("e.gtld-servers.net.")?, + }) + } + + fn example_soa() -> Result> { + Ok(SOA { + zone: FQDN::ROOT, + nameserver: FQDN("a.root-servers.net.")?, + admin: FQDN("nstld.verisign-grs.com.")?, + settings: SoaSettings::default(), + }) + } +}