From 037cf4f698e545e072a004c77e8f4726dcfad273 Mon Sep 17 00:00:00 2001 From: Jorge Aparicio Date: Tue, 6 Feb 2024 20:05:21 +0100 Subject: [PATCH] ns: sign zone file --- docker/unbound.Dockerfile | 2 +- src/client.rs | 21 +++- src/container.rs | 4 +- src/name_server.rs | 233 ++++++++++++++++++++++++++++++++++++-- src/recursive_resolver.rs | 10 +- 5 files changed, 253 insertions(+), 17 deletions(-) diff --git a/docker/unbound.Dockerfile b/docker/unbound.Dockerfile index 51c75d45..138d7abe 100644 --- a/docker/unbound.Dockerfile +++ b/docker/unbound.Dockerfile @@ -1,6 +1,6 @@ FROM ubuntu:22.04 RUN apt-get update && \ - apt-get install -y dnsutils unbound nsd iputils-ping tshark vim + apt-get install -y dnsutils unbound nsd iputils-ping tshark vim ldnsutils COPY ./files/etc/unbound/unbound.conf /etc/unbound/unbound.conf diff --git a/src/client.rs b/src/client.rs index b66522a2..0a9fb1e2 100644 --- a/src/client.rs +++ b/src/client.rs @@ -19,6 +19,7 @@ impl Client { pub fn dig( &self, recurse: Recurse, + dnssec: Dnssec, server: Ipv4Addr, record_type: RecordType, fqdn: &FQDN<'_>, @@ -26,6 +27,7 @@ impl Client { let output = self.inner.stdout(&[ "dig", recurse.as_str(), + dnssec.as_str(), &format!("@{server}"), record_type.as_str(), fqdn.as_str(), @@ -35,6 +37,21 @@ impl Client { } } +#[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, @@ -44,8 +61,8 @@ pub enum Recurse { impl Recurse { fn as_str(&self) -> &'static str { match self { - Recurse::Yes => "+recurse", - Recurse::No => "+norecurse", + Self::Yes => "+recurse", + Self::No => "+norecurse", } } } diff --git a/src/container.rs b/src/container.rs index 0848c85b..6690bed5 100644 --- a/src/container.rs +++ b/src/container.rs @@ -154,12 +154,12 @@ impl TryFrom for Output { fn try_from(output: process::Output) -> Result { let mut stderr = String::from_utf8(output.stderr)?; - while stderr.ends_with('\n') { + while stderr.ends_with(|c| matches!(c, '\n' | '\r')) { stderr.pop(); } let mut stdout = String::from_utf8(output.stdout)?; - while stdout.ends_with('\n') { + while stdout.ends_with(|c| matches!(c, '\n' | '\r')) { stdout.pop(); } diff --git a/src/name_server.rs b/src/name_server.rs index f1b36bff..61401158 100644 --- a/src/name_server.rs +++ b/src/name_server.rs @@ -1,15 +1,17 @@ +use core::array; +use core::str::FromStr; 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}; -use crate::{Result, FQDN}; +use crate::{Error, Result, FQDN}; pub struct NameServer<'a, State> { container: Container, zone_file: ZoneFile<'a>, - _state: State, + state: State, } impl<'a> NameServer<'a, Stopped> { @@ -45,7 +47,7 @@ impl<'a> NameServer<'a, Stopped> { Ok(Self { container: Container::run()?, zone_file, - _state: Stopped, + state: Stopped, }) } @@ -66,12 +68,66 @@ impl<'a> NameServer<'a, Stopped> { 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: Key = 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: Key = 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])?; + + // 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 { + zsk, + ksk, + signed_zone_file, + }, + }) + } + /// Moves the server to the "Start" state where it can answer client queries pub fn start(self) -> Result> { let Self { container, zone_file, - _state: _, + state: _, } = self; // for PID file @@ -79,24 +135,123 @@ impl<'a> NameServer<'a, Stopped> { container.cp("/etc/nsd/nsd.conf", &nsd_conf(&zone_file.origin))?; - container.status_ok(&["mkdir", "-p", "/etc/nsd/zones"])?; - container.cp("/etc/nsd/zones/main.zone", &zone_file.to_string())?; + 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 }, + state: Running { child }, }) } } +#[derive(Debug)] +pub struct Key { + pub bits: u16, + pub encoded: String, + pub id: u32, +} + +impl FromStr for Key { + 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(encoded), 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 (id, _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 { + bits, + encoded: encoded.to_string(), + id: id.parse()?, + }) + } +} + +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) -> &Key { + &self.state.ksk + } + + pub fn zone_signing_key(&self) -> &Key { + &self.state.zsk + } + + pub fn signed_zone_file(&self) -> &str { + &self.state.signed_zone_file + } +} + impl<'a, S> NameServer<'a, S> { pub fn ipv4_addr(&self) -> Ipv4Addr { self.container.ipv4_addr() @@ -117,6 +272,12 @@ impl<'a, S> NameServer<'a, S> { pub struct Stopped; +pub struct Signed { + zsk: Key, + ksk: Key, + signed_zone_file: String, +} + pub struct Running { child: Child, } @@ -144,7 +305,7 @@ fn nsd_conf(fqdn: &FQDN) -> String { #[cfg(test)] mod tests { - use crate::client::{Client, Recurse}; + use crate::client::{Client, Dnssec, Recurse}; use crate::record::RecordType; use super::*; @@ -155,7 +316,13 @@ mod tests { let ip_addr = tld_ns.ipv4_addr(); let client = Client::new()?; - let output = client.dig(Recurse::No, ip_addr, RecordType::SOA, &FQDN::COM)?; + let output = client.dig( + Recurse::No, + Dnssec::No, + ip_addr, + RecordType::SOA, + &FQDN::COM, + )?; assert!(output.status.is_noerror()); @@ -178,10 +345,56 @@ mod tests { let ipv4_addr = root_ns.ipv4_addr(); let client = Client::new()?; - let output = client.dig(Recurse::No, ipv4_addr, RecordType::NS, &FQDN::COM)?; + let output = client.dig( + Recurse::No, + Dnssec::No, + ipv4_addr, + RecordType::NS, + &FQDN::COM, + )?; assert!(output.status.is_noerror()); Ok(()) } + + #[test] + #[ignore = "FIXME need to parse RRSIG record in dig's output"] + fn signed() -> Result<()> { + let tld_ns = NameServer::new(FQDN::ROOT)?.sign()?; + + eprintln!("KSK: {:?}", tld_ns.key_signing_key()); + eprintln!("ZSK: {:?}", tld_ns.zone_signing_key()); + eprintln!("root.zone.signed:\n{}", tld_ns.signed_zone_file()); + + let tld_ns = tld_ns.start()?; + + let ipv4_addr = tld_ns.ipv4_addr(); + + let client = Client::new()?; + let output = client.dig( + Recurse::No, + Dnssec::Yes, + ipv4_addr, + RecordType::SOA, + &FQDN::ROOT, + )?; + + assert!(output.status.is_noerror()); + + Ok(()) + } + + #[test] + fn can_parse_ldns_keygen_output() -> Result<()> { + let input = "example.com. IN DNSKEY 256 3 7 AwEAAdIpMlio4GJas7GbIZ9xRpzpB2pf4SxBJcsquN/0yNBPGNE2rzcFykqMAKmLwypk1/1q/EdHVa4tQ5RlK0w09CRhgSXfCaph+yLNJKpiPyuVcXKl2k0RnO4p835sgVEUIvx8qGTDo7c7DA9UBje+/3ViFKqVhOBaWyT6gHAmNVpb ;{id = 24975 (zsk), size = 1024b}"; + + let key: Key = input.parse()?; + + assert_eq!(1024, key.bits); + let expected = "AwEAAdIpMlio4GJas7GbIZ9xRpzpB2pf4SxBJcsquN/0yNBPGNE2rzcFykqMAKmLwypk1/1q/EdHVa4tQ5RlK0w09CRhgSXfCaph+yLNJKpiPyuVcXKl2k0RnO4p835sgVEUIvx8qGTDo7c7DA9UBje+/3ViFKqVhOBaWyT6gHAmNVpb"; + assert_eq!(expected, key.encoded); + + Ok(()) + } } diff --git a/src/recursive_resolver.rs b/src/recursive_resolver.rs index 7bd8506a..4879e321 100644 --- a/src/recursive_resolver.rs +++ b/src/recursive_resolver.rs @@ -41,7 +41,7 @@ impl Drop for RecursiveResolver { #[cfg(test)] mod tests { use crate::{ - client::{Client, Recurse}, + client::{Client, Dnssec, Recurse}, name_server::NameServer, record::RecordType, FQDN, @@ -85,7 +85,13 @@ mod tests { let resolver_ip_addr = resolver.ipv4_addr(); let client = Client::new()?; - let output = client.dig(Recurse::Yes, resolver_ip_addr, RecordType::A, &needle)?; + let output = client.dig( + Recurse::Yes, + Dnssec::No, + resolver_ip_addr, + RecordType::A, + &needle, + )?; assert!(output.status.is_noerror());