diff --git a/Cargo.toml b/Cargo.toml index 55d655f7d..911359955 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -247,6 +247,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"], @@ -301,6 +302,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/Cargo.toml b/crates/api/Cargo.toml index 70f7cb4d5..a7d799bd6 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -32,7 +32,7 @@ features = ["serde1"] [dependencies.domain] workspace = true # TODO: Enable and use 'new::base'? -features = ["bytes", "serde"] +features = ["bytes", "serde", "tsig"] # The API uses 'serde' for transforming high-level types to and from the # underlying wire format. diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index ed7b07afb..aae312614 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1,8 +1,10 @@ +use std::collections::HashMap; use std::fmt::{self, Display}; use std::net::{IpAddr, SocketAddr}; use std::time::{Duration, SystemTime}; use camino::{Utf8Path, Utf8PathBuf}; +use domain::tsig::KeyName; use serde::{Deserialize, Serialize}; pub use domain::base::Serial; @@ -187,6 +189,94 @@ pub struct KmipKeyImport { pub flags: String, } +//----------- TsigKeyName ----------------------------------------------------- + +/// The name of a TSIG key. +pub type TsigKeyName = domain::tsig::KeyName; + +//----------- 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 TsigListResultItem { + /// 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 +296,8 @@ pub enum ZoneAddError { AlreadyExists, NoSuchPolicy, PolicyMidDeletion, + InvalidTsigKeyName(String), + NoSuchTsigKey, Other(String), } @@ -215,6 +307,8 @@ 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::InvalidTsigKeyName(reason) => reason, + Self::NoSuchTsigKey => "no TSIG key with that name exists", Self::Other(reason) => reason, }) } @@ -290,18 +384,30 @@ impl Display for ZoneSource { } } +/// Support parsing of ``-source`` command line arguments. +/// +/// Supported forms: +/// - `[:][^]` +/// - `` impl From<&str> for ZoneSource { - fn from(s: &str) -> Self { + fn from(mut s: &str) -> Self { + // Split out any provided TSIG key from the rest of the + // source argument. + let tsig_key = s.split_once('^').map(|(new_s, k)| { + s = new_s; + k.to_string() + }); + if let Ok(addr) = s.parse::() { ZoneSource::Server { addr, - tsig_key: None, + tsig_key, xfr_status: Default::default(), } } else if let Ok(addr) = s.parse::() { ZoneSource::Server { addr: SocketAddr::new(addr, DEFAULT_AXFR_PORT), - tsig_key: None, + tsig_key, xfr_status: Default::default(), } } else { @@ -501,6 +607,26 @@ impl Display for KeyType { } } +#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub enum TsigAlgorithm { + Sha1, + Sha256, + Sha384, + Sha512, +} + +impl Display for TsigAlgorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TsigAlgorithm::Sha1 => "hmac-sha1", + TsigAlgorithm::Sha256 => "hmac-sha256", + TsigAlgorithm::Sha384 => "hmac-sha384", + TsigAlgorithm::Sha512 => "hmac-sha512", + } + .fmt(f) + } +} + #[derive(Deserialize, Serialize, Debug, Clone)] pub struct ZoneHistory { pub history: Vec, @@ -660,12 +786,15 @@ pub struct KeyMsg { #[derive(Deserialize, Serialize, Debug, Clone)] pub enum PolicyReloadError { Io(Utf8PathBuf, String), + Check(String), } 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) => format!("{p}: {e}").fmt(f), + PolicyReloadError::Check(e) => e.to_string().fmt(f), + } } } @@ -749,12 +878,19 @@ pub struct OutboundPolicyInfo { #[derive(Deserialize, Serialize, Debug, Clone)] pub struct NameserverCommsPolicyInfo { - pub addr: SocketAddr, + pub addr: Option, + pub tsig_key_name: Option, } impl std::fmt::Display for NameserverCommsPolicyInfo { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.addr) + if let Some(addr) = self.addr { + write!(f, "{addr}")?; + } + if let Some(tsig_key_name) = &self.tsig_key_name { + write!(f, "^{tsig_key_name}")?; + } + Ok(()) } } 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..f1ffad67e --- /dev/null +++ b/crates/cli/src/commands/tsig.rs @@ -0,0 +1,204 @@ +use std::str::FromStr; + +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 to Cascade. + #[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. + #[arg(requires = "secret")] + alg: Option, + + /// Base64 encoded secret key bytes. + /// + /// Can be omitted if provided as part of the name. + /// Required if `[ALG]` is provided. + #[arg(requires = "alg")] + secret: Option, + }, + + /// Remove a TSIG key from Cascade. + #[command(name = "remove")] + Remove { name: TsigKeyName }, + + /// List the TSIG keys known to Cascade. + #[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)) => (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(", "); + + print!("{tsig_key_name}: used by 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 recognized TSIG algorithm")), + } + } +} + +impl From for crate::api::TsigAlgorithm { + fn from(alg: TsigAlgorithm) -> Self { + match alg { + TsigAlgorithm::HmacSha1 => cascade_api::TsigAlgorithm::Sha1, + TsigAlgorithm::HmacSha256 => cascade_api::TsigAlgorithm::Sha256, + TsigAlgorithm::HmacSha384 => cascade_api::TsigAlgorithm::Sha384, + TsigAlgorithm::HmacSha512 => cascade_api::TsigAlgorithm::Sha512, + } + } +} diff --git a/doc/manual/build/man/cascade-debug.1 b/doc/manual/build/man/cascade-debug.1 index 5848ae130..cd8db02e2 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 13, 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..73c81b3dc 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 13, 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..3ff0f301d 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 13, 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..c638cbe83 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 13, 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..88eaface5 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 13, 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..ffeb461d9 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 13, 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..5f649d618 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 13, 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..3c38630d2 --- /dev/null +++ b/doc/manual/build/man/cascade-tsig.1 @@ -0,0 +1,134 @@ +.\" 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 13, 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 RFC 8945 TSIG keys for authenticating zone transfers. +.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 +Register a new TSIG key. +.UNINDENT +.INDENT 0.0 +.TP +.B list +List registered TSIG keys and the zones that use them. +.UNINDENT +.INDENT 0.0 +.TP +.B add +Remove a registered 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 any zone exists that is configured to authenticate with an +.INDENT 0.0 +.INDENT 3.5 +upstream source using the specified TSIG key. +.UNINDENT +.UNINDENT +.UNINDENT +.UNINDENT +.UNINDENT +.SH ARGUMENTS FOR TSIG ADD +.INDENT 0.0 +.TP +.B +The name of the TSIG key to add. +.sp +Alternatively this argument also supports dig syntax for specifying all of +the TSIG properties at once in colon separated form. The colon separated +syntax cannot be used in combination with the \fB\-\-alg\fP and \fB\-\-secret\fP +options. If \fB\fP is not specified it defaults to SHA256. +.UNINDENT +.INDENT 0.0 +.TP +.B +The TSIG algorithm of the specified TSIG key. Can be one of: hmac\-sha1, +hmac\-sha256, hmac\-sha384 or hmac\-sha512. +.UNINDENT +.INDENT 0.0 +.TP +.B +A base64 encoded string defining the actual TSIG key material bytes. +.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..d1e2caa47 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 13, 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. +Register a new zone. The zone will be loaded, signed and published. .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 fetech +the zone! +.UNINDENT +.UNINDENT .UNINDENT .INDENT 0.0 .TP @@ -115,8 +123,40 @@ Get the history of a single zone. .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. +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.\(ga +.sp +When specifying an upstream nameserver you may also optionally suffix it +with \fB^\fP to indicate that the specified RFC 8945 TSIG key +should be used to sign any SOA, AXFR and IXFR queries that will be sent to +the upstream source. +.sp +Zones sourced from an upstream nameserver will be automatically updated if +a new version is detected. This can happen if the upstream nameserver sends +an RFC 1996 NOTIFY message to Cascade, or if an IXFR or SOA query (if the +upstream responds with NOTIMP to an IXFR request) sent by Cascade (due to a +SOA timer expiring) discovers that a newer SOA SERIAL is available, or due +to an operator issuing a \fIzone reload\fP command. 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 providing the path to a zone file to load, if \fBzone +add\fP is executed on a different host than where the \fBcascaded\fP +daemon is running the path must be valid on the \fBdaemon\fP host. +.UNINDENT +.UNINDENT +.sp +\fBNOTE:\fP +.INDENT 7.0 +.INDENT 3.5 +Note: In order to use a TSIG key you MUST also supply the key to +Cascade via \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..ba905959c 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 13, 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..c18e61275 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 13, 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..989fee49d 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 13, 2026" "0.1.0-alpha5" "Cascade" .SH NAME cascaded-policy.toml \- Cascade policy file format .sp diff --git a/doc/manual/build/man/cascaded.1 b/doc/manual/build/man/cascaded.1 index ed91e2133..b6fdaa3be 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 13, 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/index.rst b/doc/manual/source/index.rst index 503d22c82..be59ef875 100644 --- a/doc/manual/source/index.rst +++ b/doc/manual/source/index.rst @@ -112,8 +112,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 +169,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/man/cascade-tsig.rst b/doc/manual/source/man/cascade-tsig.rst new file mode 100644 index 000000000..f446d23f8 --- /dev/null +++ b/doc/manual/source/man/cascade-tsig.rst @@ -0,0 +1,89 @@ +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 transfers. + +.. 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 + + Register a new TSIG key. + +.. subcmd:: list + + List registered TSIG keys and the zones that use them. + +.. subcmd:: remove + + Remove a registered TSIG key. + + .. note:: Returns an error if the key does not exist in the TSIG key store + or if any zone exists that is configured to authenticate with an + upstream source using the specified TSIG key. + +Arguments for :subcmd:`tsig add` +-------------------------------- + +.. option:: + + The name of the TSIG key to add. + + Alternatively this argument also supports dig syntax for specifying all of + the TSIG properties at once in colon separated form. The colon separated + syntax cannot be used in combination with the ``--alg`` and ``--secret`` + options. If ```` is not specified it defaults to SHA256. + +.. option:: + + The TSIG algorithm of the specified TSIG key. Can be one of: hmac-sha1, + hmac-sha256, hmac-sha384 or hmac-sha512. + +.. option:: + + A base64 encoded string defining the actual TSIG key material bytes. + +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..19bf284a6 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. + Register a new zone. The zone will be loaded, signed and published. .. subcmd:: remove Remove a zone. + .. note:: Once removed downstream servers will no longer be able to fetech + the zone! + .. subcmd:: list List registered zones. @@ -85,8 +88,30 @@ Options for :subcmd:`zone add` .. option:: --source - The zone source can be an IP address (with or without port, defaults to port - 53) or a file path. + 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 suffix it + with ``^`` to indicate that the specified RFC 8945 TSIG key + should be used to sign any SOA, AXFR and IXFR queries that will be sent to + the upstream source. + + Zones sourced from an upstream nameserver will be automatically updated if + a new version is detected. This can happen if the upstream nameserver sends + an RFC 1996 NOTIFY message to Cascade, or if an IXFR or SOA query (if the + upstream responds with NOTIMP to an IXFR request) sent by Cascade (due to a + SOA timer expiring) discovers that a newer SOA SERIAL is available, or due + to an operator issuing a `zone reload` command. 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 providing the path to a zone file to load, if :subcmd:`zone + add` is executed on a different host than where the ``cascaded`` + daemon is running the path must be valid on the **daemon** host. + + .. note:: Note: In order to use a TSIG key you MUST also supply the key to + Cascade via :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/nsd.rst b/doc/manual/source/nsd.rst new file mode 100644 index 000000000..b8dd1a2a5 --- /dev/null +++ b/doc/manual/source/nsd.rst @@ -0,0 +1,78 @@ +Integrating with NSD +==================== + +.. epigraph:: + + Name Server Daemon (NSD) by NLnet Labs is an authoritative DNS name server. + + -- https://nsd.docs.nlnetlabs.nl/ + +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 so that NSD will send +a NOTIFY message to Cascade when the zone changes and will allow Cascade to +make an XFR request to receive the update zone. Optionally NSD and Cascade +can be configured with the same TSIG key to authenticate the NOTIFY and XFR +messages. + +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: "zonefile.name" + 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: "zonefile.name" + 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. + +Adding the TSIG key to Cascade is done using the ``cascade tsig add`` CLI +command, e.g. like so: + +.. code-block:: bash + + $ cascade tsig add --name sec1_key --alg hmac-sha256 --secret "...==" + +To use the new TAIG 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/etc/policy.template.toml b/etc/policy.template.toml index 3e27515c0..848de906b 100644 --- a/etc/policy.template.toml +++ b/etc/policy.template.toml @@ -367,5 +367,24 @@ required = false # # If empty, no NOTIFY messages will be sent. # -# A collection of IP:[port], defaulting to port 53 when not specified. +# Each nameserver must be specifeid as a string in the form `":[][^]"`. +# +# A TSIG key name can optionally be specified in order to sign the NOTIFY +# message using the specified TSIG key. The TSIG key must already have been +# added to the Cascade TSIG key store with the `cascade tsig add` CLI command. +# +# Examples: +# +# send-notify-to = [ "127.0.0.1"] +# send-notify-to = [ "127.0.0.1:53"] +# send-notify-to = [ "127.0.0.1:53^my_tsig_key"] +# send-notify-to = [ "127.0.0.1^my_tsig_key"] +# send-notify-to = [ { addr = "127.0.0.1:49", tsig-key-name = "my_tsig_key" } ] +# +# You can also use the TOML array of tables syntax like so: +# +# [[server.outbound.send-notify-to]] +# addr = "127.0.0.1:53" +# tsig-key-name = "my_tsig_key" 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 @@ -325,18 +351,34 @@ remote-control: control-enable: yes control-interface: "${base_dir}/nsd-primary/nsd.sock" +key: + name: "tsig-key" + algorithm: hmac-sha256 + secret: "COzoVsYQmXeXiyq1Quhp0bbVnMyxjPxsaGSoIWR98i0=" + pattern: name: primary zonefile: "%s.primary-zone" - allow-notify: 127.0.0.1 NOKEY provide-xfr: 127.0.0.1 NOKEY notify: 127.0.0.1@${_cascade_port} NOKEY store-ixfr: yes create-ixfr: yes +pattern: + name: primary-tsig + zonefile: "%s.primary-zone" + provide-xfr: 127.0.0.1 tsig-key + notify: 127.0.0.1@${_cascade_port} tsig-key + store-ixfr: yes + create-ixfr: yes + zone: name: example.test include-pattern: primary + +zone: + name: example-tsig.test + include-pattern: primary-tsig EOF tee "${base_dir}/nsd-primary/zones/example.test.primary-zone" <<'EOF' >&2 @@ -358,6 +400,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..d39cd31b6 100644 --- a/integration-tests/system-tests.yml +++ b/integration-tests/system-tests.yml @@ -260,3 +260,42 @@ 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: + rust: [stable] + 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 }} + + downstream-tsig: + name: Using TSIG with downstream nameservers. + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + send-notify-to: [ '', '127.0.0.1:1054', '127.0.0.1:1054^tsig-key' ] + accept-xfr-from: [ '', '127.0.0.1:1054', '127.0.0.1:1054^tsig-key', '^tsig-key' ] + downstream-expects-tsig: [ 0, 1 ] + downstream-uses-tsig: [ 0, 1 ] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/set-build-profile + with: + build-profile: ${{ inputs.build-profile }} + - uses: ./integration-tests/tests/downstream-tsig + with: + log-level: ${{ inputs.log-level }} + send-notify-to: ${{ matrix.send-notify-to }} + accept-xfr-from: ${{ matrix.accept-xfr-from }} + downstream-expects-tsig: ${{ matrix.downstream-expects-tsig }} + downstream-uses-tsig: ${{ matrix.downstream-uses-tsig }} diff --git a/integration-tests/tests/downstream-tsig/action.yml b/integration-tests/tests/downstream-tsig/action.yml new file mode 100644 index 000000000..e26bd1590 --- /dev/null +++ b/integration-tests/tests/downstream-tsig/action.yml @@ -0,0 +1,145 @@ +# 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: 'Using TSIG with downstream nameservers' +description: 'Using TSIG with downstream namservers' +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 + send-notify-to: + description: The IP address, port and name of the TSIG key to use when sending NOTIFY messages to downstream nameservers. + required: false + default: '' + type: string + accept-xfr-from: + description: The IP address, port and name of the TSIG key to permit when replying to SOA and XFR requests from downstream nameservers. + required: false + default: '' + type: string + downstream-expects-tsig: + description: Whether or not the downstream secondary NSD should expect the NOTIFY from Cascade to use TSIG. + required: true + type: number + downstream-uses-tsig: + description: Whether or not the downstream secondary NSD should use TSIG with XFR to Cascade. + required: true + type: number +runs: + using: "composite" + steps: + - uses: ./.github/actions/prepare-systest-env + - uses: ./.github/actions/setup-and-start-cascade + with: + log-level: ${{ inputs.log-level }} + + - name: Determine the downstrem secondary zone config to use + id: set-zone-name + run: | + if [[ ${{ inputs.downstream-expects-tsig }} -eq 0 && ${{ inputs.downstream-uses-tsig }} -eq 0 ]]; then + ZONE_NAME="example.test." + elif [[ ${{ inputs.downstream-expects-tsig }} -eq 0 && ${{ inputs.downstream-uses-tsig }} -eq 1 ]]; then + ZONE_NAME="xfr-tsig.test." + elif [[ ${{ inputs.downstream-expects-tsig }} -eq 1 && ${{ inputs.downstream-uses-tsig }} -eq 0 ]]; then + ZONE_NAME="notify-tsig.test." + else + ZONE_NAME="notify-and-xfr-tsig.test." + fi + echo "ZONE_NAME=${ZONE_NAME}" >> "$GITHUB_OUTPUT" + + - name: Determine if we expect this test to succeed or fail + id: set-expected-outcome + run: | + if [[ "${{ inputs.send-notify-to }}" != *"^"* && ${{ inputs.downstream-expects-tsig }} -eq 0 ]]; then + SUCCESS=1 + elif [[ "${{ inputs.send-notify-to }}" != *"^"* && ${{ inputs.downstream-expects-tsig }} -eq 1 ]]; then + SUCCESS=0 + elif [[ "${{ inputs.send-notify-to }}" == *"^"* && ${{ inputs.downstream-expects-tsig }} -eq 0 ]]; then + SUCCESS=0 + else + SUCCESS=1 + fi + echo "SUCCESS=${SUCCESS}" >> "$GITHUB_OUTPUT" + + - name: Make a zonefile to load zone ${{ steps.set-zone-name.outputs.ZONE_NAME }} + run: | + tee test.zone <<'EOF' + $TTL 5 ; use a very short TTL for sped up keyset rolls + ${{ steps.set-zone-name.outputs.ZONE_NAME }} IN SOA ns1.${{ steps.set-zone-name.outputs.ZONE_NAME }} mail.${{ steps.set-zone-name.outputs.ZONE_NAME }} ( + 1 ; serial + 60 ; refresh (60 seconds) + 60 ; retry (60 seconds) + 3600 ; expire (1 hour) + 5 ; minimum (5 seconds) + ) + @ NS ${{ steps.set-zone-name.outputs.ZONE_NAME }} + @ NS ns1.${{ steps.set-zone-name.outputs.ZONE_NAME }} + @ A 127.0.0.1 + ns1 A 127.0.0.1 + + www A 169.254.1.1 + mail MX 10 ${{ steps.set-zone-name.outputs.ZONE_NAME }} + text TXT "Hello World!" + EOF + + - name: Add a custom policy + run: | + POLICY_DIR=$(integration-tests/scripts/get-default-path.sh policy-dir) + cascade template policy | grep -Ev '(send-notify-to|accept-xfr-requests-from)' > "${POLICY_DIR}/restricted.toml" + if [ "${{ inputs.accept-xfr-from }}" != "" ]; then + echo 'accept-xfr-requests-from = ["${{ inputs.accept-xfr-from }}"]' >> "${POLICY_DIR}/restricted.toml" + fi + if [ "${{ inputs.send-notify-to }}" != "" ]; then + echo 'send-notify-to = ["${{ inputs.send-notify-to }}"]' >> "${POLICY_DIR}/restricted.toml" + fi + if [[ "${{ inputs.accept-xfr-from}}" == *"^tsig-key"* || "${{ inputs.send-notify-to }}" == *"^tsig-key"* ]]; then + cascade tsig add tsig-key hmac-sha256 "COzoVsYQmXeXiyq1Quhp0bbVnMyxjPxsaGSoIWR98i0=" + fi + cascade policy reload + + - name: Add zone ${{ steps.set-zone-name.outputs.ZONE_NAME }} using the custom policy + run: | + cascade zone add --policy restricted --source $PWD/test.zone ${{ steps.set-zone-name.outputs.ZONE_NAME }} + + - name: Wait for zone ${{ steps.set-zone-name.outputs.ZONE_NAME }} to be signed and published + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone status ${{ steps.set-zone-name.outputs.ZONE_NAME }}| grep -q "Published zone available"; do + if (($(date +%s) > (start + timeout))); then + cascade zone status ${{ steps.set-zone-name.outputs.ZONE_NAME }} + echo "timeout: zone status did not report published zone available" >&2 + exit 1 + fi + sleep 1 + done + + - name: Verify that the ${{ steps.set-zone-name.outputs.ZONE_NAME }} zone is being served + run: | + dig @127.0.0.1 -p 4542 ${{ steps.set-zone-name.outputs.ZONE_NAME }} SOA + + - name: Tell NSD that zone ${{ steps.set-zone-name.outputs.ZONE_NAME }} is now available + # Only necessary if NOTIFY is not setup for cascade + if: ${{ inputs.send-notify-to == '' }} + run: | + ./integration-tests/scripts/manage-test-environment.sh control nsd-secondary transfer ${{ steps.set-zone-name.outputs.ZONE_NAME }} + + - name: The ${{ steps.set-zone-name.outputs.ZONE_NAME }} zone should now be available via the secondary + if: ${{ steps.set-expected-outcome.output.SUCCESS }} + run: | + dig @127.0.0.1 -p 1054 text.${{ steps.set-zone-name.outputs.ZONE_NAME }} TXT | grep 'Hello World' + + - name: Print log files on any failure in this job + uses: ./.github/actions/print-logfiles + if: failure() diff --git a/integration-tests/tests/upstream-tsig/action.yml b/integration-tests/tests/upstream-tsig/action.yml new file mode 100644 index 000000000..a60f67a34 --- /dev/null +++ b/integration-tests/tests/upstream-tsig/action.yml @@ -0,0 +1,67 @@ +# 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 +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) + until cascade zone status example-tsig.test | grep -q "Published zone available"; do + if (($(date +%s) > (start + timeout))); then + exit 0 + fi + sleep 1 + done + echo "::error:: zone status unexpectedly reports TSIG required zone was retrieved and published without using the TSIG key" + exit 1 + + - name: Remove and re-add the zone with the required TSIG key. + run: | + cascade zone remove example-tsig.test + cascade tsig add tsig-key hmac-sha256 "COzoVsYQmXeXiyq1Quhp0bbVnMyxjPxsaGSoIWR98i0=" + 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 1cf829632..03e42eeab 100644 --- a/src/center.rs +++ b/src/center.rs @@ -1,6 +1,7 @@ //! Cascade's central command. use std::collections::HashMap; +use std::str::FromStr; use std::{ fmt, io, sync::{Arc, Mutex}, @@ -8,6 +9,7 @@ use std::{ }; use bytes::Bytes; +use cascade_api::{TsigAddError, TsigAddResult}; use domain::base::Name; use domain::rdata::dnssec::Timestamp; use tracing::{debug, error, info, trace}; @@ -17,9 +19,10 @@ use crate::config::RuntimeConfig; use crate::loader::Loader; use crate::loader::zone::LoaderZoneHandle; use crate::server::{LoadedReviewServer, PublicationServer, SignedReviewServer}; +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, @@ -72,11 +75,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(); @@ -87,19 +92,55 @@ 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, + xfr_status: _, + } => { + let tsig_key = if let Some(key_name) = tsig_key { + // Verify that the key name is syntactically valid. + let key_name = Name::from_str(&key_name) + .map_err(|err| ZoneAddError::InvalidTsigKeyName(err.to_string()))?; + + // 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())); + + // Use the found key. + Some(key.inner.clone()) + } else { + None + }; + + crate::loader::Source::Server { addr, tsig_key } + } + }; + let (loaded_reviewer, signed_reviewer, viewer); { let mut zone_state = zone.state.lock().unwrap(); + let policy = state.policies.get_mut(&policy_name).unwrap(); zone_state.policy = Some(policy.latest.clone()); policy.zones.insert(zone.name.clone()); loaded_reviewer = zone_state.storage.loaded_reviewer.take().unwrap(); @@ -142,23 +183,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, @@ -232,6 +256,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. @@ -339,6 +377,10 @@ pub enum ZoneAddError { NoSuchPolicy, /// The specified policy is being deleted. PolicyMidDeletion, + /// The specified TSIG key name is invalid. + InvalidTsigKeyName(String), + /// No TSIG key with that name exists. + NoSuchTsigKey, /// Some other error occurred. Other(String), } @@ -351,6 +393,8 @@ 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::InvalidTsigKeyName(reason) => reason, + Self::NoSuchTsigKey => "no TSIG key with that name exists", Self::Other(reason) => reason, }) } @@ -362,6 +406,8 @@ impl From for api::ZoneAddError { ZoneAddError::AlreadyExists => Self::AlreadyExists, ZoneAddError::NoSuchPolicy => Self::NoSuchPolicy, ZoneAddError::PolicyMidDeletion => Self::PolicyMidDeletion, + ZoneAddError::InvalidTsigKeyName(reason) => Self::InvalidTsigKeyName(reason), + ZoneAddError::NoSuchTsigKey => Self::NoSuchTsigKey, ZoneAddError::Other(reason) => Self::Other(reason), } } diff --git a/src/main.rs b/src/main.rs index dd88a6676..bee480705 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,7 +71,7 @@ fn main() -> ExitCode { } // Load the global state file or build one from scratch. - let mut state = match center::State::init_from_file(&config) { + let state = match center::State::init_from_file(&config) { Err(err) => { if err.kind() != io::ErrorKind::NotFound { error!("Could not load the state file: {err}"); @@ -106,9 +106,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}"); @@ -138,6 +143,23 @@ fn main() -> ExitCode { Ok(mut state) => { info!("Successfully loaded the global state file"); + // Load the TSIG store file. Do this before restoring zones + // because zones may contain references to TSIG keys which can + // only be reconstituted if the key material has already been + // loaded into the store. + // + // TODO: Track which TSIG keys are in use by zones. + match state.tsig_store.init_from_file(&config) { + Ok(()) => debug!("Loaded the TSIG store"), + Err(err) if err.kind() == io::ErrorKind::NotFound => { + debug!("No TSIG store found; will create one"); + } + Err(err) => { + error!("Failed to load the TSIG store: {err}"); + return ExitCode::FAILURE; + } + } + let zone_state_dir = &config.zone_state_dir; let policies = &mut state.policies; for zone in &state.zones { @@ -149,12 +171,23 @@ fn main() -> ExitCode { spec } Err(err) => { - error!("Failed to load zone state '{name}' from '{path}': {err}"); + error!("Failed to load state of zone '{name}' from '{path}': {err}"); return ExitCode::FAILURE; } }; + let tsig_store = &state.tsig_store; let mut state = zone.0.state.lock().unwrap(); - *state = spec.parse(&zone.0, policies); + match spec.parse(&zone.0, policies, tsig_store) { + Ok(restored_state) => *state = restored_state, + Err(err) => { + // We have no way to register a zone in a broken + // state, so even if all other zones and state are + // fine we can't load those and show just this one to + // the user as broken, we can only abort. + error!("Failed to parse state of zone '{name}': {err}"); + return ExitCode::FAILURE; + } + }; } state @@ -173,20 +206,6 @@ fn main() -> ExitCode { ); } - // Load the TSIG store file. - // - // TODO: Track which TSIG keys are in use by zones. - match state.tsig_store.init_from_file(&config) { - Ok(()) => debug!("Loaded the TSIG store"), - Err(err) if err.kind() == io::ErrorKind::NotFound => { - debug!("No TSIG store found; will create one"); - } - Err(err) => { - error!("Failed to load the TSIG store: {err}"); - return ExitCode::FAILURE; - } - } - // Bind to listen addresses before daemonizing. let Ok(socket_provider) = bind_to_listen_sockets_as_needed(&config) else { return ExitCode::FAILURE; diff --git a/src/policy/file/v1.rs b/src/policy/file/v1.rs index 0a803c4ce..4d5699669 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}, @@ -144,6 +145,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 @@ -239,6 +247,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(), } } @@ -270,6 +283,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), @@ -318,6 +336,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(), } @@ -889,7 +908,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), @@ -902,18 +924,32 @@ pub enum NameserverCommsSpec { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct ComplexNameserverCommsSpec { - /// The address to send NOTIFYs to. - pub addr: SocketAddr, - // TODO: Support TSIG key names? + /// The address to send NOTIFYs to or receive XFRs from. + /// + /// For sending NOTIFY it must not be None. + /// + /// For receiving XFR, if None the only restriction is the TSIG key name, + /// if defined, otherwise access is unrestricted. + pub addr: Option, + + /// An optional TSIG key to sign and authenticate messages with. + pub tsig_key_name: Option, } /// Policy for communicating with another namesever. #[derive(Clone, Debug, DeserializeFromStr, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct SimpleNameserverCommsSpec { - /// The address to send NOTIFYs to. - pub addr: SocketAddr, - // TODO: Support TSIG key names? + /// The address to send NOTIFYs to or receive XFRs from. + /// + /// For sending NOTIFY it must not be None. + /// + /// For receiving XFR, if None the only restriction is the TSIG key name, + /// if defined, otherwise access is unrestricted. + pub addr: Option, + + /// An optional TSIG key to sign and authenticate messages with. + pub tsig_key_name: Option, } //--- Conversion @@ -929,33 +965,63 @@ 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 addr = IpAddr::from_str(s) - .map(|ip| SocketAddr::new(ip, 53)) - .or_else(|_| SocketAddr::from_str(s))?; - Ok(SimpleNameserverCommsSpec { addr }) + let (s, tsig_key_name) = s + .split_once('^') + .map(|(s, n)| (s, Some(n))) + .unwrap_or((s, None)); + + let tsig_key_name = tsig_key_name + .map(KeyName::from_str) + .transpose() + .map_err(|err| format!("TSIG key name is invalid: {err}"))?; + + let addr = if s.is_empty() { + None + } else { + Some( + IpAddr::from_str(s) + .map(|ip| SocketAddr::new(ip, 53)) + .or_else(|_| SocketAddr::from_str(s)) + .map_err(|err| format!("Nameserver address is invalid: {err}"))?, + ) + }; + + Ok(SimpleNameserverCommsSpec { + addr, + tsig_key_name, + }) } } diff --git a/src/policy/mod.rs b/src/policy/mod.rs index b42560404..2d6de1e1d 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; @@ -51,9 +53,10 @@ pub enum PolicyChange { 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(); @@ -117,6 +120,7 @@ pub fn reload_all( 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 +181,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 +197,29 @@ pub fn load_all( Ok(new_policies) } +/// Perform a semantic check on the loaded policy. +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::Check(format!( + "unknown TSIG key '{tsig_name}'" + )))?; + } + + Ok(()) +} + //----------- PolicyVersion ---------------------------------------------------- /// A particular version of a policy. @@ -278,6 +307,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 ----------------------------------------------------- @@ -426,7 +458,7 @@ pub struct InboundPolicy { /// /// If empty, the nameserver from which the zone was received will be /// contacted. - pub send_xfr_requests_to: Vec, + pub send_xfr_and_soa_requests_to: Vec, /// The set of nameservers from which may NOTIFY messages may be received. /// @@ -438,15 +470,45 @@ pub struct InboundPolicy { //----------- NameserverCommsPolicy ------------------------------------------- /// Policy for communicating with another namesever. +/// +/// This type serves a dual purpose: +/// - For outbound communication it specifies the address and port of the +/// nameserver to contact, and optionally a TSIG key that should be used +/// to sign outbound requests. When used for this purpose the address and +/// port are mandatory. +/// - For inbound communication this type is intended to support the access +/// control use case, acting as a white list entry. When used for this +/// purpose all fields are optional and typically a port is not specified +/// as the sending port is not known in advance. If neither an address +/// nor TSIG key are specified the meaning is to allow all inbound +/// communication. If a TSIG key is specified a user of this type could +/// for example use it to reject incoming requests that are not signed +/// with that key, either in combination with a sender address check or +/// to allow requests from any sender address as long as the request is +/// signed using the correct TSIG key. How multiple instances of this type +/// are combined is not defined here, e.g. whether all rules must be +/// satisfied or only one or whether order of rules matters. #[derive(Clone, Debug, PartialEq, Eq)] pub struct NameserverCommsPolicy { /// The address to send to/receive from. /// - /// For sending the port MUST NOT be zero. - /// /// TODO: Support IP prefixes? - pub addr: SocketAddr, - // TODO: Support TSIG key names? + pub addr: Option, + + /// 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 { + if let Some(addr) = self.addr { + write!(f, "{addr}")?; + } + if let Some(tsig_key_name) = &self.tsig_key_name { + write!(f, "^{tsig_key_name}")?; + } + Ok(()) + } } //----------- KeyParameters --------------------------------------------------- diff --git a/src/server/service.rs b/src/server/service.rs index b6faecf37..78ef7efc3 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -82,7 +82,9 @@ mod compat { new::base::wire::ParseBytesZC, tsig, }; + use futures::Stream; + use tracing::{debug, trace}; use crate::server::request::{RequestKind, ZoneRequestKind}; @@ -128,6 +130,13 @@ mod compat { return Box::pin(std::future::ready(error(old_request.message(), rcode))); }; + if !is_permitted(&old_request, zone) { + return Box::pin(std::future::ready(error( + old_request.message(), + Rcode::REFUSED, + ))); + } + match zone_request.kind { ZoneRequestKind::Soa => Box::pin({ let viewer = zone.viewer.clone(); @@ -152,6 +161,97 @@ mod compat { } } + /// Enforce any defined access controls for the zone being requested. + fn is_permitted( + request: &Request, Option>>, + zone: &ServedZone, + ) -> bool + where + V: Viewer + Send + Sync + 'static, + { + // IP address/CIDR based access control: + // TODO + + // TSIG access control: + // If the request was TSIG authenticated it would only be passed to + // our service if the TsigMiddlewareSvc verified that the key used + // exists in our key store, so that it can sign the response that we + // generate. + // + // However, having used a known TSIG key is not enough, the key used + // must also have been permitted by the operator for the zone being + // queried. + // + // The TSIG key(s) permitted to sign a zone are defined in policy + // so we have to look that up. + let zone_state = zone.handle.state.lock().unwrap(); + + let policy = zone_state + .policy + .as_ref() + .expect("A zone must always have an associated policy"); + + let outbound_policy = &policy.server.outbound; + + debug!( + "Checking access to zone {} from {} with TSIG key {:?} due to policy '{}'", + zone.handle.name, + request.client_addr(), + request.metadata(), + policy.name + ); + trace!("Policy: {policy:?}"); + + // If no rules are defined, allow the request. + if outbound_policy.accept_xfr_requests_from.is_empty() { + return true; + } + + // At least one access control rule must match in order for the + // request to be accepted. + + for rule in &outbound_policy.accept_xfr_requests_from { + match (rule.addr, &rule.tsig_key_name, request.metadata()) { + // Allow all + (None, None, None) => return true, + + // TSIG key match required + (None, Some(wanted_key), Some(actual_key)) if wanted_key == actual_key.name() => { + // Allow the request. + return true; + } + + // IP address match required + (Some(wanted_addr), None, None) if wanted_addr == request.client_addr() => { + // Allow the request. + return true; + } + + // IP address *and* TSIG key match required + (Some(wanted_addr), Some(wanted_key), Some(actual_key)) + if wanted_addr == request.client_addr() && wanted_key == actual_key.name() => + { + // Allow the request. + return true; + } + + _ => { + // Rule not matched, see if another rule matches + } + } + } + + // Deny the request. + debug!( + "Denying request to zone {} from {} with TSIG key {:?} due to policy '{}'", + zone.handle.name, + request.client_addr(), + request.metadata(), + policy.name + ); + false + } + fn soa(request: &Message>, viewer: &V) -> ResponseStream { if viewer.is_empty() { // The zone is known to exist, but we don't have any data for it. diff --git a/src/state/v1.rs b/src/state/v1.rs index a0cf2b317..36a67e3d4 100644 --- a/src/state/v1.rs +++ b/src/state/v1.rs @@ -8,6 +8,7 @@ use domain::base::Ttl; use serde::{Deserialize, Serialize}; use tracing::info; +use crate::policy::file::v1::NameserverCommsSpec; use crate::policy::file::v1::OutboundSpec; use crate::policy::{AutoConfig, DsAlgorithm, KeyParameters}; use crate::tsig::TsigStore; @@ -255,6 +256,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 @@ -282,6 +286,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(), } } @@ -307,6 +316,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..389f2060b 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,8 @@ pub fn import_key( }); } } - state.tsig_store.mark_dirty(center); + drop(state); + save_now(center); Ok(()) } @@ -250,6 +259,7 @@ 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(); + match state.tsig_store.map.entry(name.clone()) { hash_map::Entry::Occupied(entry) => { if !entry.get().zones.is_empty() { @@ -259,7 +269,9 @@ pub fn remove_key(center: &Arc
, name: &tsig::KeyName) -> Result<(), Remo } 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 bd0f355dd..b0dee2e52 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,8 @@ use bytes::Bytes; use domain::base::Name; use domain::base::Serial; use domain::dnssec::sign::keys::keyset::KeyType; +use domain::tsig::Algorithm; +use domain::utils::base64; use domain_kmip::ConnectionSettings; use domain_kmip::dep::kmip::client::pool::ConnectionManager; use serde::Deserialize; @@ -39,6 +42,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 +103,10 @@ 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("/tsig/{name}/status", get(Self::tsig_key_status)) .route("/zone/", get(Self::zones_list)) .route("/zone/add", post(Self::zone_add)) // TODO: .route("/zone/{name}/", get(Self::zone_get)) @@ -799,20 +807,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)); @@ -905,12 +918,18 @@ impl HttpServer { accept_xfr_requests_from: p_outbound .accept_xfr_requests_from .iter() - .map(|v| NameserverCommsPolicyInfo { addr: v.addr }) + .map(|v| NameserverCommsPolicyInfo { + addr: v.addr, + tsig_key_name: v.tsig_key_name.clone(), + }) .collect(), send_notify_to: p_outbound .send_notify_to .iter() - .map(|v| NameserverCommsPolicyInfo { addr: v.addr }) + .map(|v| NameserverCommsPolicyInfo { + addr: v.addr, + tsig_key_name: v.tsig_key_name.clone(), + }) .collect(), }, }; @@ -1089,6 +1108,50 @@ 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::Sha1 => Algorithm::Sha1, + TsigAlgorithm::Sha256 => Algorithm::Sha256, + TsigAlgorithm::Sha384 => Algorithm::Sha384, + TsigAlgorithm::Sha512 => 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 res = tsig::remove_key(&http_server_state.center, &tsig_key_name) + .map(|_| TsigRemoveResult) + .map_err(|e| match e { + RemoveError::NotFound => TsigRemoveError::NotFound, + RemoveError::Used => TsigRemoveError::InUse, + }); + Json(res) + } + + 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, key) in state.tsig_store.map.iter() { + let zones = key.zones.iter().map(|item| item.0.name.clone()).collect(); + tsig_keys.insert(tsig_key_name.clone(), TsigListResultItem { 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..cdfa5df95 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.append(&mut vec![ 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 779aed87d..0f2afbac2 100644 --- a/src/units/zone_server.rs +++ b/src/units/zone_server.rs @@ -1,6 +1,6 @@ use std::future::Future; use std::marker::Sync; -use std::net::{IpAddr, SocketAddr}; +use std::net::IpAddr; use std::pin::Pin; use std::process::Stdio; use std::sync::Arc; @@ -23,6 +23,7 @@ use domain::net::server::middleware::tsig::TsigMiddlewareSvc; use domain::net::server::service::Service; use domain::net::server::stream::{self, StreamServer}; use domain::tsig::{Algorithm, KeyStore}; +use domain::zonetree::StoredName; use tracing::{debug, error, info, trace, warn}; use crate::api::{ @@ -33,6 +34,7 @@ use crate::config::SocketConfig; use crate::daemon::SocketProvider; use crate::manager::Terminated; use crate::manager::record_zone_event; +use crate::policy::NameserverCommsPolicy; use crate::server::{LoadedReviewServer, PublicationServer, SignedReviewServer}; use crate::util::AbortOnDrop; use crate::zone::{ @@ -237,19 +239,17 @@ impl ZoneServer { "[{unit_name}]: Found {} NOTIFY targets", policy.server.outbound.send_notify_to.len() ); + trace!( + "NOTIFY targets: {:?}", + policy.server.outbound.send_notify_to + ); if !policy.server.outbound.send_notify_to.is_empty() { let addrs = policy .server .outbound .send_notify_to .iter() - .filter_map(|s| { - if s.addr.port() != 0 { - Some(s.addr) - } else { - None - } - }); + .filter(|s| s.addr.filter(|addr| addr.port() != 0).is_some()); send_notify_to_addrs(zone_name.clone(), addrs, center); } @@ -705,10 +705,10 @@ impl Notifiable for LoaderNotifier { } } -pub fn send_notify_to_addrs( - apex_name: Name, - notify_set: impl Iterator, - _center: &Arc
, +pub fn send_notify_to_addrs<'a>( + apex_name: StoredName, + notify_set: impl Iterator, + center: &Arc
, ) { let mut dgram_config = domain::net::client::dgram::Config::new(); dgram_config.set_max_parallel(1); @@ -721,32 +721,19 @@ pub fn send_notify_to_addrs( let mut msg = msg.question(); msg.push((apex_name, Rtype::SOA)).unwrap(); - for nameserver_addr in notify_set { + for nameserver in notify_set.filter(|ns| ns.addr.is_some()) { let dgram_config = dgram_config.clone(); let req = RequestMessage::new(msg.clone()).unwrap(); - // let tsig_key = zone_info - // .config - // .send_notify_to - // .dst(&nameserver_addr) - // .and_then(|cfg| cfg.tsig_key.as_ref()) - // .and_then(|(name, alg)| key_store.get_key(name, *alg)); - // - // if let Some(key) = tsig_key.as_ref() { - // debug!( - // "Found TSIG key '{}' (algorith {}) for NOTIFY to {nameserver_addr}", - // key.as_ref().name(), - // key.as_ref().algorithm() - // ); - // } - + let nameserver = nameserver.clone(); + let center = center.clone(); tokio::spawn(async move { // TODO: Use the connection factory here. - let udp_connect = UdpConnect::new(nameserver_addr); + let udp_connect = UdpConnect::new(nameserver.addr.unwrap()); let client = Connection::with_config(udp_connect, dgram_config.clone()); - trace!("Sending NOTIFY to nameserver {nameserver_addr}"); - let span = tracing::trace_span!("auth", addr = %nameserver_addr); + trace!("Sending NOTIFY to nameserver {nameserver}"); + let span = tracing::trace_span!("auth", addr = %nameserver); let _guard = span.enter(); // https://datatracker.ietf.org/doc/html/rfc1996 @@ -758,16 +745,32 @@ pub fn send_notify_to_addrs( // // TODO: We have no retry queue at the moment. Do we need one? - // let res = if let Some(key) = tsig_key { - // let client = net::client::tsig::Connection::new(key.clone(), client); - // client.send_request(req.clone()).get_response().await - // } else { - // client.send_request(req.clone()).get_response().await - // }; - let res = client.send_request(req.clone()).get_response().await; + let tsig_key = { + let state = center.state.lock().unwrap(); + nameserver + .tsig_key_name + .as_ref() + .and_then(|tsig_key_name| state.tsig_store.get(tsig_key_name)) + .map(|key| key.inner.clone()) + }; + + if let Some(key) = &tsig_key { + debug!( + "Found TSIG key '{}' (algorith {}) for NOTIFY to {nameserver}", + key.name(), + key.algorithm() + ); + } + let res = if let Some(key) = tsig_key { + let client = domain::net::client::tsig::Connection::new(key.clone(), client); + client.send_request(req.clone()).get_response().await + } else { + client.send_request(req.clone()).get_response().await + }; + // let res = client.send_request(req.clone()).get_response().await; if let Err(err) = res { - warn!("Unable to send NOTIFY to nameserver {nameserver_addr}: {err}"); + warn!("Unable to send NOTIFY to nameserver {nameserver}: {err}"); } }); } diff --git a/src/zone/state/mod.rs b/src/zone/state/mod.rs index 23c1b204d..d28a5476e 100644 --- a/src/zone/state/mod.rs +++ b/src/zone/state/mod.rs @@ -7,14 +7,17 @@ use std::{ sync::Arc, }; +use bytes::Bytes; use camino::Utf8Path; +use domain::base::Name; use serde::{Deserialize, Serialize}; use tracing::warn; use crate::{ loader::zone::LoaderState, policy::{Policy, PolicyVersion}, - zone::{Zone, ZoneState}, + tsig::TsigStore, + zone::{Zone, ZoneState, state::v1::ZoneLoadSourceSpecParseError}, }; pub mod v1; @@ -37,7 +40,8 @@ impl Spec { self, zone: &Arc, policies: &mut foldhash::HashMap, Policy>, - ) -> ZoneState { + tsig_store: &TsigStore, + ) -> Result { /// Synchronize a loaded policy with global state. fn sync_policy( policy: PolicyVersion, @@ -89,7 +93,7 @@ impl Spec { history, }) => { let loader = LoaderState { - source: source.parse(), + source: source.parse(tsig_store)?, ..Default::default() }; @@ -102,14 +106,14 @@ impl Spec { p.zones.insert(zone.name.clone()); } - ZoneState { + Ok(ZoneState { policy, min_expiration, next_min_expiration, loader, history, ..Default::default() - } + }) } } } @@ -136,3 +140,30 @@ impl Spec { crate::util::write_file(path, text.as_bytes()) } } + +pub enum ZoneStateParseError { + InvalidTsigKeyName(Name), + UnknownTsigKey(Name), +} + +impl From for ZoneStateParseError { + fn from(err: ZoneLoadSourceSpecParseError) -> Self { + match err { + ZoneLoadSourceSpecParseError::InvalidTsigKeyName(name) => { + Self::InvalidTsigKeyName(name) + } + ZoneLoadSourceSpecParseError::UnknownTsigKey(name) => Self::UnknownTsigKey(name), + } + } +} + +impl std::fmt::Display for ZoneStateParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ZoneStateParseError::InvalidTsigKeyName(name) => { + write!(f, "Invalid TSIG key name '{name}'") + } + ZoneStateParseError::UnknownTsigKey(name) => write!(f, "Unknown TSIG key '{name}'"), + } + } +} diff --git a/src/zone/state/v1.rs b/src/zone/state/v1.rs index 5af808ce6..2d3c4dc0a 100644 --- a/src/zone/state/v1.rs +++ b/src/zone/state/v1.rs @@ -1,16 +1,19 @@ //! Version 1 of the zone state file. use std::net::SocketAddr; +use std::str::FromStr; use bytes::Bytes; use camino::Utf8Path; -use domain::base::Ttl; +use domain::base::{ToName, Ttl}; +use domain::tsig::KeyName; 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::tsig::TsigStore; use crate::zone::HistoryItem; use crate::{ policy::{ @@ -197,6 +200,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 @@ -224,6 +230,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(), } } @@ -249,6 +260,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(), } } } @@ -484,16 +500,33 @@ pub enum ZoneLoadSourceSpec { impl ZoneLoadSourceSpec { /// Parse from this specification. - pub fn parse(self) -> Source { - match self { + pub fn parse(self, tsig_store: &TsigStore) -> Result { + Ok(match self { Self::None => Source::None, Self::Zonefile { path } => Source::Zonefile { path }, - // TODO: Look up the TSIG key in the key store. - Self::Server { addr, tsig_key: _ } => Source::Server { + Self::Server { + addr, + tsig_key: Some(tsig_key), + } => { + let key_name = KeyName::from_str(&tsig_key.to_string()).map_err(|_| { + ZoneLoadSourceSpecParseError::InvalidTsigKeyName(tsig_key.clone()) + })?; + let Some(tsig_key) = tsig_store.get(&key_name).map(|k| k.inner.clone()) else { + return Err(ZoneLoadSourceSpecParseError::UnknownTsigKey(tsig_key)); + }; + Source::Server { + addr, + tsig_key: Some(tsig_key), + } + } + Self::Server { + addr, + tsig_key: None, + } => Source::Server { addr, tsig_key: None, }, - } + }) } /// Build into this specification. @@ -501,14 +534,15 @@ impl ZoneLoadSourceSpec { match source.clone() { Source::None => Self::None, Source::Zonefile { path } => Self::Zonefile { path }, - Source::Server { addr, tsig_key } => Self::Server { - addr, - tsig_key: tsig_key.map(|key| { - let bytes = key.name().as_slice(); - let bytes = Bytes::copy_from_slice(bytes); - Name::from_octets(bytes).unwrap() - }), - }, + Source::Server { addr, tsig_key } => { + let tsig_key = tsig_key.map(|key| key.name().to_bytes()); + Self::Server { addr, tsig_key } + } } } } + +pub enum ZoneLoadSourceSpecParseError { + InvalidTsigKeyName(Name), + UnknownTsigKey(Name), +}