From 617b623ba3dab8ecd14f661a358c843eb595cc18 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 2 Dec 2024 11:20:21 +0100 Subject: [PATCH 1/3] Add keygen command. (#9) * Keygen skeleton. * [keygen] Implement the basic features * [keygen] synchronize files before exiting * [keygen] Add help documentation * [keygen] Generate '.ds' files for KSKs * [keygen] Use 'display_as_bind()' * [keygen] Add support for symlinks (Unix only) * [keygen] Improve errors and support '.ds' symlinks * Implement ldns-specific parsing for 'keygen' * [keygen] Implement '-v' with version info * [keygen] Add 'cfg(unix)' in ldns-parsing * [keygen] Correctly handle duplicate options in ldns parsing * Revert "[keygen] Implement '-v' with version info" This reverts commit 643ab86bd4837ff19d434d6fc604f58caa4431dc. See: * [keygen] Integrate the use of 'Env' * [workflows/ci] Add OpenSSL installation steps * [workflows/ci] Integrate OpenSSL for 'minimal_versions' * [keygen] Improve the 'dnst' interface * [keygen] Satisfy clippy * [keygen] Simplify symlink CLI * Add basic filesystem operations to 'Env' * [keygen] Use symlink ops provided by 'Env' * [keygen] Add basic tests for argument parsing * [keygen] Add tests * [keygen] Satisfy 'minimal-versions' * [keygen] Satisfy clippy * [keygen] Add Windows-specific missing branch * [keygen] Document parsing for 'symlink' * [keygen] Report error on '-r' * [env] Refactor util fns into a 'util' module * [keygen::symlink] Mark params as used, for Windows * [keygen] Fix double error message See: * [keygen] Use 'Args::Report' * [keygen] Allow invalid HTML in docs for Clap * Use 'domain'-style imports * [keygen] Use uppercase for expected clap values * [keygen] use lowercase value names in Clap --------- Co-authored-by: arya dradjica --- .github/workflows/ci.yml | 19 ++ Cargo.lock | 135 +++++++- Cargo.toml | 16 +- src/commands/keygen.rs | 644 ++++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 31 +- src/commands/nsec3hash.rs | 9 +- src/env/mod.rs | 1 + src/error.rs | 8 + src/lib.rs | 5 +- src/parse.rs | 9 + src/util.rs | 75 +++++ 11 files changed, 936 insertions(+), 16 deletions(-) create mode 100644 src/commands/keygen.rs create mode 100644 src/parse.rs create mode 100644 src/util.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f8dbc5..159447b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,13 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macOS-latest] rust: [1.78.0, stable, beta, nightly] + env: + RUSTFLAGS: "-D warnings" + # We use 'vcpkg' to install OpenSSL on Windows. + VCPKG_ROOT: "${{ github.workspace }}\\vcpkg" + VCPKGRS_TRIPLET: x64-windows-release + # Ensure that OpenSSL is dynamically linked. + VCPKGRS_DYNAMIC: 1 steps: - name: Checkout repository uses: actions/checkout@v1 @@ -17,6 +24,16 @@ jobs: uses: hecrj/setup-rust-action@v2 with: rust-version: ${{ matrix.rust }} + - if: matrix.os == 'ubuntu-latest' + run: sudo apt-get install -y libssl-dev + - if: matrix.os == 'windows-latest' + id: vcpkg + uses: johnwason/vcpkg-action@v6 + with: + pkgs: openssl + triplet: ${{ env.VCPKGRS_TRIPLET }} + token: ${{ github.token }} + github-binarycache: true - if: matrix.rust == 'stable' run: rustup component add clippy - if: matrix.rust == 'stable' @@ -36,6 +53,8 @@ jobs: uses: hecrj/setup-rust-action@v2 with: rust-version: "1.78.0" + - name: Install OpenSSL + run: sudo apt-get install -y libssl-dev - name: Install nightly Rust run: rustup install nightly - name: Check with minimal-versions diff --git a/Cargo.lock b/Cargo.lock index f3d1866..22639c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.20" @@ -249,6 +258,7 @@ dependencies = [ "clap", "domain", "lexopt", + "regex", "tempfile", "test_bin", ] @@ -256,15 +266,17 @@ dependencies = [ [[package]] name = "domain" version = "0.10.3" -source = "git+https://github.com/NLnetLabs/domain.git?branch=initial-nsec3-generation#e1c1db8e4103eed5f69d77d7b88581298cc00818" +source = "git+https://github.com/NLnetLabs/domain.git?branch=initial-nsec3-generation#250b52eeb9f6b0801b5c04d14e6674f96e774246" dependencies = [ "bytes", "futures-util", "hashbrown", "moka", "octseq", + "openssl", "rand", "ring", + "secrecy", "serde", "time", "tokio", @@ -308,6 +320,21 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "futures-core" version = "0.3.31" @@ -514,6 +541,44 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "openssl" +version = "0.10.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-sys" +version = "0.9.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking" version = "2.2.1" @@ -555,6 +620,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + [[package]] name = "powerfmt" version = "0.2.0" @@ -572,9 +643,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.89" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] @@ -651,6 +722,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "ring" version = "0.17.8" @@ -700,6 +800,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "semver" version = "1.0.23" @@ -771,9 +880,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.87" +version = "2.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" dependencies = [ "proc-macro2", "quote", @@ -910,9 +1019,9 @@ checksum = "859eb650cfee7434994602c3a68b25d77ad9e68c8a6cd491616ef86661382eb3" [[package]] name = "unicode-ident" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" [[package]] name = "untrusted" @@ -935,6 +1044,12 @@ dependencies = [ "getrandom", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1130,3 +1245,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml index 1ad34fc..f300878 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,11 +8,25 @@ default-run = "dnst" name = "ldns" path = "src/bin/ldns.rs" +[features] +default = ["openssl", "ring"] + +# Cryptographic backends +openssl = ["domain/openssl"] +ring = ["domain/ring"] + [dependencies] clap = { version = "4.3.4", features = ["cargo", "derive"] } -domain = { version = "0.10.3", git = "https://github.com/NLnetLabs/domain.git", branch = "initial-nsec3-generation", features = ["unstable-validator", "zonefile"] } +domain = { git = "https://github.com/NLnetLabs/domain.git", branch = "initial-nsec3-generation", features = [ + "bytes", + "zonefile", + "unstable-sign", + "unstable-validate", + "unstable-validator", +] } lexopt = "0.3.0" [dev-dependencies] test_bin = "0.4.0" tempfile = "3.14.0" +regex = "1.11.1" diff --git a/src/commands/keygen.rs b/src/commands/keygen.rs new file mode 100644 index 0000000..779f156 --- /dev/null +++ b/src/commands/keygen.rs @@ -0,0 +1,644 @@ +use std::ffi::OsString; +use std::io::Write; +use std::path::Path; + +use clap::builder::ValueParser; +use clap::ValueEnum; +use domain::base::iana::{DigestAlg, SecAlg}; +use domain::base::name::Name; +use domain::base::zonefile_fmt::ZonefileFmt; +use domain::sign::{common, GenerateParams}; +use domain::validate::Key; +use lexopt::Arg; + +use crate::env::Env; +use crate::error::{Context, Error}; +use crate::parse::parse_name; +use crate::{util, Args}; + +use super::{parse_os, parse_os_with, Command, LdnsCommand}; + +#[derive(Clone, Debug, PartialEq, Eq, clap::Args)] +pub struct Keygen { + /// The signature algorithm to generate for + /// + /// Possible values: + /// - RSASHA256[:]: An RSA SHA-256 key (algorithm 8) of the given size (default 2048) + /// - ECDSAP256SHA256: An ECDSA P-256 SHA-256 key (algorithm 13) + /// - ECDSAP384SHA384: An ECDSA P-384 SHA-384 key (algorithm 14) + /// - ED25519: An Ed25519 key (algorithm 15) + /// - ED448: An Ed448 key (algorithm 16) + #[allow(rustdoc::invalid_html_tags)] + #[arg( + short = 'a', + long = "algorithm", + value_name = "algorithm", + value_parser = ValueParser::new(Keygen::parse_algorithm), + verbatim_doc_comment, + )] + algorithm: GenerateParams, + + /// Generate a key signing key instead of a zone signing key + #[arg(short = 'k')] + make_ksk: bool, + + /// Whether to create symlinks. + // + // We want to allow '-s' / '--symlink' to mean 'Symlink::Yes' for convenience. + // Clap supports this through 'default_missing_value', but it also requires + // 'num_args' and 'require_equals' to be explicitly set. + // + // In the end, this can be used as: + // - '-s=no' / '--symlink=no': Symlink::No (also the default) + // - '-s' / '--symlink': Symlink::Yes (convenient form) + // - '-s=yes' / '--symlink=yes': Symlink::Yes + // - '-s=force' / '--symlink=force': Symlink::Force + #[arg( + short = 's', + long = "symlink", + value_enum, + value_name = "how", + num_args = 0..=1, + require_equals = true, + default_missing_value = "yes", + default_value = "no", + )] + symlink: SymlinkArg, + + /// The domain name to generate a key for + #[arg(value_name = "domain", value_parser = ValueParser::new(parse_name))] + name: Name>, +} + +/// Symlinking behaviour. +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +pub enum SymlinkArg { + /// Don't create symlinks. + No, + + /// Create symlinks, but don't overwrite existing ones. + Yes, + + /// Create symlinks, overwriting existing ones. + Force, +} + +impl SymlinkArg { + /// Whether symlinks should be created. + pub fn create(&self) -> bool { + matches!(self, Self::Yes | Self::Force) + } + + /// Whether symlinks should be forced. + pub fn force(&self) -> bool { + matches!(self, Self::Force) + } +} + +const LDNS_HELP: &str = "\ +ldns-keygen -a [-b bits] [-r /dev/random] [-s] [-f] [-v] domain + generate a new key pair for domain + -a use the specified algorithm (-a list to show a list) + -k set the flags to 257; key signing key + -b specify the keylength (only used for RSA keys) + -r randomness device (unused) + -s create additional symlinks with constant names + -f force override of existing symlinks + -v show the version and exit + The following files will be created: + K++.key Public key in RR format + K++.private Private key in key format + K++.ds DS in RR format (only for DNSSEC KSK keys) + The base name (K++) will be printed to stdout +"; + +const LDNS_ALGS_HELP: &str = "\ +Supported algorithms: +- RSASHA256 (8) +- ECDSAP256SHA256 (13) +- ECDSAP384SHA384 (14) +- ED25519 (15) +- ED448 (16)\ +"; + +impl LdnsCommand for Keygen { + const NAME: &'static str = "keygen"; + const HELP: &'static str = LDNS_HELP; + const COMPATIBLE_VERSION: &'static str = "1.8.4"; + + fn parse_ldns>(args: I) -> Result { + let mut algorithm = None; + let mut make_ksk = false; + let mut bits = 2048; + let mut create_symlinks = false; + let mut force_symlinks = false; + let mut name = None; + + let mut parser = lexopt::Parser::from_args(args); + + while let Some(arg) = parser.next()? { + match arg { + Arg::Short('a') => { + if algorithm.is_some() { + return Err("cannot specify algorithm (-a) more than once".into()); + } + + let value = parser.value()?; + + if value == "list" { + return Ok(Args::from(Command::Report(LDNS_ALGS_HELP.into()))); + } + + algorithm = parse_os_with("algorithm (-a)", &value, |s| { + Ok(match s { + "RSASHA256" | "8" => Some(SecAlg::RSASHA256), + "ECDSAP256SHA256" | "13" => Some(SecAlg::ECDSAP256SHA256), + "ECDSAP384SHA384" | "14" => Some(SecAlg::ECDSAP384SHA384), + "ED25519" | "15" => Some(SecAlg::ED25519), + "ED448" | "16" => Some(SecAlg::ED448), + + _ => { + return Err("unknown algorithm mnemonic or number"); + } + }) + })?; + } + + Arg::Short('k') => { + // NOTE: '-k' can be repeated, to no effect. + make_ksk = true; + } + + Arg::Short('b') => { + // NOTE: '-b' can be repeated; the last instance wins. + bits = parse_os("bits (-b)", &parser.value()?)?; + } + + Arg::Short('r') => { + // We don't support '-r', people could rely on it for deterministic output. + return Err("a custom source of randomness (-r) is not supported".into()); + } + + Arg::Short('s') => { + // NOTE: '-s' can be repeated, to no effect. + create_symlinks = true; + } + + Arg::Short('f') => { + // NOTE: '-f' can be repeated, to no effect. + force_symlinks = true; + } + + Arg::Short('v') => { + return Ok(Self::report_version()); + } + + Arg::Value(value) => { + if name.is_some() { + return Err("cannot specify multiple domain names".into()); + } + + name = Some(parse_os("domain name", &value)?); + } + + Arg::Short(x) => return Err(format!("Invalid short option: -{x}").into()), + Arg::Long(x) => { + return Err(format!("Long options are not supported, but `--{x}` given").into()) + } + } + } + + let algorithm = match algorithm { + Some(SecAlg::RSASHA256) => GenerateParams::RsaSha256 { bits }, + Some(SecAlg::ECDSAP256SHA256) => GenerateParams::EcdsaP256Sha256, + Some(SecAlg::ECDSAP384SHA384) => GenerateParams::EcdsaP384Sha384, + Some(SecAlg::ED25519) => GenerateParams::Ed25519, + Some(SecAlg::ED448) => GenerateParams::Ed448, + Some(_) => unreachable!(), + None => { + return Err("Missing algorithm (-a) option".into()); + } + }; + + let symlink = match (create_symlinks, force_symlinks) { + (true, true) => SymlinkArg::Force, + (true, false) => SymlinkArg::Yes, + // If only '-f' is specified, no symlinking is done. + (false, _) => SymlinkArg::No, + }; + + let Some(name) = name else { + return Err("Missing domain name argument".into()); + }; + + Ok(Command::Keygen(Self { + algorithm, + make_ksk, + symlink, + name, + }) + .into()) + } +} + +impl From for Command { + fn from(value: Keygen) -> Self { + Self::Keygen(value) + } +} + +impl Keygen { + fn parse_algorithm(value: &str) -> Result { + match value { + "RSASHA256" => return Ok(GenerateParams::RsaSha256 { bits: 2048 }), + "ECDSAP256SHA256" => return Ok(GenerateParams::EcdsaP256Sha256), + "ECDSAP384SHA384" => return Ok(GenerateParams::EcdsaP384Sha384), + "ED25519" => return Ok(GenerateParams::Ed25519), + "ED448" => return Ok(GenerateParams::Ed448), + _ => {} + } + + // TODO: Remove attrs when more RSA algorithms are added. + #[allow(clippy::collapsible_match)] + if let Some((name, params)) = value.split_once(':') { + #[allow(clippy::single_match)] + match name { + "RSASHA256" => { + let bits: u32 = params.parse().map_err(|err| { + clap::Error::raw( + clap::error::ErrorKind::InvalidValue, + format!("invalid RSA key size '{params}': {err}"), + ) + })?; + return Ok(GenerateParams::RsaSha256 { bits }); + } + _ => {} + } + } + + Err(clap::Error::raw( + clap::error::ErrorKind::InvalidValue, + format!("unrecognized algorithm '{value}'"), + )) + } + + pub fn execute(self, env: impl Env) -> Result<(), Error> { + let mut stdout = env.stdout(); + + let params = self.algorithm; + + // The digest algorithm is selected based on the key algorithm. + let digest_alg = match params.algorithm() { + SecAlg::RSASHA256 => DigestAlg::SHA256, + SecAlg::ECDSAP256SHA256 => DigestAlg::SHA256, + SecAlg::ECDSAP384SHA384 => DigestAlg::SHA384, + SecAlg::ED25519 => DigestAlg::SHA256, + SecAlg::ED448 => DigestAlg::SHA256, + _ => unreachable!(), + }; + + // Generate the key. + // TODO: Attempt repeated generation to avoid key tag collisions. + let (secret_key, public_key) = common::generate(params) + .map_err(|err| format!("an implementation error occurred: {err}").into()) + .context("generating a cryptographic keypair")?; + // TODO: Add a high-level operation in 'domain' to select flags? + let flags = if self.make_ksk { 257 } else { 256 }; + let public_key = Key::new(self.name.clone(), flags, public_key); + let digest = self.make_ksk.then(|| { + public_key + .digest(digest_alg) + .expect("only supported digest algorithms are used") + }); + + let base = format!( + "K{}+{:03}+{:05}", + self.name.fmt_with_dot(), + public_key.algorithm().to_int(), + public_key.key_tag() + ); + + let secret_key_path = format!("{base}.private"); + let public_key_path = format!("{base}.key"); + let digest_file_path = self.make_ksk.then(|| format!("{base}.ds")); + + let mut secret_key_file = util::create_new_file(&env, &secret_key_path)?; + let mut public_key_file = util::create_new_file(&env, &public_key_path)?; + let mut digest_file = digest_file_path + .as_ref() + .map(|digest_file_path| util::create_new_file(&env, digest_file_path)) + .transpose()?; + + Self::symlink(&secret_key_path, ".private", self.symlink, &env)?; + Self::symlink(&public_key_path, ".key", self.symlink, &env)?; + if let Some(digest_file_path) = &digest_file_path { + Self::symlink(digest_file_path, ".ds", self.symlink, &env)?; + } + + // Prepare the contents to write. + let secret_key = secret_key.display_as_bind().to_string(); + let public_key = public_key.display_as_bind().to_string(); + let digest = digest.map(|digest| { + format!( + "{} IN DS {}\n", + self.name.fmt_with_dot(), + digest.display_zonefile(false) + ) + }); + + // Write the key files. + secret_key_file + .write_all(secret_key.as_bytes()) + .map_err(|err| { + format!("error while writing private key file '{base}.private': {err}") + })?; + public_key_file + .write_all(public_key.as_bytes()) + .map_err(|err| format!("error while writing public key file '{base}.key': {err}"))?; + if let Some(digest_file) = digest_file.as_mut() { + digest_file + .write_all(digest.unwrap().as_bytes()) + .map_err(|err| format!("error while writing digest file '{base}.ds': {err}"))?; + } + + // Let the user know what the base name of the files is. + writeln!(stdout, "{}", base); + + Ok(()) + } + + /// Create a symlink to the given location. + fn symlink( + target: impl AsRef, + link: impl AsRef, + how: SymlinkArg, + env: &impl Env, + ) -> Result<(), Error> { + #[cfg(unix)] + match how { + SymlinkArg::No => Ok(()), + SymlinkArg::Yes => util::symlink(env, target, link), + SymlinkArg::Force => util::symlink_force(env, target, link), + } + + #[cfg(not(unix))] + if how.create() { + let _ = (target, link, env); + Err("Symlinks can only be created on Unix platforms".into()) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +mod test { + use domain::sign::GenerateParams; + use regex::Regex; + + use crate::commands::Command; + use crate::env::fake::FakeCmd; + + use super::{Keygen, SymlinkArg}; + + #[track_caller] + fn parse(args: FakeCmd) -> Keygen { + let res = args.parse(); + let Command::Keygen(x) = res.unwrap().command else { + panic!("Not a Keygen!"); + }; + x + } + + #[test] + fn dnst_parse() { + let cmd = FakeCmd::new(["dnst", "keygen"]); + + // Algorithm and domain name are needed. + let _ = cmd.parse().unwrap_err(); + + // Multiple domain names cannot be provided. + let _ = cmd + .args(["foo.example.org", "bar.example.org"]) + .parse() + .unwrap_err(); + + let base = Keygen { + algorithm: GenerateParams::Ed25519, + make_ksk: false, + symlink: SymlinkArg::No, + name: "example.org".parse().unwrap(), + }; + + // The simplest invocation. + assert_eq!(parse(cmd.args(["-a", "ED25519", "example.org"])), base); + + // Test 'algorithm': + // - RSA-SHA256 uses 2048 bits by default. + assert_eq!( + parse(cmd.args(["-a", "RSASHA256", "example.org"])), + Keygen { + algorithm: GenerateParams::RsaSha256 { bits: 2048 }, + ..base.clone() + } + ); + // - RSA-SHA256 accepts other key sizes. + assert_eq!( + parse(cmd.args(["-a", "RSASHA256:1024", "example.org"])), + Keygen { + algorithm: GenerateParams::RsaSha256 { bits: 1024 }, + ..base.clone() + } + ); + + // Test 'make_ksk': + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-k", "example.org"])), + Keygen { + make_ksk: true, + ..base.clone() + } + ); + + // Test 'symlink': + // - Symlinks can be disabled. + for symlink in ["-s=no", "--symlink=no"] { + assert_eq!( + parse(cmd.args(["-a", "ED25519", symlink, "example.org"])), + Keygen { + symlink: SymlinkArg::No, + ..base.clone() + } + ); + } + // - Symlinks can be enabled. + for symlink in ["-s", "-s=yes", "--symlink", "--symlink=yes"] { + assert_eq!( + parse(cmd.args(["-a", "ED25519", symlink, "example.org"])), + Keygen { + symlink: SymlinkArg::Yes, + ..base.clone() + } + ); + } + // - Symlinks can be enabled with overwriting. + for symlink in ["-s=force", "--symlink=force"] { + assert_eq!( + parse(cmd.args(["-a", "ED25519", symlink, "example.org"])), + Keygen { + symlink: SymlinkArg::Force, + ..base.clone() + } + ); + } + + // Test 'name': + // - Domain names can have a trailing dot. + assert_eq!(parse(cmd.args(["-a", "ED25519", "example.org."])), base); + } + + #[test] + fn ldns_parse() { + let cmd = FakeCmd::new(["ldns-keygen"]); + + // Algorithm and domain name are needed. + let _ = cmd.parse().unwrap_err(); + + // Multiple domain names cannot be provided. + let _ = cmd + .args(["foo.example.org", "bar.example.org"]) + .parse() + .unwrap_err(); + + let base = Keygen { + algorithm: GenerateParams::Ed25519, + make_ksk: false, + symlink: SymlinkArg::No, + name: "example.org".parse().unwrap(), + }; + + // The simplest invocation. + assert_eq!(parse(cmd.args(["-a", "ED25519", "example.org"])), base); + + // Test 'algorithm': + // - RSA-SHA256 uses 2048 bits by default. + assert_eq!( + parse(cmd.args(["-a", "RSASHA256", "example.org"])), + Keygen { + algorithm: GenerateParams::RsaSha256 { bits: 2048 }, + ..base.clone() + } + ); + // - RSA-SHA256 accepts other key sizes. + assert_eq!( + parse(cmd.args(["-a", "RSASHA256", "-b", "1024", "example.org"])), + Keygen { + algorithm: GenerateParams::RsaSha256 { bits: 1024 }, + ..base.clone() + } + ); + + // Test 'make_ksk': + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-k", "example.org"])), + Keygen { + make_ksk: true, + ..base.clone() + } + ); + + // Test 'symlink': + // - Symlinks can be enabled. + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-s", "example.org"])), + Keygen { + symlink: SymlinkArg::Yes, + ..base.clone() + } + ); + // - Symlinks can be enabled with overwriting. + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-s", "-f", "example.org"])), + Keygen { + symlink: SymlinkArg::Force, + ..base.clone() + } + ); + // - '-f' without '-s' does not enable symlinks. + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-f", "example.org"])), + Keygen { + symlink: SymlinkArg::No, + ..base.clone() + } + ); + + // Test 'name': + // - Domain names can have a trailing dot. + assert_eq!(parse(cmd.args(["-a", "ED25519", "example.org."])), base); + } + + #[test] + fn simple() { + let dir = tempfile::TempDir::new().unwrap(); + let res = FakeCmd::new(["dnst", "keygen", "-a", "ED25519", "example.org"]) + .cwd(&dir) + .run(); + + let name_regex = Regex::new(r"^Kexample\.org\.\+015\+[0-9]{5}$").unwrap(); + let public_key_regex = + Regex::new(r"^example.org. IN DNSKEY 256 3 15 [A-Za-z0-9/+=]+").unwrap(); + let secret_key_regex = Regex::new( + r"^Private-key-format: v1\.2\nAlgorithm: 15 \(ED25519\)\nPrivateKey: [A-Za-z0-9/+=]+\n$", + ) + .unwrap(); + + assert_eq!(res.exit_code, 0, "{res:?}"); + assert_eq!(res.stderr, ""); + + let name = res.stdout.trim(); + assert!(name_regex.is_match(name)); + + let public_key = std::fs::read_to_string(dir.path().join(format!("{name}.key"))).unwrap(); + assert!(public_key_regex.is_match(&public_key)); + + // The digest file must not be created. + assert!(!dir.path().join("{name}.ds").try_exists().unwrap()); + + let secret_key = + std::fs::read_to_string(dir.path().join(format!("{name}.private"))).unwrap(); + assert!(secret_key_regex.is_match(&secret_key)); + } + + #[test] + fn simple_ksk() { + let dir = tempfile::TempDir::new().unwrap(); + let res = FakeCmd::new(["dnst", "keygen", "-k", "-a", "ED25519", "example.org"]) + .cwd(&dir) + .run(); + + let name_regex = Regex::new(r"^Kexample\.org\.\+015\+[0-9]{5}$").unwrap(); + let public_key_regex = + Regex::new(r"^example.org. IN DNSKEY 257 3 15 [A-Za-z0-9/+=]+").unwrap(); + let digest_key_regex = + Regex::new(r"^example.org. IN DS [0-9]+ 15 2 [0-9a-fA-F]+\n$").unwrap(); + + assert_eq!(res.exit_code, 0, "{res:?}"); + assert_eq!(res.stderr, ""); + + let name = res.stdout.trim(); + assert!(name_regex.is_match(name)); + + let public_key = std::fs::read_to_string(dir.path().join(format!("{name}.key"))).unwrap(); + assert!(public_key_regex.is_match(&public_key)); + + let digest_key = std::fs::read_to_string(dir.path().join(format!("{name}.ds"))).unwrap(); + assert!(digest_key_regex.is_match(&digest_key)); + + assert!(dir + .path() + .join(format!("{name}.private")) + .try_exists() + .unwrap()); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 011fa07..398c2bb 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,7 @@ //! The command of _dnst_. pub mod help; pub mod key2ds; +pub mod keygen; pub mod nsec3hash; use std::ffi::{OsStr, OsString}; @@ -15,7 +16,34 @@ use super::error::Error; #[derive(Clone, Debug, clap::Subcommand)] pub enum Command { - /// Prints the NSEC3 hash of a given domain name + /// Generate a new key pair for a given domain name + /// + /// The following files will be created: + /// + /// - K++.key: The public key file + /// + /// This is a DNSKEY resource record in zone file format. + /// + /// - K++.private: The private key file + /// + /// This is a text file in the conventional BIND format which + /// contains fields describing the private key data. + /// + /// - K++.ds: The public key digest file + /// + /// This is a DS resource record in zone file format. + /// It is only created for key signing keys. + /// + /// is the fully-qualified owner name for the key (with a trailing dot). + /// is the algorithm number of the key, zero-padded to 3 digits. + /// is the 16-bit tag of the key, zero-padded to 5 digits. + /// + /// Upon completion, 'K++' will be printed. + #[allow(rustdoc::invalid_html_tags)] + #[command(name = "keygen", verbatim_doc_comment)] + Keygen(self::keygen::Keygen), + + /// Print the NSEC3 hash of a given domain name #[command(name = "nsec3-hash")] Nsec3Hash(self::nsec3hash::Nsec3Hash), @@ -41,6 +69,7 @@ pub enum Command { impl Command { pub fn execute(self, env: impl Env) -> Result<(), Error> { match self { + Self::Keygen(keygen) => keygen.execute(env), Self::Nsec3Hash(nsec3hash) => nsec3hash.execute(env), Self::Key2ds(key2ds) => key2ds.execute(env), Self::Help(help) => help.execute(), diff --git a/src/commands/nsec3hash.rs b/src/commands/nsec3hash.rs index bc3edbd..dbeb484 100644 --- a/src/commands/nsec3hash.rs +++ b/src/commands/nsec3hash.rs @@ -3,13 +3,14 @@ use std::str::FromStr; use clap::builder::ValueParser; use domain::base::iana::nsec3::Nsec3HashAlg; -use domain::base::name::{self, Name}; +use domain::base::name::Name; use domain::rdata::nsec3::Nsec3Salt; use domain::validate::nsec3_hash; use lexopt::Arg; use crate::env::Env; use crate::error::Error; +use crate::parse::parse_name; use crate::Args; use super::{parse_os, parse_os_with, Command, LdnsCommand}; @@ -47,7 +48,7 @@ pub struct Nsec3Hash { salt: Nsec3Salt>, /// The domain name to hash - #[arg(value_name = "DOMAIN NAME", value_parser = ValueParser::new(Nsec3Hash::parse_name))] + #[arg(value_name = "DOMAIN NAME", value_parser = ValueParser::new(parse_name))] name: Name>, } @@ -116,10 +117,6 @@ impl LdnsCommand for Nsec3Hash { } impl Nsec3Hash { - pub fn parse_name(arg: &str) -> Result>, name::FromStrError> { - Name::from_str(&arg.to_lowercase()) - } - // Note: This function is only necessary until // https://github.com/NLnetLabs/domain/pull/431 is merged. pub fn parse_salt(arg: &str) -> Result>, Error> { diff --git a/src/env/mod.rs b/src/env/mod.rs index dd62dcb..05e2384 100644 --- a/src/env/mod.rs +++ b/src/env/mod.rs @@ -35,6 +35,7 @@ pub trait Env { // /// Get a reference to stdin // fn stdin(&self) -> impl io::Read; + /// Make relative paths absolute. fn in_cwd<'a>(&self, path: &'a impl AsRef) -> Cow<'a, Path>; } diff --git a/src/error.rs b/src/error.rs index 5d2e58c..2b9bb97 100644 --- a/src/error.rs +++ b/src/error.rs @@ -219,3 +219,11 @@ impl Context for Result { self.map_err(|err| err.context(&(context)())) } } + +/// Execute the given operation under the given context. +pub fn in_context( + context: impl FnOnce() -> String, + function: impl FnOnce() -> Result, +) -> Result { + (function)().with_context(context) +} diff --git a/src/lib.rs b/src/lib.rs index 7078fbd..05b1c80 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,7 @@ use std::ffi::OsString; use std::path::Path; use clap::Parser; -use commands::{key2ds::Key2ds, nsec3hash::Nsec3Hash, LdnsCommand}; +use commands::{key2ds::Key2ds, keygen::Keygen, nsec3hash::Nsec3Hash, LdnsCommand}; use env::Env; use error::Error; @@ -12,6 +12,8 @@ pub mod args; pub mod commands; pub mod env; pub mod error; +pub mod parse; +pub mod util; pub fn try_ldns_compatibility>( args: I, @@ -29,6 +31,7 @@ pub fn try_ldns_compatibility>( let res = match binary_name { "key2ds" => Key2ds::parse_ldns_args(args_iter), + "keygen" => Keygen::parse_ldns_args(args_iter), "nsec3-hash" => Nsec3Hash::parse_ldns_args(args_iter), _ => return Err(format!("Unrecognized ldns command 'ldns-{binary_name}'").into()), }; diff --git a/src/parse.rs b/src/parse.rs new file mode 100644 index 0000000..6aefb05 --- /dev/null +++ b/src/parse.rs @@ -0,0 +1,9 @@ +use core::str::FromStr; + +use domain::base::Name; + +use crate::error::Error; + +pub fn parse_name(arg: &str) -> Result>, Error> { + Name::from_str(&arg.to_lowercase()).map_err(|e| Error::from(e.to_string())) +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..56ebc0f --- /dev/null +++ b/src/util.rs @@ -0,0 +1,75 @@ +//! A utility module for common operations. + +use std::fs::File; +use std::path::Path; + +use crate::env::Env; +use crate::error::Result; + +/// Create and open a file. +pub fn create_new_file(env: &impl Env, path: impl AsRef) -> Result { + let path = path.as_ref(); + let abs_path = env.in_cwd(&path); + File::create_new(abs_path) + .map_err(|err| format!("cannot create '{}': {err}", path.display()).into()) +} + +/// Rename a file. +pub fn rename_path(env: &impl Env, old: impl AsRef, new: impl AsRef) -> Result<()> { + let (old, new) = (old.as_ref(), new.as_ref()); + let abs_old = env.in_cwd(&old); + let abs_new = env.in_cwd(&new); + std::fs::rename(abs_old, abs_new).map_err(|err| { + format!( + "could not move '{}' to '{}': {err}", + old.display(), + new.display() + ) + .into() + }) +} + +/// Create a symlink. +#[cfg(unix)] +pub fn symlink(env: &impl Env, target: impl AsRef, link: impl AsRef) -> Result<()> { + let (target, link) = (target.as_ref(), link.as_ref()); + let target_path = env.in_cwd(&target); + let link_path = env.in_cwd(&link); + std::os::unix::fs::symlink(target_path, link_path).map_err(|err| { + format!( + "could not create symlink '{}' to '{}': {err}", + link.display(), + target.display(), + ) + .into() + }) +} + +/// Create a symlink, overwriting if it already exists. +#[cfg(unix)] +pub fn symlink_force( + env: &impl Env, + target: impl AsRef, + link: impl AsRef, +) -> Result<()> { + use crate::error::in_context; + + let (target, link) = (target.as_ref(), link.as_ref()); + let mut temp = link.to_path_buf(); + temp.as_mut_os_string().push(".new"); + + in_context( + || { + format!( + "creating symlink '{}' to '{}'", + link.display(), + target.display() + ) + }, + || { + symlink(env, target, &temp)?; + rename_path(env, &temp, link)?; + Ok(()) + }, + ) +} From 9432f4897d3001961b2dd06fefc949adb16ac87b Mon Sep 17 00:00:00 2001 From: Terts Diepraam Date: Mon, 2 Dec 2024 14:21:59 +0100 Subject: [PATCH 2/3] Notify (#7) * start work on notify * continue on notify * various fixes to notify * notify: incorporate feedback from @mozzieongit * notify: update outdated terminology * notify: fix up comment * update: fix up error message Co-authored-by: Jannik * notify: temporarily remove -I because it's not supported * notify: imports compliant with domain's CONTRIBUTING.md * notify: improve error message for TSIG parsing * start testing notify! * stelline tests for notify * notify: implement ldns argument parsing * fix CI * cargo fmt * update last occurrence of rust 1.78 * fix lazy_static to 1.0.2 for minimal-versions * bring up to date with initial-nsec3-hash * imports more in domain style * notify (and other ldns utils): add support for printing version to env * notify: simplify with Box> * notify: improve help and error messages --------- Co-authored-by: Jannik --- .github/workflows/ci.yml | 4 +- Cargo.lock | 206 +++++++++++- Cargo.toml | 20 +- src/commands/key2ds.rs | 6 +- src/commands/mod.rs | 20 +- src/commands/notify.rs | 647 ++++++++++++++++++++++++++++++++++++++ src/commands/nsec3hash.rs | 1 + src/env/fake.rs | 66 +++- src/env/mod.rs | 41 ++- src/env/real.rs | 19 ++ src/lib.rs | 3 +- 11 files changed, 1009 insertions(+), 24 deletions(-) create mode 100644 src/commands/notify.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 159447b..e08999f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macOS-latest] - rust: [1.78.0, stable, beta, nightly] + rust: [1.79.0, stable, beta, nightly] env: RUSTFLAGS: "-D warnings" # We use 'vcpkg' to install OpenSSL on Windows. @@ -52,7 +52,7 @@ jobs: - name: Install Rust uses: hecrj/setup-rust-action@v2 with: - rust-version: "1.78.0" + rust-version: "1.79.0" - name: Install OpenSSL run: sudo apt-get install -y libssl-dev - name: Install nightly Rust diff --git a/Cargo.lock b/Cargo.lock index 22639c7..da59693 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,21 @@ version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45862d1c77f2228b9e10bc609d5bc203d86ebc9b87ad8d5d5167a6c9abf739d9" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.18" @@ -81,6 +96,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "async-lock" version = "3.4.0" @@ -163,6 +184,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets", +] + [[package]] name = "clap" version = "4.5.21" @@ -218,6 +253,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crossbeam-channel" version = "0.5.13" @@ -255,12 +296,16 @@ dependencies = [ name = "dnst" version = "0.1.0" dependencies = [ + "bytes", + "chrono", "clap", "domain", + "lazy_static", "lexopt", "regex", "tempfile", "test_bin", + "tokio", ] [[package]] @@ -268,9 +313,12 @@ name = "domain" version = "0.10.3" source = "git+https://github.com/NLnetLabs/domain.git?branch=initial-nsec3-generation#250b52eeb9f6b0801b5c04d14e6674f96e774246" dependencies = [ + "arc-swap", "bytes", + "chrono", "futures-util", "hashbrown", + "libc", "moka", "octseq", "openssl", @@ -278,9 +326,12 @@ dependencies = [ "ring", "secrecy", "serde", + "siphasher", + "smallvec", "time", "tokio", "tracing", + "tracing-subscriber", ] [[package]] @@ -410,6 +461,29 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -425,6 +499,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "lexopt" version = "0.3.0" @@ -459,6 +539,15 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "memchr" version = "2.7.4" @@ -510,12 +599,31 @@ dependencies = [ "uuid", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.36.5" @@ -533,6 +641,7 @@ checksum = "126c3ca37c9c44cec575247f43a3e4374d8927684f129d2beeb0d2cef262fe12" dependencies = [ "bytes", "serde", + "smallvec", ] [[package]] @@ -579,6 +688,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking" version = "2.2.1" @@ -730,8 +845,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -742,9 +866,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -835,12 +965,27 @@ dependencies = [ "syn", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.9" @@ -934,6 +1079,16 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.3.36" @@ -1009,6 +1164,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1044,6 +1229,12 @@ dependencies = [ "getrandom", ] +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1143,6 +1334,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index f300878..6c29d35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ name = "dnst" version = "0.1.0" edition = "2021" default-run = "dnst" +rust-version = "1.79" [[bin]] name = "ldns" @@ -13,20 +14,35 @@ default = ["openssl", "ring"] # Cryptographic backends openssl = ["domain/openssl"] -ring = ["domain/ring"] +ring = ["domain/ring"] [dependencies] +bytes = "1.8.0" +chrono = "0.4.38" clap = { version = "4.3.4", features = ["cargo", "derive"] } domain = { git = "https://github.com/NLnetLabs/domain.git", branch = "initial-nsec3-generation", features = [ "bytes", - "zonefile", + "net", + "resolv", + "tsig", + "unstable-client-transport", "unstable-sign", "unstable-validate", "unstable-validator", + "zonefile", ] } lexopt = "0.3.0" +tokio = "1.40.0" + +# This is a workaround. lazy_static 1.0.0 fails to compile, but sharded-slab +# still uses it. And sharded-slab is used by tracing-subscriber, which is +# used by domain, which is used by us. +_unused_lazy_static = { package = "lazy_static", version = "1.0.2" } [dev-dependencies] test_bin = "0.4.0" tempfile = "3.14.0" regex = "1.11.1" +domain = { version = "*", git = "https://github.com/NLnetLabs/domain.git", branch = "initial-nsec3-generation", features = [ + "unstable-stelline", +] } diff --git a/src/commands/key2ds.rs b/src/commands/key2ds.rs index 951a0da..0b6ddad 100644 --- a/src/commands/key2ds.rs +++ b/src/commands/key2ds.rs @@ -252,9 +252,9 @@ mod test { use super::Key2ds; #[track_caller] - fn parse(args: FakeCmd) -> Key2ds { - let res = args.parse(); - let Command::Key2ds(x) = res.unwrap().command else { + fn parse(cmd: FakeCmd) -> Key2ds { + let res = cmd.parse().unwrap(); + let Command::Key2ds(x) = res.command else { panic!("Not a Key2ds!"); }; x diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 398c2bb..d093399 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,18 +2,19 @@ pub mod help; pub mod key2ds; pub mod keygen; +pub mod notify; pub mod nsec3hash; +use clap::crate_version; use std::ffi::{OsStr, OsString}; use std::str::FromStr; -use clap::crate_version; - use crate::env::Env; use crate::Args; use super::error::Error; +#[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, clap::Subcommand)] pub enum Command { /// Generate a new key pair for a given domain name @@ -47,6 +48,14 @@ pub enum Command { #[command(name = "nsec3-hash")] Nsec3Hash(self::nsec3hash::Nsec3Hash), + /// Send a NOTIFY packet to DNS servers + /// + /// This tells them that an updated zone is available at the primaries. It can perform TSIG + /// signatures and it can add a SOA serial number of the updated zone. If a server already has + /// that serial number it will disregard the message. + #[command(name = "notify")] + Notify(self::notify::Notify), + /// Generate a DS RR from the DNSKEYS in keyfile /// /// The following file will be created for each key: @@ -72,6 +81,7 @@ impl Command { Self::Keygen(keygen) => keygen.execute(env), Self::Nsec3Hash(nsec3hash) => nsec3hash.execute(env), Self::Key2ds(key2ds) => key2ds.execute(env), + Self::Notify(notify) => notify.execute(env), Self::Help(help) => help.execute(), Self::Report(s) => { writeln!(env.stdout(), "{s}"); @@ -99,10 +109,14 @@ pub trait LdnsCommand { fn parse_ldns_args>(args: I) -> Result { match Self::parse_ldns(args) { Ok(c) => Ok(c), - Err(e) => Err(format!("Error: {e}\n\n{}", Self::HELP).into()), + Err(e) => Err(format!("{e}\n\n{}", Self::HELP).into()), } } + fn report_help() -> Args { + Args::from(Command::Report(Self::HELP.into())) + } + fn report_version() -> Args { let s = format!( "ldns-{} provided by dnst v{} (compatible with ldns v{})", diff --git a/src/commands/notify.rs b/src/commands/notify.rs new file mode 100644 index 0000000..ceee16e --- /dev/null +++ b/src/commands/notify.rs @@ -0,0 +1,647 @@ +use std::net::SocketAddr; +use std::str::FromStr; + +use chrono::Local; +use clap::builder::ValueParser; +use domain::base::iana::{Class, Opcode}; +use domain::base::{Message, MessageBuilder, Name, Question, Record, Rtype, Serial, Ttl}; +use domain::net::client::request::{RequestMessage, SendRequest}; +use domain::net::client::{dgram, tsig}; +use domain::rdata::Soa; +use domain::tsig::{Algorithm, Key, KeyName}; +use domain::utils::{base16, base64}; +use lexopt::Arg; + +use crate::env::Env; +use crate::error::Error; +use crate::Args; + +use super::{parse_os, Command, LdnsCommand}; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct TSigInfo { + name: KeyName, + key: Vec, + algorithm: Algorithm, +} + +impl FromStr for TSigInfo { + type Err = Error; + + fn from_str(s: &str) -> Result { + let Some((mut name, rest)) = s.split_once(':') else { + return Err("should contain at least one `:`".into()); + }; + + let mut key; + let mut algorithm; + if let Some((k, a)) = rest.split_once(':') { + key = k; + algorithm = a; + } else { + key = rest; + // This is different from ldns's default of + // hmac-md5.sig-alg.reg.int but we don't support that algorithm. + algorithm = "hmac-sha512"; + } + + // With dig TSIG keys are also specified with -y, + // but our format is: + // and dig's is: [hmac:]name:key + // + // When we detect an unknown TSIG algorithm in algo, + // but a known algorithm in name, we can assume dig + // order was used. + // + // We can correct this by checking whether the name contains a valid + // algorithm while the algorithm doesn't. + if Algorithm::from_str(algorithm).is_err() && Algorithm::from_str(name).is_ok() { + (name, key, algorithm) = (key, algorithm, name); + } + + let algorithm = Algorithm::from_str(algorithm) + .map_err(|_| format!("Unsupported TSIG algorithm: {algorithm}"))?; + + let key = base64::decode(key).map_err(|e| format!("TSIG key is invalid base64: {e}"))?; + + let name = KeyName::from_str(name).map_err(|e| format!("TSIG name is invalid: {e}"))?; + + Ok(TSigInfo { + name, + key, + algorithm, + }) + } +} + +#[derive(Clone, Debug, clap::Args, PartialEq, Eq)] +pub struct Notify { + /// The zone + #[arg(short = 'z', long = "zone", required = true)] + zone: Name>, + + // The -I option is supported by ldns but is not available in domain yet. + // It requires creating a connection from a UdpSocket (or similar). + // /// Source address to query from + // #[arg(short = 'I', required = false)] + // source_address: (), + // + /// SOA version number to include + #[arg(short = 's', long = "soa")] + soa_version: Option, + + /// A base64 tsig key and optional algorithm to include + #[arg( + short = 'y', + long = "tsig", + value_parser = ValueParser::new(TSigInfo::from_str), + value_name = "name:key[:algo]", + )] + tsig: Option, + + /// Port to use to send the packet + #[arg(short = 'p', long = "port", default_value = "53")] + port: u16, + + /// Print debug information + #[arg(short = 'd', long = "debug")] + debug: bool, + + /// Max number of retries + #[arg(short = 'r', long = "retries", default_value = "15")] + retries: u8, + + /// DNS servers to send packet to + #[arg(required = true)] + servers: Vec, +} + +const LDNS_HELP: &str = "\ +usage: ldns-notify [other options] -z zone +Ldns notify utility + + Supported options: + -z zone The zone + -I
source address to query from (currently unsupported) + -s version SOA version number to include + -y specify named base64 tsig key, and optional an + algorithm (defaults to hmac-md5.sig-alg.reg.int) + -p port port to use to send to + -v Print version information + -d Print verbose debug information + -r num max number of retries (15) + -h Print this help information + +Report bugs to +"; + +impl LdnsCommand for Notify { + const NAME: &'static str = "notify"; + const HELP: &'static str = LDNS_HELP; + const COMPATIBLE_VERSION: &'static str = "1.8.4"; + + fn parse_ldns>(args: I) -> Result { + let mut zone = None; + let mut soa_version = None; + let mut tsig = None; + let mut port = 53; + let mut debug = false; + let mut retries = 15; + let mut servers = Vec::new(); + + let mut parser = lexopt::Parser::from_args(args); + + while let Some(arg) = parser.next()? { + match arg { + Arg::Short('z') => { + let val = parser.value()?; + zone = Some(parse_os("zone (-z)", &val)?); + } + Arg::Short('I') => return Err("The -I option is currently unsupported".into()), + Arg::Short('s') => { + let val = parser.value()?; + soa_version = Some(parse_os("soa version (-s)", &val)?); + } + Arg::Short('y') => { + let val = parser.value()?; + tsig = Some(parse_os("tsig key (-y)", &val)?); + } + Arg::Short('p') => { + let val = parser.value()?; + port = parse_os("port (-p)", &val)?; + } + Arg::Short('d') => debug = true, + Arg::Short('r') => { + let val = parser.value()?; + retries = parse_os("retries (-r)", &val)?; + } + Arg::Short('h') => return Ok(Self::report_help()), + Arg::Short('v') => return Ok(Self::report_version()), + Arg::Short(x) => return Err(format!("Invalid short option: -{x}").into()), + Arg::Long(x) => { + return Err(format!("Long options are not supported, but `--{x}` given").into()) + } + Arg::Value(x) => { + servers.push(parse_os("server", &x)?); + } + } + } + + let Some(zone) = zone else { + return Err("Missing zone name argument".into()); + }; + + if servers.is_empty() { + return Err("Missing servers".into()); + } + + Ok(Args::from(Command::Notify(Notify { + zone, + soa_version, + tsig, + port, + debug, + retries, + servers, + }))) + } +} + +impl Notify { + pub fn execute(&self, env: impl Env) -> Result<(), Error> { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(self.run(env)) + } + + async fn run(&self, env: impl Env) -> Result<(), Error> { + let mut msg = MessageBuilder::new_vec(); + + let header = msg.header_mut(); + header.set_opcode(Opcode::NOTIFY); + header.set_aa(true); + header.set_random_id(); + + let mut msg = msg.question(); + let question = Question::new(&self.zone, Rtype::SOA, Class::IN); + msg.push(question) + .map_err(|e| format!("could not create question section: {e}"))?; + + let mut msg = msg.answer(); + if let Some(soa_version) = self.soa_version { + let soa = Record::new( + &self.zone, + Class::IN, + Ttl::from_secs(3600), + Soa::new( + Name::root_vec(), + Name::root_vec(), + Serial(soa_version), + Ttl::ZERO, + Ttl::ZERO, + Ttl::ZERO, + Ttl::ZERO, + ), + ); + msg.push(soa) + .map_err(|e| format!("could not add SOA record: {e}"))?; + } + + let msg = msg.additional(); + + let tsig = self + .tsig + .as_ref() + .map(|tsig| { + Key::new(tsig.algorithm, &tsig.key, tsig.name.clone(), None, None) + .map_err(|e| format!("TSIG key is invalid: {e}")) + }) + .transpose()?; + + let msg = msg.into_message(); + + writeln!(env.stdout(), "# Sending packet:"); + writeln!(env.stdout(), "{}", msg.display_dig_style()); + + if self.debug { + writeln!(env.stdout(), "Hexdump of notify packet:"); + writeln!(env.stdout(), "{}", base16::encode_display(&msg)); + } + + let resolver = env.stub_resolver().await; + + for server in &self.servers { + writeln!(env.stdout(), "# sending to {}", server); + + // The specified server might be an IP address. In ldns, this case is + // handled by `getaddrinfo`, but we have to do it ourselves. + // We parse it as an IP address and then send it to the one socket we + // can. + if let Ok(addr) = server.parse() { + let socket = SocketAddr::new(addr, self.port); + self.notify_host(&env, socket, msg.clone(), server, &tsig) + .await; + continue; + } + + let Ok(name) = Name::>::from_str(server) else { + writeln!( + env.stderr(), + "warning: invalid domain name \"{server}\", skipping." + ); + continue; + }; + + let Ok(hosts) = resolver.lookup_host(&name).await else { + writeln!( + env.stderr(), + "warning: could not resolve host \"{name}\", skipping." + ); + continue; + }; + + if hosts.is_empty() { + writeln!( + env.stderr(), + "skipping bad address: {name}: Name or service not known" + ); + continue; + } + + for socket in hosts.port_iter(self.port) { + self.notify_host(&env, socket, msg.clone(), server, &tsig) + .await; + } + } + + Ok(()) + } + + /// Send a notify packet to a single server and print the result + async fn notify_host( + &self, + env: &impl Env, + socket: SocketAddr, + msg: Message>, + server: &str, + tsig_key: &Option, + ) { + let mut config = dgram::Config::new(); + config.set_max_retries(self.retries); + + let dgram_connection = dgram::Connection::with_config(env.dgram(socket), config); + + let connection: Box> = if let Some(k) = tsig_key { + Box::new(tsig::Connection::new(k.clone(), dgram_connection)) + } else { + Box::new(dgram_connection) + }; + + let req = RequestMessage::new(msg).unwrap(); + let mut req = connection.send_request(req); + + let time1 = Local::now(); + let res = req.get_response().await; + let time2 = Local::now(); + + match res { + Ok(msg) => { + let mut out = env.stdout(); + writeln!(out, "# reply from {server} at {socket}:"); + writeln!(out, "{}", msg.display_dig_style()); + writeln!( + out, + ";; Query time: {} msec", + (time2 - time1).num_milliseconds() + ); + writeln!(out, ";; Server: {}#{}", socket.ip(), socket.port()); + writeln!(out, ";; WHEN: {}", time1.format("%a %b %d %H:%M:%S %Z %Y")); + writeln!(out, ";; MSG SIZE rcvd: {}", msg.as_slice().len()); + } + Err(e) => { + writeln!( + env.stdout(), + "warning: reply was not received or erroneous from: {socket}: {e}" + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, Ipv6Addr}; + use std::str::FromStr; + + use domain::base::Name; + use domain::tsig::Algorithm; + use domain::utils::base64; + + use crate::commands::notify::TSigInfo; + use crate::commands::Command; + use crate::env::fake::FakeCmd; + + use super::Notify; + + #[track_caller] + fn parse(cmd: FakeCmd) -> Notify { + let res = cmd.parse().unwrap(); + let Command::Notify(x) = res.command else { + panic!("not a notify!"); + }; + x + } + + #[test] + fn dnst_parse() { + let cmd = FakeCmd::new(["dnst", "notify"]); + + cmd.parse().unwrap_err(); + cmd.args(["--zone", "example.test"]).parse().unwrap_err(); + cmd.args(["--zone=example.test"]).parse().unwrap_err(); + cmd.args(["-z", "example.test"]).parse().unwrap_err(); + cmd.args(["-zexample.test"]).parse().unwrap_err(); + + let base = Notify { + zone: Name::from_str("example.test").unwrap(), + soa_version: None, + tsig: None, + port: 53, + debug: false, + retries: 15, + servers: vec!["some.example.test".into()], + }; + + // Create a command with some arguments that we reuse for some tests + let cmd2 = cmd.args(["-z", "example.test", "some.example.test"]); + + let res = parse(cmd2.clone()); + assert_eq!(res, base); + + for arg in ["-p", "--port"] { + let res = parse(cmd2.args([arg, "10"])); + assert_eq!( + res, + Notify { + port: 10, + ..base.clone() + } + ); + } + + let res = parse(cmd2.args(["-s", "10"])); + assert_eq!( + res, + Notify { + soa_version: Some(10), + ..base.clone() + } + ); + + for arg in ["-y", "--tsig"] { + let res = parse(cmd2.args([arg, "somekey:1234"])); + assert_eq!( + res, + Notify { + tsig: Some(TSigInfo { + name: "somekey".parse().unwrap(), + key: base64::decode("1234").unwrap(), + algorithm: Algorithm::Sha512, + }), + ..base.clone() + } + ); + } + } + + #[test] + fn ldns_parse() { + let cmd = FakeCmd::new(["ldns-notify"]); + + cmd.parse().unwrap_err(); + + // Shouldn't work at all + cmd.args(["--zone", "example.test"]).parse().unwrap_err(); + cmd.args(["--zone=example.test"]).parse().unwrap_err(); + + // Missing servers + cmd.args(["-z", "example.test"]).parse().unwrap_err(); + cmd.args(["-zexample.test"]).parse().unwrap_err(); + + // Create a command with some arguments that we reuse for some tests + let cmd2 = cmd.args(["-z", "example.test", "some.example.test"]); + + // Invalid numbers + cmd2.args(["-p", "blabla"]).parse().unwrap_err(); + cmd2.args(["-r", "blabla"]).parse().unwrap_err(); + + let base = Notify { + zone: Name::from_str("example.test").unwrap(), + soa_version: None, + tsig: None, + port: 53, + debug: false, + retries: 15, + servers: vec!["some.example.test".into()], + }; + + let res = parse(cmd2.clone()); + assert_eq!(res, base); + + let res = parse(cmd2.args(["-p", "10"])); + assert_eq!( + res, + Notify { + port: 10, + ..base.clone() + } + ); + + let res = parse(cmd2.args(["-s", "10"])); + assert_eq!( + res, + Notify { + soa_version: Some(10), + ..base.clone() + } + ); + + let res = parse(cmd2.args(["-y", "somekey:1234"])); + assert_eq!( + res, + Notify { + tsig: Some(TSigInfo { + name: "somekey".parse().unwrap(), + key: base64::decode("1234").unwrap(), + algorithm: Algorithm::Sha512, + }), + ..base.clone() + } + ); + } + + #[test] + fn version() { + let res = FakeCmd::new(["ldns-notify", "-v"]).run(); + assert_eq!(res.exit_code, 0); + assert!(res.stdout.contains("ldns-notify provided by dnst v")); + assert!(res.stdout.contains("(compatible with ldns v1.8.4)")); + } + + fn entries_for_name(name: &str, v4: &[Ipv4Addr], v6: &[Ipv6Addr]) -> String { + let v4 = v4 + .iter() + .map(|a| format!("{name} IN 10 A {a}")) + .collect::>() + .join("\n"); + + let v6 = v6 + .iter() + .map(|a| format!("{name} IN 10 AAAA {a}")) + .collect::>() + .join("\n"); + + format!( + " + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR RD RA NOERROR + SECTION QUESTION + {name} IN A + SECTION ANSWER + {v4} + ENTRY_END + + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR RD RA NOERROR + SECTION QUESTION + {name} IN AAAA + SECTION ANSWER + {v6} + ENTRY_END + " + ) + } + + #[test] + fn with_zone_and_ip() { + let rpl = " + CONFIG_END + + SCENARIO_BEGIN + + RANGE_BEGIN 0 100 + + ENTRY_BEGIN + ADJUST copy_id + REPLY QR + SECTION QUESTION + nlnetlabs.test SOA + SECTION ANSWER + success.test 10 A 2.2.2.2 + ENTRY_END + + RANGE_END + + SCENARIO_END + "; + + let cmd = FakeCmd::new(["dnst", "notify", "-z", "nlnetlabs.test", "1.1.1.1"]) + .stelline(rpl.as_bytes(), "notify.rpl"); + + let res = cmd.run(); + assert_eq!(res.exit_code, 0); + assert!(res.stdout.contains("success.test")); + assert_eq!(res.stderr, ""); + } + + #[test] + fn with_zone_and_domain_name() { + let foo = entries_for_name("foo.test", &[Ipv4Addr::new(1, 2, 3, 4)], &[]); + let bar = entries_for_name("bar.test", &[], &[]); + + let rpl = format!( + " + CONFIG_END + + SCENARIO_BEGIN + + RANGE_BEGIN 0 100 + + {foo} + + {bar} + + ENTRY_BEGIN + MATCH question + ADJUST copy_id + REPLY QR + SECTION QUESTION + nlnetlabs.test SOA + SECTION ANSWER + success.test IN 10 A 2.2.2.2 + ENTRY_END + + RANGE_END + + SCENARIO_END + " + ); + + let cmd = FakeCmd::new(["dnst", "notify", "-z", "nlnetlabs.test", "foo.test"]) + .stelline(rpl.as_bytes(), "notify.rpl"); + + let res = cmd.run(); + assert!(res.stdout.contains("success.test")); + assert_eq!(res.stderr, ""); + + let cmd = FakeCmd::new(["dnst", "notify", "-z", "nlnetlabs.test", "bar.test"]) + .stelline(rpl.as_bytes(), "notify.rpl"); + + let res = cmd.run(); + assert_eq!(res.exit_code, 0); + assert!(res.stderr.contains("Name or service not known")); + } +} diff --git a/src/commands/nsec3hash.rs b/src/commands/nsec3hash.rs index dbeb484..906472a 100644 --- a/src/commands/nsec3hash.rs +++ b/src/commands/nsec3hash.rs @@ -190,6 +190,7 @@ mod tests { cmd: FakeCmd::new(["unused"]), stdout: FakeStream::default(), stderr: FakeStream::default(), + stelline: None, }; // We don't test all permutations as that would take too long (~20 seconds) diff --git a/src/env/fake.rs b/src/env/fake.rs index 0715ee5..0b58840 100644 --- a/src/env/fake.rs +++ b/src/env/fake.rs @@ -1,11 +1,20 @@ use std::borrow::Cow; use std::ffi::OsString; -use std::fmt; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Mutex; +use std::{fmt, io}; -use crate::{error::Error, parse_args, run, Args}; +use domain::net::client::dgram; +use domain::net::client::protocol::{AsyncConnect, AsyncDgramRecv, AsyncDgramSend}; +use domain::resolv::stub::conf::ResolvConf; +use domain::resolv::StubResolver; +use domain::stelline::client::CurrStepValue; +use domain::stelline::dgram::Dgram; +use domain::stelline::parse_stelline::{self, Stelline}; + +use crate::error::Error; +use crate::{parse_args, run, Args}; use super::Env; use super::Stream; @@ -19,6 +28,7 @@ pub struct FakeCmd { /// The command to run, including `argv[0]` cmd: Vec, cwd: Option, + stelline: Option, } /// The result of running a [`FakeCmd`] @@ -41,8 +51,8 @@ pub struct FakeEnv { /// The mocked stderr pub stderr: FakeStream, - // pub stelline: Option, - // pub curr_step_value: Option>, + + pub stelline: Option<(Stelline, Arc)>, } impl Env for FakeEnv { @@ -64,6 +74,37 @@ impl Env for FakeEnv { None => path.as_ref().into(), } } + + fn dgram( + &self, + _addr: std::net::SocketAddr, + ) -> impl AsyncConnect + + Clone + + Send + + Sync + + 'static { + if let Some((stelline, step)) = &self.stelline { + Dgram::new(stelline.clone(), step.clone()) + } else { + panic!("Tried making a stelline connection without setting up stelline") + } + } + + async fn stub_resolver_from_conf(&self, mut config: ResolvConf) -> StubResolver { + let Some((stelline, step)) = &self.stelline else { + panic!("Tried making a stelline connection without setting up stelline") + }; + + config.servers = vec![]; + let resolver = StubResolver::from_conf(config); + resolver + .add_connection(Box::new(dgram::Connection::new(Dgram::new( + stelline.clone(), + step.clone(), + )))) + .await; + resolver + } } impl FakeCmd { @@ -74,6 +115,7 @@ impl FakeCmd { Self { cmd: cmd.into_iter().map(Into::into).collect(), cwd: None, + stelline: None, } } @@ -84,6 +126,13 @@ impl FakeCmd { } } + pub fn stelline(&self, file: impl fmt::Debug + io::Read, name: impl ToString) -> Self { + Self { + stelline: Some(parse_stelline::parse_file(file, name)), + ..self.clone() + } + } + /// Add arguments to a clone of the [`FakeCmd`] /// /// ```rust,ignore @@ -100,10 +149,15 @@ impl FakeCmd { /// Parse the arguments of this [`FakeCmd`] and return the result pub fn parse(&self) -> Result { + debug_assert!( + self.stelline.is_none(), + "We shouldn't need Stelline for argument parsing" + ); let env = FakeEnv { cmd: self.clone(), stdout: Default::default(), stderr: Default::default(), + stelline: None, }; parse_args(env) } @@ -114,6 +168,10 @@ impl FakeCmd { cmd: self.clone(), stdout: Default::default(), stderr: Default::default(), + stelline: self + .stelline + .clone() + .map(|s| (s, Arc::new(CurrStepValue::new()))), }; let exit_code = run(&env); diff --git a/src/env/mod.rs b/src/env/mod.rs index 05e2384..68fdd8c 100644 --- a/src/env/mod.rs +++ b/src/env/mod.rs @@ -1,6 +1,7 @@ use std::borrow::Cow; use std::ffi::OsString; use std::fmt; +use std::net::SocketAddr; use std::path::Path; mod real; @@ -8,15 +9,11 @@ mod real; #[cfg(test)] pub mod fake; +use domain::net::client::protocol::{AsyncConnect, AsyncDgramRecv, AsyncDgramSend}; +use domain::resolv::{stub::conf::ResolvConf, StubResolver}; pub use real::RealEnv; pub trait Env { - // /// Make a network connection - // fn make_connection(&self); - - // /// Make a new [`StubResolver`] - // fn make_stub_resolver(&self); - /// Get an iterator over the command line arguments passed to the program /// /// Equivalent to [`std::env::args_os`] @@ -37,6 +34,23 @@ pub trait Env { /// Make relative paths absolute. fn in_cwd<'a>(&self, path: &'a impl AsRef) -> Cow<'a, Path>; + + fn dgram( + &self, + socket: SocketAddr, + ) -> impl AsyncConnect + + Clone + + Send + + Sync + + 'static; + + #[allow(async_fn_in_trait)] + async fn stub_resolver(&self) -> StubResolver { + self.stub_resolver_from_conf(ResolvConf::default()).await + } + + #[allow(async_fn_in_trait)] + async fn stub_resolver_from_conf(&self, config: ResolvConf) -> StubResolver; } /// A type with an infallible `write_fmt` method for use with [`write!`] macros @@ -82,4 +96,19 @@ impl Env for &E { fn in_cwd<'a>(&self, path: &'a impl AsRef) -> Cow<'a, Path> { (**self).in_cwd(path) } + + fn dgram( + &self, + socket: SocketAddr, + ) -> impl AsyncConnect + + Clone + + Send + + Sync + + 'static { + (**self).dgram(socket) + } + + async fn stub_resolver_from_conf(&self, config: ResolvConf) -> StubResolver { + (**self).stub_resolver_from_conf(config).await + } } diff --git a/src/env/real.rs b/src/env/real.rs index 26c01aa..5b9e5b3 100644 --- a/src/env/real.rs +++ b/src/env/real.rs @@ -3,6 +3,10 @@ use std::fmt; use std::io; use std::path::Path; +use domain::net::client::protocol::{AsyncConnect, AsyncDgramRecv, AsyncDgramSend, UdpConnect}; +use domain::resolv::stub::conf::ResolvConf; +use domain::resolv::StubResolver; + use super::Env; use super::Stream; @@ -25,6 +29,21 @@ impl Env for RealEnv { fn in_cwd<'a>(&self, path: &'a impl AsRef) -> std::borrow::Cow<'a, std::path::Path> { path.as_ref().into() } + + fn dgram( + &self, + addr: std::net::SocketAddr, + ) -> impl AsyncConnect + + Clone + + Send + + Sync + + 'static { + UdpConnect::new(addr) + } + + async fn stub_resolver_from_conf(&self, config: ResolvConf) -> StubResolver { + StubResolver::from_conf(config) + } } struct FmtWriter(T); diff --git a/src/lib.rs b/src/lib.rs index 05b1c80..9717bbe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,7 @@ use std::ffi::OsString; use std::path::Path; use clap::Parser; -use commands::{key2ds::Key2ds, keygen::Keygen, nsec3hash::Nsec3Hash, LdnsCommand}; +use commands::{key2ds::Key2ds, keygen::Keygen, notify::Notify, nsec3hash::Nsec3Hash, LdnsCommand}; use env::Env; use error::Error; @@ -31,6 +31,7 @@ pub fn try_ldns_compatibility>( let res = match binary_name { "key2ds" => Key2ds::parse_ldns_args(args_iter), + "notify" => Notify::parse_ldns_args(args_iter), "keygen" => Keygen::parse_ldns_args(args_iter), "nsec3-hash" => Nsec3Hash::parse_ldns_args(args_iter), _ => return Err(format!("Unrecognized ldns command 'ldns-{binary_name}'").into()), From 6f0e27d3e15547818466152790787122e644432b Mon Sep 17 00:00:00 2001 From: Terts Diepraam Date: Mon, 2 Dec 2024 14:33:03 +0100 Subject: [PATCH 3/3] Implement ldns-update and dnst update (#10) * implement ldns-update and dnst update * implement TSIG support for dnst update * update: imports compliant with domain's CONTRIBUTING.md * Use RequestMessage instead of RequestMessageMulti as we're expecting to receive multiple responses because we don't expect to send XFR requests. * Clippy. * update: fix parsing of optional IP addr * WIP * update: make it compile after rebase * start testing update * update: stelline test * update: fix some comments and strings * update: improve TSIG error message * update: use lookup_host instead of manual A query * notify & update: merge the separate TSigInfo instances * update: look in both answer and authority sections * fix duplicate clippy attribute --------- Co-authored-by: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> --- src/args.rs | 4 +- src/commands/mod.rs | 6 + src/commands/notify.rs | 73 +---- src/commands/update.rs | 626 +++++++++++++++++++++++++++++++++++++++++ src/error.rs | 11 +- src/lib.rs | 8 +- src/parse.rs | 60 ++++ 7 files changed, 718 insertions(+), 70 deletions(-) create mode 100644 src/commands/update.rs diff --git a/src/args.rs b/src/args.rs index 7c868b5..1b1cbaf 100644 --- a/src/args.rs +++ b/src/args.rs @@ -3,7 +3,9 @@ use crate::env::Env; use super::commands::Command; use super::error::Error; -#[derive(Clone, Debug, clap::Parser)] +use clap::Parser; + +#[derive(Clone, Debug, Parser)] #[command(version, disable_help_subcommand = true)] pub struct Args { #[command(subcommand)] diff --git a/src/commands/mod.rs b/src/commands/mod.rs index d093399..8d2a211 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod key2ds; pub mod keygen; pub mod notify; pub mod nsec3hash; +pub mod update; use clap::crate_version; use std::ffi::{OsStr, OsString}; @@ -64,6 +65,10 @@ pub enum Command { #[command(name = "key2ds")] Key2ds(key2ds::Key2ds), + /// Send an UPDATE packet + #[command(name = "update")] + Update(self::update::Update), + /// Show the manual pages Help(self::help::Help), @@ -82,6 +87,7 @@ impl Command { Self::Nsec3Hash(nsec3hash) => nsec3hash.execute(env), Self::Key2ds(key2ds) => key2ds.execute(env), Self::Notify(notify) => notify.execute(env), + Self::Update(update) => update.execute(env), Self::Help(help) => help.execute(), Self::Report(s) => { writeln!(env.stdout(), "{s}"); diff --git a/src/commands/notify.rs b/src/commands/notify.rs index ceee16e..d9ac3e9 100644 --- a/src/commands/notify.rs +++ b/src/commands/notify.rs @@ -2,78 +2,22 @@ use std::net::SocketAddr; use std::str::FromStr; use chrono::Local; -use clap::builder::ValueParser; use domain::base::iana::{Class, Opcode}; use domain::base::{Message, MessageBuilder, Name, Question, Record, Rtype, Serial, Ttl}; use domain::net::client::request::{RequestMessage, SendRequest}; use domain::net::client::{dgram, tsig}; use domain::rdata::Soa; -use domain::tsig::{Algorithm, Key, KeyName}; -use domain::utils::{base16, base64}; +use domain::tsig::Key; +use domain::utils::base16; use lexopt::Arg; use crate::env::Env; use crate::error::Error; +use crate::parse::TSigInfo; use crate::Args; use super::{parse_os, Command, LdnsCommand}; -#[derive(Clone, Debug, PartialEq, Eq)] -struct TSigInfo { - name: KeyName, - key: Vec, - algorithm: Algorithm, -} - -impl FromStr for TSigInfo { - type Err = Error; - - fn from_str(s: &str) -> Result { - let Some((mut name, rest)) = s.split_once(':') else { - return Err("should contain at least one `:`".into()); - }; - - let mut key; - let mut algorithm; - if let Some((k, a)) = rest.split_once(':') { - key = k; - algorithm = a; - } else { - key = rest; - // This is different from ldns's default of - // hmac-md5.sig-alg.reg.int but we don't support that algorithm. - algorithm = "hmac-sha512"; - } - - // With dig TSIG keys are also specified with -y, - // but our format is: - // and dig's is: [hmac:]name:key - // - // When we detect an unknown TSIG algorithm in algo, - // but a known algorithm in name, we can assume dig - // order was used. - // - // We can correct this by checking whether the name contains a valid - // algorithm while the algorithm doesn't. - if Algorithm::from_str(algorithm).is_err() && Algorithm::from_str(name).is_ok() { - (name, key, algorithm) = (key, algorithm, name); - } - - let algorithm = Algorithm::from_str(algorithm) - .map_err(|_| format!("Unsupported TSIG algorithm: {algorithm}"))?; - - let key = base64::decode(key).map_err(|e| format!("TSIG key is invalid base64: {e}"))?; - - let name = KeyName::from_str(name).map_err(|e| format!("TSIG name is invalid: {e}"))?; - - Ok(TSigInfo { - name, - key, - algorithm, - }) - } -} - #[derive(Clone, Debug, clap::Args, PartialEq, Eq)] pub struct Notify { /// The zone @@ -91,12 +35,7 @@ pub struct Notify { soa_version: Option, /// A base64 tsig key and optional algorithm to include - #[arg( - short = 'y', - long = "tsig", - value_parser = ValueParser::new(TSigInfo::from_str), - value_name = "name:key[:algo]", - )] + #[arg(short = 'y', long = "tsig", value_name = "name:key[:algo]")] tsig: Option, /// Port to use to send the packet @@ -445,7 +384,7 @@ mod tests { tsig: Some(TSigInfo { name: "somekey".parse().unwrap(), key: base64::decode("1234").unwrap(), - algorithm: Algorithm::Sha512, + algorithm: Algorithm::Sha256, }), ..base.clone() } @@ -512,7 +451,7 @@ mod tests { tsig: Some(TSigInfo { name: "somekey".parse().unwrap(), key: base64::decode("1234").unwrap(), - algorithm: Algorithm::Sha512, + algorithm: Algorithm::Sha256, }), ..base.clone() } diff --git a/src/commands/update.rs b/src/commands/update.rs new file mode 100644 index 0000000..c7fab0a --- /dev/null +++ b/src/commands/update.rs @@ -0,0 +1,626 @@ +use std::ffi::OsString; +use std::net::{IpAddr, SocketAddr}; + +use domain::base::iana::{Class, Opcode, Rcode}; +use domain::base::{ + Message, MessageBuilder, Name, Question, Record, Rtype, ToName, Ttl, UnknownRecordData, +}; +use domain::net::client::request::{RequestMessage, SendRequest}; +use domain::net::client::{dgram, tsig}; +use domain::rdata::{Aaaa, AllRecordData, Ns, Soa, A}; +use domain::resolv::stub::conf::{ResolvConf, ServerConf, Transport}; +use domain::tsig::Key; +use domain::utils::base64; + +use crate::env::Env; +use crate::error::Error; +use crate::parse::TSigInfo; +use crate::Args; + +use super::{parse_os, parse_os_with, Command, LdnsCommand}; + +// Clap gives `Option` special handling by making the argument optional. +// This is not what we want because we require an explicit "none" value. So, +// we create an alias, so that clap doesn't recognize that we are using an +// option and pray that Ed Page doesn't make clap smart enough to figure +// this out. +type OptionIpAddr = Option; + +#[derive(Clone, Debug, clap::Args, PartialEq, Eq)] +pub struct Update { + /// Domain name to update + #[arg(value_name = "DOMAIN NAME")] + domain: Name>, + + /// IP address to associate with the given domain name. + /// Use `none` to delete the records for the domain name. + #[arg(value_name = "IP", value_parser = optional_ip)] + ip: OptionIpAddr, + + /// Zone to update + #[arg(long = "zone")] + zone: Option>>, + + /// TSIG credentials for the UPDATE packet + #[arg(short = 'y', long = "tsig", value_name = "name:key[:algo]")] + tsig: Option, +} + +fn optional_ip(s: &str) -> Result, Error> { + if s == "none" { + Ok(None) + } else { + let ip = s.parse().map_err(|_| format!("Invalid IP address: {s}"))?; + Ok(Some(ip)) + } +} + +const LDNS_HELP: &str = "\ +ldns-update domain [zone] ip tsig_name tsig_alg tsig_hmac + send a dynamic update packet to + + Use 'none' instead of ip to remove any previous address + If 'zone' is not specified, try to figure it out from the zone's SOA + Example: ldns-update my.example.org 1.2.3.4 + +This command exists for compatibility purposes. +For a more modern version of this command try `dnst update`\ +"; + +impl LdnsCommand for Update { + const NAME: &'static str = "update"; + const HELP: &'static str = LDNS_HELP; + const COMPATIBLE_VERSION: &'static str = "1.8.4"; + + fn parse_ldns>(args: I) -> Result { + let args: Vec<_> = args.into_iter().collect(); + + // We have this signature + // [ZONE] [TSIG_NAME TSIG_ALG TSIG_HMAC] + // So we parse it by splitting the cases by number of arguments: + // 1: DOMAIN IP + // 2: DOMAIN ZONE IP + // 4: DOMAIN IP TSIG_NAME TSIG_ALG TSIG_HMAC + // 5: DOMAIN ZONE IP TSIG_NAME TSIG_ALG TSIG_HMAC + let (domain, zone, ip, tsig) = match &args[..] { + [domain, ip] => (domain, None, ip, None), + [domain, zone, ip] => (domain, Some(zone), ip, None), + [domain, ip, tsig_name, tsig_key, tsig_hmac] => { + (domain, None, ip, Some((tsig_name, tsig_key, tsig_hmac))) + } + [domain, zone, ip, tsig_name, tsig_alg, tsig_hmac] => ( + domain, + Some(zone), + ip, + Some((tsig_name, tsig_alg, tsig_hmac)), + ), + _ => { + return if args.len() < 2 { + Err("Not enough arguments. ldns-update requires at least 2 arguments".into()) + } else if args.len() > 6 { + Err("Too many arguments. ldns-update requires at most 6 arguments".into()) + } else { + Err("Cannot take 4 arguments. ldns-update needs 2, 3, 5 or 6 arguments".into()) + } + } + }; + + let domain = parse_os("domain name", domain)?; + + let ip = if *ip != "none" { + Some(parse_os("IP address", ip)?) + } else { + None + }; + + let zone = match zone { + Some(z) => Some(parse_os("zone", z)?), + None => None, + }; + + Ok(Args::from(Command::Update(Self { + domain, + ip, + zone, + tsig: match tsig { + Some((name, algorithm, key)) => Some(TSigInfo { + name: parse_os("TSIG name", name)?, + key: parse_os_with("TSIG key", key, base64::decode)?, + algorithm: parse_os("TSIG algorithm", algorithm)?, + }), + None => None, + }, + }))) + } +} + +impl Update { + pub fn execute(self, env: impl Env) -> Result<(), Error> { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(self.run(&env)) + } + + /// Run the command as an async function + pub async fn run(self, env: &impl Env) -> Result<(), Error> { + writeln!( + env.stdout(), + ";; trying UPDATE with FQDN \"{}\" and IP \"{}\"", + self.domain, + self.ip.map_or("".into(), |ip| ip.to_string()) + ); + + let soa_zone; + let soa_mname; + if let Some(zone) = &self.zone { + soa_mname = self.find_mname(env, zone).await?; + soa_zone = zone.clone(); + } else { + let name = self.domain.clone(); + (soa_zone, soa_mname) = self.find_mname_and_zone(env, &name).await?; + }; + + let nsnames = self.determine_nsnames(env, &soa_zone, &soa_mname).await?; + let msg = self.create_update_message(&soa_zone); + + self.send_update(env, msg, nsnames).await + } + + /// Find the MNAME by sending a SOA query for the zone + async fn find_mname( + &self, + env: &impl Env, + zone: &Name>, + ) -> Result>, Error> { + let resolver = env.stub_resolver().await; + + let response = resolver + .query(Question::new(&zone, Rtype::SOA, Class::IN)) + .await?; + + let mut answer = response.answer()?.limit_to::>(); + if let Some(soa) = answer.next() { + Ok(soa?.data().mname().to_name()) + } else { + Err("no SOA record found".into()) + } + } + + /// Find the MNAME and zone + /// + /// This is achieved in 3 steps: + /// 1. Get the MNAME with a SOA query for the domain name + /// 2. Get the A record for the MNAME + /// 3. Send a SOA query to that IP address and use the owner as zone + /// and the MNAME from that response. + async fn find_mname_and_zone( + &self, + env: &impl Env, + name: &Name>, + ) -> Result<(Name>, Name>), Error> { + let resolver = env.stub_resolver().await; + + // Step 1 - first find a nameserver that should know *something* + let response = resolver + .query(Question::new(&name, Rtype::SOA, Class::IN)) + .await?; + + // We look in both the answer and authority sections. + // The answer section is used if the domain name is the zone apex, + // otherwise the SOA is in the authority section. + let mut sections = response + .answer()? + .limit_to_in::>() + .chain(response.authority()?.limit_to_in::>()); + + let Some(soa) = sections.next() else { + return Err("no SOA found".into()); + }; + + let soa_mname: Name> = soa?.data().mname().to_name(); + + // Step 2 - find SOA MNAME IP address, add to resolver + let response = resolver.lookup_host(&soa_mname).await?; + + let Some(ipaddr) = response.iter().next() else { + return Err("no A record found".into()); + }; + + // Step 3 - Redo SOA query, sending to SOA MNAME directly. + let mut conf = ResolvConf::new(); + conf.servers = vec![ServerConf::new( + SocketAddr::new(ipaddr, 53), + Transport::UdpTcp, + )]; + // TODO: Add the standard servers? Is that necessary or just a quirk + // of ldns. + let resolver = env.stub_resolver_from_conf(conf).await; + + let response = resolver + .query(Question::new(&name, Rtype::SOA, Class::IN)) + .await?; + + // We look in both the answer and authority sections. + // The answer section is used if the domain name is the zone apex, + // otherwise the SOA is in the authority section. + let mut sections = response + .answer()? + .limit_to_in::>() + .chain(response.authority()?.limit_to_in::>()); + + let Some(soa) = sections.next() else { + return Err("no SOA found".into()); + }; + + let soa = soa?; + + let zone = soa.owner().to_name(); + let mname = soa.data().mname().to_name(); + Ok((zone, mname)) + } + + /// Send an NS query to find all nameservers for the given zone + /// + /// The name server with the given MNAME is put at the start of the list. + async fn determine_nsnames( + &self, + env: &impl Env, + zone: &Name>, + mname: &Name>, + ) -> Result>>, Error> { + let response = env + .stub_resolver() + .await + .query(Question::new(&zone, Rtype::NS, Class::IN)) + .await?; + + let mut nsnames = response + .answer()? + .limit_to_in::>() + .map(|ns| Ok(ns?.data().nsdname().to_name::>())) + .collect::, Error>>()?; + + // The MNAME should be tried first according to RFC2136 4.3 + // so we put that NSNAME first in the list. + if let Some(mname_idx) = nsnames.iter().position(|name| name == mname) { + nsnames.swap(0, mname_idx); + } + + Ok(nsnames) + } + + /// Create the packet of the update message to send to the name servers + fn create_update_message(&self, zone: &Name>) -> Vec { + let mut message = MessageBuilder::new_vec(); + + let header = message.header_mut(); + header.set_opcode(Opcode::UPDATE); + header.set_qr(false); + + let mut zone_section = message.question(); + zone_section + .push(Question::new(zone, Rtype::SOA, Class::IN)) + .unwrap(); + + let mut update_section = zone_section.authority(); + + // If we have an IP address, remove that ip address + // else remove A and/or AAAA as defined in RFC2136 2.5.2. + if let Some(ip) = self.ip { + let rdata: AllRecordData<&[u8], Name<&[u8]>> = match ip { + IpAddr::V4(ip) => AllRecordData::A(A::new(ip)), + IpAddr::V6(ip) => AllRecordData::Aaaa(Aaaa::new(ip)), + }; + update_section + .push(Record::new( + &self.domain, + Class::IN, + Ttl::from_secs(300), + rdata, + )) + .unwrap(); + } else { + update_section + .push(Record::new( + &self.domain, + Class::ANY, + Ttl::from_secs(0), + UnknownRecordData::from_octets(Rtype::A, &[]).unwrap(), + )) + .unwrap(); + + update_section + .push(Record::new( + &self.domain, + Class::ANY, + Ttl::from_secs(0), + UnknownRecordData::from_octets(Rtype::AAAA, &[]).unwrap(), + )) + .unwrap(); + } + + update_section.finish() + } + + /// Send the update packet to the names in nsnames in order until one responds + async fn send_update( + &self, + env: impl Env, + msg: Vec, + nsnames: Vec>>, + ) -> Result<(), Error> { + let msg = Message::from_octets(msg).unwrap(); + let resolver = env.stub_resolver().await; + + let tsig_key = self + .tsig + .as_ref() + .map(|tsig| { + Key::new(tsig.algorithm, &tsig.key, tsig.name.clone(), None, None) + .map_err(|e| format!("TSIG key is invalid: {e}")) + }) + .transpose()?; + + for name in nsnames { + let found_ips = resolver.lookup_host(&name).await?; + for socket in found_ips.port_iter(53) { + let dgram_connection = dgram::Connection::new(env.dgram(socket)); + + let connection: Box> = if let Some(k) = &tsig_key { + Box::new(tsig::Connection::new(k.clone(), dgram_connection)) + } else { + Box::new(dgram_connection) + }; + + let response = connection + .send_request(RequestMessage::new(msg.clone()).unwrap()) + .get_response() + .await; + + let resp = match response { + Ok(resp) => resp, + Err(err) => { + writeln!(env.stderr(), "{name} @ {socket}: {err}"); + continue; + } + }; + + let rcode = resp.header().rcode(); + if rcode != Rcode::NOERROR { + writeln!(env.stdout(), ";; UPDATE response was {rcode}"); + } + return Ok(()); + } + } + + // Our list of nsnames has been exhausted, we can only report that + // we couldn't find anything. + writeln!(env.stdout(), ";; No responses"); + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use domain::{tsig::Algorithm, utils::base64}; + + use crate::{commands::Command, env::fake::FakeCmd}; + + use super::{TSigInfo, Update}; + + #[track_caller] + fn parse(cmd: FakeCmd) -> Update { + let res = cmd.parse().unwrap(); + let Command::Update(x) = res.command else { + panic!("Not an Update!"); + }; + x + } + + #[test] + fn dnst_parse() { + let cmd = FakeCmd::new(["dnst", "update"]); + + cmd.parse().unwrap_err(); + cmd.args(["example.test"]).parse().unwrap_err(); + cmd.args(["--zone", "example.test"]).parse().unwrap_err(); + cmd.args(["--zone", "example.test", "ns.example.test"]) + .parse() + .unwrap_err(); + cmd.args(["foo.test", "bar.test", "none"]) + .parse() + .unwrap_err(); + + let base = Update { + domain: "foo.test".parse().unwrap(), + ip: None, + zone: None, + tsig: None, + }; + + let res = parse(cmd.args(["foo.test", "none"])); + assert_eq!(res, base); + + let res = parse(cmd.args(["foo.test", "1.1.1.1"])); + assert_eq!( + res, + Update { + ip: Some("1.1.1.1".parse().unwrap()), + ..base.clone() + } + ); + + let res = parse(cmd.args(["foo.test", "1.1.1.1", "--zone", "bar.test"])); + assert_eq!( + res, + Update { + ip: Some("1.1.1.1".parse().unwrap()), + zone: Some("bar.test".parse().unwrap()), + ..base.clone() + } + ); + + let res = parse(cmd.args(["foo.test", "none", "--tsig", "somekey:1234"])); + assert_eq!( + res, + Update { + tsig: Some(TSigInfo { + name: "somekey".parse().unwrap(), + key: base64::decode("1234").unwrap(), + algorithm: Algorithm::Sha256, + }), + ..base.clone() + } + ); + } + + #[test] + fn ldns_parse() { + let cmd = FakeCmd::new(["ldns-update"]); + + let base = Update { + domain: "foo.test".parse().unwrap(), + ip: None, + zone: None, + tsig: None, + }; + + cmd.args(["foo.test"]).parse().unwrap_err(); + + let res = parse(cmd.args(["foo.test", "none"])); + assert_eq!(res, base.clone()); + + let res = parse(cmd.args(["foo.test", "1.1.1.1"])); + assert_eq!( + res, + Update { + ip: Some("1.1.1.1".parse().unwrap()), + ..base.clone() + } + ); + + let res = parse(cmd.args(["foo.test", "base.test", "1.1.1.1"])); + assert_eq!( + res, + Update { + ip: Some("1.1.1.1".parse().unwrap()), + zone: Some("base.test".parse().unwrap()), + ..base.clone() + } + ); + } + + #[test] + fn run_with_stelline() { + let rpl = " + CONFIG_END + + SCENARIO_BEGIN + + RANGE_BEGIN 0 100 + + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR + SECTION QUESTION + foo.test IN SOA + SECTION ANSWER + foo.test 0 IN SOA ns.foo.test admin.foo.test 1 1 1 1 1 + SECTION AUTHORITY + foo.test 0 IN SOA ns.foo.test admin.foo.test 1 1 1 1 1 + ENTRY_END + + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR + SECTION QUESTION + zone.foo.test IN SOA + SECTION ANSWER + zone.foo.test 0 IN SOA ns.foo.test admin.foo.test 1 1 1 1 1 + ENTRY_END + + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR + SECTION QUESTION + zone.foo.test IN NS + SECTION ANSWER + zone.foo.test IN 0 NS ns.foo.test + ENTRY_END + + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR + SECTION QUESTION + foo.test IN NS + SECTION ANSWER + foo.test IN 0 NS ns.foo.test + ENTRY_END + + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR + SECTION QUESTION + ns.foo.test IN A + SECTION ANSWER + ns.foo.test IN 0 A 12.34.56.78 + ENTRY_END + + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR + SECTION QUESTION + ns.foo.test IN AAAA + SECTION ANSWER + ENTRY_END + + ENTRY_BEGIN + MATCH question opcode + ADJUST copy_id copy_query + OPCODE UPDATE + REPLY QR + SECTION QUESTION + zone.foo.test IN SOA + SECTION ANSWER + ENTRY_END + + RANGE_END + SCENARIO_END + "; + + let cmd = FakeCmd::new([ + "dnst", + "update", + "foo.test", + "none", + "--zone", + "zone.foo.test", + ]) + .stelline(rpl.as_bytes(), "update.rpl"); + + let res = cmd.run(); + assert_eq!(res.exit_code, 0); + assert_eq!( + res.stdout, + ";; trying UPDATE with FQDN \"foo.test\" and IP \"\"\n" + ); + assert_eq!(res.stderr, ""); + + let cmd = FakeCmd::new(["dnst", "update", "foo.test", "none"]) + .stelline(rpl.as_bytes(), "update.rpl"); + + let res = cmd.run(); + assert_eq!(res.exit_code, 0); + assert_eq!( + res.stdout, + ";; trying UPDATE with FQDN \"foo.test\" and IP \"\"\n" + ); + assert_eq!(res.stderr, ""); + } +} diff --git a/src/error.rs b/src/error.rs index 2b9bb97..ad4a331 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,7 +1,10 @@ -use crate::env::Env; use std::fmt; use std::{error, io}; +use domain::base::wire::ParseError; + +use crate::env::Env; + //------------ Error --------------------------------------------------------- /// A program error. @@ -122,6 +125,12 @@ impl From for Error { } } +impl From for Error { + fn from(error: ParseError) -> Self { + Self::new(&error.to_string()) + } +} + impl From for Error { fn from(value: lexopt::Error) -> Self { value.to_string().into() diff --git a/src/lib.rs b/src/lib.rs index 9717bbe..c8b435f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,12 @@ use std::ffi::OsString; use std::path::Path; use clap::Parser; -use commands::{key2ds::Key2ds, keygen::Keygen, notify::Notify, nsec3hash::Nsec3Hash, LdnsCommand}; +use commands::key2ds::Key2ds; +use commands::keygen::Keygen; +use commands::notify::Notify; +use commands::nsec3hash::Nsec3Hash; +use commands::update::Update; +use commands::LdnsCommand; use env::Env; use error::Error; @@ -34,6 +39,7 @@ pub fn try_ldns_compatibility>( "notify" => Notify::parse_ldns_args(args_iter), "keygen" => Keygen::parse_ldns_args(args_iter), "nsec3-hash" => Nsec3Hash::parse_ldns_args(args_iter), + "update" => Update::parse_ldns_args(args_iter), _ => return Err(format!("Unrecognized ldns command 'ldns-{binary_name}'").into()), }; diff --git a/src/parse.rs b/src/parse.rs index 6aefb05..3a6639c 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -1,9 +1,69 @@ use core::str::FromStr; use domain::base::Name; +use domain::tsig::{Algorithm, KeyName}; +use domain::utils::base64; use crate::error::Error; pub fn parse_name(arg: &str) -> Result>, Error> { Name::from_str(&arg.to_lowercase()).map_err(|e| Error::from(e.to_string())) } + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TSigInfo { + pub name: KeyName, + pub key: Vec, + pub algorithm: Algorithm, +} + +impl FromStr for TSigInfo { + type Err = Error; + + fn from_str(s: &str) -> Result { + // TODO: better error messages + let Some((mut name, rest)) = s.split_once(':') else { + return Err("should contain at least one `:`".into()); + }; + + let mut key; + let mut algorithm; + if let Some((k, a)) = rest.split_once(':') { + key = k; + algorithm = a; + } else { + key = rest; + // This is different from the default algorithm that ldns-notify uses, which is MD5, + // but we don't support that. So we use the default that is also used by dig when MD5 + // is disabled. + algorithm = "hmac-sha256"; + }; + + // With dig TSIG keys are also specified with -y, + // but our format is: + // and dig's is: [hmac:]name:key + // + // When we detect an unknown TSIG algorithm in algo, + // but a known algorithm in name, we can assume dig + // order was used. + // + // We can correct this by checking whether the name contains a valid + // algorithm while the algorithm doesn't. + if Algorithm::from_str(algorithm).is_err() && Algorithm::from_str(name).is_ok() { + (name, key, algorithm) = (key, algorithm, name); + } + + let algorithm = Algorithm::from_str(algorithm) + .map_err(|_| format!("Unsupported TSIG algorithm: {algorithm}"))?; + + let key = base64::decode(key).map_err(|e| format!("TSIG key is invalid base64: {e}"))?; + + let name = KeyName::from_str(name).map_err(|e| format!("TSIG name is invalid: {e}"))?; + + Ok(TSigInfo { + name, + key, + algorithm, + }) + } +}