#![cfg(feature = "net")] use std::collections::VecDeque; use std::fs::File; use std::future::Future; use std::net::IpAddr; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use octseq::Octets; use rstest::rstest; use tracing::instrument; use tracing::{trace, warn}; use domain::base::iana::Rcode; use domain::base::name::{Name, ToName}; use domain::base::wire::Composer; use domain::net::client::{dgram, stream}; use domain::net::server::buf::VecBufSource; use domain::net::server::dgram::DgramServer; use domain::net::server::message::Request; use domain::net::server::middleware::builder::MiddlewareBuilder; #[cfg(feature = "siphasher")] use domain::net::server::middleware::processors::cookies::CookiesMiddlewareProcessor; use domain::net::server::middleware::processors::edns::EdnsMiddlewareProcessor; use domain::net::server::service::{ CallResult, Service, ServiceError, Transaction, }; use domain::net::server::stream::StreamServer; use domain::net::server::util::{mk_builder_for_target, service_fn}; use domain::utils::base16; use domain::zonefile::inplace::{Entry, ScannedRecord, Zonefile}; use domain::stelline::channel::ClientServerChannel; use domain::stelline::client::do_client; use domain::stelline::client::ClientFactory; use domain::stelline::client::{ CurrStepValue, PerClientAddressClientFactory, QueryTailoredClientFactory, }; use domain::stelline::parse_stelline; use domain::stelline::parse_stelline::parse_file; use domain::stelline::parse_stelline::Config; use domain::stelline::parse_stelline::Matches; //----------- Tests ---------------------------------------------------------- /// Stelline test cases for which the .rpl file defines a server: config block. /// /// Note: Adding or removing .rpl files on disk won't be detected until the /// test is re-compiled. // #[cfg(feature = "mock-time")] # Needed for the cookies test but that is // currently disabled by renaming it to .rpl.not. #[instrument(skip_all, fields(rpl = rpl_file.file_name().unwrap().to_str()))] #[rstest] #[tokio::test(start_paused = true)] async fn server_tests(#[files("test-data/server/*.rpl")] rpl_file: PathBuf) { // Load the test .rpl file that determines which queries will be sent // and which responses will be expected, and how the server that // answers them should be configured. let file = File::open(&rpl_file).unwrap(); let stelline = parse_file(&file, rpl_file.to_str().unwrap()); let server_config = parse_server_config(&stelline.config); // Create a service to answer queries received by the DNS servers. let zonefile = server_config.zonefile.clone(); let service: Arc<_> = service_fn(test_service, zonefile).into(); // Create dgram and stream servers for answering requests let (dgram_srv, dgram_conn, stream_srv, stream_conn) = mk_servers(service, &server_config); // Create a client factory for sending requests let client_factory = mk_client_factory(dgram_conn, stream_conn); // Run the Stelline test! let step_value = Arc::new(CurrStepValue::new()); do_client(&stelline, &step_value, client_factory).await; // Await shutdown if !dgram_srv.await_shutdown(Duration::from_secs(5)).await { warn!("Datagram server did not shutdown on time."); } if !stream_srv.await_shutdown(Duration::from_secs(5)).await { warn!("Stream server did not shutdown on time."); } } //----------- test helpers --------------------------------------------------- #[allow(clippy::type_complexity)] fn mk_servers( service: Arc, server_config: &ServerConfig, ) -> ( Arc>>, ClientServerChannel, Arc>>, ClientServerChannel, ) where Svc: Service + Send + Sync + 'static, Svc::Future: Send, Svc::Target: Composer + Default + Send + Sync, { // Prepare middleware to be used by the DNS servers to pre-process // received requests and post-process created responses. let (dgram_config, stream_config) = mk_server_configs(server_config); // Create a dgram server for handling UDP requests. let dgram_server_conn = ClientServerChannel::new_dgram(); let dgram_server = DgramServer::with_config( dgram_server_conn.clone(), VecBufSource, service.clone(), dgram_config, ); let dgram_server = Arc::new(dgram_server); let cloned_dgram_server = dgram_server.clone(); tokio::spawn(async move { cloned_dgram_server.run().await }); // Create a stream server for handling TCP requests, i.e. Stelline queries // with "MATCH TCP". let stream_server_conn = ClientServerChannel::new_stream(); let stream_server = StreamServer::with_config( stream_server_conn.clone(), VecBufSource, service, stream_config, ); let stream_server = Arc::new(stream_server); let cloned_stream_server = stream_server.clone(); tokio::spawn(async move { cloned_stream_server.run().await }); ( dgram_server, dgram_server_conn, stream_server, stream_server_conn, ) } fn mk_client_factory( dgram_server_conn: ClientServerChannel, stream_server_conn: ClientServerChannel, ) -> impl ClientFactory { // Create a TCP client factory that only creates a client if (a) no // existing TCP client exists for the source address of the Stelline query, // and (b) if the query specifies "MATCHES TCP". Clients created by this // factory connect to the TCP server created above. let only_for_tcp_queries = |entry: &parse_stelline::Entry| { matches!(entry.matches, Some(Matches { tcp: true, .. })) }; let tcp_client_factory = PerClientAddressClientFactory::new( move |_source_addr| { let stream = stream_server_conn.connect(); let (conn, transport) = stream::Connection::new(stream); tokio::spawn(transport.run()); Box::new(conn) }, only_for_tcp_queries, ); // Create a UDP client factory that only creates a client if (a) no // existing UDP client exists for the source address of the Stelline query. let for_all_other_queries = |_: &_| true; let udp_client_factory = PerClientAddressClientFactory::new( move |_| Box::new(dgram::Connection::new(dgram_server_conn.clone())), for_all_other_queries, ); // Create a combined client factory that will allow the Stelline runner to // use existing or create new client connections as appropriate for the // Stelline query being evaluated. QueryTailoredClientFactory::new(vec![ Box::new(tcp_client_factory), Box::new(udp_client_factory), ]) } fn mk_server_configs( config: &ServerConfig, ) -> ( domain::net::server::dgram::Config, domain::net::server::stream::Config, ) where RequestOctets: Octets, Target: Composer + Default, { let mut middleware = MiddlewareBuilder::minimal(); if config.cookies.enabled { #[cfg(feature = "siphasher")] if let Some(secret) = config.cookies.secret { let secret = base16::decode_vec(secret).unwrap(); let secret = <[u8; 16]>::try_from(secret).unwrap(); let processor = CookiesMiddlewareProcessor::new(secret); let processor = processor .with_denied_ips(config.cookies.ip_deny_list.clone()); middleware.push(processor.into()); } #[cfg(not(feature = "siphasher"))] panic!("The test uses cookies but the required 'siphasher' feature is not enabled."); } if config.edns_tcp_keepalive { let processor = EdnsMiddlewareProcessor::new(); middleware.push(processor.into()); } let middleware = middleware.build(); let mut dgram_config = domain::net::server::dgram::Config::default(); dgram_config.set_middleware_chain(middleware.clone()); let mut stream_config = domain::net::server::stream::Config::default(); if let Some(idle_timeout) = config.idle_timeout { let mut connection_config = domain::net::server::ConnectionConfig::default(); connection_config.set_idle_timeout(idle_timeout); connection_config.set_middleware_chain(middleware); stream_config.set_connection_config(connection_config); } (dgram_config, stream_config) } // A test `Service` impl. // // This function can be used with `service_fn()` to create a `Service` // instance designed to respond to test queries. // // The functionality provided is the mininum common set of behaviour needed // by the tests that use it. // // It's behaviour should be influenced to match the conditions under test by: // - Using different `MiddlewareChain` setups with the server(s) to which // the `Service` will be passed. // - Controlling the content of the `Zonefile` passed to instances of // this `Service` impl. #[allow(clippy::type_complexity)] fn test_service( request: Request>, zonefile: Zonefile, ) -> Result< Transaction< Vec, impl Future>, ServiceError>> + Send, >, ServiceError, > { fn as_record_and_dname( r: ScannedRecord, ) -> Option<(ScannedRecord, Name>)> { let dname = r.owner().to_name(); Some((r, dname)) } fn as_records( e: Result, ) -> Option { match e { Ok(Entry::Record(r)) => Some(r), Ok(_) => None, Err(err) => panic!( "Error while extracting records from the zonefile: {err}" ), } } trace!("Service received request"); Ok(Transaction::single(async move { trace!("Service is constructing a single response"); // If given a single question: let answer = request .message() .sole_question() .ok() .and_then(|q| { // Walk the zone to find the queried name zonefile .clone() .filter_map(as_records) .filter_map(as_record_and_dname) .find(|(_record, dname)| dname == q.qname()) }) .map_or_else( || { // The Qname was not found in the zone: mk_builder_for_target() .start_answer(request.message(), Rcode::NXDOMAIN) .unwrap() }, |(record, _)| { // Respond with the found record: let mut answer = mk_builder_for_target() .start_answer(request.message(), Rcode::NOERROR) .unwrap(); // As we serve all answers from our own zones we are the // authority for the domain in question. answer.header_mut().set_aa(true); answer.push(record).unwrap(); answer }, ); Ok(CallResult::new(answer.additional())) })) } //----------- Stelline config block parsing ----------------------------------- #[derive(Default)] struct ServerConfig<'a> { cookies: CookieConfig<'a>, edns_tcp_keepalive: bool, idle_timeout: Option, zonefile: Zonefile, } #[derive(Default)] struct CookieConfig<'a> { enabled: bool, secret: Option<&'a str>, ip_deny_list: Vec, } fn parse_server_config(config: &Config) -> ServerConfig { let mut parsed_config = ServerConfig::default(); let mut zone_file_bytes = VecDeque::::new(); let mut in_server_block = false; for line in config.lines() { if line.starts_with("server:") { in_server_block = true; } else if in_server_block { if !line.starts_with(|c: char| c.is_whitespace()) { in_server_block = false; } else if let Some((setting, value)) = line.trim().split_once(':') { // Trim off whitespace and trailing comments. let setting = setting.trim(); let value = value .split_once('#') .map_or(value, |(value, _rest)| value) .trim(); match (setting, value) { ("answer-cookie", "yes") => { parsed_config.cookies.enabled = true } ("cookie-secret", v) => { parsed_config.cookies.secret = Some(v.trim_matches('"')); } ("access-control", v) => { // TODO: Strictly speaking the "ip" is a netblock // "given as an IPv4 or IPv6 address /size appended // for a classless network block", but we only handle // an IP address here for now. // See: https://unbound.docs.nlnetlabs.nl/en/latest/manpages/unbound.conf.html?highlight=edns-tcp-keepalive#unbound-conf-access-control if let Some((ip, action)) = v.split_once(|c: char| c.is_whitespace()) { match action { "allow_cookie" => { if let Ok(ip) = ip.parse() { parsed_config .cookies .ip_deny_list .push(ip); } else { eprintln!("Ignoring malformed IP address '{ip}' in 'access-control' setting"); } } _ => { eprintln!("Ignoring unknown action '{action}' for 'access-control' setting"); } } } } ("local-data", v) => { if !zone_file_bytes.is_empty() { zone_file_bytes.push_back(b'\n'); } zone_file_bytes .extend(v.trim_matches('"').as_bytes().iter()); zone_file_bytes.push_back(b'\n'); } ("edns-tcp-keepalive", "yes") => { parsed_config.edns_tcp_keepalive = true; } ("edns-tcp-keepalive-timeout", v) => { if parsed_config.edns_tcp_keepalive { parsed_config.idle_timeout = Some( Duration::from_millis(v.parse().unwrap()), ); } } _ => { eprintln!("Ignoring unknown server setting '{setting}' with value: {value}"); } } } } } if !zone_file_bytes.is_empty() { parsed_config.zonefile = Zonefile::load(&mut zone_file_bytes).unwrap(); } parsed_config }