diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f8dbc59..f06ccfa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,11 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macOS-latest] rust: [1.78.0, stable, beta, nightly] + env: + 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 +22,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' diff --git a/Cargo.lock b/Cargo.lock index f3d18667..83126b42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,6 +209,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width 0.1.14", + "windows-sys 0.52.0", +] + [[package]] name = "crossbeam-channel" version = "0.5.13" @@ -218,6 +231,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -246,9 +269,12 @@ dependencies = [ name = "dnst" version = "0.1.0" dependencies = [ + "bytes", "clap", "domain", + "indicatif", "lexopt", + "rayon", "tempfile", "test_bin", ] @@ -256,21 +282,37 @@ 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?branch=report-signing-progress#bc14779780e6f6b3ce4c317526335ce3ff3f334b" dependencies = [ "bytes", "futures-util", "hashbrown", "moka", "octseq", + "openssl", + "parking_lot", "rand", "ring", + "rustversion", + "secrecy", "serde", "time", "tokio", "tracing", ] +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "errno" version = "0.3.9" @@ -308,6 +350,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" @@ -383,6 +440,19 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "indicatif" +version = "0.17.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf675b85ed934d3c67b5c5469701eec7db22689d0a2139d856e0925fa28b281" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.0", + "web-time", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -398,6 +468,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" @@ -489,6 +565,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "object" version = "0.36.5" @@ -514,6 +596,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 +675,18 @@ 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 = "portable-atomic" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" + [[package]] name = "powerfmt" version = "0.2.0" @@ -572,9 +704,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", ] @@ -642,6 +774,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.7" @@ -694,12 +846,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustversion" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" + [[package]] name = "scopeguard" 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 +938,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 +1077,21 @@ 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 = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "untrusted" @@ -935,6 +1114,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" @@ -1006,6 +1191,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1130,3 +1325,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 f5838fc7..cbdfe2cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,21 @@ name = "ldns" path = "src/bin/ldns.rs" [dependencies] -clap = { version = "4.3.4", features = ["derive"] } -domain = { version = "0.10.3", git = "https://github.com/NLnetLabs/domain.git", branch = "initial-nsec3-generation", features = ["unstable-validator", "zonefile"] } +bytes = { version = "1.1", default-features = false } +clap = { version = "4.3.4", features = ["cargo", "derive"] } +domain = { git = "https://github.com/NLnetLabs/domain", branch = "report-signing-progress", features = [ + "bytes", + "openssl", + "ring", + "unstable-sign", + "unstable-validate", + "unstable-validator", + "unstable-zonetree", + "zonefile", +] } +indicatif = { version = "0.17.9" } lexopt = "0.3.0" +rayon = "1.10.0" [dev-dependencies] test_bin = "0.4.0" diff --git a/src/commands/key2ds.rs b/src/commands/key2ds.rs index b10f7bcf..77b596ca 100644 --- a/src/commands/key2ds.rs +++ b/src/commands/key2ds.rs @@ -140,7 +140,7 @@ impl Key2ds { })?; // We only care about records in a zonefile - let Entry::Record(record) = entry else { + let Entry::Record(record, _) = entry else { continue; }; @@ -180,7 +180,7 @@ impl Key2ds { let rr = Record::new(owner, class, ttl, ds); if self.write_to_stdout { - writeln!(env.stdout(), "{}", rr.display_zonefile(false)); + writeln!(env.stdout(), "{}", rr.display_zonefile(false, true)); } else { let owner = owner.fmt_with_dot(); let sec_alg = sec_alg.to_int(); @@ -210,7 +210,7 @@ impl Key2ds { let mut out_file = res.map_err(|e| format!("Could not create file \"{filename}\": {e}"))?; - writeln!(out_file, "{}", rr.display_zonefile(false)) + writeln!(out_file, "{}", rr.display_zonefile(false, true)) .map_err(|e| format!("Could not write to file \"{filename}\": {e}"))?; writeln!(env.stdout(), "{keyname}"); @@ -436,7 +436,7 @@ mod test { assert_eq!(res.stderr, ""); let out = std::fs::read_to_string(dir.path().join("Kexample.test.+015+60136.ds")).unwrap(); - assert_eq!(out, "example.test. 3600 IN DS 60136 15 2 52BD3BF40C8220BF1A3E2A3751C423BC4B69BCD7F328D38C4CD021A85DE65AD4\n"); + assert_eq!(out, "example.test.\t3600\tIN\tDS\t60136\t15\t2\t52BD3BF40C8220BF1A3E2A3751C423BC4B69BCD7F328D38C4CD021A85DE65AD4\n"); } #[test] @@ -450,10 +450,10 @@ mod test { assert_eq!(res.stderr, ""); let out = std::fs::read_to_string(dir.path().join("Kone.test.+015+38429.ds")).unwrap(); - assert_eq!(out, "one.test. 3600 IN DS 38429 15 2 B85F7D27C48A7B84D633C7A41C3022EA0F7FC80896227B61AE7BFC59BF5F0256\n"); + assert_eq!(out, "one.test.\t3600\tIN\tDS\t38429\t15\t2\tB85F7D27C48A7B84D633C7A41C3022EA0F7FC80896227B61AE7BFC59BF5F0256\n"); let out = std::fs::read_to_string(dir.path().join("Ktwo.test.+015+00425.ds")).unwrap(); - assert_eq!(out, "two.test. 3600 IN DS 425 15 2 AA2030287A7C5C56CB3C0E9C64BE55616729C0C78DE2B83613D03B10C0F1EA93\n"); + assert_eq!(out, "two.test.\t3600\tIN\tDS\t425\t15\t2\tAA2030287A7C5C56CB3C0E9C64BE55616729C0C78DE2B83613D03B10C0F1EA93\n"); } #[test] @@ -467,7 +467,7 @@ mod test { assert_eq!(res.exit_code, 0); assert_eq!( res.stdout, - "example.test. 3600 IN DS 60136 15 2 52BD3BF40C8220BF1A3E2A3751C423BC4B69BCD7F328D38C4CD021A85DE65AD4\n" + "example.test.\t3600\tIN\tDS\t60136\t15\t2\t52BD3BF40C8220BF1A3E2A3751C423BC4B69BCD7F328D38C4CD021A85DE65AD4\n" ); assert_eq!(res.stderr, ""); } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 3b34f42e..b5ad21b9 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,12 +2,14 @@ pub mod help; pub mod key2ds; pub mod nsec3hash; +pub mod signzone; use std::ffi::{OsStr, OsString}; use std::str::FromStr; use key2ds::Key2ds; use nsec3hash::Nsec3Hash; +use signzone::SignZone; use crate::env::Env; use crate::Args; @@ -20,6 +22,10 @@ pub enum Command { #[command(name = "nsec3-hash")] Nsec3Hash(self::nsec3hash::Nsec3Hash), + /// Sign the zone with the given key(s) + #[command(name = "signzone")] + SignZone(self::signzone::SignZone), + /// Generate a DS RR from the DNSKEYS in keyfile /// /// The following file will be created for each key: @@ -35,8 +41,9 @@ pub enum Command { impl Command { pub fn execute(self, env: impl Env) -> Result<(), Error> { match self { - Self::Nsec3Hash(nsec3hash) => nsec3hash.execute(env), Self::Key2ds(key2ds) => key2ds.execute(env), + Self::Nsec3Hash(nsec3hash) => nsec3hash.execute(env), + Self::SignZone(signzone) => signzone.execute(env), Self::Help(help) => help.execute(), } } @@ -63,15 +70,21 @@ pub trait LdnsCommand: Into { } } +impl From for Command { + fn from(val: Key2ds) -> Self { + Command::Key2ds(val) + } +} + impl From for Command { fn from(val: Nsec3Hash) -> Self { Command::Nsec3Hash(val) } } -impl From for Command { - fn from(val: Key2ds) -> Self { - Command::Key2ds(val) +impl From for Command { + fn from(val: SignZone) -> Self { + Command::SignZone(val) } } diff --git a/src/commands/signzone.rs b/src/commands/signzone.rs new file mode 100644 index 00000000..6cc2699d --- /dev/null +++ b/src/commands/signzone.rs @@ -0,0 +1,1269 @@ +use core::cmp::Ordering; +use core::fmt::Write; +use core::ops::Add; +use core::str::FromStr; + +use std::cmp::min; +use std::collections::HashMap; +use std::ffi::OsString; +use std::fmt; +use std::fs::File; +use std::hash::RandomState; +use std::io::{self, BufWriter}; +use std::path::{Path, PathBuf}; + +// TODO: use a re-export from domain? +use bytes::{BufMut, Bytes, BytesMut}; +use clap::builder::ValueParser; +use domain::base::iana::nsec3::Nsec3HashAlg; +use domain::base::name::FlattenInto; +use domain::base::zonefile_fmt::ZonefileFmt; +use domain::base::{Name, NameBuilder, Record, Rtype, Serial, Ttl}; +use domain::rdata::dnssec::Timestamp; +use domain::rdata::nsec3::Nsec3Salt; +use domain::rdata::{Dnskey, Nsec3, Nsec3param, Soa, ZoneRecordData}; +use domain::sign::common::KeyPair; +use domain::sign::records::{ + Family, FamilyName, Nsec3OptOut, Nsec3Records, RecordsIter, SortedRecords, +}; +use domain::sign::{SecretKeyBytes, SigningKey}; +use domain::validate::Key; +use domain::zonefile::inplace::{self, Entry}; +use domain::zonetree::types::StoredRecordData; +use domain::zonetree::{StoredName, StoredRecord}; +use indicatif::{ + HumanBytes, HumanDuration, ProgressBar, ProgressDrawTarget, ProgressState, ProgressStyle, +}; +use lexopt::Arg; +use rayon::slice::ParallelSliceMut; + +use crate::env::{Env, Stream}; +use crate::error::Error; + +use super::nsec3hash::Nsec3Hash; +use super::{parse_os, parse_os_with, LdnsCommand}; + +//------------ Constants ----------------------------------------------------- + +const FOUR_WEEKS: u32 = 2419200; +const PROGRESS_STYLE: &str = + "{msg} {spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} ({eta})"; + +//------------ SignZone ------------------------------------------------------ + +#[derive(Clone, Debug, clap::Args)] +#[clap( + after_help = "Keys must be specified by their base name (usually K++), i.e. WITHOUT the .private or .key extension. Both .private and .key files are required. + +A date can be a timestamp (seconds since the epoch), or of the form +" +)] +pub struct SignZone { + // ----------------------------------------------------------------------- + // Original ldns-signzone options in ldns-signzone -h order: + // ----------------------------------------------------------------------- + /// Use layout in signed zone and print comments on DNSSEC records + #[arg(short = 'b', default_value_t = false)] + extra_comments: bool, + + /// Used keys are not added to the zone + #[arg(short = 'd', default_value_t = false)] + do_not_add_keys_to_zone: bool, + + /// Expiration date [default: 4 weeks from now] + // Default is not documented in ldns-signzone -h or man ldns-signzone but + // in code (see ldns/dnssec_sign.c::ldns_create_empty_rrsig()) LDNS uses + // now + 4 weeks if no expiration timestamp is specified. + #[arg( + short = 'e', + value_name = "date", + default_value_t = Timestamp::now().into_int().add(FOUR_WEEKS).into(), + hide_default_value = true, + value_parser = ValueParser::new(SignZone::parse_timestamp), + )] + expiration: Timestamp, + + /// Output zone to file [default: .signed] + /// + /// Use '-f -' to output to stdout. + #[arg(short = 'f', value_name = "file")] + out_file: Option, + + /// Inception date [default: now] + // Default is not documented in ldns-signzone -h or man ldns-signzone but + // in code (see ldns/dnssec_sign.c::ldns_create_empty_rrsig()) LDNS uses + // now if no inception timestamp is specified. + #[arg( + short = 'i', + value_name = "date", + default_value_t = Timestamp::now(), + hide_default_value = true, + value_parser = ValueParser::new(SignZone::parse_timestamp), + )] + inception: Timestamp, + + /// Origin for the zone (for zonefiles with relative names and no $ORIGIN) + #[arg(short = 'o', value_name = "domain")] + origin: Option>, + + /// Set SOA serial to the number of seconds since Jan 1st 1970 + /// + /// If this would NOT result in the SOA serial increasing it will be + /// incremented instead. + #[arg(short = 'u', default_value_t = false)] + set_soa_serial_to_epoch_time: bool, + + // SKIPPED: -v + // This should be handled at the dnst top level, not per subcommand. + + // Add ZONEMD resource record + // Can occur more than once. + //#[arg(short = 'z', group = "zonemd")] + // TODO + + // Allow ZONEMDs to be added without signing + //#[arg(short = 'Z', value_name = "[scheme]:hash", requires = "zonemd")] + // TODO + + // Sign DNSKEY with all keys instead of minimal + //#[arg(short = 'A', default_value_t = false)] + // TODO: sign_dnskey_with_all_keys: bool, + + // Sign with every unique algorithm in the provided keys + //#[arg(short = 'U', default_value_t = false)] + // TODO: sign_with_every_unique_algorithm: bool, + /// Use NSEC3 instead of NSEC + #[arg(short = 'n', default_value_t = false, group = "nsec3")] + use_nsec3: bool, + + /// Hashing algorithm + #[arg( + help_heading = Some("NSEC3 (when using '-n')"), + short = 'a', + value_name = "algorithm", + default_value = "SHA-1", + value_parser = ValueParser::new(Nsec3Hash::parse_nsec3_alg), + requires = "nsec3" + )] + algorithm: Nsec3HashAlg, + + /// Number of hash iterations + // TODO: make the default for dnst-signzone be 0 (to match best practice) + // while leaving the default for ldns-signzone be 1 (to match ldns), or + // maybe even change the default for both to 0. + #[arg( + help_heading = Some("NSEC3 (when using '-n')"), + short = 't', + value_name = "number", + default_value_t = 0, + requires = "nsec3" + )] + iterations: u16, + + /// Salt + #[arg( + help_heading = Some("NSEC3 (when using '-n')"), + short = 's', + value_name = "string", + default_value_t = Nsec3Salt::empty(), + requires = "nsec3" + )] + salt: Nsec3Salt, + + /// Set the opt-out flag on all NSEC3 RRs + #[arg( + help_heading = Some("NSEC3 (when using '-n')"), + short = 'p', + default_value_t = false, + requires = "nsec3", + conflicts_with = "nsec3_opt_out" + )] + nsec3_opt_out_flags_only: bool, + + // ----------------------------------------------------------------------- + // Extra options not supported by the original ldns-signzone: + // ----------------------------------------------------------------------- + /// Set the opt-out flag on all NSEC3 RRs and skip unsigned delegations + #[arg( + help_heading = Some("NSEC3 (when using '-n')"), + short = 'A', // Matches BIND dnssec-signzone + default_value_t = false, + requires = "nsec3", + conflicts_with = "nsec3_opt_out_flags_only" + )] + nsec3_opt_out: bool, + + /// Hash only, don't sign + #[arg(short = 'H', default_value_t = false)] + hash_only: bool, + + /// Do not require that key names match the apex. + #[arg(short = 'M', default_value_t = false)] + no_require_keys_match_apex: bool, + + /// Show progress bars. + #[arg(long = "progress", default_value_t = false)] + progress: bool, + + /// Show verbose output. + #[arg(long = "verbose", default_value_t = false)] + verbose: bool, + + // ----------------------------------------------------------------------- + // Original ldns-signzone positional arguments in position order: + // ----------------------------------------------------------------------- + /// The zonefile to sign + #[arg(value_name = "zonefile")] + zonefile_path: PathBuf, + + /// The keys to sign the zone with + #[arg(value_name = "key")] + key_paths: Vec, +} + +const LDNS_HELP: &str = r###"ldns-signzone [OPTIONS] zonefile key [key [key]] + signs the zone with the given key(s) + -b use layout in signed zone and print comments DNSSEC records + -d used keys are not added to the zone + -e expiration date + -f output zone to file (default .signed) + -i inception date + -o origin for the zone + -u set SOA serial to the number of seconds since 1-1-1970 + -v print version and exit + -z <[scheme:]hash> Add ZONEMD resource record + should be "simple" (or 1) + should be "sha384" or "sha512" (or 1 or 2) + this option can be given more than once + -Z Allow ZONEMDs to be added without signing + -A sign DNSKEY with all keys instead of minimal + -U Sign with every unique algorithm in the provided keys + -n use NSEC3 instead of NSEC. + If you use NSEC3, you can specify the following extra options: + -a [algorithm] hashing algorithm + -t [number] number of hash iterations + -s [string] salt + -p set the opt-out flag on all nsec3 rrs + + keys must be specified by their base name (usually K++), + i.e. WITHOUT the .private or .key extension. + A date can be a timestamp (seconds since the epoch), or of + the form +"###; + +impl LdnsCommand for SignZone { + const HELP: &'static str = LDNS_HELP; + + fn parse_ldns>(args: I) -> Result { + let mut extra_comments = false; + let mut do_not_add_keys_to_zone = false; + let mut expiration = Timestamp::now().into_int().add(FOUR_WEEKS).into(); + let mut out_file = Option::::None; + let mut inception = Timestamp::now(); + let mut origin = Option::>::None; + let mut set_soa_serial_to_epoch_time = false; + let mut use_nsec3 = false; + let mut algorithm = Nsec3HashAlg::SHA1; + let mut iterations = 1u16; + let mut salt = Nsec3Salt::::empty(); + let mut nsec3_opt_out = false; + let mut key_paths = Vec::::new(); + let mut zonefile = Option::::None; + + let mut parser = lexopt::Parser::from_args(args); + + while let Some(arg) = parser.next()? { + match arg { + Arg::Short('b') => { + extra_comments = true; + } + Arg::Short('d') => { + do_not_add_keys_to_zone = true; + } + Arg::Short('e') => { + let val = parser.value()?; + expiration = parse_os_with("-e", &val, SignZone::parse_timestamp)?; + } + Arg::Short('f') => { + let val = parser.value()?; + out_file = Some(parse_os("-f", &val)?); + } + Arg::Short('i') => { + let val = parser.value()?; + inception = parse_os_with("-i", &val, SignZone::parse_timestamp)?; + } + Arg::Short('o') => { + let val = parser.value()?; + origin = Some(parse_os("-o", &val)?); + } + Arg::Short('u') => { + set_soa_serial_to_epoch_time = true; + } + Arg::Short('v') => { + let version = clap::crate_version!(); + println!("zone signer version {version} (dnst version {version})"); + std::process::exit(0); + } + Arg::Short('n') => { + use_nsec3 = true; + } + Arg::Short('a') => { + let val = parser.value()?; + algorithm = parse_os_with("-a", &val, Nsec3Hash::parse_nsec3_alg)?; + } + Arg::Short('t') => { + let val = parser.value()?; + iterations = parse_os("-t", &val)?; + } + Arg::Short('s') => { + let val = parser.value()?; + salt = parse_os("-s", &val)?; + } + Arg::Short('p') => { + nsec3_opt_out = true; + } + Arg::Value(val) => { + if zonefile.is_none() { + zonefile = Some(parse_os("zonefile", &val)?); + } else { + key_paths.push(parse_os("key", &val)?); + } + } + 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 Some(zonefile_path) = zonefile else { + return Err("Missing zonefile argument".into()); + }; + + if let Some(out_file) = &out_file { + if out_file.as_os_str() == "-" { + extra_comments = false; + } + } + + if key_paths.is_empty() { + return Err("Missing key argument".into()); + }; + + Ok(Self { + extra_comments, + do_not_add_keys_to_zone, + expiration, + out_file, + inception, + origin, + set_soa_serial_to_epoch_time, + use_nsec3, + algorithm, + iterations, + salt, + nsec3_opt_out_flags_only: true, + nsec3_opt_out, + hash_only: false, + zonefile_path, + key_paths, + no_require_keys_match_apex: false, + progress: false, + verbose: false, + }) + } +} + +impl SignZone { + pub fn parse_timestamp(arg: &str) -> Result { + // We can't just use Timestamp::from_str from the domain crate because + // ldns-signzone treats YYYYMMDD as a special case and domain does + // not. For invalid values this YYYYMMDDD prevents use of valid Unix + // timestamps that have the same value, e.g. ldns-signzone complains + // that for 99999999 "The month must be in the range 1 to 12". There's + // also no checking that an expiration timestamp is in the future of + // an inception timestamp (which for serial numbers is hard to say for + // sure but for YYYYMMDD or YYYYMMDDHHmmSS we could check). + let res = if arg.len() == 8 && arg.parse::().is_ok() { + // This can give strange errors, e.g. 99999999 warns about illegal + // signature time, but the alternative would be to add a + // dependency on chrono and parse the value ourselves in order to + // produce a better error message. Given that this only happens + // for very old or far future Unix timestamps we don't attempt to + // do better than this for now. + Timestamp::from_str(&format!("{arg}000000")) + } else { + Timestamp::from_str(arg) + }; + + res.map_err(|err| Error::from(format!("Invalid timestamp: {err}"))) + } + + pub fn execute(self, env: impl Env) -> Result<(), Error> { + // Post-process arguments. + // TODO: Can Clap do this for us? + let opt_out = if self.nsec3_opt_out { + Nsec3OptOut::OptOut + } else if self.nsec3_opt_out_flags_only { + Nsec3OptOut::OptOutFlagsOnly + } else { + Nsec3OptOut::NoOptOut + }; + + let signing_mode = if self.hash_only { + SigningMode::HashOnly + } else { + SigningMode::HashAndSign + }; + + if self.key_paths.is_empty() && !self.hash_only { + return Err("Missing key argument".into()); + }; + + let out_file = if let Some(out_file) = &self.out_file { + out_file.clone() + } else { + let out_file = format!("{}.signed", self.zonefile_path.display()); + PathBuf::from_str(&out_file) + .map_err(|err| format!("Cannot write to {out_file}: {err}"))? + }; + + let (mut writer, mut log) = if out_file.as_os_str() == "-" { + // Don't allow logging to stdout when the output zone will be + // written to stdout. + (FileOrStdout::Stdout(env.stdout()), None) + } else { + let file = File::create(env.in_cwd(&out_file))?; + let file = BufWriter::new(file); + (FileOrStdout::File(file), Some(env.stdout())) + }; + + // Import the specified keys and check that the keys are all for the same zone. + let mut keys: Vec> = vec![]; + + if self.key_paths.is_empty() { + return Err("No keys to sign with. Aborting.".into()); + } + + let mut first_key_owner = None; + + for key_path in &self.key_paths { + let key = self.load_key_pair(key_path, &mut log)?; + + if !self.no_require_keys_match_apex { + if first_key_owner.is_none() { + first_key_owner.replace(key.owner().clone()); + } + + for key in &keys { + // Check the key name against the specified origin, if any, + if let Some(origin) = &self.origin { + if origin != key.owner() { + return Err(format!("{}.key has different name ({}) than the specified origin ({origin})", key_path.display(), key.owner()).into()); + } + } + + if Some(key.owner()) != first_key_owner.as_ref() { + return Err(format!( + "{}.key has different name ({}) to key {} ({})", + key_path.display(), + key.owner(), + self.key_paths[0].display(), + first_key_owner.unwrap() + ) + .into()); + } + } + } + + keys.push(key); + } + + // Read the zone file. + let mut records = self.load_zone(&env, first_key_owner.as_ref(), &mut log)?; + let n_records = records.len(); + + // Change the SOA serial. + if self.set_soa_serial_to_epoch_time { + Self::bump_soa_serial(&mut records)?; + } + + // Find the apex. + let (apex, ttl) = Self::find_apex(&records).unwrap(); + + // Hash the zone with NSEC or NSEC3. + let pb = if matches!(&log, Some(log) if log.is_terminal() && self.progress) { + let pb = ProgressBar::new(0).with_message("Hashing"); + pb.set_draw_target(ProgressDrawTarget::stderr_with_hz(1)); + pb.set_style( + ProgressStyle::with_template(PROGRESS_STYLE) + .unwrap() + .with_key("eta", |state: &ProgressState, w: &mut dyn Write| { + write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap() + }) + .progress_chars("#>-"), + ); + Some(pb) + } else { + None + }; + let hashes = if self.use_nsec3 { + let params = Nsec3param::new(self.algorithm, 0, self.iterations, self.salt.clone()); + let Nsec3Records { + recs, + param, + hashes, + } = records + .nsec3s::<_, BytesMut, _>( + &apex, + ttl, + params, + opt_out, + !self.do_not_add_keys_to_zone, + self.extra_comments, + |inc_pos, inc_len, new_phase| { + if let Some(pb) = &pb { + if inc_len > 0 { + pb.inc_length(inc_len as u64); + } + if inc_pos > 0 { + pb.inc(inc_pos as u64); + } + if let Some(new_phase) = new_phase { + pb.set_message(new_phase); + } + } + }, + ) + .unwrap(); + records.extend(recs.into_iter().map(Record::from_record)); + records.insert(Record::from_record(param)).unwrap(); + hashes + } else { + let nsecs = records.nsecs::(&apex, ttl, !self.do_not_add_keys_to_zone); + records.extend(nsecs.into_iter().map(Record::from_record)); + None + }; + + if let Some(pb) = pb { + let len = pb.length().unwrap(); + let elapsed = pb.elapsed(); + pb.finish_and_clear(); + if self.verbose { + if let Some(log) = &mut log { + writeln!( + log, + "Hashed {n_records} records in {len} steps in {}", + HumanDuration(elapsed) + ); + } + } + } + + // Sign the zone unless disabled. + if signing_mode == SigningMode::HashAndSign { + let n_records = records.len(); + let pb = if matches!(&log, Some(log) if log.is_terminal() && self.progress) { + let pb = ProgressBar::new(0).with_message("Signing"); + pb.set_draw_target(ProgressDrawTarget::stderr_with_hz(1)); + pb.set_style( + ProgressStyle::with_template(PROGRESS_STYLE) + .unwrap() + .with_key("eta", |state: &ProgressState, w: &mut dyn Write| { + write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap() + }) + .progress_chars("#>-"), + ); + Some(pb) + } else { + None + }; + let extra_records = records + .sign( + &apex, + self.expiration, + self.inception, + keys.as_slice(), + !self.do_not_add_keys_to_zone, + |inc_pos, inc_len, new_phase| { + if let Some(pb) = &pb { + if inc_len > 0 { + pb.inc_length(inc_len as u64); + } + if inc_pos > 0 { + pb.inc(inc_pos as u64); + } + if let Some(new_phase) = new_phase { + pb.set_message(new_phase); + } + } + }, + ) + .unwrap(); + records.extend(extra_records.into_iter().map(Record::from_record)); + + if let Some(pb) = pb { + let len = pb.length().unwrap(); + let elapsed = pb.elapsed(); + pb.finish_and_clear(); + if self.verbose { + if let Some(log) = &mut log { + writeln!( + log, + "Signed {n_records} records in {len} steps in {}", + HumanDuration(elapsed) + ); + } + } + } + } + + let n_records = records.len(); + let pb = if matches!(&log, Some(log) if log.is_terminal() && self.progress) { + let num_families = records.families().count(); + let pb = ProgressBar::new(num_families as u64); + pb.set_draw_target(ProgressDrawTarget::stderr_with_hz(1)); + pb.set_style( + ProgressStyle::with_template(PROGRESS_STYLE) + .unwrap() + .with_key("eta", |state: &ProgressState, w: &mut dyn Write| { + write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap() + }) + .progress_chars("#>-"), + ); + Some(pb) + } else { + None + }; + + // Output the resulting zone, with comments if enabled. + if self.extra_comments { + writer.write_fmt(format_args!(";; Zone: {}\n;\n", apex.owner()))?; + } + + if let Some(record) = records.iter().find(|r| r.rtype() == Rtype::SOA) { + writer.write_fmt(format_args!("{}\n", record.display_zonefile(false, true)))?; + if let Some(record) = records.iter().find(|r| { + if let ZoneRecordData::Rrsig(rrsig) = r.data() { + rrsig.type_covered() == Rtype::SOA + } else { + false + } + }) { + writer.write_fmt(format_args!("{}\n", record.display_zonefile(false, true)))?; + } + if self.extra_comments { + writer.write_str(";\n")?; + } + if let Some(pb) = &pb { + pb.inc(1); + } + } + + let hashes_ref = hashes.as_ref(); + let apex = &apex; + let nsec3_cs = Nsec3CommentState { hashes_ref, apex }; + + // The signed RRs are in DNSSEC canonical order by owner name. For + // compatibility with ldns-signzone, re-order them to be in canonical + // order by unhashed owner name and so that hashed names come after + // equivalent unhashed names. + // + // INCOMAPATIBILITY WARNING: Unlike ldns-signzone, we only apply this + // ordering if `-b` is specified. + // + // Note: Family refers to the underlying record data, so while we are + // creating a new Vec, it only contains references to the original + // data so it's indiividual are not the records themselves. + let mut families; + let family_iter: AnyFamiliesIter = if self.extra_comments && hashes_ref.is_some() { + if let Some(pb) = &pb { + pb.set_message("Applying diagnostic ordering"); + } + families = records.families().collect::>(); + let Some(hashes) = hashes_ref else { + unreachable!(); + }; + families.sort_unstable_by(|a, b| { + let mut hashed_count = 0; + let unhashed_a = if let Some(unhashed_owner) = hashes.get(a.owner()) { + hashed_count += 1; + unhashed_owner + } else { + a.owner() + }; + let unhashed_b = if let Some(unhashed_owner) = hashes.get(b.owner()) { + hashed_count += 2; + unhashed_owner + } else { + b.owner() + }; + + match unhashed_a.cmp(unhashed_b) { + Ordering::Less => Ordering::Less, + Ordering::Equal => match hashed_count { + 0 | 3 => Ordering::Equal, + 1 => Ordering::Greater, + 2 => Ordering::Less, + _ => unreachable!(), + }, + Ordering::Greater => Ordering::Greater, + } + }); + families.iter().into() + } else { + records.families().into() + }; + + if let Some(pb) = &pb { + pb.set_message("Saving"); + } + for family in family_iter { + if let Some(hashes_ref) = hashes_ref { + // If this is family contains an NSEC3 RR and the number of + // RRs in the RRSET of the unhashed owner name is zero, then + // the NSEC3 was generated for an empty non-terminal. + if family.rrsets().any(|rrset| rrset.rtype() == Rtype::NSEC3) { + if let Some(unhashed_name) = hashes_ref.get(family.owner()) { + if !records + .families() + .any(|family| family.owner() == unhashed_name) + { + writer.write_fmt(format_args!( + ";; Empty nonterminal: {unhashed_name}\n" + ))?; + } + } else { + // ??? Every hashed name must correspond to an unhashed name? + } + } + } + + for rrset in family.rrsets() { + if rrset.rtype() == Rtype::SOA { + // This is output separately above as the very first RRset. + continue; + } + + if rrset.rtype() == Rtype::RRSIG { + // We output RRSIGs only after the RRset that they cover. + continue; + } + + // For each non-RRSIG RRSET RR of a given type. + for rr in rrset.iter() { + writer.write_fmt(format_args!("{}", rr.display_zonefile(false, true)))?; + match rr.data() { + ZoneRecordData::Nsec3(nsec3) if self.extra_comments => { + nsec3.comment(&mut writer, rr, nsec3_cs)? + } + ZoneRecordData::Dnskey(dnskey) => dnskey.comment(&mut writer, rr, ())?, + _ => { /* Nothing to do */ } + } + writer.write_str("\n")?; + } + + // Now attempt to print the RRSIG that covers the RTYPE of this RRSET. + if let Some(covering_rrsig_rr) = family + .rrsets() + .filter(|this_rrset| this_rrset.rtype() == Rtype::RRSIG) + .find_map(|this_rrset| this_rrset.iter().find(|rr| matches!(rr.data(), ZoneRecordData::Rrsig(rrsig) if rrsig.type_covered() == rrset.rtype()))) + { + writer.write_fmt(format_args!("{}", covering_rrsig_rr.display_zonefile(false, true)))?; + // rrsig.comment(&mut writer, rr, ())?; + writer.write_str("\n")?; + let ZoneRecordData::Rrsig(rrsig) = covering_rrsig_rr.data() else { + unreachable!(); + }; + if self.extra_comments && rrsig.type_covered() == Rtype::NSEC3 { + writer.write_str(";\n")?; + } + } + } + + if let Some(pb) = &pb { + pb.inc(1); + } + } + + if let Some(pb) = pb { + let len = pb.length().unwrap(); + let elapsed = pb.elapsed(); + pb.finish_and_clear(); + if self.verbose { + if let Some(log) = &mut log { + writeln!( + log, + "Saved {n_records} records in {len} steps in {}", + HumanDuration(elapsed) + ); + } + } + } + + Ok(()) + } + + fn load_zone( + &self, + env: &impl Env, + expected_apex: Option<&Name>, + log: &mut Option>, + ) -> Result, Error> { + if self.verbose { + if let Some(log) = log { + writeln!(log, "Loading zone from '{}'", self.zonefile_path.display()); + } + } + + // Don't use Zonefile::load() as it knows nothing about the size of + // the original file so uses default allocation which allocates more + // bytes than are needed. Instead control the allocation size based on + // our knowledge of the file size. + let mut zone_file = File::open(env.in_cwd(&self.zonefile_path))?; + let zone_file_len = zone_file.metadata()?.len(); + let mut buf = inplace::Zonefile::with_capacity(zone_file_len as usize).writer(); + std::io::copy(&mut zone_file, &mut buf)?; + let mut reader = buf.into_inner(); + let n_records = reader.len() as u64; + + if let Some(origin) = &self.origin { + reader.set_origin(origin.clone()); + } + + let pb = if matches!(log, Some(log) if log.is_terminal() && self.progress) { + let pb = ProgressBar::new(n_records).with_message("Parsing"); + pb.set_draw_target(ProgressDrawTarget::stderr_with_hz(1)); + pb.set_style( + ProgressStyle::with_template(PROGRESS_STYLE) + .unwrap() + .with_key("eta", |state: &ProgressState, w: &mut dyn Write| { + write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap() + }) + .progress_chars("#>-"), + ); + Some(pb) + } else { + None + }; + + // Push records to an unsorted vec, then sort at the end, as this is faster than + // sorting one record at a time. + let mut records = Vec::>::new(); + + for entry in reader { + let entry = entry.map_err(|err| format!("Invalid zone file: {err}"))?; + + match entry { + Entry::Record(record, pos) => { + let record: StoredRecord = record.flatten_into(); + if let Some(expected_apex) = expected_apex { + if record.rtype() == Rtype::SOA && record.owner() != expected_apex { + return Err(format!( + "Zone apex ({}) does not match the expected apex ({expected_apex})", + record.owner() + ) + .into()); + } + } + + records.push(record); + if let Some(pb) = &pb { + pb.set_position(pos as u64); + } + } + Entry::Include { .. } => { + return Err("Invalid zone file: $INCLUDE directive is not supported".into()); + } + } + } + + if let Some(pb) = &pb { + pb.set_message("Sorting"); + } + + // Use a multi-threaded parallel sorter to sort our unsorted vec into + // a `SortedRecords` type. + let records = SortedRecords::<_, _, MultiThreadedSorter>::from(records); + + if let Some(pb) = pb { + let len = pb.length().unwrap(); + let elapsed = pb.elapsed(); + pb.finish_and_clear(); + if self.verbose { + if let Some(log) = log { + writeln!( + log, + "Loaded {len} records from {} [{} bytes] in {}", + self.zonefile_path.display(), + HumanBytes(zone_file_len), + HumanDuration(elapsed) + ); + } + } + } + + Ok(records) + } + + fn find_apex( + records: &SortedRecords, + ) -> Result<(FamilyName>, Ttl), Error> { + let soa = match records.find_soa() { + Some(soa) => soa, + None => { + return Err(Error::from("Invalid zone file: Cannot find SOA record")); + } + }; + + let ttl = match *soa.first().data() { + ZoneRecordData::Soa(ref soa_data) => { + // RFC 9077 updated RFC 4034 (NSEC) and RFC 5155 (NSSE3) to + // say that the "TTL of the NSEC(3) RR that is returned MUST be + // the lesser of the MINIMUM field of the SOA record and the + // TTL of the SOA itself". + min(soa_data.minimum(), soa.ttl()) + } + _ => unreachable!(), + }; + + Ok((soa.family_name().cloned(), ttl)) + } + + fn bump_soa_serial( + records: &mut SortedRecords< + Name, + ZoneRecordData>, + MultiThreadedSorter, + >, + ) -> Result<(), Error> { + let Some(old_soa_rr) = records.find_soa() else { + return Err(Error::from("Error reading zonefile: missing SOA record")); + }; + let ZoneRecordData::Soa(old_soa) = old_soa_rr.first().data() else { + return Err(Error::from("Error reading zonefile: missing SOA record")); + }; + + // Undocumented behaviour in ldns-signzone: it doesn't just set the + // SOA serial to the current unix timestamp as is documented for '-u' + // but rather only does that if the resulting value would be larger + // than the current unix timestamp, otherwise it increments it. I + // assume it does that to ensure that the SOA serial advances on zone + // change per expectations defined in RFC 1034, though it is assuming + // that the SOA serial can be interpreted as a unix timestamp which + // may not be the intention of the zone owner. + + let now = Serial::now(); + let new_serial = if now > old_soa.serial() { + now + } else { + old_soa.serial().add(1) + }; + + let new_soa = Soa::new( + old_soa.mname().clone(), + old_soa.rname().clone(), + new_serial, + old_soa.refresh(), + old_soa.retry(), + old_soa.expire(), + old_soa.minimum(), + ); + records.replace_soa(new_soa); + + Ok(()) + } + + /// Given a BIND style key pair path prefix load the keys from disk. + /// + /// Expects a path that is the common prefix in BIND style of a pair of '.key' + /// (public) and '.private' key files, i.e. given + /// /path/to/K.++ load and parse the following + /// files: + /// + /// - /path/to/K.++.key + /// - /path/to/K.++.private + /// + /// However, this function is not strict about the format of the prefix, it + /// will attempt to load files with suffixes '.key' and '.private' irrespective + /// of the format of the rest of the path. + fn load_key_pair( + &self, + key_path: &Path, + log: &mut Option>, + ) -> Result, Error> { + let key_path_str = key_path.to_string_lossy(); + let public_key_path = PathBuf::from(format!("{key_path_str}.key")); + let private_key_path = PathBuf::from(format!("{key_path_str}.private")); + + if self.verbose { + if let Some(log) = log { + writeln!( + log, + "Loading private key from '{}'", + private_key_path.display() + ); + } + } + + let private_data = std::fs::read_to_string(&private_key_path).map_err(|err| { + format!( + "Unable to load private key from file '{}': {}", + private_key_path.display(), + err + ) + })?; + + if self.verbose { + if let Some(log) = log { + writeln!( + log, + "Loading public key from '{}'", + public_key_path.display() + ); + } + } + + let public_data = std::fs::read_to_string(&public_key_path).map_err(|err| { + format!( + "Unable to load public key from file '{}': {}", + public_key_path.display(), + err + ) + })?; + + let secret_key = SecretKeyBytes::parse_from_bind(&private_data).map_err(|err| { + format!( + "Unable to parse BIND formatted private key file '{}': {}", + private_key_path.display(), + err + ) + })?; + + let public_key_info = Key::parse_from_bind(&public_data).map_err(|err| { + format!( + "Unable to parse BIND formatted public key file '{}': {}", + public_key_path.display(), + err + ) + })?; + + let key_pair = + KeyPair::from_bytes(&secret_key, public_key_info.raw_public_key()).map_err(|err| { + format!( + "Unable to import private key from file '{}': {}", + private_key_path.display(), + err + ) + })?; + + let signing_key = SigningKey::new( + public_key_info.owner().clone(), + public_key_info.flags(), + key_pair, + ); + + Ok(signing_key) + } +} + +fn next_owner_hash_to_name( + next_owner_hash_hex: &str, + apex: &FamilyName>, +) -> Result, ()> { + let mut builder = NameBuilder::new_bytes(); + builder + .append_chars(next_owner_hash_hex.chars()) + .map_err(|_| ())?; + let next_owner_name = builder.append_origin(apex.owner()).map_err(|_| ())?; + Ok(next_owner_name) +} + +//------------ SigningMode --------------------------------------------------- + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +enum SigningMode { + /// Both hash (NSEC/NSEC3) and sign zone records. + #[default] + HashAndSign, + + /// Only hash (NSEC/NSEC3) zone records, don't sign them. + HashOnly, + // /// Only sign zone records, assume they are already hashed. + // SignOnly, +} + +//------------ FileOrStdout -------------------------------------------------- + +enum FileOrStdout { + File(T), + Stdout(Stream), +} + +impl fmt::Write for FileOrStdout { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + match self { + FileOrStdout::File(f) => f.write_all(s.as_bytes()).map_err(|_| fmt::Error), + FileOrStdout::Stdout(o) => { + o.write_str(s); + Ok(()) + } + } + } + + fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result { + match self { + FileOrStdout::File(f) => f.write_fmt(args).map_err(|_| fmt::Error), + FileOrStdout::Stdout(o) => { + o.write_fmt(args); + Ok(()) + } + } + } +} + +//------------ Commented ----------------------------------------------------- + +trait Commented { + fn comment( + &self, + writer: &mut W, + record: &Record, ZoneRecordData>>, + metadata: T, + ) -> Result<(), fmt::Error>; +} + +#[derive(Copy, Clone)] +struct Nsec3CommentState<'a> { + hashes_ref: Option<&'a HashMap, Name, RandomState>>, + apex: &'a FamilyName>, +} + +impl<'b, O: AsRef<[u8]>> Commented> for Nsec3 { + fn comment<'a, W: fmt::Write>( + &self, + writer: &mut W, + record: &'a Record, ZoneRecordData>>, + state: Nsec3CommentState<'b>, + ) -> Result<(), fmt::Error> { + if let Some(hashes) = state.hashes_ref { + // TODO: For ldns-signzone backward compatibilty we output + // " ;{... .}" but I find the spacing ugly and + // would prefer for dnst to output " ; {... . }" + // instead. + writer.write_str(" ;{ flags: ")?; + + if self.opt_out() { + writer.write_str("optout")?; + } else { + writer.write_str("-")?; + } + + let next_owner_hash_hex = format!("{}", self.next_owner()); + let next_owner_name = next_owner_hash_to_name(&next_owner_hash_hex, state.apex); + + let from = hashes + .get(record.owner()) + .map(|n| format!("{}", n.fmt_with_dot())) + .unwrap_or_default(); + + let to = if let Ok(next_owner_name) = next_owner_name { + hashes + .get(&next_owner_name) + .map(|n| format!("{}", n.fmt_with_dot())) + .unwrap_or_else(|| format!("")) + } else { + format!("") + }; + + writer.write_fmt(format_args!(", from: {from}, to: {to}}}"))?; + } + Ok(()) + } +} + +impl Commented<()> for Dnskey { + fn comment( + &self, + writer: &mut W, + record: &Record, ZoneRecordData>>, + _metadata: (), + ) -> Result<(), fmt::Error> { + writer.write_fmt(format_args!(" ;{{id = {}", self.key_tag()))?; + if self.is_secure_entry_point() { + writer.write_str(" (ksk)")?; + } else if self.is_zone_key() { + writer.write_str(" (zsk)")?; + } + let owner = record.owner().clone(); + let key = domain::validate::Key::from_dnskey(owner, self.clone()).unwrap(); + let key_size = key.key_size(); + writer.write_fmt(format_args!(", size = {key_size}b}}")) + } +} + +//------------ AnyFamiliesIter ----------------------------------------------- + +type FamilyIterByValue<'a> = + std::slice::Iter<'a, Family<'a, Name, ZoneRecordData>>>; +type FamilyIterByRef<'a> = RecordsIter<'a, Name, ZoneRecordData>>; + +/// An iterator over a collection of [`Family`], whether by reference or not. +enum AnyFamiliesIter<'a> { + VecIter(FamilyIterByValue<'a>), + FamiliesIter(FamilyIterByRef<'a>), +} + +impl<'a> Iterator for AnyFamiliesIter<'a> +where + Family<'a, Name, ZoneRecordData>>: Clone, +{ + type Item = Family<'a, Name, ZoneRecordData>>; + + fn next(&mut self) -> Option { + match self { + AnyFamiliesIter::VecIter(it) => it.next().cloned(), + AnyFamiliesIter::FamiliesIter(it) => it.next(), + } + } +} + +//--- From>> + +impl<'a> From, ZoneRecordData>>>> + for AnyFamiliesIter<'a> +{ + fn from( + iter: std::slice::Iter<'a, Family<'a, Name, ZoneRecordData>>>, + ) -> Self { + Self::VecIter(iter) + } +} + +//--- From> + +impl<'a> From, ZoneRecordData>>> + for AnyFamiliesIter<'a> +{ + fn from(iter: RecordsIter<'a, Name, ZoneRecordData>>) -> Self { + Self::FamiliesIter(iter) + } +} + +//------------ MultiThreadedSorter ------------------------------------------- + +/// A parallelized sort implementation for use with [`SortedRecords`]. +/// +/// TODO: Should we add a `-j` (jobs) command line argument to override the +/// default Rayon behaviour of using as many threads as their are CPU cores? +struct MultiThreadedSorter; + +impl domain::sign::records::Sorter for MultiThreadedSorter { + fn sort_by(records: &mut Vec>, compare: F) + where + Record: Send, + F: Fn(&Record, &Record) -> Ordering + Sync, + { + records.par_sort_by(compare); + } +} diff --git a/src/env/fake.rs b/src/env/fake.rs index 0715ee59..9caf4c5b 100644 --- a/src/env/fake.rs +++ b/src/env/fake.rs @@ -51,11 +51,11 @@ impl Env for FakeEnv { } fn stdout(&self) -> Stream { - Stream(self.stdout.clone()) + Stream(self.stdout.clone(), false) } fn stderr(&self) -> Stream { - Stream(self.stderr.clone()) + Stream(self.stderr.clone(), false) } fn in_cwd<'a>(&self, path: &'a impl AsRef) -> Cow<'a, Path> { diff --git a/src/env/mod.rs b/src/env/mod.rs index dd62dcb1..40703ee6 100644 --- a/src/env/mod.rs +++ b/src/env/mod.rs @@ -44,7 +44,7 @@ pub trait Env { /// [`std::io::Write`]. Additionally, this `write_fmt` does not return a /// result. This means that we can use the [`write!`] and [`writeln`] macros /// without handling errors. -pub struct Stream(T); +pub struct Stream(T, bool); impl Stream { pub fn write_fmt(&mut self, args: fmt::Arguments<'_>) { @@ -55,6 +55,15 @@ impl Stream { // hard anyway. self.0.write_fmt(args).unwrap(); } + + pub fn write_str(&mut self, s: &str) { + // Same as with write_fmt... + self.0.write_str(s).unwrap(); + } + + pub fn is_terminal(&self) -> bool { + self.1 + } } impl Env for &E { diff --git a/src/env/real.rs b/src/env/real.rs index 26c01aa5..0b71baba 100644 --- a/src/env/real.rs +++ b/src/env/real.rs @@ -1,6 +1,6 @@ use std::ffi::OsString; use std::fmt; -use std::io; +use std::io::{self, IsTerminal}; use std::path::Path; use super::Env; @@ -15,11 +15,15 @@ impl Env for RealEnv { } fn stdout(&self) -> Stream { - Stream(FmtWriter(io::stdout())) + let stdout = io::stdout(); + let is_term = stdout.is_terminal(); + Stream(FmtWriter(stdout), is_term) } fn stderr(&self) -> Stream { - Stream(FmtWriter(io::stderr())) + let stderr = io::stderr(); + let is_term = stderr.is_terminal(); + Stream(FmtWriter(stderr), is_term) } fn in_cwd<'a>(&self, path: &'a impl AsRef) -> std::borrow::Cow<'a, std::path::Path> { diff --git a/src/error.rs b/src/error.rs index 5d2e58ca..65cd9a8f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,7 +1,8 @@ -use crate::env::Env; use std::fmt; use std::{error, io}; +use crate::env::Env; + //------------ Error --------------------------------------------------------- /// A program error. @@ -116,6 +117,12 @@ impl From for Error { } } +impl From for Error { + fn from(error: fmt::Error) -> Self { + Self::new(&error.to_string()) + } +} + impl From for Error { fn from(error: io::Error) -> Self { Self::new(&error.to_string()) diff --git a/src/lib.rs b/src/lib.rs index 7078fbd1..c10575b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,10 @@ use std::ffi::OsString; use std::path::Path; use clap::Parser; -use commands::{key2ds::Key2ds, nsec3hash::Nsec3Hash, LdnsCommand}; +use commands::key2ds::Key2ds; +use commands::nsec3hash::Nsec3Hash; +use commands::signzone::SignZone; +use commands::LdnsCommand; use env::Env; use error::Error; @@ -30,6 +33,7 @@ pub fn try_ldns_compatibility>( let res = match binary_name { "key2ds" => Key2ds::parse_ldns_args(args_iter), "nsec3-hash" => Nsec3Hash::parse_ldns_args(args_iter), + "signzone" => SignZone::parse_ldns_args(args_iter), _ => return Err(format!("Unrecognized ldns command 'ldns-{binary_name}'").into()), };