Merge pull request #6 from japaric/reorg

reorganize tests in modules
This commit is contained in:
Jorge Aparicio 2024-02-09 18:22:27 +01:00 committed by GitHub
commit 77150bbca6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 725 additions and 331 deletions

View File

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

9
Cargo.lock generated
View File

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

View File

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

View File

@ -1,4 +0,0 @@
FROM ubuntu:22.04
RUN apt-get update && \
apt-get install -y dnsutils unbound nsd iputils-ping tshark vim ldnsutils

View File

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

View File

@ -0,0 +1,3 @@
#![cfg(test)]
mod resolver;

View File

@ -0,0 +1,4 @@
//! Recursive resolver role
mod dns;
mod dnssec;

View File

@ -0,0 +1,3 @@
//! plain DNS functionality
mod scenarios;

View File

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

View File

@ -0,0 +1,3 @@
//! DNSSEC functionality
mod scenarios;

View File

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

View File

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

View File

@ -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<Self> {
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<String> {
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()),
};

View File

@ -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<Inner>,
}
const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME");
impl Container {
/// Starts the container in a "parked" state
pub fn run() -> Result<Self> {
static ONCE: Once = Once::new();
static COUNT: AtomicUsize = AtomicUsize::new(0);
pub fn run(implementation: Implementation) -> Result<Self> {
// 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<Output> {
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<String> {
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<ExitStatus> {
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<Child> {
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<process::Child>,
_container: Arc<Inner>,
}
impl Child {
pub fn wait(mut self) -> Result<Output> {
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<Ipv4Addr> {
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";

View File

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

View File

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

View File

@ -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<dyn std::error::Error>;
pub type Result<T> = core::result::Result<T, Error>;
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()
}
}

View File

@ -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<String> {
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(())
}
}

View File

@ -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<Self> {
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<String> {
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(())
}
}

View File

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

View File

@ -0,0 +1,51 @@
use core::fmt;
use crate::zone_file::DNSKEY;
pub struct TrustAnchor {
keys: Vec<DNSKEY>,
}
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<DNSKEY> for TrustAnchor {
fn from_iter<T: IntoIterator<Item = DNSKEY>>(iter: T) -> Self {
Self {
keys: iter.into_iter().collect(),
}
}
}

View File

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

View File

@ -1,13 +0,0 @@
pub use crate::fqdn::FQDN;
pub use crate::recursive_resolver::RecursiveResolver;
pub type Error = Box<dyn std::error::Error>;
pub type Result<T> = core::result::Result<T, Error>;
pub mod client;
mod container;
mod fqdn;
pub mod name_server;
pub mod record;
mod recursive_resolver;
pub mod zone_file;

View File

@ -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<Self> {
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(())
}
}