diff --git a/Cargo.toml b/Cargo.toml index 8937f111a..a1ee7d0a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -246,6 +246,7 @@ assets = [ ["doc/manual/build/man/cascade-config.1", "usr/share/man/man1/cascade-config.1", "644"], ["doc/manual/build/man/cascade-health.1", "usr/share/man/man1/cascade-health.1", "644"], ["doc/manual/build/man/cascade-hsm.1", "usr/share/man/man1/cascade-hsm.1", "644"], +["doc/manual/build/man/cascade-tsig.1", "usr/share/man/man1/cascade-tsig.1", "644"], ["doc/manual/build/man/cascade-keyset.1", "usr/share/man/man1/cascade-keyset.1", "644"], ["doc/manual/build/man/cascade-policy.1", "usr/share/man/man1/cascade-policy.1", "644"], ["doc/manual/build/man/cascade-status.1", "usr/share/man/man1/cascade-status.1", "644"], @@ -300,6 +301,7 @@ assets = [ { source = "doc/manual/build/man/cascade-config.1", dest = "/usr/share/man/man1/cascade-config.1", mode = "644", doc = true }, { source = "doc/manual/build/man/cascade-health.1", dest = "/usr/share/man/man1/cascade-health.1", mode = "644", doc = true }, { source = "doc/manual/build/man/cascade-hsm.1", dest = "/usr/share/man/man1/cascade-hsm.1", mode = "644", doc = true }, +{ source = "doc/manual/build/man/cascade-tsig.1", dest = "/usr/share/man/man1/cascade-tsig.1", mode = "644", doc = true }, { source = "doc/manual/build/man/cascade-keyset.1", dest = "/usr/share/man/man1/cascade-keyset.1", mode = "644", doc = true }, { source = "doc/manual/build/man/cascade-policy.1", dest = "/usr/share/man/man1/cascade-policy.1", mode = "644", doc = true }, { source = "doc/manual/build/man/cascade-status.1", dest = "/usr/share/man/man1/cascade-status.1", mode = "644", doc = true }, diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index c4048e68e..1cf17b4a5 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1,5 +1,6 @@ +use std::collections::HashMap; use std::fmt::{self, Display}; -use std::net::{IpAddr, SocketAddr}; +use std::net::SocketAddr; use std::time::{Duration, SystemTime}; use camino::{Utf8Path, Utf8PathBuf}; @@ -9,8 +10,6 @@ pub use domain::base::Serial; pub mod dep; -const DEFAULT_AXFR_PORT: u16 = 53; - //----------- ZoneName --------------------------------------------------------- /// The name of a zone. @@ -187,6 +186,94 @@ pub struct KmipKeyImport { pub flags: String, } +//----------- TsigKeyName ----------------------------------------------------- + +/// The name of a TSIG key. +pub type TsigKeyName = domain::base::Name>; + +//----------- TsigAdd --------------------------------------------------------- + +/// Add a TSIG key to Cascade. +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct TsigAdd { + /// The name of the TSIG key to add. + pub name: TsigKeyName, + + /// The algorithm of the TSIG key. + pub alg: TsigAlgorithm, + + /// The base64 encoded key material bytes. + pub secret: String, +} + +/// The successful result of adding a TSIG key to Cascade. +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct TsigAddResult; + +/// An error result indicating why an attempt to add a TSIG key to Cascade +/// failed. +#[derive(Deserialize, Serialize, Debug, Clone)] +pub enum TsigAddError { + /// A TSIG key by the given name already exists in Cascade. + AlreadyExists, + + /// The provided TSIG key secret was not correctly base64 encoded. + InvalidBase64Secret, +} + +impl Display for TsigAddError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TsigAddError::AlreadyExists => write!(f, "TSIG key already exists"), + TsigAddError::InvalidBase64Secret => write!(f, "invalid TSIG base64 encoded secret"), + } + } +} + +//------------ TsigRemove ---------------------------------------------------- + +/// The successful result of removing a TSIG key from Cascade. +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct TsigRemoveResult; + +/// An error result indicating why an attempt to remove a TSIG key from +/// Cascade failed. +#[derive(Deserialize, Serialize, Debug, Clone)] +pub enum TsigRemoveError { + /// The specified TSIG key name was not found in Cascade. + NotFound, + + /// The specified TSIG key cannot be removed as it is in use. + InUse, +} + +impl fmt::Display for TsigRemoveError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + TsigRemoveError::NotFound => "no such TSIG key was found", + TsigRemoveError::InUse => "the TSIG key cannot be removed as it is in use", + }) + } +} + +//------------ TsigListResult ------------------------------------------------ + +/// The successful result of listing TSIG Cascade keys known to Cascade. +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct TsigListResult { + /// The set of TSIG keys known to Cascade plus information about each key. + pub tsig_keys: HashMap, +} + +/// Information about a single listed TSIG key. +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct TsigKeyInfo { + /// The set of zones with which this TSIG key is used. + pub zones: Vec, +} + +//----------- ZoneAdd -------------------------------------------------------- + #[derive(Deserialize, Serialize, Debug, Clone)] pub struct ZoneAdd { pub name: ZoneName, @@ -206,6 +293,7 @@ pub enum ZoneAddError { AlreadyExists, NoSuchPolicy, PolicyMidDeletion, + NoSuchTsigKey, Other(String), } @@ -215,6 +303,7 @@ impl fmt::Display for ZoneAddError { Self::AlreadyExists => "a zone of this name already exists", Self::NoSuchPolicy => "no policy with that name exists", Self::PolicyMidDeletion => "the specified policy is being deleted", + Self::NoSuchTsigKey => "no TSIG key with that name exists", Self::Other(reason) => reason, }) } @@ -240,6 +329,10 @@ impl fmt::Display for ZoneRemoveError { /// How to load the contents of a zone. #[derive(Deserialize, Serialize, Debug, Clone)] +// Allow the large enum variant caused by TsigKeyName using Name> +// to avoid the conversions that would be needed if Name were to be +// used instead. +#[allow(clippy::large_enum_variant)] pub enum ZoneSource { /// Don't load the zone at all. None, @@ -256,10 +349,7 @@ pub enum ZoneSource { addr: SocketAddr, /// The name of a TSIG key, if any. - tsig_key: Option, - - /// The XFR status of the zone. - xfr_status: ZoneRefreshStatus, + tsig_key: Option, }, } @@ -290,28 +380,6 @@ impl Display for ZoneSource { } } -impl From<&str> for ZoneSource { - fn from(s: &str) -> Self { - if let Ok(addr) = s.parse::() { - ZoneSource::Server { - addr, - tsig_key: None, - xfr_status: Default::default(), - } - } else if let Ok(addr) = s.parse::() { - ZoneSource::Server { - addr: SocketAddr::new(addr, DEFAULT_AXFR_PORT), - tsig_key: None, - xfr_status: Default::default(), - } - } else { - ZoneSource::Zonefile { - path: Utf8PathBuf::from(s).into_boxed_path(), - } - } - } -} - #[derive(Deserialize, Serialize, Debug, Clone)] pub struct ZonesListResult { pub zones: Vec, @@ -503,6 +571,26 @@ impl Display for KeyType { } } +#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub enum TsigAlgorithm { + HmacSha1, + HmacSha256, + HmacSha384, + HmacSha512, +} + +impl Display for TsigAlgorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TsigAlgorithm::HmacSha1 => "hmac-sha1", + TsigAlgorithm::HmacSha256 => "hmac-sha256", + TsigAlgorithm::HmacSha384 => "hmac-sha384", + TsigAlgorithm::HmacSha512 => "hmac-sha512", + } + .fmt(f) + } +} + #[derive(Deserialize, Serialize, Debug, Clone)] pub struct ZoneHistory { pub history: Vec, @@ -675,14 +763,21 @@ pub struct KeyMsg { } #[derive(Deserialize, Serialize, Debug, Clone)] +// Allow the large enum variant caused by TsigKeyName using Name> +// to avoid the conversions that would be needed if Name were to be +// used instead. +#[allow(clippy::large_enum_variant)] pub enum PolicyReloadError { Io(Utf8PathBuf, String), + NoSuchTsigKey(TsigKeyName), } impl Display for PolicyReloadError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let PolicyReloadError::Io(p, e) = self; - format!("{p}: {e}").fmt(f) + match self { + PolicyReloadError::Io(p, e) => write!(f, "{p}: {e}"), + PolicyReloadError::NoSuchTsigKey(k) => write!(f, "no TSIG key with name '{k}' exists"), + } } } diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 5e19da16d..1a31590bd 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod keyset; pub mod policy; pub mod status; pub mod template; +pub mod tsig; pub mod zone; use crate::client::CascadeApiClient; @@ -37,10 +38,10 @@ pub enum Command { /// Execute manual key roll or key removal commands #[command(name = "keyset")] KeySet(self::keyset::KeySet), - // - // /// Manage keys - // #[command(name = "key")] - // Key(self::key::Key), + + /// Manage TSIG keys + #[command(name = "tsig")] + Tsig(self::tsig::Tsig), // - Command: add/remove/modify a zone // - Command: add/remove/modify a key for a zone // - Command: add/remove/modify a key @@ -74,6 +75,7 @@ impl Command { Self::Policy(policy) => policy.execute(client).await, Self::KeySet(keyset) => keyset.execute(client).await, Self::Hsm(hsm) => hsm.execute(client).await, + Self::Tsig(tsig) => tsig.execute(client).await, Self::Template(template) => template.execute(client).await, } } diff --git a/crates/cli/src/commands/tsig.rs b/crates/cli/src/commands/tsig.rs new file mode 100644 index 000000000..93b85ac76 --- /dev/null +++ b/crates/cli/src/commands/tsig.rs @@ -0,0 +1,232 @@ +use std::str::FromStr; + +use camino::Utf8PathBuf; +use cascade_api::{ + TsigAddError, TsigAddResult, TsigKeyName, TsigListResult, TsigRemoveError, TsigRemoveResult, +}; + +use crate::client::CascadeApiClient; +use crate::println; + +#[derive(Clone, Debug, clap::Args)] +pub struct Tsig { + #[command(subcommand)] + command: TsigCommand, +} + +#[derive(Clone, Debug, clap::Subcommand)] +#[allow(clippy::large_enum_variant)] +pub enum TsigCommand { + /// Add a TSIG key + #[command(name = "add")] + Add { + /// The name of the TSIG key to add. + /// + /// Can also be in the form `[algorithm]:keyname:secret`. + name: String, + + /// The TSIG algorithm to use. + /// + /// Can be omitted if provided as part of the name. + /// Required if `[SECRET]` is provided. + /// + /// Must be one of: + /// hmac-sha1 + /// hmac-sha256 + /// hmac-sha384 + /// hmac-sha512 + #[arg(requires = "secret")] + alg: Option, + + /// Base64 encoded secret key material. + /// + /// Can be omitted if provided as part of the name. + /// Required if `[ALG]` is provided. + /// + /// Can also be a path to a file containing the Base64 encoded secret + /// key material. + #[arg(requires = "alg")] + secret: Option, + }, + + /// Remove a TSIG key + #[command(name = "remove")] + Remove { name: TsigKeyName }, + + /// List registered TSIG keys + #[command(name = "list")] + List, +} + +impl Tsig { + pub async fn execute(self, client: CascadeApiClient) -> Result<(), String> { + match self.command { + // Add a TSIG key to Cascade. + TsigCommand::Add { name, alg, secret } => { + let (name, alg, secret) = match (alg, secret) { + // No separate algorithm or secret argument values + // were provided, instead they must be extracted + // from the name string which should be in the form + // [algorithm]:keyname:secret. + (None, None) => { + let parts: Vec<&str> = name.split(':').collect(); + match parts.as_slice() { + // The algorithm was provided. + [alg_part, name_part, secret_part] => { + let alg = TsigAlgorithm::from_str(alg_part)?; + let name = name_part.to_string(); + let secret = secret_part.to_string(); + (name, alg, secret) + } + + // The algorithm was not provided, use the default. + [name_part, secret_part] => { + let alg = TsigAlgorithm::HmacSha256; + let name = name_part.to_string(); + let secret = secret_part.to_string(); + (name, alg, secret) + } + + // The name value was not in the expected format. + _ => { + return Err( + "Invalid TSIG key format, should be: [algorithm]:keyname:secret" + .to_string(), + ); + } + } + } + + // Separate name, algorithm and secret argument values + // were provided. + (Some(alg), Some(secret)) => { + let path = Utf8PathBuf::from_str(&secret).unwrap(); + if path.exists() { + // Assume that the secret is contained in the + // specified file. + let secret = std::fs::read_to_string(&path) + .map_err(|err| { + format!("Failed to read TSIG key file '{path}': {err}") + })? + .trim() + .to_string(); + (name, alg, secret) + } else { + // Assume that the secret was provided directly. + (name, alg, secret) + } + } + + // An unsupported combination of arguments was provided + // but this should not be possible due to the Clap + // attributes that we used. + _ => unreachable!("Excluded via Clap 'requires' rules"), + }; + + // Parse the TSIG key name as a domain name. + let tsig_key_name = TsigKeyName::from_str(&name) + .map_err(|err| format!("Invalid TSIG key name: {err}"))?; + + // Send a TSIG add message to the Cascade HTTP API. + let res: Result = client + .post_json_with( + "tsig/add", + &crate::api::TsigAdd { + name: tsig_key_name, + alg: alg.into(), + secret, + }, + ) + .await?; + + // Handle the API command result. + match res { + // Success, the key was added! + Ok(TsigAddResult) => { + println!("Added TSIG key '{name}'"); + Ok(()) + } + + // Failure, something went wrong. + Err(err) => Err(format!("Failed to add TSIG key '{name}': {err}")), + } + } + + // Remove a TSIG key (if possible). + TsigCommand::Remove { name } => { + let res: Result = + client.post_json(&format!("tsig/{name}/remove")).await?; + + match res { + Ok(TsigRemoveResult) => { + println!("Removed TSIG key {name}"); + Ok(()) + } + Err(e) => Err(format!("Failed to remove TSIG key: {e}")), + } + } + + // List the set of TSIG keys known to Cascade. + TsigCommand::List => { + let response: TsigListResult = client.get_json("tsig/").await?; + + for (tsig_key_name, key_info) in response.tsig_keys { + // For each TSIG key also list the zones that it is used + // with. + let zones = key_info + .zones + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + + println!("{tsig_key_name}"); + print!(" zones: "); + if !zones.is_empty() { + println!("{zones}"); + } else { + println!("none"); + } + } + + Ok(()) + } + } + } +} + +//------------ TsigAlgorithm ------------------------------------------------- + +/// The TSIG key algorithms supported by Cascade. +#[derive(Clone, Debug, clap::ValueEnum)] +pub enum TsigAlgorithm { + HmacSha1, + HmacSha256, + HmacSha384, + HmacSha512, +} + +impl FromStr for TsigAlgorithm { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "hmac-sha1" => Ok(TsigAlgorithm::HmacSha1), + "hmac-sha256" => Ok(TsigAlgorithm::HmacSha256), + "hmac-sha384" => Ok(TsigAlgorithm::HmacSha384), + "hmac-sha512" => Ok(TsigAlgorithm::HmacSha512), + other => Err(format!("'{other}' is not a supported TSIG algorithm")), + } + } +} + +impl From for crate::api::TsigAlgorithm { + fn from(alg: TsigAlgorithm) -> Self { + match alg { + TsigAlgorithm::HmacSha1 => cascade_api::TsigAlgorithm::HmacSha1, + TsigAlgorithm::HmacSha256 => cascade_api::TsigAlgorithm::HmacSha256, + TsigAlgorithm::HmacSha384 => cascade_api::TsigAlgorithm::HmacSha384, + TsigAlgorithm::HmacSha512 => cascade_api::TsigAlgorithm::HmacSha512, + } + } +} diff --git a/crates/cli/src/commands/zone.rs b/crates/cli/src/commands/zone.rs index a8fd92ff2..bd46f1b3b 100644 --- a/crates/cli/src/commands/zone.rs +++ b/crates/cli/src/commands/zone.rs @@ -1,6 +1,8 @@ +use std::net::{IpAddr, SocketAddr}; +use std::str::FromStr; use std::time::{Duration, SystemTime}; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use crate::ansi; use crate::api::*; @@ -16,7 +18,7 @@ pub struct Zone { #[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, clap::Subcommand)] pub enum ZoneCommand { - /// Register a new zone + /// Add a new zone #[command(name = "add")] Add { name: ZoneName, @@ -219,7 +221,7 @@ impl Zone { "zone/add", &ZoneAdd { name, - source, + source: source.try_into()?, policy, key_imports, }, @@ -928,3 +930,82 @@ fn kmip_imports(key_type: KeyType, x: &[String]) -> Vec { }) .collect() } + +//------------ ZoneSource ---------------------------------------------------- + +const DEFAULT_NS_PORT: u16 = 53; + +/// How to load the contents of a zone. +#[derive(Debug, Clone)] +pub enum ZoneSource { + /// Don't load the zone at all. + None, + + /// From a zonefile on disk. + Zonefile { + /// The path to the zonefile. + path: Box, + }, + + /// From a DNS server via XFR. + Server { + /// The address of the server. + addr: SocketAddr, + + /// The name of a TSIG key, if any. + tsig_key: Option, + }, +} + +/// Support parsing of `-source` command line arguments. +/// +/// Supported forms: +/// - `[:][^]` +/// - `` +impl From<&str> for ZoneSource { + fn from(s: &str) -> Self { + // Split out any provided TSIG key from the rest of the + // source argument. + let (s, tsig_key) = s.split_once('^').unwrap_or((s, "")); + + let tsig_key = if !tsig_key.is_empty() { + Some(tsig_key.to_string()) + } else { + None + }; + + if let Ok(addr) = s.parse::() { + ZoneSource::Server { addr, tsig_key } + } else if let Ok(addr) = s.parse::() { + ZoneSource::Server { + addr: SocketAddr::new(addr, DEFAULT_NS_PORT), + tsig_key, + } + } else { + ZoneSource::Zonefile { + path: Utf8PathBuf::from(s).into_boxed_path(), + } + } + } +} + +impl TryFrom for cascade_api::ZoneSource { + type Error = String; + + fn try_from(source: ZoneSource) -> Result { + Ok(match source { + ZoneSource::None => cascade_api::ZoneSource::None, + ZoneSource::Zonefile { path } => cascade_api::ZoneSource::Zonefile { path }, + ZoneSource::Server { addr, tsig_key } => { + let tsig_key = if let Some(tsig_key) = tsig_key { + Some(TsigKeyName::from_str(&tsig_key).map_err(|err| { + format!("TSIG key name '{tsig_key}' is not a valid domain name: {err}") + })?) + } else { + None + }; + cascade_api::ZoneSource::Server { addr, tsig_key } + } + }) + } +} diff --git a/doc/manual/build/man/cascade-debug.1 b/doc/manual/build/man/cascade-debug.1 index 5848ae130..fa379a47b 100644 --- a/doc/manual/build/man/cascade-debug.1 +++ b/doc/manual/build/man/cascade-debug.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 "CASCADE-DEBUG" "1" "Apr 10, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-DEBUG" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-debug \- Debug / troubleshoot Cascade .SH SYNOPSIS diff --git a/doc/manual/build/man/cascade-health.1 b/doc/manual/build/man/cascade-health.1 index 133e4cd03..85821acb5 100644 --- a/doc/manual/build/man/cascade-health.1 +++ b/doc/manual/build/man/cascade-health.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 "CASCADE-HEALTH" "1" "Apr 10, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-HEALTH" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-health \- Check the health of Cascade .sp diff --git a/doc/manual/build/man/cascade-hsm.1 b/doc/manual/build/man/cascade-hsm.1 index 0b0deaf70..62ab56db8 100644 --- a/doc/manual/build/man/cascade-hsm.1 +++ b/doc/manual/build/man/cascade-hsm.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 "CASCADE-HSM" "1" "Apr 10, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-HSM" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-hsm \- Manage HSMs .SH SYNOPSIS diff --git a/doc/manual/build/man/cascade-keyset.1 b/doc/manual/build/man/cascade-keyset.1 index 207ad3ec3..fb0f43cf1 100644 --- a/doc/manual/build/man/cascade-keyset.1 +++ b/doc/manual/build/man/cascade-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 "CASCADE-KEYSET" "1" "Apr 10, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-KEYSET" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-keyset \- Execute manual key roll or key removal commands .SH SYNOPSIS diff --git a/doc/manual/build/man/cascade-policy.1 b/doc/manual/build/man/cascade-policy.1 index 39a2dc766..0b5ad6a69 100644 --- a/doc/manual/build/man/cascade-policy.1 +++ b/doc/manual/build/man/cascade-policy.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 "CASCADE-POLICY" "1" "Apr 10, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-POLICY" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-policy \- Manage policies .SH SYNOPSIS diff --git a/doc/manual/build/man/cascade-status.1 b/doc/manual/build/man/cascade-status.1 index f25e7c890..8de2073be 100644 --- a/doc/manual/build/man/cascade-status.1 +++ b/doc/manual/build/man/cascade-status.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 "CASCADE-STATUS" "1" "Apr 10, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-STATUS" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-status \- Show the status of Cascade .sp diff --git a/doc/manual/build/man/cascade-template.1 b/doc/manual/build/man/cascade-template.1 index 4afae1f31..6f96a458c 100644 --- a/doc/manual/build/man/cascade-template.1 +++ b/doc/manual/build/man/cascade-template.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 "CASCADE-TEMPLATE" "1" "Apr 10, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-TEMPLATE" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-template \- Print example config or policy files .SH SYNOPSIS diff --git a/doc/manual/build/man/cascade-tsig.1 b/doc/manual/build/man/cascade-tsig.1 new file mode 100644 index 000000000..4ec29e03f --- /dev/null +++ b/doc/manual/build/man/cascade-tsig.1 @@ -0,0 +1,163 @@ +.\" Man page generated from reStructuredText. +. +. +.nr rst2man-indent-level 0 +. +.de1 rstReportMargin +\\$1 \\n[an-margin] +level \\n[rst2man-indent-level] +level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] +- +\\n[rst2man-indent0] +\\n[rst2man-indent1] +\\n[rst2man-indent2] +.. +.de1 INDENT +.\" .rstReportMargin pre: +. RS \\$1 +. nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] +. nr rst2man-indent-level +1 +.\" .rstReportMargin post: +.. +.de UNINDENT +. RE +.\" indent \\n[an-margin] +.\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] +.nr rst2man-indent-level -1 +.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] +.in \\n[rst2man-indent\\n[rst2man-indent-level]]u +.. +.TH "CASCADE-TSIG" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" +.SH NAME +cascade-tsig \- Manage TSIG keys +.sp +Added in version 0.1.0\-beta1. + +.SH SYNOPSIS +.sp +\fBcascade\fP \fB[GLOBAL OPTIONS]\fP tsig \fB\fP +.sp +\fBcascade\fP \fB[GLOBAL OPTIONS]\fP tsig \fI\%add\fP \fB\fP \fB\fP \fB\fP +.sp +\fBcascade\fP \fB[GLOBAL OPTIONS]\fP tsig \fI\%list\fP +.sp +\fBcascade\fP \fB[GLOBAL OPTIONS]\fP tsig \fI\%remove\fP \fB\fP +.SH DESCRIPTION +.sp +Manage \X'tty: link https://datatracker.ietf.org/doc/html/rfc8945.html'\fI\%RFC 8945\fP\X'tty: link' (TSIG) keys for authenticating zone transfer (AXFR, IXFR) and +related messages (SOA and NOTIFY). +.sp +\fBTIP:\fP +.INDENT 0.0 +.INDENT 3.5 +Cascade isn\(aqt currently able to generate TSIG keys itself. +One way to generate a TSIG key is to use the \X'tty: link https://bind9.readthedocs.io/en/latest/manpages.html#tsig-keygen-tsig-key-generation-tool'\fI\%tsig\-keygen\fP\X'tty: link' tool from the ISC BIND project. +.UNINDENT +.UNINDENT +.SH GLOBAL OPTIONS +.sp +See \fI\%Cascade CLI\fP for information about global options supported by every CLI +command. +.SH COMMANDS +.INDENT 0.0 +.TP +.B add +Add a new TSIG key. +.sp +Incoming DNS messages that are TSIG signed will be rejected if the key used +to sign the message is not registered with Cascade. +.UNINDENT +.INDENT 0.0 +.TP +.B list +List registered TSIG keys. +.UNINDENT +.INDENT 0.0 +.TP +.B remove +Remove a TSIG key. +.sp +\fBNOTE:\fP +.INDENT 7.0 +.INDENT 3.5 +Returns an error if the key does not exist in the TSIG key store, +or if the key is still referenced by other configuration. +.UNINDENT +.UNINDENT +.UNINDENT +.SH ARGUMENTS FOR TSIG ADD +.INDENT 0.0 +.TP +.B +.UNINDENT +.INDENT 0.0 +.TP +.B []:: +The name of the TSIG key to add, or a complete TSIG key specification. +.sp +TSIG key names must be valid domain names. +.sp +A complete TSIG key specification consists of an optional algorithm +(default \fBhmac\-sha256\fP), a key name and the secret key material. When a +complete TSIG key specification is supplied, supplying the \fB\fP +and \fB\fP arguments as well will result in an error. +.sp +Secret key material must be the correct length for the specified algorithm +and must be encoded using the \X'tty: link https://datatracker.ietf.org/doc/html/rfc4648.html'\fI\%RFC 4648\fP\X'tty: link' Base64 encoding. +.sp +\fBWARNING:\fP +.INDENT 7.0 +.INDENT 3.5 +Secret key material supplied via a command\-line argument may +be visible to other processes running on the same computer as +the Cascade CLI. +.UNINDENT +.UNINDENT +.UNINDENT +.INDENT 0.0 +.TP +.B +The TSIG algorithm of the specified TSIG key. Can be one of: \fBhmac\-sha1\fP, +\fBhmac\-sha256\fP, \fBhmac\-sha384\fP or \fBhmac\-sha512\fP\&. +.UNINDENT +.INDENT 0.0 +.TP +.B +\X'tty: link https://datatracker.ietf.org/doc/html/rfc4648.html'\fI\%RFC 4648\fP\X'tty: link' Base64 encoded secret key material. The number of bytes prior +to encoding must be correct for the specified \fB\fP\&. +.sp +Can also be a path to a file containing the Base64 encoded secret material. +.sp +\fBNOTE:\fP +.INDENT 7.0 +.INDENT 3.5 +Secret key material supplied via a command\-line argument may be +visible to other processes running on the same computer as the +Cascade CLI. Consider supplying a file name instead. +.UNINDENT +.UNINDENT +.UNINDENT +.SH SEE ALSO +.INDENT 0.0 +.TP +.B \X'tty: link https://cascade.docs.nlnetlabs.nl'\fI\%https://cascade.docs.nlnetlabs.nl\fP\X'tty: link' +Cascade online documentation +.TP +\fBcascade\fP(1) +\fI\%Cascade CLI\fP +.TP +\fBcascaded\fP(1) +\fI\%Cascade Daemon\fP +.TP +\fBcascaded\-config.toml\fP(5) +\fI\%Configuration File Format\fP +.TP +\fBcascaded\-policy.toml\fP(5) +\fI\%Policy File Format\fP +.UNINDENT +.SH AUTHOR +NLnet Labs +.SH COPYRIGHT +2025–2026, NLnet Labs +.\" Generated by docutils manpage writer. +. diff --git a/doc/manual/build/man/cascade-zone.1 b/doc/manual/build/man/cascade-zone.1 index d4ad13c08..e1923b128 100644 --- a/doc/manual/build/man/cascade-zone.1 +++ b/doc/manual/build/man/cascade-zone.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 "CASCADE-ZONE" "1" "Apr 10, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE-ZONE" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade-zone \- Manage zones .SH SYNOPSIS @@ -64,12 +64,20 @@ command. .INDENT 0.0 .TP .B add -Register a new zone. +Add a new zone. .UNINDENT .INDENT 0.0 .TP .B remove Remove a zone. +.sp +\fBNOTE:\fP +.INDENT 7.0 +.INDENT 3.5 +Once removed, downstream servers will no longer be able to fetch +the zone! +.UNINDENT +.UNINDENT .UNINDENT .INDENT 0.0 .TP @@ -114,9 +122,43 @@ Get the history of a single zone. .SH OPTIONS FOR ZONE ADD .INDENT 0.0 .TP -.B \-\-source -The zone source can be an IP address (with or without port, defaults to port -53) or a file path. +.B \-\-source [:][^] +The zone source can be the IP address of an upstream nameserver (with +or without port, defaults to port 53) or the path to a zone file locally +available to the \fBcascaded\fP daemon. +.sp +When specifying an upstream nameserver you may also optionally specify +the name of an \X'tty: link https://datatracker.ietf.org/doc/html/rfc8945.html'\fI\%RFC 8945\fP\X'tty: link' TSIG key that should be used to authenticate +communication with the upstream. +.sp +Zones sourced from an upstream nameserver will be automatically updated +if a new version is detected via a SOA query, either based on the zone\(aqs +SOA record timers, or in response to an \X'tty: link https://datatracker.ietf.org/doc/html/rfc1996.html'\fI\%RFC 1996\fP\X'tty: link' NOTIFY message from +the upstream. +.sp +Zones can also be manualy updated via \fBcascade\fP \fI\%reload\fP\&. +.sp +For zones that have already been retrieved at least once via AXFR, subsequent +refreshes will attempt to use IXFR and fallback to AXFR if IXFR is not +available. +.sp +\fBNOTE:\fP +.INDENT 7.0 +.INDENT 3.5 +When running \fBcascade\fP \fBzone add\fP from a +different host than where the Cascade daemon is running, make +sure that the source (whether filesystem path or IP address) is +reachable by the Cascade daemon. +.UNINDENT +.UNINDENT +.sp +\fBNOTE:\fP +.INDENT 7.0 +.INDENT 3.5 +If using a TSIG key the key must first be added to Cascade via +\fBcascade\fP \fBtsig add\fP\&. +.UNINDENT +.UNINDENT .UNINDENT .INDENT 0.0 .TP diff --git a/doc/manual/build/man/cascade.1 b/doc/manual/build/man/cascade.1 index 9a7536d78..1c68e3994 100644 --- a/doc/manual/build/man/cascade.1 +++ b/doc/manual/build/man/cascade.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 "CASCADE" "1" "Apr 10, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADE" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascade \- Cascade CLI .SH SYNOPSIS @@ -73,6 +73,9 @@ Manage policies. \fBcascade\-keyset\fP(1) Execute manual key roll or key removal commands. .TP +\fBcascade\-tsig\fP(1) +Manage TSIG keys. +.TP \fBcascade\-hsm\fP(1) Manage HSMs. .TP diff --git a/doc/manual/build/man/cascaded-config.toml.5 b/doc/manual/build/man/cascaded-config.toml.5 index 029c34183..e9d409acf 100644 --- a/doc/manual/build/man/cascaded-config.toml.5 +++ b/doc/manual/build/man/cascaded-config.toml.5 @@ -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 "CASCADED-CONFIG.TOML" "5" "Apr 10, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADED-CONFIG.TOML" "5" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascaded-config.toml \- Cascade configuration file .sp diff --git a/doc/manual/build/man/cascaded-policy.toml.5 b/doc/manual/build/man/cascaded-policy.toml.5 index b56777c67..e12339485 100644 --- a/doc/manual/build/man/cascaded-policy.toml.5 +++ b/doc/manual/build/man/cascaded-policy.toml.5 @@ -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 "CASCADED-POLICY.TOML" "5" "Apr 10, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADED-POLICY.TOML" "5" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascaded-policy.toml \- Cascade policy file format .sp @@ -96,6 +96,7 @@ csk.auto\-done = true algorithm.auto\-done = true ds\-algorithm = \(dqSHA256\(dq auto\-remove = true +publication\-nameservers = [] [key\-manager.records] ttl = \(dq1h\(dq @@ -335,8 +336,28 @@ Supported options: .B auto\-remove = true Whether to automatically remove expired keys. .sp -If this is set, expired keys will be removed automatically (by deleting the -files for on\-disk keys or removing it from the HSM). +If this option is set, expired keys will be removed automatically (by +deleting the files for on\-disk keys or removing it from the HSM). +.UNINDENT +.INDENT 0.0 +.TP +.B publication\-nameservers = [] +The set of nameservers to use when checking for RRSIG propagation during a +key roll. +.sp +Each nameserver must be specified as a string in the form: +.INDENT 7.0 +.INDENT 3.5 +\fB\(dq:[][^]\(dq\fP +.UNINDENT +.UNINDENT +.sp +If a TSIG key name is specified, a key by that name must exist in the +Cascade TSIG key store and will be used to authenticate communication with +the nameserver. +.sp +If no nameservers are specified, the nameserver specified by the SOA MNAME +field will be checked. .UNINDENT .SS The management of DNS records by the key manager. .sp @@ -554,9 +575,9 @@ The type of denial\-of\-existence records to generate. Supported options: .INDENT 7.0 .IP \(bu 2 -\fBnsec\fP: Use NSEC records (RFC 4034). +\fBnsec\fP: Use NSEC records (\X'tty: link https://datatracker.ietf.org/doc/html/rfc4034.html'\fI\%RFC 4034\fP\X'tty: link'). .IP \(bu 2 -\fBnsec3\fP: Use NSEC3 records (RFC 5155). +\fBnsec3\fP: Use NSEC3 records (\X'tty: link https://datatracker.ietf.org/doc/html/rfc5155.html'\fI\%RFC 5155\fP\X'tty: link'). .UNINDENT .UNINDENT .INDENT 0.0 @@ -620,12 +641,17 @@ The \fB[server.outbound]\fP section. .INDENT 0.0 .TP .B send\-notify\-to = [] -The set of nameservers to which NOTIFY messages should be sent. +The set of nameservers to which NOTIFY messages should be sent +.sp +Each nameserver must be specified as a string in the form: +.sp +\fI\(dq:[][^]\(dq\fP .sp -If empty, no NOTIFY messages will be sent. +If a TSIG key name is specified, a key by that name must exist in the +Cascade TSIG key store and will be used to authenticate communication with +the nameserver. .sp -A collection of \fBIP:[port]\fP, defaulting to port 53 when not specified, e.g.: -\fBsend\-notify\-to = [\(dq[::1]:53\(dq]\fP +If not specified, no NOTIFY messages will be sent. .UNINDENT .SH FILES .INDENT 0.0 diff --git a/doc/manual/build/man/cascaded.1 b/doc/manual/build/man/cascaded.1 index ed91e2133..e4a58dd89 100644 --- a/doc/manual/build/man/cascaded.1 +++ b/doc/manual/build/man/cascaded.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 "CASCADED" "1" "Apr 10, 2026" "0.1.0-alpha5" "Cascade" +.TH "CASCADED" "1" "Apr 24, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascaded \- DNSSEC signer .SH SYNOPSIS diff --git a/doc/manual/source/conf.py b/doc/manual/source/conf.py index d0f2c767d..9f935d2e1 100644 --- a/doc/manual/source/conf.py +++ b/doc/manual/source/conf.py @@ -250,6 +250,7 @@ ('man/cascade', 'cascade', 'Cascade CLI', author, 1), ('man/cascade-debug', 'cascade-debug', 'Debug / troubleshoot Cascade', author, 1), ('man/cascade-health', 'cascade-health', 'Check the health of Cascade', author, 1), + ('man/cascade-tsig', 'cascade-tsig', 'Manage TSIG keys', author, 1), ('man/cascade-hsm', 'cascade-hsm', 'Manage HSMs', author, 1), ('man/cascade-keyset', 'cascade-keyset', 'Execute manual key roll or key removal commands', author, 1), ('man/cascade-policy', 'cascade-policy', 'Manage policies', author, 1), diff --git a/doc/manual/source/faq.rst b/doc/manual/source/faq.rst index 2c2e59cf0..03dc0549f 100644 --- a/doc/manual/source/faq.rst +++ b/doc/manual/source/faq.rst @@ -57,7 +57,7 @@ Key rolls should be automatic and frequent. Frequent key rolls help to ensure that they become normal operational practice and not an exception. Key rolls should be automated as much as possible to avoid mistakes. -Unfortunately, the standard for updating DS records (CDS, RFC 8078) is not +Unfortunately, the standard for updating DS records (CDS, :RFC:`8078`) is not widely implemented so in many cases a KSK roll has to have a manual component, namely submitting and updating the DS record at the parent. diff --git a/doc/manual/source/index.rst b/doc/manual/source/index.rst index 503d22c82..5224a697e 100644 --- a/doc/manual/source/index.rst +++ b/doc/manual/source/index.rst @@ -100,6 +100,7 @@ Examples of things we're interested in: key-management hsms review-hooks + zone-transfers .. toctree:: :maxdepth: 2 @@ -112,8 +113,16 @@ Examples of things we're interested in: .. toctree:: :maxdepth: 2 :hidden: - :caption: Integrations - :name: toc-integrations + :caption: Nameserver Integrations + :name: toc-nameserver-integrations + + nsd + +.. toctree:: + :maxdepth: 2 + :hidden: + :caption: HSM Integrations + :name: toc-hsm-integrations softhsm thales @@ -161,6 +170,7 @@ Examples of things we're interested in: man/cascade-policy man/cascade-status man/cascade-template + man/cascade-tsig man/cascade-zone cascade-hsm-bridge Daemon cascade-hsm-bridge Configuration File Format diff --git a/doc/manual/source/limitations.rst b/doc/manual/source/limitations.rst index ecda1e88f..6c6535427 100644 --- a/doc/manual/source/limitations.rst +++ b/doc/manual/source/limitations.rst @@ -82,7 +82,7 @@ Other known limitations ----------------------- - No NOTIFY retry support. -- No NOTIFY "Notify Set" (RFC 1996) discovery. +- No NOTIFY "Notify Set" (:RFC:`1996`) discovery. - No KMIP batching support. - No DNS UPDATE support. - HSM algorithm support is limited to RSASHA256 and ECDSAP256SHA256. diff --git a/doc/manual/source/man/cascade-tsig.rst b/doc/manual/source/man/cascade-tsig.rst new file mode 100644 index 000000000..2aae53f4e --- /dev/null +++ b/doc/manual/source/man/cascade-tsig.rst @@ -0,0 +1,109 @@ +cascade tsig +============ + +.. versionadded:: 0.1.0-beta1 + +Synopsis +-------- + +:program:`cascade` ``[GLOBAL OPTIONS]`` tsig ```` + +:program:`cascade` ``[GLOBAL OPTIONS]`` tsig :subcmd:`add` ```` ```` ```` + +:program:`cascade` ``[GLOBAL OPTIONS]`` tsig :subcmd:`list` + +:program:`cascade` ``[GLOBAL OPTIONS]`` tsig :subcmd:`remove` ```` + +Description +----------- + +Manage :RFC:`8945` (TSIG) keys for authenticating zone transfer (AXFR, IXFR) and +related messages (SOA and NOTIFY). + +.. tip:: Cascade isn't currently able to generate TSIG keys itself. + One way to generate a TSIG key is to use the `tsig-keygen + `_ tool from the ISC BIND project. + +Global Options +-------------- + +See :doc:`cascade` for information about global options supported by every CLI +command. + +Commands +-------- + +.. subcmd:: add + + Add a new TSIG key. + + Incoming DNS messages that are TSIG signed will be rejected if the key used + to sign the message is not registered with Cascade. + +.. subcmd:: list + + List registered TSIG keys. + +.. subcmd:: remove + + Remove a TSIG key. + + .. note:: Returns an error if the key does not exist in the TSIG key store, + or if the key is still referenced by other configuration. + +Arguments for :subcmd:`tsig add` +-------------------------------- + +.. option:: +.. option:: []:: + + The name of the TSIG key to add, or a complete TSIG key specification. + + TSIG key names must be valid domain names. + + A complete TSIG key specification consists of an optional algorithm + (default ``hmac-sha256``), a key name and the secret key material. When a + complete TSIG key specification is supplied, supplying the ```` + and ```` arguments as well will result in an error. + + Secret key material must be the correct length for the specified algorithm + and must be encoded using the :RFC:`4648` Base64 encoding. + + .. warning:: Secret key material supplied via a command-line argument may + be visible to other processes running on the same computer as + the Cascade CLI. + +.. option:: + + The TSIG algorithm of the specified TSIG key. Can be one of: ``hmac-sha1``, + ``hmac-sha256``, ``hmac-sha384`` or ``hmac-sha512``. + +.. option:: + + :RFC:`4648` Base64 encoded secret key material. The number of bytes prior + to encoding must be correct for the specified ````. + + Can also be a path to a file containing the Base64 encoded secret material. + + .. note:: Secret key material supplied via a command-line argument may be + visible to other processes running on the same computer as the + Cascade CLI. Consider supplying a file name instead. + +See Also +-------- + +https://cascade.docs.nlnetlabs.nl + Cascade online documentation + +**cascade**\ (1) + :doc:`cascade` + +**cascaded**\ (1) + :doc:`cascaded` + +**cascaded-config.toml**\ (5) + :doc:`cascaded-config.toml` + +**cascaded-policy.toml**\ (5) + :doc:`cascaded-policy.toml` diff --git a/doc/manual/source/man/cascade-zone.rst b/doc/manual/source/man/cascade-zone.rst index 61ae02fe6..1716b3bb7 100644 --- a/doc/manual/source/man/cascade-zone.rst +++ b/doc/manual/source/man/cascade-zone.rst @@ -42,12 +42,15 @@ Commands .. subcmd:: add - Register a new zone. + Add a new zone. .. subcmd:: remove Remove a zone. + .. note:: Once removed, downstream servers will no longer be able to fetch + the zone! + .. subcmd:: list List registered zones. @@ -83,10 +86,34 @@ Commands Options for :subcmd:`zone add` ------------------------------ -.. option:: --source +.. option:: --source [:][^] + + The zone source can be the IP address of an upstream nameserver (with + or without port, defaults to port 53) or the path to a zone file locally + available to the ``cascaded`` daemon. + + When specifying an upstream nameserver you may also optionally specify + the name of an :RFC:`8945` TSIG key that should be used to authenticate + communication with the upstream. + + Zones sourced from an upstream nameserver will be automatically updated + if a new version is detected via a SOA query, either based on the zone's + SOA record timers, or in response to an :RFC:`1996` NOTIFY message from + the upstream. + + Zones can also be manualy updated via :program:`cascade` :subcmd:`reload`. + + For zones that have already been retrieved at least once via AXFR, subsequent + refreshes will attempt to use IXFR and fallback to AXFR if IXFR is not + available. + + .. note:: When running :program:`cascade` :subcmd:`zone add` from a + different host than where the Cascade daemon is running, make + sure that the source (whether filesystem path or IP address) is + reachable by the Cascade daemon. - The zone source can be an IP address (with or without port, defaults to port - 53) or a file path. + .. note:: If using a TSIG key the key must first be added to Cascade via + :program:`cascade` :subcmd:`tsig add`. .. option:: --policy diff --git a/doc/manual/source/man/cascade.rst b/doc/manual/source/man/cascade.rst index e10cfef13..5d84c9ee0 100644 --- a/doc/manual/source/man/cascade.rst +++ b/doc/manual/source/man/cascade.rst @@ -55,6 +55,10 @@ Commands Execute manual key roll or key removal commands. + :doc:`cascade-tsig `\ (1) + + Manage TSIG keys. + :doc:`cascade-hsm `\ (1) Manage HSMs. @@ -81,6 +85,9 @@ Commands **cascade-keyset**\ (1) Execute manual key roll or key removal commands. + **cascade-tsig**\ (1) + Manage TSIG keys. + **cascade-hsm**\ (1) Manage HSMs. diff --git a/doc/manual/source/man/cascaded-policy.toml.rst b/doc/manual/source/man/cascaded-policy.toml.rst index 6057b9fc8..a876c73e9 100644 --- a/doc/manual/source/man/cascaded-policy.toml.rst +++ b/doc/manual/source/man/cascaded-policy.toml.rst @@ -62,6 +62,7 @@ Example algorithm.auto-done = true ds-algorithm = "SHA256" auto-remove = true + publication-nameservers = [] [key-manager.records] ttl = "1h" @@ -254,9 +255,24 @@ The ``[key-manager]`` section. Whether to automatically remove expired keys. - If this is set, expired keys will be removed automatically (by deleting the - files for on-disk keys or removing it from the HSM). + If this option is set, expired keys will be removed automatically (by + deleting the files for on-disk keys or removing it from the HSM). +.. option:: publication-nameservers = [] + + The set of nameservers to use when checking for RRSIG propagation during a + key roll. + + Each nameserver must be specified as a string in the form: + + ``"[:][^]"`` + + If a TSIG key name is specified, a key by that name must exist in the + Cascade TSIG key store and will be used to authenticate communication with + the nameserver. + + If no nameservers are specified, the nameserver specified by the SOA MNAME + field will be checked. The management of DNS records by the key manager. +++++++++++++++++++++++++++++++++++++++++++++++++ @@ -444,8 +460,8 @@ The ``[signer.denial]`` section. Supported options: - - ``nsec``: Use NSEC records (RFC 4034). - - ``nsec3``: Use NSEC3 records (RFC 5155). + - ``nsec``: Use NSEC records (:RFC:`4034`). + - ``nsec3``: Use NSEC3 records (:RFC:`5155`). .. option:: opt-out = false @@ -504,13 +520,17 @@ The ``[server.outbound]`` section. .. option:: send-notify-to = [] - The set of nameservers to which NOTIFY messages should be sent. + The set of nameservers to which NOTIFY messages should be sent + + Each nameserver must be specified as a string in the form: - If empty, no NOTIFY messages will be sent. + `"[:][^]"` - A collection of ``IP:[port]``, defaulting to port 53 when not specified, e.g.: - ``send-notify-to = ["[::1]:53"]`` + If a TSIG key name is specified, a key by that name must exist in the + Cascade TSIG key store and will be used to authenticate communication with + the nameserver. + If not specified, no NOTIFY messages will be sent. Files ----- diff --git a/doc/manual/source/nsd.rst b/doc/manual/source/nsd.rst new file mode 100644 index 000000000..8834355e4 --- /dev/null +++ b/doc/manual/source/nsd.rst @@ -0,0 +1,97 @@ +Integrating with NSD +==================== + +.. epigraph:: + + Name Server Daemon (NSD) by NLnet Labs is an authoritative DNS name server. + + -- https://nsd.docs.nlnetlabs.nl/ + +Suggested reading +~~~~~~~~~~~~~~~~~ + +The :ref:`Zone Transfers ` page explains the general +functionality in Cascade that is referred to below. + +Using NSD as a primary to Cascade +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To use NSD as an upstream name server of Cascade you must add a zone to NSD +that refers to Cascade as a secondary name server. If enabled in NSD, NSD will +send an :RFC:`1996` DNS NOTIFY message to Cascade notifying it when changes to +the zone occur. + +The NOTIFY message will trigger Cascade to perform an AXFR transfer to fetch +the full zone content from NSD, or, if already fetched and IXFR is enabled in +NSD, an IXFR transfer will be performed to fetch just the incremental changes +since the last fetch. + +If NOTIFY is NOT enabled in NSD, Cascade will monitor NSD for a newer version +of the zone by periodically sending SOA queries according to the number of +seconds defined by the REFRESH field of the zone apex SOA record. + +Optionally NSD and Cascade can be configured with the same TSIG key to +authenticate the NOTIFY and XFR messages. + +The NSD settings relevant here are: + - `notify `_ + - `provide-xfr `_ + +For example the following NSD configuration file fragment adds an +``example.com`` zone to NSD that is to be served as input to a Cascade daemon +running on host 192.168.0.2 listening on the default port 4542: + +.. code-block:: + + zone: + name: example.com + zonefile: /etc/nsd/example.com.zone + notify: 192.168.0.2@4542 NOKEY + provide-xfr: 192.168.0.2 NOKEY + store-ixfr: yes + create-ixfr: yes + +A TSIG key can be used to authenticate the NOTIFY and XFR communications. For +example\: + +.. code-block:: + + key: + name: "sec1_key" + algorithm: hmac-sha256 + secret: "..." + + zone: + name: example.com + zonefile: /etc/nsd/example.com.zone + notify: 192.168.0.2@4542 sec1_key + provide-xfr: 192.168.0.2 sec1_key + store-ixfr: yes + create-ixfr: yes + +See https://nsd.docs.nlnetlabs.nl/en/latest/running/using-tsig.html for more +information. + +.. tip:: Remember to reload the NSD configuration or restart NSD so that + changes to the configuration take effect. + +To add the TSIG key to Cascade use :program:`cascade` :subcmd:`tsig add`: + +.. code-block:: bash + + $ cascade tsig add --name sec1_key --alg hmac-sha256 --secret "...==" + +To use the new TSIG key it must be specified when adding a zone to +Cascade. Assuming that NSD is running on host 192.168.0.1 on port 53, +the following command instructs Cascade to add the ``example.com`` +zone sourced from the NSD server using the ``sec1_key`` TSIG key to +authenticate with NSD: + +.. code-block:: bash + + $ cascade zone add --source "192.168.0.1^sec1_key" --policy default example.com + +Using NSD as a secondary to Cascade +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +TODO diff --git a/doc/manual/source/zone-transfers.rst b/doc/manual/source/zone-transfers.rst new file mode 100644 index 000000000..738930123 --- /dev/null +++ b/doc/manual/source/zone-transfers.rst @@ -0,0 +1,77 @@ +Zone Transfers +============== + +Cascade is expected to be deployed between a hidden upstream nameserver and +public downstream nameservers. The hidden upstream serves the unsigned zone, +Cascade signs it, and serves it to downstream nameservers for publication +to consumers. + +Communication of changed zone records from upstream to downstream should +be done via the network using the :RFC:`5936` (AXFR) and :RFC:`1995` (IXFR) +protocols. + +Authentication of transfering parties can be done using :RFC:`8945` (TSIG) +keys, using a shared secret communicated out of band to the nameservers +sending and receiving the zone records. + +Cascade supports timely discovery of zone changes by sending SOA queries to +the upsream nameserver, either in response to an :RFC:`1996` NOTIFY message or +based on the zone's SOA timers. + +.. note:: Cascade also supports loading the zone from a file. However, if + only a small fraction of the records in the zone change from one + version to the next, loading the entire file every time the zone + file changes will require more time, CPU and memory compared to + processing only the differences when using IXFR. Cascade doesn't + yet support direct writing of signed zones to a file, though a + signed zone review hook could be used to AXFR the signed zone to + a file on disk to achieve this. + +Using zone transfers with an upstream nameserver +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To instruct Cascade to transfer a zone via the network instead of loading +it from a file you must supply an upstream nameserver IP address when +adding the zone. See :program:`cascade` :subcmd:`zone add`, and optionally +a TSIG key to use to authenticate communication. + +Cascade will then attempt to fetch the zone. Where possible it will fetch +newer versions of the zone incrementally, as this is more efficient. + +Cascade can be instructed to authenticate the upstream nameserver by use of a +TSIG key. The TSIG key to use must be provided to Cascade _before_ adding the +zone. See :program:`cascade` :subcmd:`tsig add`. + +Using zone transfers with a downstream server +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default, Cascade allows downstream servers to access published zones by +zone transfer, no configuration is needed. + +To ensure timely update by secondaries, Cascade can be configured to send +:RFC:`1996` NOTIFY messages to specified secondaries. This is done via the +policy setting ``server.outbound.send-notify-to``, optionally specifying an +:RFC:8945` TSIG key to use to authenticate communication. + +.. tip:: Remember to reload the policy file after changing it. See + :program:`cascade` :subcmd:`policy reload`. + +.. tip:: Use :program:`cascade` :subcmd:`tsig add` to add a TSIG key to + Cascade _before_ reloading policy file changes. + +Controlling automatic key rollover zone transfer settings +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When using automatic key rollover (the default) Cascade will attempt to verify +that certain key properties of the signed zone being served to consumers are +correct. + +This verification is done by transferring the zone and inspecting it. By +default transfer is attempted from the nameserver identified by the MNAME +field of the apex SOA record in the zone. + +If an alternate nameserver should be queried instead of the MNAME +nameserver, or if a specific port number or TSIG key should be used +to request the transfer, you will also need to configure the Cascade +key manager to fetch the zone correctly. This can be done via the +``key-manager.publication-nameservers`` policy setting. diff --git a/etc/policy.template.toml b/etc/policy.template.toml index 3e27515c0..80a434c3a 100644 --- a/etc/policy.template.toml +++ b/etc/policy.template.toml @@ -156,6 +156,22 @@ ds-algorithm = "SHA-256" # TODO: Perhaps support removing keys after a certain delay? auto-remove = true +# The set of nameservers to use when checking for RRSIG propagation during a +# key roll. +# +# Each nameserver must be specified as a string in the form: +# +# `"[:][^]"` +# +# If a TSIG key name is specified, a key by that name must exist in the +# Cascade TSIG key store and will be used to authenticate communication with +# the nameserver. +# +# If no nameservers are specified, the nameserver specified by the SOA MNAME +# field will be checked. +# +# publication-nameservers = [] + # The management of DNS records by the key manager. # # The key manager generates and signs several records (DNSKEY and CDS). This @@ -364,8 +380,14 @@ required = false [server.outbound] # The set of nameservers to which NOTIFY messages should be sent. -# -# If empty, no NOTIFY messages will be sent. # -# A collection of IP:[port], defaulting to port 53 when not specified. +# Each nameserver must be specified as a string in the form: +# +# `"[:][^]"` +# +# If a TSIG key name is specified, a key by that name must exist in the +# Cascade TSIG key store and will be used to authenticate communication with +# the nameserver. +# +# If not specified, no NOTIFY messages will be sent. send-notify-to = [] diff --git a/integration-tests/cascade-test-image/Dockerfile b/integration-tests/cascade-test-image/Dockerfile index efd188728..e3f975625 100644 --- a/integration-tests/cascade-test-image/Dockerfile +++ b/integration-tests/cascade-test-image/Dockerfile @@ -50,8 +50,8 @@ RUN <&2 @@ -358,6 +374,24 @@ mail MX 10 example.test. text TXT "Hello World!" EOF +tee "${base_dir}/nsd-primary/zones/example-tsig.test.primary-zone" <<'EOF' >&2 +$TTL 5 ; use a very short TTL for sped up keyset rolls +example-tsig.test. IN SOA ns1.example-tsig.test. mail.example-tsig.test. ( + 1 ; serial + 60 ; refresh (60 seconds) + 60 ; retry (60 seconds) + 3600 ; expire (1 hour) + 5 ; minimum (5 seconds) + ) +@ NS example-tsig.test. +@ NS ns1.example-tsig.test. +@ A 127.0.0.1 +ns1 A 127.0.0.1 + +www A 169.254.1.1 +mail MX 10 example-tsig.test. +text TXT "Hello TSIG World!" +EOF } diff --git a/integration-tests/system-tests.yml b/integration-tests/system-tests.yml index c9d3592af..be189554d 100644 --- a/integration-tests/system-tests.yml +++ b/integration-tests/system-tests.yml @@ -260,3 +260,20 @@ jobs: - uses: ./integration-tests/tests/all-rr-types with: log-level: ${{ inputs.log-level }} + + # Added for https://github.com/NLnetLabs/cascade/pull/564 + upstream-tsig: + name: Use TSIG with an upstream nameserver. + runs-on: ubuntu-latest + strategy: + matrix: + key-source: [cmdline, file] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/set-build-profile + with: + build-profile: ${{ inputs.build-profile }} + - uses: ./integration-tests/tests/upstream-tsig + with: + log-level: ${{ inputs.log-level }} + key-source: ${{ matrix.key-source }} diff --git a/integration-tests/tests/upstream-tsig/action.yml b/integration-tests/tests/upstream-tsig/action.yml new file mode 100644 index 000000000..d30fa8b2e --- /dev/null +++ b/integration-tests/tests/upstream-tsig/action.yml @@ -0,0 +1,90 @@ +# Making reusable composite actions documented at +# https://docs.github.com/en/actions/tutorials/create-actions/create-a-composite-action#creating-a-composite-action-within-the-same-repository +name: 'Use TSIG with an upstream nameserver.' +description: 'Use TSIG with an upstream nameserver.' +defaults: + # see: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#defaultsrunshell + run: + shell: bash --noprofile --norc -eo pipefail -x {0} +inputs: + log-level: + description: The level of logging that Cascade should output. + required: false + default: debug + type: choice + options: + - error + - warning + - info + - debug + - trace + key-source: + description: Where to take the TSIG key from. + required: true + type: choice + options: + - cmdline + - file + - none +runs: + using: "composite" + steps: + - uses: ./.github/actions/prepare-systest-env + - uses: ./.github/actions/setup-and-start-cascade + with: + log-level: ${{ inputs.log-level }} + + - name: Add zone without the required TSIG key. + run: | + cascade zone add --policy default --source "127.0.0.1:1055" example-tsig.test + + - name: Check zone status + run: | + timeout=10 # seconds + start=$(date +%s) + + # TODO: The error message we are checking for is the one that Cascade + # currently produces, but doesn't seem like the correct error... + # See: https://github.com/NLnetLabs/cascade/issues/603 + until cascade zone status example-tsig.test | grep -q "ERROR: the AXFR failed: the server's response was semantically incorrect: Not a valid XFR response"; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: zone status failed to report the expected error" + cascade zone status example-tsig.test + exit 1 + fi + sleep 1 + done + exit 0 + + - name: Remove and re-add the zone with the required TSIG key. + run: | + cascade zone remove example-tsig.test + + case "${{ inputs.key-source }}" in + "cmdline") + cascade tsig add tsig-key hmac-sha256 "COzoVsYQmXeXiyq1Quhp0bbVnMyxjPxsaGSoIWR98i0=" + ;; + "file") + echo -n "COzoVsYQmXeXiyq1Quhp0bbVnMyxjPxsaGSoIWR98i0=" >/tmp/tsig.key + cascade tsig add tsig-key hmac-sha256 /tmp/tsig.key + ;; + esac + + cascade zone add --policy default --source "127.0.0.1:1055^tsig-key" example-tsig.test + + - name: Check zone status + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone status example-tsig.test | grep -q "Published zone available"; do + if (($(date +%s) > (start + timeout))); then + cascade zone status example-tsig.test + echo "::error:: timeout: zone status did not report published zone available" + exit 1 + fi + sleep 1 + done + + - name: Print log files on any failure in this job + uses: ./.github/actions/print-logfiles + if: failure() diff --git a/src/center.rs b/src/center.rs index d7f976a4b..707843249 100644 --- a/src/center.rs +++ b/src/center.rs @@ -8,6 +8,7 @@ use std::{ }; use bytes::Bytes; +use cascade_api::{TsigAddError, TsigAddResult}; use domain::base::Name; use domain::dnssec::sign::keys::keyset::UnixTime; use tracing::{debug, error, info, trace}; @@ -19,9 +20,10 @@ use crate::loader::zone::LoaderZoneHandle; use crate::persistence::{Persister, Restorer}; use crate::server::{LoadedReviewServer, PublicationServer, SignedReviewServer}; use crate::state::PolicySpec; +use crate::tsig::ImportError; use crate::units::key_manager::KeyManager; use crate::units::zone_signer::ZoneSigner; -use crate::zone::{HistoricalEvent, ZoneHandle}; +use crate::zone::{HistoricalEvent, ZoneByPtr, ZoneHandle}; use crate::{ api, config::Config, @@ -80,11 +82,13 @@ pub async fn add_zone( center: &Arc
, name: Name, policy_name: Box, - source: api::ZoneSource, + api_source: api::ZoneSource, key_imports: Vec, ) -> Result<(), ZoneAddError> { // Create and insert the zone. let zone; + let source; + { // Lock the global state to check consistency and insert the zone. let mut state = center.state.lock().unwrap(); @@ -95,19 +99,54 @@ pub async fn add_zone( } // Look up the requested policy. - let policy = state - .policies - .get_mut(&policy_name) - .ok_or(ZoneAddError::NoSuchPolicy)?; - if policy.mid_deletion { - return Err(ZoneAddError::PolicyMidDeletion); + { + let policy = state + .policies + .get(&policy_name) + .ok_or(ZoneAddError::NoSuchPolicy)?; + if policy.mid_deletion { + return Err(ZoneAddError::PolicyMidDeletion); + } } // Create the zone and initialize its state. zone = Arc::new(Zone::new(name)); + + source = match api_source { + cascade_api::ZoneSource::None => crate::loader::Source::None, + cascade_api::ZoneSource::Zonefile { path } => crate::loader::Source::Zonefile { path }, + cascade_api::ZoneSource::Server { addr, tsig_key } => { + let tsig_key = if let Some(key_name) = tsig_key { + // Lookup the key in the TSIG key store. + let key = state + .tsig_store + .get_mut(&key_name) + .ok_or(ZoneAddError::NoSuchTsigKey)?; + + // Record that this zone uses this key. + key.zones.insert(ZoneByPtr(zone.clone())); + + let key = key.inner.clone(); + + state.tsig_store.mark_dirty(center); + + // Remember the found key. + Some(key) + } else { + None + }; + + crate::loader::Source::Server { addr, tsig_key } + } + }; + { let mut zone_state = zone.state.lock().unwrap(); let restorer = zone_state.storage.restorer.take().unwrap(); + let policy = state + .policies + .get_mut(&policy_name) + .ok_or(ZoneAddError::NoSuchPolicy)?; zone_state.policy = Some(policy.latest.clone()); policy.zones.insert(zone.name.clone()); @@ -141,11 +180,35 @@ pub async fn add_zone( register_zone(center, zone.name.clone(), policy_name.clone(), key_imports).await { // Remove in reverse order what was added above. + LoadedReviewServer::remove_zone(center, &zone); + SignedReviewServer::remove_zone(center, &zone); + PublicationServer::remove_zone(center, &zone); + let mut state = center.state.lock().unwrap(); + state.zones.remove(&zone.name); + if let Some(policy) = state.policies.get_mut(&policy_name) { policy.zones.remove(&zone.name); } + + state.mark_dirty(center); + + if let crate::loader::Source::Server { + tsig_key: Some(key_name), + .. + } = &source + { + state + .tsig_store + .get_mut(key_name.name()) + .unwrap() + .zones + .remove(&ZoneByPtr(zone)); + + state.tsig_store.mark_dirty(center); + } + return Err(err); } @@ -154,23 +217,6 @@ pub async fn add_zone( state.record_event(HistoricalEvent::Added, None); - let source = match source { - cascade_api::ZoneSource::None => crate::loader::Source::None, - cascade_api::ZoneSource::Zonefile { path } => crate::loader::Source::Zonefile { path }, - cascade_api::ZoneSource::Server { - addr, - tsig_key, - xfr_status: _, - } => { - // TODO: TSIG. - let _ = tsig_key; - crate::loader::Source::Server { - addr, - tsig_key: None, - } - } - }; - // Set the source of the zone, and begin loading it. LoaderZoneHandle { zone: &zone, @@ -233,6 +279,21 @@ pub fn remove_zone(center: &Arc
, name: Name) -> Result<(), ZoneRe state.mark_dirty(center); } + // Update the TSIG key's referenced zones. + if let crate::loader::Source::Server { + tsig_key: Some(key_name), + .. + } = &zone_state.loader.source + { + state + .tsig_store + .get_mut(key_name.name()) + .unwrap() + .zones + .remove(&ZoneByPtr(zone.clone())); + state.tsig_store.mark_dirty(center); + } + info!("Removed zone '{name}'"); zone_state.record_event(HistoricalEvent::Removed, None); zone.mark_dirty(&mut zone_state, center); @@ -244,6 +305,20 @@ pub fn get_zone(center: &Arc
, name: &Name) -> Option> { state.zones.get(name).map(|zone| zone.0.clone()) } +pub async fn add_tsig_key( + center: &Arc
, + name: Name>, + alg: domain::tsig::Algorithm, + secret: &[u8], +) -> Result { + crate::tsig::import_key(center, name.clone(), alg, secret, false) + .map_err(|ImportError::AlreadyExists| TsigAddError::AlreadyExists)?; + + info!("Added TSIG key '{name}'"); + + Ok(TsigAddResult) +} + //----------- State ------------------------------------------------------------ /// Global state for Cascade. @@ -362,6 +437,8 @@ pub enum ZoneAddError { NoSuchPolicy, /// The specified policy is being deleted. PolicyMidDeletion, + /// No TSIG key with that name exists. + NoSuchTsigKey, /// Some other error occurred. Other(String), } @@ -374,6 +451,7 @@ impl fmt::Display for ZoneAddError { Self::AlreadyExists => "a zone of this name already exists", Self::NoSuchPolicy => "no policy with that name exists", Self::PolicyMidDeletion => "the specified policy is being deleted", + Self::NoSuchTsigKey => "no TSIG key with that name exists", Self::Other(reason) => reason, }) } @@ -385,6 +463,7 @@ impl From for api::ZoneAddError { ZoneAddError::AlreadyExists => Self::AlreadyExists, ZoneAddError::NoSuchPolicy => Self::NoSuchPolicy, ZoneAddError::PolicyMidDeletion => Self::PolicyMidDeletion, + ZoneAddError::NoSuchTsigKey => Self::NoSuchTsigKey, ZoneAddError::Other(reason) => Self::Other(reason), } } diff --git a/src/loader/mod.rs b/src/loader/mod.rs index 0c3d65e2c..5bb26dd71 100644 --- a/src/loader/mod.rs +++ b/src/loader/mod.rs @@ -310,6 +310,22 @@ pub enum Source { }, } +impl std::fmt::Display for Source { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Source::None => f.write_str("none"), + Source::Zonefile { path } => write!(f, "zone file '{path}'"), + Source::Server { addr, tsig_key } => { + write!(f, "{addr}")?; + if let Some(tsig_key) = &tsig_key { + write!(f, " with TSIG key '{}'", tsig_key.name())?; + } + Ok(()) + } + } + } +} + //============ Metrics ========================================================= //----------- LoadMetrics ------------------------------------------------------ diff --git a/src/loader/zone.rs b/src/loader/zone.rs index 5988ed8be..df1c67c52 100644 --- a/src/loader/zone.rs +++ b/src/loader/zone.rs @@ -46,7 +46,7 @@ impl LoaderZoneHandle<'_> { /// A (soft) refresh will be initiated via [`Self::enqueue_refresh()`]. pub fn set_source(&mut self, source: Source) { info!( - "Setting source of zone '{}' from '{:?}' to '{source:?}'", + "Setting source of zone '{}' from '{}' to '{source}'", self.zone.name, self.state.loader.source ); diff --git a/src/main.rs b/src/main.rs index d6236347b..6b1e6a305 100644 --- a/src/main.rs +++ b/src/main.rs @@ -138,9 +138,14 @@ fn main() -> ExitCode { // Load all policies. let mut updates = Vec::new(); - let res = policy::reload_all(&mut state.policies, &config, |name, _| { - updates.push(name.clone()); - }); + let res = policy::reload_all( + &mut state.policies, + &config, + &state.tsig_store, + |name, _| { + updates.push(name.clone()); + }, + ); if let Err(err) = res { error!("Cascade couldn't load all policies: {err}"); diff --git a/src/policy/file/v1.rs b/src/policy/file/v1.rs index cc0bfce37..fceb15148 100644 --- a/src/policy/file/v1.rs +++ b/src/policy/file/v1.rs @@ -2,10 +2,11 @@ use std::{ fmt::{self, Display}, - net::{AddrParseError, IpAddr, SocketAddr}, + net::{IpAddr, SocketAddr}, str::FromStr, }; +use domain::tsig::KeyName; use serde::{ Deserialize, Serialize, de::{self, Visitor}, @@ -156,6 +157,13 @@ pub struct KeyManagerSpec { /// How keys are generated. pub generation: KeyManagerGenerationSpec, + + /// The upstream nameservers to use when checking for RRSIG propagation + /// during a key roll. The value is a list of strings. Each string has the following + /// syntax: `:[^].` + /// The port is mandatory. The TSIG key name is optional and the name + /// of the key is preceded by a caret character (`^`). + pub publication_nameservers: Vec, } //--- Conversion @@ -251,6 +259,11 @@ impl KeyManagerSpec { default_ttl: self.records.ttl.as_ttl(), ds_algorithm: self.ds_algorithm, auto_remove: self.auto_remove, + publication_nameservers: self + .publication_nameservers + .into_iter() + .map(|v| v.parse()) + .collect(), } } @@ -282,6 +295,11 @@ impl KeyManagerSpec { ds_algorithm: policy.ds_algorithm.clone(), auto_remove: policy.auto_remove, + publication_nameservers: policy + .publication_nameservers + .iter() + .map(NameserverCommsSpec::build) + .collect(), records: KeyManagerRecordsSpec { ttl: TimeSpan::from_ttl(policy.default_ttl), @@ -330,6 +348,7 @@ impl Default for KeyManagerSpec { algorithm: Default::default(), ds_algorithm: DsAlgorithm::Sha256, auto_remove: true, + publication_nameservers: Default::default(), records: Default::default(), generation: Default::default(), } @@ -914,7 +933,10 @@ impl OutboundSpec { /// Policy for communicating with another namesever. #[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(untagged, expecting = "a string ('[:]') or an inline table")] +#[serde( + untagged, + expecting = "a string ('[:][^]') or an inline table" +)] pub enum NameserverCommsSpec { /// A simple notify specification. Simple(SimpleNameserverCommsSpec), @@ -929,7 +951,9 @@ pub enum NameserverCommsSpec { pub struct ComplexNameserverCommsSpec { /// The address to send NOTIFYs to. pub addr: SocketAddr, - // TODO: Support TSIG key names? + + /// An optional TSIG key to sign and authenticate messages with. + tsig_key_name: Option, } /// Policy for communicating with another namesever. @@ -938,7 +962,9 @@ pub struct ComplexNameserverCommsSpec { pub struct SimpleNameserverCommsSpec { /// The address to send NOTIFYs to. pub addr: SocketAddr, - // TODO: Support TSIG key names? + + /// An optional TSIG key to sign and authenticate messages with. + tsig_key_name: Option, } //--- Conversion @@ -954,33 +980,59 @@ impl NameserverCommsSpec { /// Build into this specification. pub fn build(policy: &NameserverCommsPolicy) -> Self { - Self::Complex(ComplexNameserverCommsSpec { addr: policy.addr }) + Self::Complex(ComplexNameserverCommsSpec { + addr: policy.addr, + tsig_key_name: policy.tsig_key_name.clone(), + }) } } impl SimpleNameserverCommsSpec { /// Parse from this specification. pub fn parse(self) -> NameserverCommsPolicy { - NameserverCommsPolicy { addr: self.addr } + NameserverCommsPolicy { + addr: self.addr, + tsig_key_name: self.tsig_key_name, + } } } impl ComplexNameserverCommsSpec { /// Parse from this specification. pub fn parse(self) -> NameserverCommsPolicy { - NameserverCommsPolicy { addr: self.addr } + NameserverCommsPolicy { + addr: self.addr, + tsig_key_name: self.tsig_key_name, + } } } -/// Parse as an IpAddr (assuming port 53), or as a SocketAddr. +/// Parse`[:]` impl FromStr for SimpleNameserverCommsSpec { - type Err = AddrParseError; + type Err = String; fn from_str(s: &str) -> Result { + let (tsig_key_name, s) = s.split_once('^').unwrap_or(("", s)); + + let tsig_key_name = if !tsig_key_name.is_empty() { + Some( + KeyName::from_str(tsig_key_name) + .map_err(|err| format!("Invalid TSIG key name '{tsig_key_name}': {err}"))?, + ) + } else { + None + }; + let addr = IpAddr::from_str(s) .map(|ip| SocketAddr::new(ip, 53)) - .or_else(|_| SocketAddr::from_str(s))?; - Ok(SimpleNameserverCommsSpec { addr }) + .or_else(|_| { + SocketAddr::from_str(s) + .map_err(|err| format!("Invalid socket address '{s}': {err}")) + })?; + Ok(SimpleNameserverCommsSpec { + addr, + tsig_key_name, + }) } } diff --git a/src/policy/mod.rs b/src/policy/mod.rs index 48e9686e4..06ee7f663 100644 --- a/src/policy/mod.rs +++ b/src/policy/mod.rs @@ -8,9 +8,11 @@ use bytes::Bytes; use camino::Utf8PathBuf; use domain::base::Name; use domain::base::Ttl; +use domain::tsig::KeyName; use serde::{Deserialize, Serialize}; use tracing::{debug, error, info, warn}; +use crate::tsig::TsigStore; use crate::{api::PolicyReloadError, config::Config}; pub mod file; @@ -48,12 +50,17 @@ pub enum PolicyChange { /// Reload all policies. /// /// Any changes are reported via the `on_change` callback. +// Allow the large enum variant caused by TsigKeyName using Name> +// to avoid the conversions that would be needed if Name were to be +// used instead. +#[allow(clippy::result_large_err)] pub fn reload_all( policies: &mut foldhash::HashMap, Policy>, config: &Config, + tsig_store: &TsigStore, mut on_change: impl FnMut(&Box, PolicyChange), ) -> Result<(), PolicyReloadError> { - let new_versions = load_all(policies, config)?; + let new_versions = load_all(policies, config, tsig_store)?; let mut new_policies = foldhash::HashMap::default(); @@ -114,9 +121,14 @@ pub fn reload_all( /// /// The current policies are used for logging purposes so we can log whether /// a policy is new, updated, unchanged or removed. +// Allow the large enum variant caused by TsigKeyName using Name> +// to avoid the conversions that would be needed if Name were to be +// used instead. +#[allow(clippy::result_large_err)] pub fn load_all( policies: &foldhash::HashMap, Policy>, config: &Config, + tsig_store: &TsigStore, ) -> Result, PolicyVersion>, PolicyReloadError> { // Write the loaded policies to a new hashmap, so policies that no longer // exist can be detected easily. @@ -177,6 +189,8 @@ pub fn load_all( .expect("this path points to a readable file, so it must have a file name"); let policy = spec.parse(name); + + check_policy(&policy, tsig_store)?; if policies.contains_key(name) { info!("Reloaded policy '{name}'"); } else { @@ -191,6 +205,30 @@ pub fn load_all( Ok(new_policies) } +/// Perform a semantic check on the loaded policy. +// Allow the large enum variant caused by TsigKeyName using Name> +// to avoid the conversions that would be needed if Name were to be +// used instead. +#[allow(clippy::result_large_err)] +fn check_policy(policy: &PolicyVersion, tsig_store: &TsigStore) -> Result<(), PolicyReloadError> { + // Check the publication nameservers for the key manager. Any TSIG key + // that is part of those nameservers has to exist in the TSIG key store. + let tsig_names = policy + .key_manager + .publication_nameservers + .iter() + .chain(policy.server.outbound.accept_xfr_requests_from.iter()) + .chain(policy.server.outbound.send_notify_to.iter()) + .filter_map(|ns| ns.tsig_key_name.as_ref()); + + for tsig_name in tsig_names { + tsig_store + .get(tsig_name) + .ok_or(PolicyReloadError::NoSuchTsigKey(tsig_name.clone()))?; + } + Ok(()) +} + //----------- PolicyVersion ---------------------------------------------------- /// A particular version of a policy. @@ -278,6 +316,9 @@ pub struct KeyManagerPolicy { /// Automatically remove keys that are no long in use. pub auto_remove: bool, + + /// Nameservers to check for RRSIG propagation during a key roll. + pub publication_nameservers: Vec, } //----------- SignerPolicy ----------------------------------------------------- @@ -453,7 +494,19 @@ pub struct NameserverCommsPolicy { /// /// TODO: Support IP prefixes? pub addr: SocketAddr, - // TODO: Support TSIG key names? + + /// An optional TSIG key to sign and authenticate messages with. + pub tsig_key_name: Option, +} + +impl Display for NameserverCommsPolicy { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.addr)?; + if let Some(tsig_key_name) = &self.tsig_key_name { + write!(f, "^{tsig_key_name}")?; + } + Ok(()) + } } //----------- KeyParameters --------------------------------------------------- diff --git a/src/state/v1.rs b/src/state/v1.rs index 8c1e741ad..0a77a21cd 100644 --- a/src/state/v1.rs +++ b/src/state/v1.rs @@ -8,6 +8,7 @@ use domain::base::Name; use domain::base::Ttl; use serde::{Deserialize, Serialize}; +use crate::policy::file::v1::NameserverCommsSpec; use crate::policy::file::v1::OutboundSpec; use crate::policy::{AutoConfig, DsAlgorithm, KeyParameters}; use crate::{ @@ -241,6 +242,9 @@ pub struct KeyManagerPolicySpec { /// Automatically remove keys that are no long in use. auto_remove: bool, + + /// Nameservers to check for RRSIG propagation during a key roll. + pub publication_nameservers: Vec, } //--- Conversion @@ -268,6 +272,11 @@ impl KeyManagerPolicySpec { ds_algorithm: self.ds_algorithm, default_ttl: self.default_ttl, auto_remove: self.auto_remove, + publication_nameservers: self + .publication_nameservers + .into_iter() + .map(|v| v.parse()) + .collect(), } } @@ -293,6 +302,11 @@ impl KeyManagerPolicySpec { ds_algorithm: policy.ds_algorithm.clone(), default_ttl: policy.default_ttl, auto_remove: policy.auto_remove, + publication_nameservers: policy + .publication_nameservers + .iter() + .map(NameserverCommsSpec::build) + .collect(), } } } diff --git a/src/tsig/file/v1.rs b/src/tsig/file/v1.rs index a6e6615e0..26cc0c9fd 100644 --- a/src/tsig/file/v1.rs +++ b/src/tsig/file/v1.rs @@ -86,9 +86,31 @@ pub struct KeySpec { pub alg: AlgSpec, /// The private key material. + #[serde(with = "tsig_base64")] pub data: Box<[u8]>, } +mod tsig_base64 { + use domain::utils::base64; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub fn serialize(data: &[u8], serializer: S) -> Result + where + S: Serializer, + { + base64::encode_string(data).serialize(serializer) + } + + 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()) + } +} + //--- Conversion impl KeySpec { @@ -129,20 +151,27 @@ impl KeySpec { //----------- AlgSpec ---------------------------------------------------------- /// A TSIG key algorithm specification. +/// +/// A subset of the [IANA TSIG algorithm name registry]. +/// +/// [IANA TSIG algorithm name registry]: https://www.iana.org/assignments/tsig-algorithm-names/tsig-algorithm-names.xhtml #[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] pub enum AlgSpec { - /// SHA-1. - Sha1, + /// hmac-sha1. + #[serde(rename = "hmac-sha1")] + HmacSha1, - /// SHA-256. - Sha256, + /// hmac-sha256. + #[serde(rename = "hmac-sha256")] + HmacSha256, - /// SHA-384, - Sha384, + /// hmac-sha384, + #[serde(rename = "hmac-sha384")] + HmacSha384, - /// SHA-512. - Sha512, + /// hmac-sha512. + #[serde(rename = "hmac-sha512")] + HmacSha512, } //--- Conversion @@ -151,20 +180,20 @@ impl AlgSpec { /// Parse from this specification. pub fn parse(self) -> tsig::Algorithm { match self { - AlgSpec::Sha1 => tsig::Algorithm::Sha1, - AlgSpec::Sha256 => tsig::Algorithm::Sha256, - AlgSpec::Sha384 => tsig::Algorithm::Sha384, - AlgSpec::Sha512 => tsig::Algorithm::Sha512, + AlgSpec::HmacSha1 => tsig::Algorithm::Sha1, + AlgSpec::HmacSha256 => tsig::Algorithm::Sha256, + AlgSpec::HmacSha384 => tsig::Algorithm::Sha384, + AlgSpec::HmacSha512 => tsig::Algorithm::Sha512, } } /// Build into this specification. pub fn build(alg: tsig::Algorithm) -> Self { match alg { - tsig::Algorithm::Sha1 => AlgSpec::Sha1, - tsig::Algorithm::Sha256 => AlgSpec::Sha256, - tsig::Algorithm::Sha384 => AlgSpec::Sha384, - tsig::Algorithm::Sha512 => AlgSpec::Sha512, + tsig::Algorithm::Sha1 => AlgSpec::HmacSha1, + tsig::Algorithm::Sha256 => AlgSpec::HmacSha256, + tsig::Algorithm::Sha384 => AlgSpec::HmacSha384, + tsig::Algorithm::Sha512 => AlgSpec::HmacSha512, } } } diff --git a/src/tsig/mod.rs b/src/tsig/mod.rs index a2b959b31..ace134997 100644 --- a/src/tsig/mod.rs +++ b/src/tsig/mod.rs @@ -81,6 +81,14 @@ impl TsigStore { }); self.enqueued_save = Some(task); } + + pub fn get(&self, key_name: &tsig::KeyName) -> Option<&TsigKey> { + self.map.get(key_name) + } + + pub fn get_mut(&mut self, key_name: &tsig::KeyName) -> Option<&mut TsigKey> { + self.map.get_mut(key_name) + } } //----------- Actions ---------------------------------------------------------- @@ -200,7 +208,16 @@ pub fn import_key( }); } } - state.tsig_store.mark_dirty(center); + + // Release the lock before calling save_now() as it will attempt to + // acquire the same lock. + drop(state); + + // Ensure that the TSIG key store is persisted to disk before a zone add + // causes `dnst keyset` to attempt to read the added TSIG key from the + // on-disk copy of the key store. + save_now(center); + Ok(()) } @@ -250,16 +267,81 @@ pub fn generate_key( pub fn remove_key(center: &Arc
, name: &tsig::KeyName) -> Result<(), RemoveError> { // Lock the global state and try to remove the key. let mut state = center.state.lock().unwrap(); + + // Currently if a zone was added with `--source + // [:]^` that would cause the TSIG key to be used + // by the loader when refreshing the zone. + // + // In future policies may refer to TSIG keys in a couple of places: + // + // 1. In server outbound settings for signing NOTIFY, SOA and XFR messages + // to downstream nameservers. + // 2. In key manager settings for instructing dnst keyset which nameserver + // to query to sanity check the signed zone contents, with a TSIG key if + // one is needed to authenticate to the specified nameserver in order to + // do XFR. + // + // So we need to check all of these places to see if a key is in use. + + if !state.tsig_store.map.contains_key(name) { + return Err(RemoveError::NotFound); + } + + // Is the TSIG key in use with a zone source? + if state.zones.iter().any(|z| { + let zone_state = z.0.state.lock().unwrap(); + matches!(zone_state.loader.source, crate::loader::Source::Server { tsig_key: Some(ref key), .. } if name == key.name()) + }) { + return Err(RemoveError::Used); + } + + // Is the TSIG key referenced by any active (not being deleted) policy? + let tsig_key_found = state + .policies + .values() + .filter_map(|p| (!p.mid_deletion).then_some(&p.latest)) + .any(|p| { + p.key_manager + .publication_nameservers + .iter() + .any(|ns| ns.tsig_key_name.as_ref() == Some(name)) + || p.server + .outbound + .accept_xfr_requests_from + .iter() + .any(|acl| acl.tsig_key_name.as_ref() == Some(name)) + || p.server + .outbound + .send_notify_to + .iter() + .any(|acl| acl.tsig_key_name.as_ref() == Some(name)) + }); + + if tsig_key_found { + // TODO: Indicate to the operator where the key is in use. + return Err(RemoveError::Used); + } + + // Delete the TSIG key. The TSIG key store has a set of zones that + // refer to the key to avoid having to lock and inspect zone state, + // so we can also find that the TSIG key is still referenced there + // if an operation to remove a zone hasn't cleaned up the reference + // to the zone in the TSIG store yet (even though its source no + // longer refers to it in the check we did above - can this ever + // happen?). match state.tsig_store.map.entry(name.clone()) { hash_map::Entry::Occupied(entry) => { if !entry.get().zones.is_empty() { + // TODO: Indicate to the operator where the key is in use. return Err(RemoveError::Used); } entry.remove_entry(); } hash_map::Entry::Vacant(_) => return Err(RemoveError::NotFound), } + state.tsig_store.mark_dirty(center); + Ok(()) } diff --git a/src/units/http_server.rs b/src/units/http_server.rs index ffaac3345..3324bcc3f 100644 --- a/src/units/http_server.rs +++ b/src/units/http_server.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::future::IntoFuture; use std::sync::Arc; use std::sync::atomic::Ordering::Relaxed; @@ -17,6 +18,7 @@ use bytes::Bytes; use domain::base::Name; use domain::base::Serial; use domain::dnssec::sign::keys::keyset::KeyType; +use domain::utils::base64; use domain_kmip::ConnectionSettings; use domain_kmip::dep::kmip::client::pool::ConnectionManager; use serde::Deserialize; @@ -39,6 +41,7 @@ use crate::policy::SignerDenialPolicy; use crate::policy::SignerSerialPolicy; use crate::server::LoadedReviewServer; use crate::server::SignedReviewServer; +use crate::tsig::{self, RemoveError}; use crate::units::key_manager::KmipClientCredentials; use crate::units::key_manager::KmipClientCredentialsFile; use crate::units::key_manager::KmipServerCredentialsFileMode; @@ -99,6 +102,9 @@ impl HttpServer { .route("/status", get(Self::status)) .route("/status/keys", get(Self::status_keys)) .route("/debug/change-logging", post(Self::change_logging)) + .route("/tsig/", get(Self::tsig_key_list)) + .route("/tsig/add", post(Self::tsig_key_add)) + .route("/tsig/{name}/remove", post(Self::tsig_key_remove)) .route("/zone/", get(Self::zones_list)) .route("/zone/add", post(Self::zone_add)) // TODO: .route("/zone/{name}/", get(Self::zone_get)) @@ -379,7 +385,6 @@ impl HttpServer { loader::Source::Server { addr, tsig_key: _ } => api::ZoneSource::Server { addr, tsig_key: None, - xfr_status: Default::default(), }, }; unsigned_review_addr = state @@ -845,20 +850,25 @@ impl HttpServer { .collect::>(); let mut changed = false; let mut updates = Vec::new(); - let res = crate::policy::reload_all(&mut state.policies, ¢er.config, |name, change| { - changed = true; - - changes.insert( - name.clone(), - match change { - crate::policy::PolicyChange::Removed { .. } => PolicyChange::Removed, - crate::policy::PolicyChange::Updated { .. } => PolicyChange::Updated, - crate::policy::PolicyChange::Added { .. } => PolicyChange::Added, - }, - ); + let res = crate::policy::reload_all( + &mut state.policies, + ¢er.config, + &state.tsig_store, + |name, change| { + changed = true; + + changes.insert( + name.clone(), + match change { + crate::policy::PolicyChange::Removed { .. } => PolicyChange::Removed, + crate::policy::PolicyChange::Updated { .. } => PolicyChange::Updated, + crate::policy::PolicyChange::Added { .. } => PolicyChange::Added, + }, + ); - updates.push((name.clone(), change)); - }); + updates.push((name.clone(), change)); + }, + ); if let Err(err) = res { return Json(Err(err)); @@ -1135,6 +1145,68 @@ impl HttpServer { Json(KeyStatusResult { expirations, zones }) } + + async fn tsig_key_add( + State(state): State>, + Json(tsig_add): Json, + ) -> Json> { + let Ok(secret) = base64::decode::>(&tsig_add.secret) else { + return Json(Err(TsigAddError::InvalidBase64Secret)); + }; + + let alg = match tsig_add.alg { + TsigAlgorithm::HmacSha1 => domain::tsig::Algorithm::Sha1, + TsigAlgorithm::HmacSha256 => domain::tsig::Algorithm::Sha256, + TsigAlgorithm::HmacSha384 => domain::tsig::Algorithm::Sha384, + TsigAlgorithm::HmacSha512 => domain::tsig::Algorithm::Sha512, + }; + + match center::add_tsig_key(&state.center, tsig_add.name, alg, &secret).await { + Ok(TsigAddResult) => Json(Ok(TsigAddResult)), + Err(err) => Json(Err(err)), + } + } + + async fn tsig_key_remove( + State(http_server_state): State>, + Path(tsig_key_name): Path, + ) -> Json> { + let result = tsig::remove_key(&http_server_state.center, &tsig_key_name) + .map_err(|e| match e { + RemoveError::NotFound => TsigRemoveError::NotFound, + RemoveError::Used => TsigRemoveError::InUse, + }) + // Map Ok value that we don't use. + .map(|_| TsigRemoveResult); + if result.is_err() { + return Json(result); + } + + Json(Ok(TsigRemoveResult)) + } + + async fn tsig_key_list(State(http_state): State>) -> Json { + let state = http_state.center.state.lock().unwrap(); + let mut tsig_keys = HashMap::new(); + for tsig_key_name in state.tsig_store.map.keys() { + let zones = state + .zones + .iter() + .filter_map(|zone| { + let zone_state = zone.0.state.lock().unwrap(); + match &zone_state.loader.source { + loader::Source::Server { + tsig_key: Some(tsig_key), + .. + } if tsig_key.name() == tsig_key_name => Some(zone.0.name.clone()), + _ => None, + } + }) + .collect::>(); + tsig_keys.insert(tsig_key_name.clone(), TsigKeyInfo { zones }); + } + Json(TsigListResult { tsig_keys }) + } } //------------ HttpServer Handler for /kmip ---------------------------------- diff --git a/src/units/key_manager.rs b/src/units/key_manager.rs index 47b6e2016..752320552 100644 --- a/src/units/key_manager.rs +++ b/src/units/key_manager.rs @@ -248,7 +248,7 @@ impl KeyManager { tokio::spawn(async move { // Keep it simple, just send all config items to keyset even // if they didn't change. - let config_commands = policy_to_commands(&new); + let config_commands = policy_to_commands(¢er, &new); for c in config_commands { let mut cmd = Self::keyset_cmd(¢er, name.clone(), RecordingMode::Record); cmd.arg("set"); @@ -380,7 +380,7 @@ impl KeyManager { // Pass `set` and `import` commands to `dnst keyset`. let config_commands = imports_to_commands(key_imports).into_iter().chain( - policy_to_commands(&policy.latest) + policy_to_commands(center, &policy.latest) .into_iter() .chain({ match var("CASCADE_FAKETIME") { @@ -686,7 +686,7 @@ macro_rules! strs { }; } -fn policy_to_commands(policy: &PolicyVersion) -> Vec> { +fn policy_to_commands(center: &Arc
, policy: &PolicyVersion) -> Vec> { let km = &policy.key_manager; let mut algorithm_cmd = vec!["algorithm".to_string()]; @@ -710,7 +710,30 @@ fn policy_to_commands(policy: &PolicyVersion) -> Vec> { let seconds = |x| format!("{x}s"); - vec![ + let mut cmds = vec![]; + + if km + .publication_nameservers + .iter() + .any(|ns| ns.tsig_key_name.is_some()) + { + let tsig_store_cmd = vec![ + "tsig-store-path".to_string(), + center.config.tsig_store_path.as_str().to_string(), + ]; + cmds.push(tsig_store_cmd); + } + + let mut publication_nameservers_cmd = vec!["publication-nameservers".to_string()]; + publication_nameservers_cmd.append( + &mut km + .publication_nameservers + .iter() + .map(ToString::to_string) + .collect(), + ); + + cmds.extend([ strs!["use-csk", km.use_csk], algorithm_cmd, strs!["ksk-validity", validity(km.ksk_validity)], @@ -756,7 +779,9 @@ fn policy_to_commands(policy: &PolicyVersion) -> Vec> { strs!["ds-algorithm", km.ds_algorithm], strs!["default-ttl".to_string(), km.default_ttl.as_secs(),], strs!["autoremove", km.auto_remove], - ] + publication_nameservers_cmd, + ]); + cmds } //============ KMIP Credential Management ==================================== diff --git a/src/units/zone_server.rs b/src/units/zone_server.rs index 792c92526..bfea55287 100644 --- a/src/units/zone_server.rs +++ b/src/units/zone_server.rs @@ -698,8 +698,23 @@ impl Notifiable for LoaderNotifier { // Don't do anything if the notifier is disabled. if self.enabled && class == Class::IN { // Propagate a request for the zone refresh. - // This request ignores the serial and source because we will just - // do a SOA query to our configured upstreams. + // + // We ignore the serial because we will just do a SOA query to our + // configured upstream. + // + // TODO: Do we want to try enforcing IP address based access + // control at this point? Would we want CIDR matching support? + // Would we want to require a DNS COOKIE if the transport is UDP? + // + // TODO: Do we want to try enforcing TSIG key based access control + // at this point? We can determine the key that the zone source + // is configured to use but we can't actually verify that that key + // was used. The TsigMiddlewareSvc will have ensured that the a + // valid key present in our key store was used, but that may not + // be the actual key configured on the zone source. We cannot test + // for the actual correct key because NotifyMiddlewareSvc that + // invokes us doesn't pass us the Request from which we would be + // able to learn the used TSIG key. let center = &self.center; if let Some(zone) = crate::center::get_zone(center, apex_name) { info!("Instructing zone loader to refresh zone '{apex_name}"); diff --git a/src/zone/state/v1.rs b/src/zone/state/v1.rs index 53c4e9de6..81e684153 100644 --- a/src/zone/state/v1.rs +++ b/src/zone/state/v1.rs @@ -11,7 +11,7 @@ use domain::{base::Name, rdata::dnssec::Timestamp}; use serde::{Deserialize, Serialize}; use crate::loader::Source; -use crate::policy::file::v1::OutboundSpec; +use crate::policy::file::v1::{NameserverCommsSpec, OutboundSpec}; use crate::policy::{AutoConfig, DsAlgorithm, KeyParameters}; use crate::zone::HistoryItem; use crate::{ @@ -244,6 +244,9 @@ pub struct KeyManagerPolicySpec { /// Automatically remove keys that are no long in use. auto_remove: bool, + + /// Nameservers to check for RRSIG propagation during a key roll. + publication_nameservers: Vec, } //--- Conversion @@ -271,6 +274,11 @@ impl KeyManagerPolicySpec { ds_algorithm: self.ds_algorithm, default_ttl: self.default_ttl, auto_remove: self.auto_remove, + publication_nameservers: self + .publication_nameservers + .into_iter() + .map(|v| v.parse()) + .collect(), } } @@ -296,6 +304,11 @@ impl KeyManagerPolicySpec { ds_algorithm: policy.ds_algorithm.clone(), default_ttl: policy.default_ttl, auto_remove: policy.auto_remove, + publication_nameservers: policy + .publication_nameservers + .iter() + .map(NameserverCommsSpec::build) + .collect(), } } }