diff --git a/doc/manual/build/man/dnst-key2ds.1 b/doc/manual/build/man/dnst-key2ds.1 index 595a25b..cee7acd 100644 --- a/doc/manual/build/man/dnst-key2ds.1 +++ b/doc/manual/build/man/dnst-key2ds.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "DNST-KEY2DS" "1" "Apr 07, 2026" "0.2.0-alpha1" "dnst" +.TH "DNST-KEY2DS" "1" "Apr 09, 2026" "0.2.0-alpha1" "dnst" .SH NAME dnst-key2ds \- Generate DS RRs from the DNSKEYs in a keyfile .SH SYNOPSIS diff --git a/doc/manual/build/man/dnst-keygen.1 b/doc/manual/build/man/dnst-keygen.1 index 8884ec4..53b8ac0 100644 --- a/doc/manual/build/man/dnst-keygen.1 +++ b/doc/manual/build/man/dnst-keygen.1 @@ -28,7 +28,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "DNST-KEYGEN" "1" "Apr 07, 2026" "0.2.0-alpha1" "dnst" +.TH "DNST-KEYGEN" "1" "Apr 09, 2026" "0.2.0-alpha1" "dnst" .SH NAME dnst-keygen \- Generate a new key pair for a domain name .SH SYNOPSIS diff --git a/doc/manual/build/man/dnst-keyset.1 b/doc/manual/build/man/dnst-keyset.1 index 512a0e0..179f13c 100644 --- a/doc/manual/build/man/dnst-keyset.1 +++ b/doc/manual/build/man/dnst-keyset.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "DNST-KEYSET" "1" "Apr 07, 2026" "0.2.0-alpha1" "dnst" +.TH "DNST-KEYSET" "1" "Apr 09, 2026" "0.2.0-alpha1" "dnst" .SH NAME dnst-keyset \- Manage DNSSEC signing keys for a domain .SH SYNOPSIS @@ -214,8 +214,9 @@ For the \fBReportDnskeyPropagated\fP and \fBReportDsPropagated\fP actions, each the queried to see if the DNSKEY RRset or DS RRset match the KSKs. The \fBReportRrsigPropagated\fP action is more complex. -First the entire zone is transferred from the primary nameserver listed in the -SOA record. +First the entire zone is transferred from the nameservers specified via +\fBset publication\-nameservers\fP, or if not set form the primary nameserver +listed in the SOA record. Then all relevant signatures are checked if they have the expected key tags. The maximum TTL in the zone is recorded to be reported. Finally, all addresses of listed nameservers are checked to see if they @@ -804,6 +805,63 @@ to be updated. This command can, for example, alert the operator or use an API provided by the parent zone to update the DS records automatically. .IP \(bu 2 +tsig\-store\-path +.sp +Set the path to a TSIG key store file to use. +.sp +Keys defined in the store file must use one of the following algorithms: +.INDENT 2.0 +.INDENT 3.5 +.INDENT 0.0 +.IP \(bu 2 +hmac\-sha1 +.IP \(bu 2 +hmac\-sha256 +.IP \(bu 2 +hmac\-sha384 +.IP \(bu 2 +hmac\-sha512 +.UNINDENT +.UNINDENT +.UNINDENT +.sp +Currently there is no way to create this file using \fBdnst keyset\fP\&. +The file is in JSON format and defines zero or more TSIG keys as +entries in a map. The example below defines a single TSIG key with name +\fBtsig\-zonedata\-ch\-public\-21\-03\fP using algorithm \fBhmac\-sha512\fP with a +base64 encoded secret. +.INDENT 2.0 +.INDENT 3.5 +.sp +.EX +{ + \(dqversion\(dq: \(dqv1\(dq, + \(dqmap\(dq: { + \(dqtsig\-zonedata\-ch\-public\-21\-01\(dq: { + \(dqalg\(dq: \(dqhmac\-sha512\(dq, + \(dqdata\(dq: \(dqstZw...iJ3Q==\(dq + } + } +} +.EE +.UNINDENT +.UNINDENT +.IP \(bu 2 +publication\-nameservers +.sp +Set the nameservers to transfer from when checking a zone. +.sp +If no nameserver values are specified the default behaviour of querying +the primary nameserver defined in the SOA record will be used. +.sp +Nameservers should be specified as space separated +arguments, each nameserver being one argument in the form: +.INDENT 2.0 +.INDENT 3.5 +:[^] +.UNINDENT +.UNINDENT +.IP \(bu 2 fake\-time .sp Set the \(aqwall clock\(aq time to be used for testing. diff --git a/doc/manual/build/man/dnst-notify.1 b/doc/manual/build/man/dnst-notify.1 index 0d6b8b1..e19ff75 100644 --- a/doc/manual/build/man/dnst-notify.1 +++ b/doc/manual/build/man/dnst-notify.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "DNST-NOTIFY" "1" "Apr 07, 2026" "0.2.0-alpha1" "dnst" +.TH "DNST-NOTIFY" "1" "Apr 09, 2026" "0.2.0-alpha1" "dnst" .SH NAME dnst-notify \- Send a NOTIFY message to a list of name servers .SH SYNOPSIS diff --git a/doc/manual/build/man/dnst-nsec3-hash.1 b/doc/manual/build/man/dnst-nsec3-hash.1 index d7f4afb..986dabb 100644 --- a/doc/manual/build/man/dnst-nsec3-hash.1 +++ b/doc/manual/build/man/dnst-nsec3-hash.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "DNST-NSEC3-HASH" "1" "Apr 07, 2026" "0.2.0-alpha1" "dnst" +.TH "DNST-NSEC3-HASH" "1" "Apr 09, 2026" "0.2.0-alpha1" "dnst" .SH NAME dnst-nsec3-hash \- Print out the NSEC3 hash of a domain name .SH SYNOPSIS diff --git a/doc/manual/build/man/dnst-signzone.1 b/doc/manual/build/man/dnst-signzone.1 index 5bcb721..6ce30bd 100644 --- a/doc/manual/build/man/dnst-signzone.1 +++ b/doc/manual/build/man/dnst-signzone.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "DNST-SIGNZONE" "1" "Apr 07, 2026" "0.2.0-alpha1" "dnst" +.TH "DNST-SIGNZONE" "1" "Apr 09, 2026" "0.2.0-alpha1" "dnst" .SH NAME dnst-signzone \- Sign the zone with the given key(s) .SH SYNOPSIS diff --git a/doc/manual/build/man/dnst-update.1 b/doc/manual/build/man/dnst-update.1 index adf3318..b4014e5 100644 --- a/doc/manual/build/man/dnst-update.1 +++ b/doc/manual/build/man/dnst-update.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "DNST-UPDATE" "1" "Apr 07, 2026" "0.2.0-alpha1" "dnst" +.TH "DNST-UPDATE" "1" "Apr 09, 2026" "0.2.0-alpha1" "dnst" .SH NAME dnst-update \- Send a dynamic update packet to update an IP (or delete all existing IPs) for a domain name .SH SYNOPSIS diff --git a/doc/manual/build/man/dnst.1 b/doc/manual/build/man/dnst.1 index 776f1c3..a1299bb 100644 --- a/doc/manual/build/man/dnst.1 +++ b/doc/manual/build/man/dnst.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "DNST" "1" "Apr 07, 2026" "0.2.0-alpha1" "dnst" +.TH "DNST" "1" "Apr 09, 2026" "0.2.0-alpha1" "dnst" .SH NAME dnst \- DNS Management Tools .SH SYNOPSIS diff --git a/doc/manual/build/man/ldns-key2ds.1 b/doc/manual/build/man/ldns-key2ds.1 index 64291bf..7473fa9 100644 --- a/doc/manual/build/man/ldns-key2ds.1 +++ b/doc/manual/build/man/ldns-key2ds.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "LDNS-KEY2DS" "1" "Apr 07, 2026" "0.2.0-alpha1" "dnst" +.TH "LDNS-KEY2DS" "1" "Apr 09, 2026" "0.2.0-alpha1" "dnst" .SH NAME ldns-key2ds \- Generate DS RRs from the DNSKEYs in a keyfile .SH SYNOPSIS diff --git a/doc/manual/build/man/ldns-keygen.1 b/doc/manual/build/man/ldns-keygen.1 index 73a41a0..4bf2a93 100644 --- a/doc/manual/build/man/ldns-keygen.1 +++ b/doc/manual/build/man/ldns-keygen.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "LDNS-KEYGEN" "1" "Apr 07, 2026" "0.2.0-alpha1" "dnst" +.TH "LDNS-KEYGEN" "1" "Apr 09, 2026" "0.2.0-alpha1" "dnst" .SH NAME ldns-keygen \- Generate a new key pair for a domain name .SH SYNOPSIS diff --git a/doc/manual/build/man/ldns-notify.1 b/doc/manual/build/man/ldns-notify.1 index 350fb0d..4f9ca4e 100644 --- a/doc/manual/build/man/ldns-notify.1 +++ b/doc/manual/build/man/ldns-notify.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "LDNS-NOTIFY" "1" "Apr 07, 2026" "0.2.0-alpha1" "dnst" +.TH "LDNS-NOTIFY" "1" "Apr 09, 2026" "0.2.0-alpha1" "dnst" .SH NAME ldns-notify \- Send a NOTIFY message to a list of name servers .SH SYNOPSIS diff --git a/doc/manual/build/man/ldns-nsec3-hash.1 b/doc/manual/build/man/ldns-nsec3-hash.1 index b855555..4d2cf0a 100644 --- a/doc/manual/build/man/ldns-nsec3-hash.1 +++ b/doc/manual/build/man/ldns-nsec3-hash.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "LDNS-NSEC3-HASH" "1" "Apr 07, 2026" "0.2.0-alpha1" "dnst" +.TH "LDNS-NSEC3-HASH" "1" "Apr 09, 2026" "0.2.0-alpha1" "dnst" .SH NAME ldns-nsec3-hash \- Print out the NSEC3 hash of a domain name .SH SYNOPSIS diff --git a/doc/manual/build/man/ldns-signzone.1 b/doc/manual/build/man/ldns-signzone.1 index a84fb16..7158359 100644 --- a/doc/manual/build/man/ldns-signzone.1 +++ b/doc/manual/build/man/ldns-signzone.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "LDNS-SIGNZONE" "1" "Apr 07, 2026" "0.2.0-alpha1" "dnst" +.TH "LDNS-SIGNZONE" "1" "Apr 09, 2026" "0.2.0-alpha1" "dnst" .SH NAME ldns-signzone \- Sign the zone with the given key(s) .SH SYNOPSIS diff --git a/doc/manual/build/man/ldns-update.1 b/doc/manual/build/man/ldns-update.1 index 3f745b0..f88eb19 100644 --- a/doc/manual/build/man/ldns-update.1 +++ b/doc/manual/build/man/ldns-update.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "LDNS-UPDATE" "1" "Apr 07, 2026" "0.2.0-alpha1" "dnst" +.TH "LDNS-UPDATE" "1" "Apr 09, 2026" "0.2.0-alpha1" "dnst" .SH NAME ldns-update \- Send a dynamic update packet to update an IP (or delete all existing IPs) for a domain name .SH SYNOPSIS diff --git a/doc/manual/source/man/dnst-keyset.rst b/doc/manual/source/man/dnst-keyset.rst index 413475e..aaa1774 100644 --- a/doc/manual/source/man/dnst-keyset.rst +++ b/doc/manual/source/man/dnst-keyset.rst @@ -198,8 +198,9 @@ For the ``ReportDnskeyPropagated`` and ``ReportDsPropagated`` actions, each addr the queried to see if the DNSKEY RRset or DS RRset match the KSKs. The ``ReportRrsigPropagated`` action is more complex. -First the entire zone is transferred from the primary nameserver listed in the -SOA record. +First the entire zone is transferred from the nameservers specified via +``set publication-nameservers``, or if not set form the primary nameserver +listed in the SOA record. Then all relevant signatures are checked if they have the expected key tags. The maximum TTL in the zone is recorded to be reported. Finally, all addresses of listed nameservers are checked to see if they @@ -784,6 +785,47 @@ The keyset subcommand provides the following commands: This command can, for example, alert the operator or use an API provided by the parent zone to update the DS records automatically. + * tsig-store-path + + Set the path to a TSIG key store file to use. + + Keys defined in the store file must use one of the following algorithms: + + - hmac-sha1 + - hmac-sha256 + - hmac-sha384 + - hmac-sha512 + + Currently there is no way to create this file using ``dnst keyset``. + The file is in JSON format and defines zero or more TSIG keys as + entries in a map. The example below defines a single TSIG key with name + ``tsig-zonedata-ch-public-21-03`` using algorithm ``hmac-sha512`` with a + base64 encoded secret. + + .. code-block:: json + + { + "version": "v1", + "map": { + "tsig-zonedata-ch-public-21-01": { + "alg": "hmac-sha512", + "data": "stZw...iJ3Q==" + } + } + } + + * publication-nameservers + + Set the nameservers to transfer from when checking a zone. + + If no nameserver values are specified the default behaviour of querying + the primary nameserver defined in the SOA record will be used. + + Nameservers should be specified as space separated + arguments, each nameserver being one argument in the form: + + :[^] + * fake-time Set the 'wall clock' time to be used for testing. diff --git a/src/commands/keyset/cmd.rs b/src/commands/keyset/cmd.rs index 0d77363..e72702a 100644 --- a/src/commands/keyset/cmd.rs +++ b/src/commands/keyset/cmd.rs @@ -2,6 +2,7 @@ #![warn(missing_docs)] #![warn(clippy::missing_docs_in_private_items)] +use crate::commands::keyset::tsig::{TsigKeyName, TsigKeyStore}; use crate::env::Env; use crate::error::Error; use crate::util; @@ -31,6 +32,8 @@ use domain::net::client::request::{ ComposeRequest, RequestMessage, RequestMessageMulti, SendRequest, SendRequestMulti, }; use domain::net::client::stream; +use domain::net::client::tsig::Connection as TsigConnection; +use domain::net::client::tsig::RequestMessage as TsigRequestMessage; use domain::rdata::dnssec::Timestamp; use domain::rdata::{AllRecordData, Cdnskey, Cds, Dnskey, Ds, Rrsig, Soa, ZoneRecordData}; use domain::resolv::lookup::lookup_host; @@ -60,7 +63,7 @@ use std::net::{IpAddr, SocketAddr}; use std::path::{absolute, Path, PathBuf}; use std::process::Command; use std::str::FromStr; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use std::time::{Duration, UNIX_EPOCH}; use tokio::net::TcpStream; #[cfg(feature = "kmip")] @@ -168,6 +171,10 @@ type OptDuration = Option; /// treats Option special. type OptUnixTime = Option; +/// Type for an optional path name. A separate type is needed because CLAP +/// treats Option special. +type OptPathBuf = Option; + /// The subcommands of the keyset utility. #[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, Subcommand)] @@ -303,6 +310,7 @@ enum GetCommands { /// The fields that can be changed with a set command. #[derive(Clone, Debug, Subcommand)] +#[allow(clippy::large_enum_variant)] enum SetCommands { /// Set the use_csk config variable. UseCsk { @@ -494,6 +502,26 @@ enum SetCommands { args: Vec, }, + /// Set the location of the TSIG store to use to retrieve TSIG secrets + /// when needed. + TsigStorePath { + /// The path to the TSIG store file. + #[arg(value_parser = parse_opt_pathbuf)] + opt_path: OptPathBuf, + }, + + /// Specify a nameserver to request XFR from. If not specified the + /// SOA MNAME nameserver will be used. + PublicationNameservers { + /// The address and port number of the nameserver. + /// Optionally followed by the TSIG key name to use. The TSIG key + /// name is preceded by a caret (^) character. + /// + /// TsigStorePath must also have been provided and the specified + /// store must contain a key by this name. + addrs: Vec, + }, + /// Set the fake time to use when signing and other time related /// operations. FakeTime { @@ -673,6 +701,9 @@ struct WorkSpace { #[cfg(feature = "kmip")] /// The current set of KMIP server pools. pools: HashMap, + + /// A store of TSIG keys indexed by key name. + tsig_store: TsigKeyStore, } impl Keyset { @@ -739,6 +770,8 @@ impl Keyset { autoremove_delay: DEFAULT_AUTOREMOVE_DELAY, update_ds_command: Vec::new(), faketime: None, + tsig_store_path: None, + nameservers: HashSet::new(), }; // Create the parent directies. @@ -764,6 +797,7 @@ impl Keyset { _locked_config_file: None, #[cfg(feature = "kmip")] pools: HashMap::new(), + tsig_store: TsigKeyStore::new(), }; ws.write_state()?; @@ -785,6 +819,15 @@ impl Keyset { let kss: KeySetState = serde_json::from_reader(file) .map_err(|e| format!("error loading {:?}: {e}\n", ksc.state_file))?; + let tsig_store = if let Some(path) = &ksc.tsig_store_path { + let store_file = file_with_write_lock(path)?; + let store: TsigKeyStore = serde_json::from_reader(&store_file) + .map_err(|e| format!("error loading {}: {e}\n", path.display()))?; + store + } else { + TsigKeyStore::new() + }; + let mut ws = WorkSpace { config: ksc, state: kss, @@ -794,6 +837,7 @@ impl Keyset { _locked_config_file: Some(config_file), #[cfg(feature = "kmip")] pools: HashMap::new(), + tsig_store, }; let now = ws.faketime_or_now(); @@ -1729,6 +1773,14 @@ struct KeySetConfig { /// /// This is needed for integration tests. faketime: Option, + + /// Path to TSIG secret store to lookup TSIG secrets when needed. + tsig_store_path: Option, + + /// Optional nameservers to request XFR from instead of the SOA MNAME + /// defined nameserver. + #[serde(default)] + nameservers: HashSet, } /// Configuration for key roll automation. @@ -1816,6 +1868,56 @@ impl Display for ZskRollType { } } +/// Details needed to connect to a nameserver. +#[derive(Debug, Deserialize, Serialize, Hash, PartialEq, Eq)] +pub struct NameserverConnectionDetails { + /// The address and port number at which this nameserver accepts DNS + /// requests. + pub addr: SocketAddr, + + /// Optional TSIG key to use when communicating with this nameserver. + pub tsig_key_name: Option, +} + +impl From<&IpAddr> for NameserverConnectionDetails { + fn from(ip: &IpAddr) -> Self { + Self { + addr: SocketAddr::new(*ip, 53), + tsig_key_name: None, + } + } +} + +impl TryFrom<&str> for NameserverConnectionDetails { + type Error = Error; + + // Note: this only accepts IP addresses, not hostnames. In addition, + // a port is required, there is no default port. TODO: allow hostnames + // and allow the port to be optional. + fn try_from(s: &str) -> Result { + let mut iter = s.split('^'); + let Some(addr_port) = iter.next() else { + return Err("Address expected".into()); + }; + let addr = addr_port + .parse() + .map_err(|e| format!("unable to parse address {addr_port}: {e}"))?; + + let tsig_key_name = match iter.next() { + Some(name) => Some( + Name::from_str(name) + .map_err(|err| format!("Invalid TSIG key name '{name}': {err}"))?, + ), + None => None, + }; + + Ok(Self { + addr, + tsig_key_name, + }) + } +} + /// Persistent state for the keyset command. #[derive(Deserialize, Serialize)] pub struct KeySetState { @@ -2151,7 +2253,51 @@ impl WorkSpace { SetCommands::UpdateDsCommand { args } => { self.config.update_ds_command = args; } - SetCommands::FakeTime { opt_unixtime } => self.config.faketime = opt_unixtime, + SetCommands::FakeTime { opt_unixtime } => { + self.config.faketime = opt_unixtime; + } + SetCommands::TsigStorePath { opt_path } => { + // TODO: when removing the TSIG store, check that there are + // no publication nameservers that reference the store. + self.config.tsig_store_path = opt_path; + } + SetCommands::PublicationNameservers { addrs } => { + let mut nameservers = HashSet::new(); + + for a in addrs { + // When adding nameservers, check that referenced TSIG + // keys are in the TSIG store. + nameservers.insert(NameserverConnectionDetails::try_from(a.as_str())?); + } + + if nameservers.iter().any(|ns| ns.tsig_key_name.is_some()) { + let Some(key_store_path) = &self.config.tsig_store_path else { + return Err("keyset set tsig-store-path MUST be called first".into()); + }; + + let key_store_file = file_with_write_lock(key_store_path)?; + let key_store: TsigKeyStore = serde_json::from_reader(&key_store_file) + .map_err(|e| { + format!("error loading {}: {e}\n", key_store_path.display()) + })?; + + for tsig_key_name in nameservers + .iter() + .filter_map(|ns| ns.tsig_key_name.as_ref()) + { + // Verify that the key exists in the key store. + if key_store.get(tsig_key_name).is_none() { + return Err(format!( + "No TSIG key with name '{tsig_key_name}' found in store '{}'", + key_store_path.display() + ) + .into()); + } + } + } + + self.config.nameservers = nameservers; + } } self.config_changed = true; Ok(()) @@ -3790,6 +3936,8 @@ impl WorkSpace { report_state, &mut self.state_changed, now.clone(), + &self.config.nameservers, + &self.tsig_store, ) .await { @@ -3858,6 +4006,8 @@ impl WorkSpace { report_state, &mut self.state_changed, now.clone(), + &self.config.nameservers, + &self.tsig_store, ) .await { @@ -4243,6 +4393,16 @@ fn parse_opt_unixtime(value: &str) -> Result, Error> { Ok(Some(unixtime)) } +/// Parse an optional PathBuf from a string but also allow 'off' to signal +/// no PathBuf. +fn parse_opt_pathbuf(value: &str) -> Result, Error> { + if value == "off" { + return Ok(None); + } + let path_buf = PathBuf::from(value); + Ok(Some(path_buf)) +} + /// Check whether signatures need to be renewed. /// /// The input is an RRset plus signatures in zonefile format plus a @@ -4408,6 +4568,8 @@ async fn auto_wait_actions( report_state: &Mutex, state_changed: &mut bool, now: UnixTime, + nameservers: &HashSet, + tsig_store: &TsigKeyStore, ) -> AutoActionsResult { for a in actions { match a { @@ -4602,7 +4764,7 @@ async fn auto_wait_actions( } } - let result = report_rrsig_propagated(state, now.clone()) + let result = report_rrsig_propagated(state, now.clone(), nameservers, tsig_store) .await .unwrap_or_else(|e| { warn!("Check RRSIG propagation failed: {e}"); @@ -4643,6 +4805,8 @@ async fn auto_report_actions( report_state: &Mutex, state_changed: &mut bool, now: UnixTime, + nameservers: &HashSet, + tsig_store: &TsigKeyStore, ) -> AutoReportActionsResult { assert!(!actions.is_empty()); let mut max_ttl = Ttl::from_secs(0); @@ -4843,7 +5007,7 @@ async fn auto_report_actions( } } - let result = report_rrsig_propagated(kss, now.clone()) + let result = report_rrsig_propagated(kss, now.clone(), nameservers, tsig_store) .await .unwrap_or_else(|e| { warn!("Check RRSIG propagation failed: {e}"); @@ -5034,6 +5198,8 @@ async fn report_ds_propagated( async fn report_rrsig_propagated( kss: &KeySetState, now: UnixTime, + nameservers: &HashSet, + tsig_store: &TsigKeyStore, ) -> Result { // This function assume a single signer. Multi-signer is not supported // at all, but any kind of active-passive or active-active setup would also @@ -5043,7 +5209,7 @@ async fn report_rrsig_propagated( // Check the zone. If the zone checks out, make sure that all nameservers // have at least the version of the zone that was checked. - let result = check_zone(kss, now.clone()).await?; + let result = check_zone(kss, now.clone(), nameservers, tsig_store).await?; let (serial, ttl, report_ttl) = match result { // check_zone never returns Report or Wait. AutoReportRrsigResult::Report(_) | AutoReportRrsigResult::Wait(_) => unreachable!(), @@ -5091,7 +5257,12 @@ async fn report_rrsig_propagated( /// a HashSet of type as the value. Check that each name and type has a /// corresponding complete RRSIG set. /// Ignore delegated records -async fn check_zone(kss: &KeySetState, now: UnixTime) -> Result { +async fn check_zone( + kss: &KeySetState, + now: UnixTime, + nameservers: &HashSet, + tsig_store: &TsigKeyStore, +) -> Result { let expected_set = get_expected_zsk_key_tags(kss); let zone = kss.keyset.name(); @@ -5115,19 +5286,60 @@ async fn check_zone(kss: &KeySetState, now: UnixTime) -> Result; + let nameservers = if !nameservers.is_empty() { + nameservers + } else { + mname_nameservers = addresses_for_name(&resolver, mname) + .await? + .iter() + .map(Into::into) + .collect(); + &mname_nameservers + }; - 'addr: for a in &addresses { - let tcp_conn = match TcpStream::connect((*a, 53_u16)).await { + 'addr: for ns in nameservers { + let tcp_conn = match TcpStream::connect(ns.addr).await { Ok(conn) => conn, Err(e) => { - warn!("DNS TCP connection to {a} failed: {e}"); + warn!("DNS TCP connection to {} failed: {e}", ns.addr); continue; } }; - let (tcp, transport) = stream::Connection::>, _>::new(tcp_conn); - tokio::spawn(transport.run()); + // Prepare the named TSIG key for use, if any. + let tsig_key = if let Some(name) = ns.tsig_key_name.as_ref() { + match tsig_store.get(name) { + Some(key) => Some(key), + None => { + warn!("Unknown TSIG key name '{name}'"); + continue; + } + } + } else { + None + }; + + // If we have a TSIG key, setup a TSIG capable transport, otherwise + // use a normal transport. Use Multi types because only those support + // the multiple possible responses that can occur when sending an + // XFR request. + let tcp: Box>>> = if let Some(tsig_key) = + tsig_key + { + let (conn, transport) = stream::Connection::< + TsigRequestMessage>, Arc>, + _, + >::new(tcp_conn); + tokio::spawn(transport.run()); + Box::new(TsigConnection::new(tsig_key, conn)) + } else { + let (conn, transport) = stream::Connection::>, _>::new(tcp_conn); + tokio::spawn(transport.run()); + Box::new(conn) + }; let msg = MessageBuilder::new_vec(); let mut msg = msg.question(); @@ -5135,7 +5347,7 @@ async fn check_zone(kss: &KeySetState, now: UnixTime) -> Result Result reply, Err(e) => { - warn!("reading AXFR response from {a} failed: {e}"); + warn!("reading AXFR response from {} failed: {e}", ns.addr); continue 'addr; } }; @@ -5156,7 +5368,7 @@ async fn check_zone(kss: &KeySetState, now: UnixTime) -> Result Result + +/// Support conversion from domain TSIG algorithm identifiers to our +/// equivalent. +impl From for domain::tsig::Algorithm { + fn from(alg: AlgSpec) -> Self { + match alg { + AlgSpec::HmacSha1 => domain::tsig::Algorithm::Sha1, + AlgSpec::HmacSha256 => domain::tsig::Algorithm::Sha256, + AlgSpec::HmacSha384 => domain::tsig::Algorithm::Sha384, + AlgSpec::HmacSha512 => domain::tsig::Algorithm::Sha512, + } + } +} + +//------------ KeySpec ------------------------------------------------------ + +/// A Casdade (de)serialization compatible TSIG key specification. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct KeySpec { + /// The key algorithm. + pub alg: AlgSpec, + + /// The private key material. + #[serde(with = "tsig_base64")] + pub data: Box<[u8]>, +} + +/// Support for deserializing from base64 to Box<[u8]i> and vice versa. +mod tsig_base64 { + use domain::utils::base64; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + /// Serialize from a byte slice to a base64 encoded string. + pub fn serialize(data: &[u8], serializer: S) -> Result + where + S: Serializer, + { + base64::encode_string(data).serialize(serializer) + } + + /// Deserialize from a base64 encoded string to a boxed byte array. + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let data = base64::decode::>(&s).map_err(serde::de::Error::custom)?; + Ok(data.into()) + } +} + +//------------ TsigKeyName --------------------------------------------------- + +/// A Cascade (de)serialization compatible TSIG key name. +pub type TsigKeyName = Name>; + +//------------ TsigKeyStoreVersion ------------------------------------------- + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub enum TsigKeyStoreVersion { + V1, +} + +//------------ TsigKeyStore -------------------------------------------------- + +/// A Cascade (de)serialization compatible TSIG key store. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct TsigKeyStore { + /// The data format version of the store file. + pub version: TsigKeyStoreVersion, + + /// A mapping of TSIG key names to key details. + pub map: HashMap, +} + +impl TsigKeyStore { + /// Create an empty TSIG key store. + pub fn new() -> Self { + Self { + version: TsigKeyStoreVersion::V1, + map: HashMap::new(), + } + } + + /// Get the TSIG key corresponding to the given key name, if any. + /// + /// Returns Some(key) if the key was found, None otherwise. + pub fn get(&self, name: &TsigKeyName) -> Option { + if let Some(key) = self.map.get(name) { + domain::tsig::Key::new(key.alg.into(), &key.data, name.to_owned(), None, None) + .map(Option::Some) + .unwrap_or_else(|_err| { + unreachable!("domain::tsig::Key::new() can only fail with non-None arguments") + }) + } else { + None + } + } +} + +//--- impl Default + +impl Default for TsigKeyStore { + fn default() -> Self { + Self::new() + } +}