diff --git a/Cargo.lock b/Cargo.lock index 958daa20..4a21c242 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -311,7 +311,7 @@ dependencies = [ [[package]] name = "domain" version = "0.10.3" -source = "git+https://github.com/NLnetLabs/domain.git?branch=initial-nsec3-generation#5efcccfabd4535a40c8981fc36f89ca73899c3b4" +source = "git+https://github.com/NLnetLabs/domain.git?branch=extend-stelline-for-ldns-testns#4e7adbc290ad0a0ac3d40cfdff39bae7df7c7b8b" dependencies = [ "arc-swap", "bytes", diff --git a/Cargo.toml b/Cargo.toml index ff61413d..c470d18a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,13 +20,14 @@ ring = ["domain/ring"] bytes = "1.8.0" chrono = "0.4.38" clap = { version = "4.3.4", features = ["cargo", "derive"] } -domain = { git = "https://github.com/NLnetLabs/domain.git", branch = "initial-nsec3-generation", features = [ +domain = { git = "https://github.com/NLnetLabs/domain.git", branch = "extend-stelline-for-ldns-testns", features = [ "bytes", "net", "resolv", "tsig", "unstable-client-transport", "unstable-sign", + "unstable-stelline", "unstable-validate", "unstable-validator", "zonefile", @@ -43,6 +44,6 @@ _unused_lazy_static = { package = "lazy_static", version = "1.0.2" } test_bin = "0.4.0" tempfile = "3.14.0" regex = "1.11.1" -domain = { git = "https://github.com/NLnetLabs/domain.git", branch = "initial-nsec3-generation", features = [ +domain = { git = "https://github.com/NLnetLabs/domain.git", branch = "extend-stelline-for-ldns-testns", features = [ "unstable-stelline", ] } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8d2a2114..1623fb6b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod key2ds; pub mod keygen; pub mod notify; pub mod nsec3hash; +pub mod testns; pub mod update; use clap::crate_version; @@ -65,6 +66,10 @@ pub enum Command { #[command(name = "key2ds")] Key2ds(key2ds::Key2ds), + /// Simple fake nameserver tool + #[command(name = "testns")] + TestNs(self::testns::TestNs), + /// Send an UPDATE packet #[command(name = "update")] Update(self::update::Update), @@ -87,6 +92,7 @@ impl Command { Self::Nsec3Hash(nsec3hash) => nsec3hash.execute(env), Self::Key2ds(key2ds) => key2ds.execute(env), Self::Notify(notify) => notify.execute(env), + Self::TestNs(testns) => testns.execute(env), Self::Update(update) => update.execute(env), Self::Help(help) => help.execute(), Self::Report(s) => { diff --git a/src/commands/testns.rs b/src/commands/testns.rs new file mode 100644 index 00000000..94f8773a --- /dev/null +++ b/src/commands/testns.rs @@ -0,0 +1,243 @@ +use std::ffi::OsString; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::env::Env; +use crate::error::Error; +use crate::Args; + +use domain::base::wire::Composer; +use domain::dep::octseq::{OctetsBuilder, Truncate}; +use domain::net::server::buf::VecBufSource; +use domain::net::server::dgram::DgramServer; +use domain::net::server::message::Request; +use domain::net::server::service::{CallResult, ServiceError, ServiceResult}; +use domain::net::server::stream::StreamServer; +use domain::net::server::util::service_fn; +use domain::stelline::client::CurrStepValue; +use domain::stelline::parse_stelline::{self, Stelline}; +use domain::stelline::server::do_server; +use lexopt::Arg; +use tokio::net::{TcpListener, UdpSocket}; +use tokio::sync::mpsc::Sender; + +use super::{parse_os, Command, LdnsCommand}; + +#[derive(Clone, Debug, clap::Args, PartialEq, Eq)] +pub struct TestNs { + /// Listens on the specified port, default 53. + #[arg(short = 'p', value_name = "PORT")] + port: Option, + + /// Verbose output. + #[arg(short = 'v')] + verbose: bool, + + /// Datafile + #[arg()] + datafile: PathBuf, + + /// Running in LDNS mode? + #[arg(skip)] + is_ldns: bool, +} + +const LDNS_HELP: &str = "\ +Usage: ldns-testns [options] + -p listens on the specified port, default 53. +The program answers queries with canned replies from the datafile.\ +"; + +impl LdnsCommand for TestNs { + const NAME: &'static str = "testns"; + const HELP: &'static str = LDNS_HELP; + const COMPATIBLE_VERSION: &'static str = "1.8.4"; + + fn parse_ldns>(args: I) -> Result { + let mut port = 53; + let mut verbose = false; + let mut datafile = None; + + let mut parser = lexopt::Parser::from_args(args); + + while let Some(arg) = parser.next()? { + match arg { + Arg::Short('p') => { + let val = parser.value()?; + port = parse_os("port (-p)", &val)?; + } + Arg::Short('v') => { + verbose = true; + } + Arg::Value(val) => { + if datafile.is_some() { + return Err("Only one datafile is allowed".into()); + } + datafile = Some(val); + } + Arg::Short(x) => return Err(format!("Invalid short option: -{x}").into()), + Arg::Long(x) => { + return Err(format!("Long options are not supported, but `--{x}` given").into()) + } + } + } + + let Some(datafile) = datafile else { + return Err("No datafile given".into()); + }; + + Ok(Args::from(Command::TestNs(Self { + port: Some(port), + verbose, + datafile: datafile.into(), + is_ldns: true, + }))) + } +} + +impl TestNs { + pub fn execute(self, env: impl Env) -> Result<(), Error> { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(self.run(&env)) + } + + /// Run the command as an async function + pub async fn run(self, env: &impl Env) -> Result<(), Error> { + let port = self.port.unwrap(); + let mut datafile = std::fs::read_to_string(&self.datafile)?; + + if !datafile.contains("RANGE_BEGIN") { + datafile.insert_str(0, "RANGE_BEGIN 0 999\n"); + datafile.push_str("RANGE_END\n"); + } + if !datafile.contains("SCENARIO_BEGIN") { + datafile.insert_str(0, "SCENARIO_BEGIN Scenario to emulate\n"); + datafile.push_str("SCENARIO_END\n"); + } + if !datafile.contains("CONFIG_END") { + datafile.insert_str(0, "CONFIG_END\n"); + } + + let stelline = Arc::new(parse_stelline::parse_file( + datafile.as_bytes(), + self.datafile.to_str().unwrap(), + )); + + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + + let svc = service_fn(answer_from_datafile, (stelline, tx)); + + let sock = UdpSocket::bind(format!("127.0.0.1:{port}")).await.unwrap(); + let listener = TcpListener::bind(format!("127.0.0.1:{port}")) + .await + .unwrap(); + + if self.is_ldns && self.verbose { + writeln!(env.stdout(), "Listening on port {port}"); + } + + let udp_srv = DgramServer::new(sock, VecBufSource, svc.clone()); + tokio::spawn(async move { udp_srv.run().await }); + + let tcp_srv = StreamServer::new(listener, VecBufSource, svc); + tokio::spawn(async move { tcp_srv.run().await }); + + while let Some(msg) = rx.recv().await { + if self.is_ldns && self.verbose { + writeln!(env.stdout(), "{}", msg); + } + } + + Ok(()) + } +} + +fn answer_from_datafile( + req: Request>, + (stelline, tx): (Arc, Sender), +) -> ServiceResult { + let step_value = CurrStepValue::new(); + let tx = tx.clone(); + let (res, msg) = match do_server(&req, &stelline, &step_value) { + Some((builder, (range_idx, entry_idx))) => { + let q = req.message().first_question(); + let hdr = builder.header(); + let hdr_opcode = hdr.opcode(); + let hdr_rcode = hdr.rcode(); + let hdr_flags = hdr.flags(); + (Ok(CallResult::new(builder)), + format!( + "comparepkt: match! at range {range_idx} entry {entry_idx} for question {q:?} with reply header {hdr_opcode}/{hdr_rcode}/{hdr_flags}", + )) + } + None => ( + Err(ServiceError::Refused), + format!( + "comparepkt: no match to question {:?}", + req.message().first_question() + ), + ), + }; + tokio::spawn(async move { + tx.send(msg).await.unwrap(); + }); + res +} + +// Hacky work around for the fact that StreamTarget::default() calls +// Target::default() which in our case creates an empty Vec, which will then +// cause a panic when the Stelline code creates an empty target via the +// Default trait thereby invoking truncate() which attempts to truncate to len +// + 2, but truncation of len + 2 is a NO-OP and then the Vec doesn't have the +// expected two leading TCP stream length header bytes and an out of bounds +// access occurs. +#[derive(Clone)] +struct AtLeastTwoBytesVec(Vec); + +impl Default for AtLeastTwoBytesVec { + fn default() -> Self { + Self(vec![0, 0]) + } +} + +impl std::ops::Deref for AtLeastTwoBytesVec { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for AtLeastTwoBytesVec { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Composer for AtLeastTwoBytesVec {} + +impl Truncate for AtLeastTwoBytesVec { + fn truncate(&mut self, len: usize) { + self.0.truncate(len); + } +} + +impl AsMut<[u8]> for AtLeastTwoBytesVec { + fn as_mut(&mut self) -> &mut [u8] { + self.0.as_mut() + } +} + +impl AsRef<[u8]> for AtLeastTwoBytesVec { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl OctetsBuilder for AtLeastTwoBytesVec { + type AppendError = as OctetsBuilder>::AppendError; + + fn append_slice(&mut self, slice: &[u8]) -> Result<(), Self::AppendError> { + self.0.append_slice(slice) + } +} diff --git a/src/lib.rs b/src/lib.rs index c8b435f2..e95ac7ef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ use env::Env; use error::Error; pub use self::args::Args; +use commands::testns::TestNs; pub mod args; pub mod commands; @@ -39,6 +40,7 @@ pub fn try_ldns_compatibility>( "notify" => Notify::parse_ldns_args(args_iter), "keygen" => Keygen::parse_ldns_args(args_iter), "nsec3-hash" => Nsec3Hash::parse_ldns_args(args_iter), + "testns" => TestNs::parse_ldns_args(args_iter), "update" => Update::parse_ldns_args(args_iter), _ => return Err(format!("Unrecognized ldns command 'ldns-{binary_name}'").into()), };