From 7ad5bacbdccfaef34911b067f14ecbfc07ff1bc3 Mon Sep 17 00:00:00 2001 From: Jorge Aparicio Date: Tue, 6 Feb 2024 16:47:18 +0100 Subject: [PATCH] parse dig's output --- src/authoritative_name_server.rs | 51 ++-- src/client.rs | 408 +++++++++++++++++++++++++++++++ src/container.rs | 12 + src/domain.rs | 19 +- src/lib.rs | 2 + src/recursive_resolver.rs | 30 +-- 6 files changed, 466 insertions(+), 56 deletions(-) create mode 100644 src/client.rs diff --git a/src/authoritative_name_server.rs b/src/authoritative_name_server.rs index 8a911da7..7aafdb7a 100644 --- a/src/authoritative_name_server.rs +++ b/src/authoritative_name_server.rs @@ -85,7 +85,6 @@ impl StoppedAuthoritativeNameServer { /// - one SOA record, with the primary name server set to the name server domain /// - one NS record, with the name server domain set as the only available name server for /// `zone` - /// - one A record, that maps the name server domain to its IPv4 address /// - one NS + A record pair, for each referral in the `referrals` list /// - the A records in the `a_records` list pub fn start<'a>( @@ -119,10 +118,6 @@ impl StoppedAuthoritativeNameServer { domain: zone.clone(), ns: nameserver.clone(), }); - zone_file.record(record::A { - domain: nameserver, - ipv4_addr: container.ipv4_addr(), - }); for referral in referrals { zone_file.referral(referral) @@ -153,45 +148,35 @@ fn nsd_conf(domain: &Domain) -> String { #[cfg(test)] mod tests { + use crate::{ + client::{RecordType, Recurse}, + Client, + }; + use super::*; #[test] - fn tld_ns() -> Result<()> { - let tld_ns = AuthoritativeNameServer::start(Domain("com.")?, &[], &[])?; + fn simplest() -> Result<()> { + let com_domain = Domain("com.")?; + let tld_ns = AuthoritativeNameServer::start(com_domain.clone(), &[], &[])?; let ip_addr = tld_ns.ipv4_addr(); - let client = Container::run()?; - let output = client.output(&["dig", &format!("@{ip_addr}"), "SOA", "com."])?; + let client = Client::new()?; + let output = client.dig(Recurse::No, ip_addr, RecordType::SOA, &com_domain)?; - assert!(output.status.success()); - eprintln!("{}", output.stdout); - assert!(output.stdout.contains("status: NOERROR")); + assert!(output.status.is_noerror()); Ok(()) } #[test] - fn root_ns() -> Result<()> { - let root_ns = AuthoritativeNameServer::start(Domain::ROOT, &[], &[])?; - let ip_addr = root_ns.ipv4_addr(); - - let client = Container::run()?; - let output = client.output(&["dig", &format!("@{ip_addr}"), "SOA", "."])?; - - assert!(output.status.success()); - eprintln!("{}", output.stdout); - assert!(output.stdout.contains("status: NOERROR")); - - Ok(()) - } - - #[test] - fn root_ns_with_referral() -> Result<()> { + fn with_referral() -> Result<()> { let expected_ip_addr = Ipv4Addr::new(172, 17, 200, 1); + let com_domain = Domain("com.")?; let root_ns = AuthoritativeNameServer::start( Domain::ROOT, &[Referral { - domain: Domain("com.")?, + domain: com_domain.clone(), ipv4_addr: expected_ip_addr, ns: Domain("primary.tld-server.com.")?, }], @@ -199,12 +184,10 @@ mod tests { )?; let ip_addr = root_ns.ipv4_addr(); - let client = Container::run()?; - let output = client.output(&["dig", &format!("@{ip_addr}"), "NS", "com."])?; + let client = Client::new()?; + let output = client.dig(Recurse::No, ip_addr, RecordType::NS, &com_domain)?; - assert!(output.status.success()); - eprintln!("{}", output.stdout); - assert!(output.stdout.contains("status: NOERROR")); + assert!(output.status.is_noerror()); Ok(()) } diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 00000000..2a7190e9 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,408 @@ +use core::array; +use core::result::Result as CoreResult; +use core::str::FromStr; +use std::net::Ipv4Addr; + +use crate::container::Container; +use crate::{Domain, Error, Result}; + +pub struct Client { + inner: Container, +} + +impl Client { + pub fn new() -> Result { + Ok(Self { + inner: Container::run()?, + }) + } + + pub fn dig( + &self, + recurse: Recurse, + server: Ipv4Addr, + record_type: RecordType, + domain: &Domain<'_>, + ) -> Result { + let output = self.inner.stdout(&[ + "dig", + recurse.as_str(), + &format!("@{server}"), + record_type.as_str(), + domain.as_str(), + ])?; + + output.parse() + } +} + +#[allow(clippy::upper_case_acronyms)] +pub enum RecordType { + A, + NS, + SOA, +} + +impl RecordType { + fn as_str(&self) -> &'static str { + match self { + RecordType::A => "A", + RecordType::SOA => "SOA", + RecordType::NS => "NS", + } + } +} + +#[derive(Clone, Copy)] +pub enum Recurse { + Yes, + No, +} + +impl Recurse { + fn as_str(&self) -> &'static str { + match self { + Recurse::Yes => "+recurse", + Recurse::No => "+norecurse", + } + } +} + +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, +} + +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; + + for flag in input.split_whitespace() { + match flag { + "qr" => qr = true, + "rd" => recursion_desired = true, + "ra" => recursion_available = true, + "aa" => authoritative_answer = true, + _ => return Err(format!("unknown flag: {flag}").into()), + } + } + + Ok(Self { + qr, + recursion_desired, + recursion_available, + authoritative_answer, + }) + } +} + +#[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) + } +} + +#[derive(Debug)] +#[allow(clippy::upper_case_acronyms)] +pub enum Record { + A(A), + SOA(SOA), +} + +impl Record { + pub fn try_into_a(self) -> CoreResult { + if let Self::A(v) = self { + Ok(v) + } else { + Err(self) + } + } +} + +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!(), + "SOA" => Record::SOA(input.parse()?), + _ => return Err(format!("unknown record type: {record_type}").into()), + }; + + Ok(record) + } +} + +#[derive(Debug)] +pub struct A { + pub domain: Domain<'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(domain), Some(ttl), Some(class), Some(record_type), Some(ipv4_addr), None] = + array::from_fn(|_| columns.next()) + else { + return Err("expected 5 columns".into()); + }; + + if record_type != "A" { + return Err(format!("tried to parse `{record_type}` record as an A record").into()); + } + + if class != "IN" { + return Err(format!("unknown class: {class}").into()); + } + + Ok(Self { + domain: domain.parse()?, + ttl: ttl.parse()?, + ipv4_addr: ipv4_addr.parse()?, + }) + } +} + +#[allow(clippy::upper_case_acronyms)] +#[derive(Debug)] +pub struct SOA { + pub domain: Domain<'static>, + pub ttl: u32, + pub nameserver: Domain<'static>, + pub admin: Domain<'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(domain), 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 { + domain: domain.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 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(()) + } + + #[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.domain.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.domain.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(()) + } +} diff --git a/src/container.rs b/src/container.rs index e7f12759..095e591b 100644 --- a/src/container.rs +++ b/src/container.rs @@ -95,6 +95,18 @@ impl Container { 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"); diff --git a/src/domain.rs b/src/domain.rs index 922347cd..159d4623 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -1,9 +1,10 @@ use core::fmt; +use core::str::FromStr; use std::borrow::Cow; -use crate::Result; +use crate::{Error, Result}; -#[derive(Clone)] +#[derive(Clone, PartialEq)] pub struct Domain<'a> { inner: Cow<'a, str>, } @@ -48,6 +49,20 @@ impl<'a> Domain<'a> { } } +impl FromStr for Domain<'static> { + type Err = Error; + + fn from_str(input: &str) -> Result { + Ok(Domain(input)?.into_owned()) + } +} + +impl fmt::Debug for Domain<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + impl fmt::Display for Domain<'_> { 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 82c2a386..95b95cde 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ use std::sync::atomic::{self, AtomicUsize}; pub use crate::authoritative_name_server::AuthoritativeNameServer; +pub use crate::client::Client; pub use crate::domain::Domain; pub use crate::recursive_resolver::RecursiveResolver; @@ -10,6 +11,7 @@ pub type Result = core::result::Result; const CHMOD_RW_EVERYONE: &str = "666"; mod authoritative_name_server; +mod client; pub mod container; mod domain; pub mod record; diff --git a/src/recursive_resolver.rs b/src/recursive_resolver.rs index c54afda8..22fcea0e 100644 --- a/src/recursive_resolver.rs +++ b/src/recursive_resolver.rs @@ -41,8 +41,9 @@ impl Drop for RecursiveResolver { #[cfg(test)] mod tests { use crate::{ + client::{RecordType, Recurse}, record::{self, Referral}, - AuthoritativeNameServer, Domain, + AuthoritativeNameServer, Client, Domain, }; use super::*; @@ -51,6 +52,7 @@ mod tests { fn can_resolve() -> Result<()> { let expected_ipv4_addr = Ipv4Addr::new(1, 2, 3, 4); let needle = Domain("example.nameservers.com.")?; + let root_ns = AuthoritativeNameServer::reserve()?; let com_ns = AuthoritativeNameServer::reserve()?; @@ -105,28 +107,16 @@ mod tests { let resolver = RecursiveResolver::start(roots)?; let resolver_ip_addr = resolver.ipv4_addr(); - let container = Container::run()?; - let output = container.output(&[ - "dig", - &format!("@{}", resolver_ip_addr), - &needle.to_string(), - ])?; + let client = Client::new()?; + let output = client.dig(Recurse::Yes, resolver_ip_addr, RecordType::A, &needle)?; - eprintln!("{}", output.stdout); + assert!(output.status.is_noerror()); - assert!(output.status.success()); - assert!(output.stdout.contains("status: NOERROR")); + let [answer] = output.answer.try_into().unwrap(); + let a = answer.try_into_a().unwrap(); - let mut found = false; - let needle = needle.to_string(); - for line in output.stdout.lines() { - if line.starts_with(&needle) { - found = true; - assert!(line.ends_with(&expected_ipv4_addr.to_string())); - } - } - - assert!(found); + assert_eq!(needle, a.domain); + assert_eq!(expected_ipv4_addr, a.ipv4_addr); Ok(()) }