diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea123563..963da5c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,11 +20,23 @@ jobs: toolchain: stable components: clippy, rustfmt - - name: Run tests - run: cargo t + - name: Run dns-test tests + run: cargo test -p dns-test -- --include-ignored + + - name: Run tests against unbound + run: cargo test -p conformance-tests -- --include-ignored + + - name: Run tests against hickory + run: DNS_TEST_SUBJECT=hickory cargo test -p conformance-tests + + - name: Check that ignored tests fail with hickory + run: | + tmpfile="$(mktemp)" + DNS_TEST_SUBJECT=hickory cargo test -p conformance-tests -- --ignored | tee "$tmpfile" + grep 'test result: FAILED. 0 passed' "$tmpfile" || ( echo "expected ALL tests to fail but at least one passed; the passing tests must be un-#[ignore]-d" && exit 1 ) - name: Check that code is formatted - run: cargo fmt -- --check + run: cargo fmt --all -- --check - name: Lint code - run: cargo clippy -- -D warnings + run: cargo clippy --workspace -- -D warnings diff --git a/Cargo.lock b/Cargo.lock index 9b6bb82d..46f2b24b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "dnssec-tests" +name = "conformance-tests" +version = "0.1.0" +dependencies = [ + "dns-test", +] + +[[package]] +name = "dns-test" version = "0.1.0" dependencies = [ "minijinja", diff --git a/Cargo.toml b/Cargo.toml index 72d5349d..225effa9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,3 @@ -[package] -name = "dnssec-tests" -version = "0.1.0" -edition = "2021" -license = "MIT or Apache 2.0" - -[dependencies] -minijinja = "1.0.12" -tempfile = "3.9.0" - -[lib] -doctest = false +[workspace] +members = ["packages/*"] +resolver = "2" diff --git a/docker/unbound.Dockerfile b/docker/unbound.Dockerfile deleted file mode 100644 index ce8e2a62..00000000 --- a/docker/unbound.Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM ubuntu:22.04 - -RUN apt-get update && \ - apt-get install -y dnsutils unbound nsd iputils-ping tshark vim ldnsutils diff --git a/packages/conformance-tests/Cargo.toml b/packages/conformance-tests/Cargo.toml new file mode 100644 index 00000000..548b378d --- /dev/null +++ b/packages/conformance-tests/Cargo.toml @@ -0,0 +1,11 @@ +[package] +edition = "2021" +name = "conformance-tests" +publish = false +version = "0.1.0" + +[dependencies] +dns-test.path = "../dns-test" + +[lib] +doctest = false diff --git a/packages/conformance-tests/src/lib.rs b/packages/conformance-tests/src/lib.rs new file mode 100644 index 00000000..dd939657 --- /dev/null +++ b/packages/conformance-tests/src/lib.rs @@ -0,0 +1,3 @@ +#![cfg(test)] + +mod resolver; diff --git a/packages/conformance-tests/src/resolver.rs b/packages/conformance-tests/src/resolver.rs new file mode 100644 index 00000000..85e4d364 --- /dev/null +++ b/packages/conformance-tests/src/resolver.rs @@ -0,0 +1,4 @@ +//! Recursive resolver role + +mod dns; +mod dnssec; diff --git a/packages/conformance-tests/src/resolver/dns.rs b/packages/conformance-tests/src/resolver/dns.rs new file mode 100644 index 00000000..4902447e --- /dev/null +++ b/packages/conformance-tests/src/resolver/dns.rs @@ -0,0 +1,3 @@ +//! plain DNS functionality + +mod scenarios; diff --git a/packages/conformance-tests/src/resolver/dns/scenarios.rs b/packages/conformance-tests/src/resolver/dns/scenarios.rs new file mode 100644 index 00000000..a25d3bce --- /dev/null +++ b/packages/conformance-tests/src/resolver/dns/scenarios.rs @@ -0,0 +1,104 @@ +use std::net::Ipv4Addr; + +use dns_test::client::{Client, Dnssec, Recurse}; +use dns_test::name_server::NameServer; +use dns_test::record::RecordType; +use dns_test::zone_file::Root; +use dns_test::{Resolver, Result, TrustAnchor, FQDN}; + +#[test] +fn can_resolve() -> Result<()> { + let expected_ipv4_addr = Ipv4Addr::new(1, 2, 3, 4); + let needle_fqdn = 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_fqdn.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 = Resolver::start(dns_test::subject(), roots, &TrustAnchor::empty())?; + 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_fqdn, + )?; + + assert!(output.status.is_noerror()); + + let [answer] = output.answer.try_into().unwrap(); + let a = answer.try_into_a().unwrap(); + + assert_eq!(needle_fqdn, a.fqdn); + assert_eq!(expected_ipv4_addr, a.ipv4_addr); + + Ok(()) +} + +#[ignore] +#[test] +fn nxdomain() -> Result<()> { + let needle_fqdn = FQDN("unicorn.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()); + let nameservers_ns = nameservers_ns.start()?; + + com_ns.referral( + nameservers_ns.zone().clone(), + nameservers_ns.fqdn().clone(), + nameservers_ns.ipv4_addr(), + ); + let com_ns = com_ns.start()?; + + root_ns.referral(FQDN::COM, com_ns.fqdn().clone(), com_ns.ipv4_addr()); + let root_ns = root_ns.start()?; + + let roots = &[Root::new(root_ns.fqdn().clone(), root_ns.ipv4_addr())]; + let resolver = Resolver::start(dns_test::subject(), roots, &TrustAnchor::empty())?; + 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_fqdn, + )?; + + assert!(dbg!(output).status.is_nxdomain()); + + Ok(()) +} diff --git a/packages/conformance-tests/src/resolver/dnssec.rs b/packages/conformance-tests/src/resolver/dnssec.rs new file mode 100644 index 00000000..63400356 --- /dev/null +++ b/packages/conformance-tests/src/resolver/dnssec.rs @@ -0,0 +1,3 @@ +//! DNSSEC functionality + +mod scenarios; diff --git a/packages/conformance-tests/src/resolver/dnssec/scenarios.rs b/packages/conformance-tests/src/resolver/dnssec/scenarios.rs new file mode 100644 index 00000000..31c5df7b --- /dev/null +++ b/packages/conformance-tests/src/resolver/dnssec/scenarios.rs @@ -0,0 +1,128 @@ +use std::net::Ipv4Addr; + +use dns_test::client::{Client, Dnssec, Recurse}; +use dns_test::name_server::NameServer; +use dns_test::record::RecordType; +use dns_test::zone_file::Root; +use dns_test::{Resolver, Result, TrustAnchor, FQDN}; + +// no DS records are involved; this is a single-link chain of trust +#[ignore] +#[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 = TrustAnchor::from_iter([root_ksk.clone(), root_zsk.clone()]); + let resolver = Resolver::start(dns_test::subject(), 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); + + let output = client.delv(resolver_addr, RecordType::SOA, &FQDN::ROOT, &trust_anchor)?; + assert!(output.starts_with("; fully validated")); + + Ok(()) +} + +#[ignore] +#[test] +fn can_validate_with_delegation() -> Result<()> { + let expected_ipv4_addr = Ipv4Addr::new(1, 2, 3, 4); + let needle_fqdn = 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_fqdn.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 trust_anchor = TrustAnchor::from_iter([root_ksk.clone(), root_zsk.clone()]); + let resolver = Resolver::start(dns_test::subject(), roots, &trust_anchor)?; + let resolver_addr = resolver.ipv4_addr(); + + let client = Client::new()?; + let output = client.dig( + Recurse::Yes, + Dnssec::Yes, + resolver_addr, + RecordType::A, + &needle_fqdn, + )?; + + 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_fqdn, a.fqdn); + assert_eq!(expected_ipv4_addr, a.ipv4_addr); + + let output = client.delv(resolver_addr, RecordType::A, &needle_fqdn, &trust_anchor)?; + assert!(output.starts_with("; fully validated")); + + Ok(()) +} + +// TODO nxdomain with NSEC records +// TODO nxdomain with NSEC3 records diff --git a/packages/dns-test/Cargo.toml b/packages/dns-test/Cargo.toml new file mode 100644 index 00000000..a9c5e601 --- /dev/null +++ b/packages/dns-test/Cargo.toml @@ -0,0 +1,13 @@ +[package] +edition = "2021" +license = "MIT OR Apache-2.0" +name = "dns-test" +publish = false +version = "0.1.0" + +[dependencies] +minijinja = "1.0.12" +tempfile = "3.9.0" + +[lib] +doctest = false diff --git a/src/client.rs b/packages/dns-test/src/client.rs similarity index 91% rename from src/client.rs rename to packages/dns-test/src/client.rs index 1902ac7d..5cce1e4e 100644 --- a/src/client.rs +++ b/packages/dns-test/src/client.rs @@ -3,7 +3,8 @@ use std::net::Ipv4Addr; use crate::container::Container; use crate::record::{Record, RecordType}; -use crate::{Error, Result, FQDN}; +use crate::trust_anchor::TrustAnchor; +use crate::{Error, Implementation, Result, FQDN}; pub struct Client { inner: Container, @@ -12,23 +13,33 @@ pub struct Client { impl Client { pub fn new() -> Result { Ok(Self { - inner: Container::run()?, + inner: Container::run(Implementation::Unbound)?, }) } - // 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<'_>, + trust_anchor: &TrustAnchor, ) -> Result { + const TRUST_ANCHOR_PATH: &str = "/etc/bind.keys"; + + assert!( + !trust_anchor.is_empty(), + "`delv` cannot be used with an empty trust anchor" + ); + + self.inner.cp(TRUST_ANCHOR_PATH, &trust_anchor.delv())?; + self.inner.stdout(&[ "delv", - "+mtrace", &format!("@{server}"), - record_type.as_str(), + "-a", + TRUST_ANCHOR_PATH, fqdn.as_str(), + record_type.as_str(), ]) } @@ -209,6 +220,7 @@ pub enum DigStatus { NOERROR, NXDOMAIN, REFUSED, + SERVFAIL, } impl DigStatus { @@ -216,6 +228,11 @@ impl DigStatus { pub fn is_noerror(&self) -> bool { matches!(self, Self::NOERROR) } + + #[must_use] + pub fn is_nxdomain(&self) -> bool { + matches!(self, Self::NXDOMAIN) + } } impl FromStr for DigStatus { @@ -226,6 +243,7 @@ impl FromStr for DigStatus { "NXDOMAIN" => Self::NXDOMAIN, "NOERROR" => Self::NOERROR, "REFUSED" => Self::REFUSED, + "SERVFAIL" => Self::SERVFAIL, _ => return Err(format!("unknown status: {input}").into()), }; diff --git a/src/container.rs b/packages/dns-test/src/container.rs similarity index 63% rename from src/container.rs rename to packages/dns-test/src/container.rs index 6690bed5..006770b3 100644 --- a/src/container.rs +++ b/packages/dns-test/src/container.rs @@ -1,56 +1,52 @@ use core::str; use std::fs; use std::net::Ipv4Addr; -use std::path::Path; -use std::process::{self, Child, ExitStatus}; +use std::process::{self, ExitStatus}; use std::process::{Command, Stdio}; use std::sync::atomic::AtomicUsize; -use std::sync::{atomic, Once}; +use std::sync::{atomic, Arc}; -use tempfile::NamedTempFile; +use tempfile::{NamedTempFile, TempDir}; -use crate::{Error, Result}; +use crate::{Error, Implementation, Result}; pub struct Container { - name: String, - id: String, - // TODO probably also want the IPv6 address - ipv4_addr: Ipv4Addr, + inner: Arc, } +const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME"); + impl Container { /// Starts the container in a "parked" state - pub fn run() -> Result { - static ONCE: Once = Once::new(); - static COUNT: AtomicUsize = AtomicUsize::new(0); + pub fn run(implementation: Implementation) -> Result { + // TODO make this configurable and support hickory & bind + let dockerfile = implementation.dockerfile(); + let docker_build_dir = TempDir::new()?; + let docker_build_dir = docker_build_dir.path(); + fs::write(docker_build_dir.join("Dockerfile"), dockerfile)?; - // 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 image_tag = format!("{PACKAGE_NAME}-{implementation}"); let mut command = Command::new("docker"); command .args(["build", "-t"]) .arg(&image_tag) - .arg("-f") - .arg(dockerfile_path) - .arg(docker_dir_path); + .arg(docker_build_dir); - ONCE.call_once(|| { - let status = command.status().unwrap(); - assert!(status.success()); + implementation.once().call_once(|| { + let output = command.output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "--- STDOUT ---\n{stdout}\n--- STDERR ---\n{stderr}" + ); }); 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}"); + let count = container_count(); + let name = format!("{PACKAGE_NAME}-{implementation}-{pid}-{count}"); command .args(["run", "--rm", "--detach", "--name", &name]) .arg("-it") @@ -62,10 +58,13 @@ impl Container { let ipv4_addr = get_ipv4_addr(&id)?; - Ok(Self { + let inner = Inner { id, name, ipv4_addr, + }; + Ok(Self { + inner: Arc::new(inner), }) } @@ -76,7 +75,7 @@ impl Container { 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 dest_path = format!("{}:{path_in_container}", self.inner.id); let mut command = Command::new("docker"); command.args(["cp", &src_path, &dest_path]); @@ -91,7 +90,7 @@ impl Container { pub fn output(&self, command_and_args: &[&str]) -> Result { let mut command = Command::new("docker"); command - .args(["exec", "-t", &self.id]) + .args(["exec", "-t", &self.inner.id]) .args(command_and_args); command.output()?.try_into() @@ -100,12 +99,18 @@ impl Container { /// 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)?; + let Output { + status, + stderr, + stdout, + } = self.output(command_and_args)?; - if output.status.success() { - Ok(output.stdout) + if status.success() { + Ok(stdout) } else { - Err(format!("[{}] `{command_and_args:?}` failed", self.name).into()) + eprintln!("STDOUT:\n{stdout}\nSTDERR:\n{stderr}"); + + Err(format!("[{}] `{command_and_args:?}` failed", self.inner.name).into()) } } @@ -113,7 +118,7 @@ impl Container { pub fn status(&self, command_and_args: &[&str]) -> Result { let mut command = Command::new("docker"); command - .args(["exec", "-t", &self.id]) + .args(["exec", "-t", &self.inner.id]) .args(command_and_args); Ok(command.status()?) @@ -126,19 +131,62 @@ impl Container { if status.success() { Ok(()) } else { - Err(format!("[{}] `{command_and_args:?}` failed", self.name).into()) + Err(format!("[{}] `{command_and_args:?}` failed", self.inner.name).into()) } } pub fn spawn(&self, cmd: &[&str]) -> Result { let mut command = Command::new("docker"); - command.args(["exec", "-t", &self.id]).args(cmd); + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + command.args(["exec", "-t", &self.inner.id]).args(cmd); - Ok(command.spawn()?) + let inner = command.spawn()?; + Ok(Child { + inner: Some(inner), + _container: self.inner.clone(), + }) } pub fn ipv4_addr(&self) -> Ipv4Addr { - self.ipv4_addr + self.inner.ipv4_addr + } +} + +fn container_count() -> usize { + static COUNT: AtomicUsize = AtomicUsize::new(0); + + COUNT.fetch_add(1, atomic::Ordering::Relaxed) +} + +struct Inner { + name: String, + id: String, + // TODO probably also want the IPv6 address + ipv4_addr: Ipv4Addr, +} + +/// NOTE unlike `std::process::Child`, the drop implementation of this type will `kill` the +/// child process +// this wrapper over `std::process::Child` stores a reference to the container the child process +// runs inside of, to prevent the scenario of the container being destroyed _before_ +// the child is killed +pub struct Child { + inner: Option, + _container: Arc, +} + +impl Child { + pub fn wait(mut self) -> Result { + let output = self.inner.take().expect("unreachable").wait_with_output()?; + output.try_into() + } +} + +impl Drop for Child { + fn drop(&mut self) { + if let Some(mut inner) = self.inner.take() { + let _ = inner.kill(); + } } } @@ -200,8 +248,8 @@ fn get_ipv4_addr(container_id: &str) -> Result { Ok(ipv4_addr.parse()?) } -// ensure the container gets deleted -impl Drop for Container { +// this ensures the container gets deleted and does not linger after the test runner process ends +impl Drop for Inner { fn drop(&mut self) { // running this to completion would block the current thread for several seconds so just // fire and forget @@ -219,7 +267,7 @@ mod tests { #[test] fn run_works() -> Result<()> { - let container = Container::run()?; + let container = Container::run(Implementation::Unbound)?; let output = container.output(&["true"])?; assert!(output.status.success()); @@ -229,7 +277,7 @@ mod tests { #[test] fn ipv4_addr_works() -> Result<()> { - let container = Container::run()?; + let container = Container::run(Implementation::Unbound)?; let ipv4_addr = container.ipv4_addr(); let output = container.output(&["ping", "-c1", &format!("{ipv4_addr}")])?; @@ -240,7 +288,7 @@ mod tests { #[test] fn cp_works() -> Result<()> { - let container = Container::run()?; + let container = Container::run(Implementation::Unbound)?; let path = "/tmp/somefile"; let contents = "hello"; diff --git a/packages/dns-test/src/docker/hickory.Dockerfile b/packages/dns-test/src/docker/hickory.Dockerfile new file mode 100644 index 00000000..d2edd1a9 --- /dev/null +++ b/packages/dns-test/src/docker/hickory.Dockerfile @@ -0,0 +1,8 @@ +FROM rust:1-slim-bookworm + +RUN apt-get update && \ + apt-get install -y \ + tshark + +RUN cargo install hickory-dns --version 0.24.0 --features recursor --debug +env RUST_LOG=debug diff --git a/packages/dns-test/src/docker/unbound.Dockerfile b/packages/dns-test/src/docker/unbound.Dockerfile new file mode 100644 index 00000000..b42777ef --- /dev/null +++ b/packages/dns-test/src/docker/unbound.Dockerfile @@ -0,0 +1,13 @@ +FROM debian:bookworm-slim + +# dnsutils = dig & delv +# iputils-ping = ping +# ldns-utils = ldns-{key2ds,keygen,signzone} +RUN apt-get update && \ + apt-get install -y \ + dnsutils \ + iputils-ping \ + ldnsutils \ + nsd \ + tshark \ + unbound diff --git a/src/fqdn.rs b/packages/dns-test/src/fqdn.rs similarity index 100% rename from src/fqdn.rs rename to packages/dns-test/src/fqdn.rs diff --git a/packages/dns-test/src/lib.rs b/packages/dns-test/src/lib.rs new file mode 100644 index 00000000..c88e1209 --- /dev/null +++ b/packages/dns-test/src/lib.rs @@ -0,0 +1,76 @@ +//! A test framework for all things DNS + +use core::fmt; +use std::sync::Once; + +pub use crate::fqdn::FQDN; +pub use crate::resolver::Resolver; +pub use crate::trust_anchor::TrustAnchor; + +pub type Error = Box; +pub type Result = core::result::Result; + +pub mod client; +mod container; +mod fqdn; +pub mod name_server; +pub mod record; +mod resolver; +mod trust_anchor; +pub mod zone_file; + +#[derive(Clone, Copy)] +pub enum Implementation { + Unbound, + Hickory, +} + +impl Implementation { + fn dockerfile(&self) -> &'static str { + match self { + Implementation::Unbound => include_str!("docker/unbound.Dockerfile"), + Implementation::Hickory => include_str!("docker/hickory.Dockerfile"), + } + } + + fn once(&self) -> &'static Once { + match self { + Implementation::Unbound => { + static UNBOUND_ONCE: Once = Once::new(); + &UNBOUND_ONCE + } + Implementation::Hickory => { + static HICKORY_ONCE: Once = Once::new(); + &HICKORY_ONCE + } + } + } +} + +impl Default for Implementation { + fn default() -> Self { + Self::Unbound + } +} + +impl fmt::Display for Implementation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Implementation::Unbound => "unbound", + Implementation::Hickory => "hickory", + }; + f.write_str(s) + } +} + +pub fn subject() -> Implementation { + if let Ok(subject) = std::env::var("DNS_TEST_SUBJECT") { + match subject.as_str() { + "hickory" => Implementation::Hickory, + "unbound" => Implementation::Unbound, + _ => panic!("unknown implementation: {subject}"), + } + } else { + Implementation::default() + } +} diff --git a/src/name_server.rs b/packages/dns-test/src/name_server.rs similarity index 88% rename from src/name_server.rs rename to packages/dns-test/src/name_server.rs index c6c8fb6e..5de5a2e9 100644 --- a/src/name_server.rs +++ b/packages/dns-test/src/name_server.rs @@ -1,10 +1,9 @@ use core::sync::atomic::{self, AtomicUsize}; use std::net::Ipv4Addr; -use std::process::Child; -use crate::container::Container; +use crate::container::{Child, Container}; use crate::zone_file::{self, SoaSettings, ZoneFile, DNSKEY, DS}; -use crate::{Result, FQDN}; +use crate::{Implementation, Result, FQDN}; pub struct NameServer<'a, State> { container: Container, @@ -43,7 +42,7 @@ impl<'a> NameServer<'a, Stopped> { }); Ok(Self { - container: Container::run()?, + container: Container::run(Implementation::Unbound)?, zone_file, state: Stopped, }) @@ -210,6 +209,31 @@ impl<'a> NameServer<'a, Signed> { } } +impl<'a> NameServer<'a, Running> { + /// gracefully terminates the name server collecting all logs + pub fn terminate(self) -> Result { + let pidfile = "/run/nsd/nsd.pid"; + // if `terminate` is called right after `start` NSD may not have had the chance to create + // the PID file so if it doesn't exist wait for a bit before invoking `kill` + let kill = format!( + "test -f {pidfile} || sleep 1 +kill -TERM $(cat {pidfile})" + ); + self.container.status_ok(&["sh", "-c", &kill])?; + let output = self.state.child.wait()?; + + if !output.status.success() { + return Err("could not terminate the `unbound` process".into()); + } + + assert!( + output.stderr.is_empty(), + "stderr should be returned if not empty" + ); + Ok(output.stdout) + } +} + impl<'a, S> NameServer<'a, S> { pub fn ipv4_addr(&self) -> Ipv4Addr { self.container.ipv4_addr() @@ -242,12 +266,6 @@ 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() } @@ -352,4 +370,14 @@ mod tests { Ok(()) } + + #[test] + fn terminate_works() -> Result<()> { + let ns = NameServer::new(FQDN::ROOT)?.start()?; + let logs = ns.terminate()?; + + assert!(logs.contains("nsd starting")); + + Ok(()) + } } diff --git a/src/record.rs b/packages/dns-test/src/record.rs similarity index 100% rename from src/record.rs rename to packages/dns-test/src/record.rs diff --git a/packages/dns-test/src/resolver.rs b/packages/dns-test/src/resolver.rs new file mode 100644 index 00000000..12d94193 --- /dev/null +++ b/packages/dns-test/src/resolver.rs @@ -0,0 +1,107 @@ +use core::fmt::Write; +use std::net::Ipv4Addr; + +use crate::container::{Child, Container}; +use crate::trust_anchor::TrustAnchor; +use crate::zone_file::Root; +use crate::{Implementation, Result}; + +pub struct Resolver { + container: Container, + child: Child, +} + +impl Resolver { + pub fn start( + implementation: Implementation, + roots: &[Root], + trust_anchor: &TrustAnchor, + ) -> Result { + const TRUST_ANCHOR_FILE: &str = "/etc/trusted-key.key"; + + let container = Container::run(implementation)?; + + let mut hints = String::new(); + for root in roots { + writeln!(hints, "{root}").unwrap(); + } + + let use_dnssec = !trust_anchor.is_empty(); + match implementation { + Implementation::Unbound => { + container.cp("/etc/unbound/root.hints", &hints)?; + + container.cp("/etc/unbound/unbound.conf", &unbound_conf(use_dnssec))?; + } + + Implementation::Hickory => { + container.status_ok(&["mkdir", "-p", "/etc/hickory"])?; + + container.cp("/etc/hickory/root.hints", &hints)?; + + container.cp("/etc/named.toml", &hickory_conf(use_dnssec))?; + } + } + + if use_dnssec { + container.cp(TRUST_ANCHOR_FILE, &trust_anchor.to_string())?; + } + + let command: &[_] = match implementation { + Implementation::Unbound => &["unbound", "-d"], + Implementation::Hickory => &["hickory-dns", "-d"], + }; + let child = container.spawn(command)?; + + Ok(Self { child, container }) + } + + pub fn ipv4_addr(&self) -> Ipv4Addr { + self.container.ipv4_addr() + } + + /// gracefully terminates the name server collecting all logs + pub fn terminate(self) -> Result { + let pidfile = "/run/unbound.pid"; + let kill = format!( + "test -f {pidfile} || sleep 1 +kill -TERM $(cat {pidfile})" + ); + self.container.status_ok(&["sh", "-c", &kill])?; + let output = self.child.wait()?; + + if !output.status.success() { + return Err("could not terminate the `unbound` process".into()); + } + + assert!( + output.stderr.is_empty(), + "stderr should be returned if not empty" + ); + Ok(output.stdout) + } +} + +fn unbound_conf(use_dnssec: bool) -> String { + minijinja::render!(include_str!("templates/unbound.conf.jinja"), use_dnssec => use_dnssec) +} + +fn hickory_conf(use_dnssec: bool) -> String { + minijinja::render!(include_str!("templates/hickory.resolver.toml.jinja"), use_dnssec => use_dnssec) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminate_works() -> Result<()> { + let resolver = Resolver::start(Implementation::Unbound, &[], &TrustAnchor::empty())?; + let logs = resolver.terminate()?; + + eprintln!("{logs}"); + assert!(logs.contains("start of service")); + + Ok(()) + } +} diff --git a/packages/dns-test/src/templates/hickory.resolver.toml.jinja b/packages/dns-test/src/templates/hickory.resolver.toml.jinja new file mode 100644 index 00000000..d3da6496 --- /dev/null +++ b/packages/dns-test/src/templates/hickory.resolver.toml.jinja @@ -0,0 +1,5 @@ +[[zones]] +zone = "." +zone_type = "Hint" +stores = { type = "recursor", roots = "/etc/hickory/root.hints", ns_cache_size = 1024, record_cache_size = 1048576 } +enable_dnssec = {{ use_dnssec }} diff --git a/src/templates/nsd.conf.jinja b/packages/dns-test/src/templates/nsd.conf.jinja similarity index 100% rename from src/templates/nsd.conf.jinja rename to packages/dns-test/src/templates/nsd.conf.jinja diff --git a/src/templates/unbound.conf.jinja b/packages/dns-test/src/templates/unbound.conf.jinja similarity index 100% rename from src/templates/unbound.conf.jinja rename to packages/dns-test/src/templates/unbound.conf.jinja diff --git a/packages/dns-test/src/trust_anchor.rs b/packages/dns-test/src/trust_anchor.rs new file mode 100644 index 00000000..b14173e2 --- /dev/null +++ b/packages/dns-test/src/trust_anchor.rs @@ -0,0 +1,51 @@ +use core::fmt; + +use crate::zone_file::DNSKEY; + +pub struct TrustAnchor { + keys: Vec, +} + +impl TrustAnchor { + pub fn empty() -> Self { + Self { keys: Vec::new() } + } + + pub fn is_empty(&self) -> bool { + self.keys.is_empty() + } + + pub fn add(&mut self, key: DNSKEY) -> &mut Self { + self.keys.push(key); + self + } + + /// formats the `TrustAnchor` in the format `delv` expects + pub(super) fn delv(&self) -> String { + let mut buf = "trust-anchors {".to_string(); + + for key in &self.keys { + buf.push_str(&key.delv()); + } + + buf.push_str("};"); + buf + } +} + +impl fmt::Display for TrustAnchor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for key in &self.keys { + writeln!(f, "{key}")?; + } + Ok(()) + } +} + +impl FromIterator for TrustAnchor { + fn from_iter>(iter: T) -> Self { + Self { + keys: iter.into_iter().collect(), + } + } +} diff --git a/src/zone_file.rs b/packages/dns-test/src/zone_file.rs similarity index 97% rename from src/zone_file.rs rename to packages/dns-test/src/zone_file.rs index 71a7b9c6..8dc153c9 100644 --- a/src/zone_file.rs +++ b/packages/dns-test/src/zone_file.rs @@ -165,6 +165,20 @@ impl DNSKEY { pub fn key_tag(&self) -> u16 { self.key_tag } + + /// formats the `DNSKEY` in the format `delv` expects + pub(super) fn delv(&self) -> String { + let Self { + zone, + flags, + protocol, + algorithm, + public_key, + .. + } = self; + + format!("{zone} static-key {flags} {protocol} {algorithm} \"{public_key}\";\n") + } } impl FromStr for DNSKEY { diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 60f55948..00000000 --- a/src/lib.rs +++ /dev/null @@ -1,13 +0,0 @@ -pub use crate::fqdn::FQDN; -pub use crate::recursive_resolver::RecursiveResolver; - -pub type Error = Box; -pub type Result = core::result::Result; - -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/recursive_resolver.rs b/src/recursive_resolver.rs deleted file mode 100644 index 5e369e69..00000000 --- a/src/recursive_resolver.rs +++ /dev/null @@ -1,236 +0,0 @@ -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(()) - } -}