From 58748f71d2458fe3ad2c2ae762c064663f5ae79c Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Mon, 4 Aug 2025 18:14:55 +0200 Subject: [PATCH 01/32] Fix missing newline in keygen The missing newline broke key2ds, because the inplace zonefile parser requires that every line ends with a newline character. --- src/commands/keygen.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/keygen.rs b/src/commands/keygen.rs index 415274b..36ac04d 100644 --- a/src/commands/keygen.rs +++ b/src/commands/keygen.rs @@ -357,7 +357,7 @@ impl Keygen { let algorithm = public_key.algorithm(); let secret_key = secret_key.display_as_bind().to_string(); let public_key = format!( - "{} IN DNSKEY {}", + "{} IN DNSKEY {}\n", self.name.fmt_with_dot(), public_key.display_zonefile(DisplayKind::Simple) ); @@ -612,7 +612,7 @@ mod test { 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(); + Regex::new(r"^example.org. IN DNSKEY 256 3 15 [A-Za-z0-9/+=]+\n$").unwrap(); let secret_key_regex = Regex::new( r"^Private-key-format: v1\.2\nAlgorithm: 15 \(ED25519\)\nPrivateKey: [A-Za-z0-9/+=]+\n$", ) @@ -644,7 +644,7 @@ mod test { 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(); + Regex::new(r"^example.org. IN DNSKEY 257 3 15 [A-Za-z0-9/+=]+\n$").unwrap(); let digest_key_regex = Regex::new(r"^example.org. IN DS [0-9]+ 15 2 [0-9a-fA-F]+\n$").unwrap(); From 6f5902192d7220efc6be865883b51041fd79b801 Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Wed, 6 Aug 2025 09:58:03 +0200 Subject: [PATCH 02/32] Adapt CI workflow from domain (#107) --- .github/workflows/ci.yml | 456 ++++++++++++++++++++++++++++++++++----- 1 file changed, 397 insertions(+), 59 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dadfad..3349d7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,83 +1,421 @@ -name: ci -on: [push, pull_request] -env: - RUSTFLAGS: "-D warnings" +# ====================================================== +# Continuous Integration: making sure the codebase works +# ====================================================== +# +# This workflow tests modifications to 'dnst', ensuring that 'dnst' can be +# used by others successfully. It verifies certain aspects of the codebase, +# such as the formatting and feature flag combinations, and runs the full test +# suite. It runs on Ubuntu, Mac OS, and Windows. +# +# Based on https://github.com/NLnetLabs/domain/blob/main/.github/workflows/ci.yml + +name: CI + +# When the worflow runs +# --------------------- +on: + # Execute when a pull request is (re-) opened or its head changes (e.g. new + # commits are added or the commit history is rewritten) ... but only if + # build-related files change. + pull_request: + paths: + - '**.rs' + - 'Cargo.{toml,lock}' + - '.github/workflows/ci.yml' + + # If a pull request is merged, at least one commit is added to the target + # branch. If the target is another pull request, it will be caught by the + # above event. We miss PRs that merge to a non-PR branch, except for the + # 'main' branch. + + # Execute when a commit is pushed to 'main' (including merged PRs) or to a + # release tag ... but only if build-related files change. + push: + branches: + - 'main' + - 'releases/**' + paths: + - '**.rs' + - 'Cargo.{toml,lock}' + - '.github/workflows/ci.yml' + + # Rebuild 'main' every week. This will account for changes to dependencies + # and to Rust, either of which can trigger new failures. Rust releases are + # every 6 weeks, on a Thursday; this event runs every Friday. + schedule: + - cron: '0 10 * * FRI' + +# Jobs +# ---------------------------------------------------------------------------- jobs: - test: - name: Test - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macOS-latest] - rust: [1.79.0, stable, beta, nightly] + + # Check Formatting + # ---------------- + # + # NOTE: This job is run even if no '.rs' files have changed. Inserting such + # a check would require using a separate workflow file or using third-party + # actions. Most commits do change '.rs' files, and 'cargo-fmt' is pretty + # fast, so optimizing this is not necessary. + check-fmt: + name: Check formatting + runs-on: ubuntu-latest + steps: + + # Load the repository. + - name: Checkout repository + uses: actions/checkout@v4 + + # Set up the Rust toolchain. + # + # Disable the cache since it's not relevant for formatting. + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + components: rustfmt + cache: false + + # Do the actual formatting check. + - name: Check formatting + run: cargo fmt --all -- --check + + # Determine MSRV + # -------------- + # + # The MSRV needs to be determined as we will test 'dnst' against the Rust + # compiler at that version. + determine-msrv: + name: Determine MSRV + runs-on: ubuntu-latest + outputs: + msrv: ${{ steps.determine-msrv.outputs.msrv }} + steps: + + # Load the repository. + - name: Checkout repository + uses: actions/checkout@v4 + + # Determine the MSRV. + - name: Determine MSRV + id: determine-msrv + run: | + msrv=`cargo metadata --no-deps --format-version 1 | jq -r '.packages[]|select(.name=="dnst")|.rust_version'` + echo "msrv=$msrv" >> "$GITHUB_OUTPUT" + + # Check Feature Flags + # ------------------- + # + # Rust does not provide any way to check that all possible feature flag + # combinations will succeed, so we need to try them manually here. We will + # assume this choice is not influenced by the OS or Rust version. + check-feature-flags: + name: Check feature flags + runs-on: ubuntu-latest + steps: + + # Load the repository. + - name: Checkout repository + uses: actions/checkout@v4 + + # Set up the Rust toolchain. + - name: Set up Rust + id: setup-rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + cache: false + + - name: Install OpenSSL + run: sudo apt-get install -y libssl-dev + + # Restore a cache of dependencies and 'target'. + - name: Restore a dependency cache + id: cache-restore + uses: actions/cache/restore@v4 + with: + path: | + ~/.cargo + target/ + # Cache by OS and Rust version. + key: ${{ runner.os }}-${{ steps.setup-rust.outputs.cachekey }} + + # Do the actual feature flag checks. + # (--all-features is done in check-minimal-versions) + # + # NOTE: This does not benefit from the 'target' folder cached by a 'cargo + # check --all-features --all-targets' execution. Due to the minimal + # dependency set, it still runs fairly quickly. + # The empty feature set (as it is done in domain) is not allowed in dnst. + - name: Check default feature set + run: cargo check --all-targets + + - name: Check openssl feature + run: cargo check --all-targets --no-default-features -F openssl + + - name: Check ring feature + run: cargo check --all-targets --no-default-features -F ring + + # Check Minimal Versions + # ---------------------- + # + # Ensure that 'dnst' compiles with the oldest compatible versions of all + # packages, even those 'dnst' depends upon indirectly. + check-minimal-versions: + name: Check minimal versions + runs-on: ubuntu-latest + needs: determine-msrv env: RUSTFLAGS: "-D warnings" + steps: + + # Load the repository. + - name: Checkout repository + uses: actions/checkout@v4 + + # Set up the Rust toolchain. + - name: Set up Rust nightly + id: setup-rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ needs.determine-msrv.outputs.msrv }},nightly + cache: false + + - name: Install OpenSSL + run: sudo apt-get install -y libssl-dev + + # TODO: Cache minimal-version dependencies? + + # Lock all dependencies to their minimal versions. + - name: Lock dependencies to minimal versions + run: cargo +nightly update -Z minimal-versions + + # Check that 'dnst' compiles. + # + # NOTE: This does not benefit from the 'target' folder cached by a 'cargo + # check --all-features --all-targets' execution. It may be worthwhile to + # cache this 'target' folder separately (TODO). + - name: Check + run: cargo check --all-targets --all-features --locked + + # Clippy + # ------ + # + # We run Clippy separately, and only on nightly Rust because it offers a + # superset of the lints. + # + # 'cargo clippy' and 'cargo build' can share some state for fast execution, + # but it's faster to execute them in parallel than to establish an ordering + # between them. + clippy: + name: Clippy + runs-on: ubuntu-latest + env: + RUSTFLAGS: "-D warnings" + steps: + + # Load the repository. + - name: Checkout repository + uses: actions/checkout@v4 + + # Set up the Rust toolchain. + - name: Set up Rust nightly + id: setup-rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: nightly + components: clippy + cache: false + + # Restore a cache of dependencies and 'target'. + - name: Restore from cache + id: cache-restore + uses: actions/cache/restore@v4 + with: + path: | + ~/.cargo + target/ + # Cache by OS and Rust version. + key: ${{ runner.os }}-${{ steps.setup-rust.outputs.cachekey }} + + # Do the actually Clippy run. + - name: Check Clippy + run: cargo +nightly clippy --all-targets --all-features + + # Test + # ---- + # + # Ensure that 'dnst' compiles and its test suite passes, on a large number + # of operating systems and Rust versions. + test: + name: Test + needs: determine-msrv + strategy: + matrix: + os: [ubuntu-latest, macOS-latest, windows-latest] + rust: ["${{ needs.determine-msrv.outputs.msrv }}", stable, beta, nightly] + runs-on: ${{ matrix.os }} + env: + RUSTFLAGS: "-D warnings" + DNST_FEATURES: "--all-features" # 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: + + # Load the repository. - name: Checkout repository - uses: actions/checkout@v1 - - name: Install Rust - 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' + uses: actions/checkout@v4 + + # Prepare the environment on Windows + - name: Prepare Windows environment + if: matrix.os == 'windows-latest' id: vcpkg - uses: johnwason/vcpkg-action@v6 + uses: johnwason/vcpkg-action@v7 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' - run: cargo clippy --all-features --all-targets -- -D warnings - - if: matrix.rust == 'stable' && matrix.os == 'ubuntu-latest' - run: cargo fmt --all -- --check - - run: cargo check --no-default-features -F ring --all-targets - - run: cargo check --no-default-features -F openssl --all-targets - - run: cargo test --all-features - minimal-versions: - name: Check minimal versions - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v1 - - name: Install Rust - uses: hecrj/setup-rust-action@v2 - with: - rust-version: "1.79.0" - name: Install OpenSSL + if: matrix.os == 'ubuntu-latest' run: sudo apt-get install -y libssl-dev - - name: Install nightly Rust - run: rustup install nightly - - name: Check with minimal-versions - run: | - cargo +nightly update -Z minimal-versions - cargo check --all-features --all-targets --locked + # Set up the Rust toolchain. + - name: Set up Rust ${{ matrix.rust }} + id: setup-rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ matrix.rust }} + cache: false + + # Restore a cache of dependencies and 'target'. + - name: Restore from cache + id: cache-restore + uses: actions/cache/restore@v4 + with: + path: | + ~/.cargo + target/ + # Cache by OS and Rust version. + key: ${{ runner.os }}-${{ steps.setup-rust.outputs.cachekey }} + + # Build and run the test suite. + - name: Test + run: cargo test --all-targets $DNST_FEATURES + + # Additional (ignored) Tests + # -------------------------- + # + # Ensure that the extended test suit passes. + # (only on ubuntu-latest and rust stable) extra-tests: name: Extra tests runs-on: ubuntu-latest + env: + RUSTFLAGS: "-D warnings" + DNST_FEATURES: "--all-features" steps: + + # Load the repository. - name: Checkout repository - uses: actions/checkout@v1 - - name: Install Rust - uses: hecrj/setup-rust-action@v2 - - name: Install supporting tools/libraries - run: | - # The tests compare their output to that of LDNS tools, so we need to - # install them. Some tests work with DNSSEC keys for which the OpenSSL - # library must be compiled against which requires C build programs and - # pkg-config. Install everything we need. - sudo apt-get update - sudo apt-get install -y build-essential ldnsutils libssl-dev pkg-config - - name: Run tests that are normally ignored - run: cargo test --all-features --all-targets -- --ignored + uses: actions/checkout@v4 + + - name: Install supporting tools and libraries + # The tests compare their output to that of LDNS tools, so we need to + # install them. Some tests work with DNSSEC keys for which the OpenSSL + # library must be compiled against which requires C build programs and + # pkg-config. Install everything we need. + run: sudo apt-get install -y build-essential ldnsutils libssl-dev pkg-config + + # Set up the Rust toolchain. + - name: Set up Rust stable + id: setup-rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + cache: false + + # Restore a cache of dependencies and 'target'. + - name: Restore from cache + id: cache-restore + uses: actions/cache/restore@v4 + with: + path: | + ~/.cargo + target/ + # Cache by OS and Rust version. + key: ${{ runner.os }}-${{ steps.setup-rust.outputs.cachekey }} + + # Build and run the test suite. + - name: Test ignored + run: cargo test --all-targets $DNST_FEATURES -- --ignored + + # Build Cache + # ----------- + # + # Prepare a cache for checking and building 'dnst', on 'main'. + cache: + name: Cache + needs: determine-msrv + strategy: + matrix: + os: [ubuntu-latest, macOS-latest, windows-latest] + rust: ["${{ needs.determine-msrv.outputs.msrv }}", stable, beta, nightly] + runs-on: ${{ matrix.os }} + if: github.ref == 'refs/heads/main' + env: + RUSTFLAGS: "-D warnings" + DNST_FEATURES: "--all-features" + steps: + + # Load the repository. + - name: Checkout repository + uses: actions/checkout@v4 + + # Prepare the environment on Windows + - name: Prepare Windows environment + if: matrix.os == 'windows-latest' + id: vcpkg + uses: johnwason/vcpkg-action@v7 + with: + pkgs: openssl + triplet: ${{ env.VCPKGRS_TRIPLET }} + token: ${{ github.token }} + + - name: Install OpenSSL + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get install -y libssl-dev + + # Set up the Rust toolchain. + - name: Set up Rust ${{ matrix.rust }} + id: setup-rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ matrix.rust }} + cache: false + + # Restore a cache of dependencies and 'target'. + - name: Restore from cache + uses: actions/cache/restore@v4 + with: + path: | + ~/.cargo + target/ + # Cache by OS and Rust version. + key: ${{ runner.os }}-${{ steps.setup-rust.outputs.cachekey }} + + # Build all of 'dnst'. + - name: Build + run: cargo build --all-targets $DNST_FEATURES + + # Save to the cache. + - name: Save to the cache + uses: actions/cache/save@v4 + with: + path: | + ~/.cargo + target/ + # Cache by OS and Rust version. + key: ${{ runner.os }}-${{ steps.setup-rust.outputs.cachekey }} From 565b6ef7680c546bb1a91c9c1431e28eb50870aa Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Wed, 6 Aug 2025 10:19:01 +0200 Subject: [PATCH 03/32] Fix missing vcpkg variables for windows cache job --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3349d7f..bbb2609 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -368,6 +368,11 @@ jobs: env: RUSTFLAGS: "-D warnings" DNST_FEATURES: "--all-features" + # 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: # Load the repository. From d242740e3eb6ce9ced7589422f8320a6e0998c6b Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Wed, 6 Aug 2025 12:11:26 +0200 Subject: [PATCH 04/32] Allow specifying algorithms by number in dnst keygen (#106) --- src/commands/keygen.rs | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/commands/keygen.rs b/src/commands/keygen.rs index 36ac04d..569ebf4 100644 --- a/src/commands/keygen.rs +++ b/src/commands/keygen.rs @@ -36,6 +36,8 @@ pub struct Keygen { feature = "openssl", doc = " - ED448: An Ed448 key (algorithm 16)" )] + /// + /// Tip: Using the algorithm number instead of the name is also supported. #[allow(rustdoc::invalid_html_tags)] #[arg( short = 'a', @@ -268,11 +270,11 @@ impl From for Command { 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), + "RSASHA256" | "8" => return Ok(GenerateParams::RsaSha256 { bits: 2048 }), + "ECDSAP256SHA256" | "13" => return Ok(GenerateParams::EcdsaP256Sha256), + "ECDSAP384SHA384" | "14" => return Ok(GenerateParams::EcdsaP384Sha384), + "ED25519" | "15" => return Ok(GenerateParams::Ed25519), + "ED448" | "16" => return Ok(GenerateParams::Ed448), _ => {} } @@ -281,7 +283,7 @@ impl Keygen { if let Some((name, params)) = value.split_once(':') { #[allow(clippy::single_match)] match name { - "RSASHA256" => { + "RSASHA256" | "8" => { let bits: u32 = params.parse().map_err(|err| { clap::Error::raw( clap::error::ErrorKind::InvalidValue, @@ -475,6 +477,22 @@ mod test { ..base.clone() } ); + // - Specifying the algorithm by number + assert_eq!( + parse(cmd.args(["-a", "8", "example.org"])), + Keygen { + algorithm: GenerateParams::RsaSha256 { bits: 2048 }, + ..base.clone() + } + ); + // - Specifying the algorithm by number incl. keysize + assert_eq!( + parse(cmd.args(["-a", "8:1024", "example.org"])), + Keygen { + algorithm: GenerateParams::RsaSha256 { bits: 1024 }, + ..base.clone() + } + ); // Test 'make_ksk': assert_eq!( From 0c56ee7f8e742a788cb824a072b94a850615cfbc Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 7 Aug 2025 08:23:59 +0200 Subject: [PATCH 05/32] Follow change in domain regarding vendoring of OpenSSL. (#94) --- Cargo.lock | 5 +++-- Cargo.toml | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f98800d..44c0d01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -325,6 +325,7 @@ dependencies = [ "lazy_static", "lexopt", "octseq", + "openssl", "pretty_assertions", "rayon", "regex", @@ -339,7 +340,7 @@ dependencies = [ [[package]] name = "domain" version = "0.11.1-dev" -source = "git+https://github.com/NLnetLabs/domain.git?rev=bb6bd2b3f45fea8c00b6ef21e666d268e9d2e37e#bb6bd2b3f45fea8c00b6ef21e666d268e9d2e37e" +source = "git+https://github.com/NLnetLabs/domain.git?rev=a3e7ab0bc248bada2c2564bf77ddc58010d4ce25#a3e7ab0bc248bada2c2564bf77ddc58010d4ce25" dependencies = [ "arc-swap", "bumpalo", @@ -371,7 +372,7 @@ dependencies = [ [[package]] name = "domain-macros" version = "0.11.1-dev" -source = "git+https://github.com/NLnetLabs/domain.git?rev=bb6bd2b3f45fea8c00b6ef21e666d268e9d2e37e#bb6bd2b3f45fea8c00b6ef21e666d268e9d2e37e" +source = "git+https://github.com/NLnetLabs/domain.git?rev=a3e7ab0bc248bada2c2564bf77ddc58010d4ce25#a3e7ab0bc248bada2c2564bf77ddc58010d4ce25" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 42da120..a474ffe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,13 +25,13 @@ ring = ["domain/ring"] # For building in a cargo cross container that lacks openssl-dev so cannot # successfully compile the Rust OpenSSL crate. -static-openssl = ["domain/static-openssl"] +static-openssl = ["openssl/vendored"] [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", rev = "bb6bd2b3f45fea8c00b6ef21e666d268e9d2e37e", features = [ +domain = { git = "https://github.com/NLnetLabs/domain.git", rev = "a3e7ab0bc248bada2c2564bf77ddc58010d4ce25", features = [ "bytes", "net", "resolv", @@ -40,13 +40,14 @@ domain = { git = "https://github.com/NLnetLabs/domain.git", rev = "bb6bd2b3f45fe "unstable-client-transport", "unstable-sign", "unstable-validator", - "unstable-zonetree", + "unstable-zonetree" ] } lexopt = "0.3.0" rayon = "1.10.0" octseq = "0.5.2" ring = "0.17.8" tokio = "1.40.0" +openssl = { version = "*", features = ["vendored"], optional = true } # LDNS-xxx mode specific dependencies. # TODO: put these behind a feature gate? @@ -64,7 +65,7 @@ const_format = " 0.2.33" test_bin = "0.4.0" tempfile = "3.20.0" regex = "1.11.1" -domain = { git = "https://github.com/NLnetLabs/domain.git", rev = "bb6bd2b3f45fea8c00b6ef21e666d268e9d2e37e", features = [ +domain = { git = "https://github.com/NLnetLabs/domain.git", rev = "a3e7ab0bc248bada2c2564bf77ddc58010d4ce25", features = [ "unstable-stelline", ] } pretty_assertions = "1.4.1" From 0d0787169a0d276d89428d62dd73e81eacc3e655 Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Thu, 7 Aug 2025 10:06:08 +0200 Subject: [PATCH 06/32] Remove digest algorithm fallback for dnst key2ds (#105) --- src/commands/key2ds.rs | 70 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/src/commands/key2ds.rs b/src/commands/key2ds.rs index 676cb8d..9480dd6 100644 --- a/src/commands/key2ds.rs +++ b/src/commands/key2ds.rs @@ -45,6 +45,13 @@ pub struct Key2ds { /// Keyfile to read #[arg()] keyfile: PathBuf, + + // ----------------------------------------------------------------------- + // Non-command line argument fields: + // ----------------------------------------------------------------------- + /// Whether or not we were invoked as `ldns-key2ds`. + #[arg(skip = false)] + invoked_as_ldns: bool, } pub fn parse_digest_alg(arg: &str) -> Result { @@ -123,6 +130,7 @@ impl LdnsCommand for Key2ds { // present in the ldns version of this command. force_overwrite: true, keyfile: keyfile.into(), + invoked_as_ldns: true, }))) } } @@ -166,9 +174,13 @@ impl Key2ds { let key_tag = dnskey.key_tag(); let sec_alg = dnskey.algorithm(); - let digest_alg = self - .algorithm - .unwrap_or_else(|| determine_hash_from_sec_alg(sec_alg)); + let digest_alg = if let Some(alg) = self.algorithm { + alg + } else if self.invoked_as_ldns { + determine_hash_from_sec_alg(sec_alg).unwrap_or(DigestAlgorithm::SHA1) + } else { + determine_hash_from_sec_alg(sec_alg)? + }; if digest_alg == DigestAlgorithm::GOST { return Err("Error: the GOST algorithm is deprecated and must not be used. Try a different algorithm.".into()); @@ -226,16 +238,20 @@ impl Key2ds { } } -fn determine_hash_from_sec_alg(sec_alg: SecurityAlgorithm) -> DigestAlgorithm { +fn determine_hash_from_sec_alg(sec_alg: SecurityAlgorithm) -> Result { match sec_alg { SecurityAlgorithm::RSASHA256 | SecurityAlgorithm::RSASHA512 | SecurityAlgorithm::ED25519 | SecurityAlgorithm::ED448 - | SecurityAlgorithm::ECDSAP256SHA256 => DigestAlgorithm::SHA256, - SecurityAlgorithm::ECDSAP384SHA384 => DigestAlgorithm::SHA384, - SecurityAlgorithm::ECC_GOST => DigestAlgorithm::GOST, - _ => DigestAlgorithm::SHA1, + | SecurityAlgorithm::ECDSAP256SHA256 => Ok(DigestAlgorithm::SHA256), + SecurityAlgorithm::ECDSAP384SHA384 => Ok(DigestAlgorithm::SHA384), + SecurityAlgorithm::ECC_GOST => Ok(DigestAlgorithm::GOST), + _ => Err(concat!( + "Unable to derive digest algorithm from used key algorithm. ", + "Please select a digest algorithm using --algorithm." + ) + .into()), } } @@ -276,6 +292,7 @@ mod test { force_overwrite: false, algorithm: None, keyfile: PathBuf::from("keyfile1.key"), + invoked_as_ldns: false, }; // Check the defaults @@ -365,6 +382,7 @@ mod test { force_overwrite: true, // note that this is true algorithm: None, keyfile: PathBuf::from("keyfile1.key"), + invoked_as_ldns: true, }; // Check the defaults @@ -426,6 +444,11 @@ mod test { ", ) .unwrap(); + let mut file = File::create(dir.path().join("key3.key")).unwrap(); + // DSA Key for fallback testing + file + .write_all(b". IN DNSKEY 256 3 3 CJ7RxFssAV8F41ftNWyGNW6eo49FhiDQpuR1Nop1dIzcFVD15Do76z1mZaLoheVttGMtE3jah0gFThjyUeBI65pmofV1pALXzrfbyDsoKJM2cjdaqICr9Zg/OcQ4yqkkJpTt1+e+w8xv6YrBOrvSwYwOIUGkj2ci/84HXC90W8I8ph6MHk1vaYmGoOS9wM+S9x2RO6sxD/UtB4QUGFp1qDlFc+C4dc1kB+rs868C004mRGoHe+PHHeDXdfKwr5zVpdgPc85TkBJAY82XYfzIE7rUpXFI4JzRE1Lz1Mu7rXZnwNve6bVykghJ8uOAaDO1KFLdIgThqJh1N8XMSinNKhPwU2qPMX7dsC5hh82BHg04fc3O2hzUG3A1z49i6Hbqa+zgvJe70AQZdqiFSaOpfgC6a8wRfdyGSTYBxsi2YhG2G1/x6qr8sToEeZjq9awPRY+bXT8kJijg9AH9/adrjvF77m+NJASepFTre40I79+Ymo3fnjFU1oSnsGTB91tQbbSNV55fd5Jsqndj9AJkhYJUpbVN ;{id = 15147 (zsk), size = 1024b}\n") + .unwrap(); dir } @@ -500,4 +523,35 @@ mod test { assert_eq!(res.stdout, "Kexample.test.+015+60136\n"); assert_eq!(res.stderr, ""); } + + #[test] + fn ldns_algorithm_fallback() { + let dir = run_setup(); + + let res = FakeCmd::new(["ldns-key2ds", "-nf", "key3.key"]) + .cwd(&dir) + .run(); + + assert_eq!(res.exit_code, 0); + assert_eq!( + res.stdout, + ". 3600 IN DS 15147 3 1 DBCCC2CC359D4661AB39C72898EF58E9CDCD27AB\n" + ); + assert_eq!(res.stderr, ""); + } + + #[test] + fn dnst_algorithm_fallback_err() { + let dir = run_setup(); + + let res = FakeCmd::new(["dnst", "key2ds", "-n", "--ignore-sep", "key3.key"]) + .cwd(&dir) + .run(); + + assert_eq!(res.exit_code, 1); + assert_eq!(res.stdout, ""); + assert!(res.stderr.contains( + "Unable to derive digest algorithm from used key algorithm. Please select a digest algorithm using --algorithm." + )); + } } From 79baa0438979ff6126eb21841eec099671e8454e Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Thu, 7 Aug 2025 10:20:25 +0200 Subject: [PATCH 07/32] Remove NSEC3 algorithm option for dnst (#104) The NSEC3 algorithm option only had one possible value (SHA-1) and is therefore unnecessary. --- doc/manual/source/man/dnst-signzone.rst | 4 ---- src/commands/signzone.rs | 9 +-------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/doc/manual/source/man/dnst-signzone.rst b/doc/manual/source/man/dnst-signzone.rst index 7274b8c..e8fecbe 100644 --- a/doc/manual/source/man/dnst-signzone.rst +++ b/doc/manual/source/man/dnst-signzone.rst @@ -140,10 +140,6 @@ NSEC3 options The following options can be used with ``-n`` to override the default NSEC3 settings used. -.. option:: -a - - Specify the hashing algorithm. Defaults to SHA-1. - .. option:: -s Specify the salt as a hex string. Defaults to ``-``, meaning empty salt. diff --git a/src/commands/signzone.rs b/src/commands/signzone.rs index d236e8c..58a07ce 100644 --- a/src/commands/signzone.rs +++ b/src/commands/signzone.rs @@ -186,14 +186,7 @@ pub struct SignZone { allow_zonemd_without_signing: 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" - )] + #[arg(skip = Nsec3HashAlgorithm::SHA1)] algorithm: Nsec3HashAlgorithm, /// Number of hash iterations. From 8592d87afd39a98215fcd561bd49c2bf63f7d566 Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Thu, 7 Aug 2025 10:23:58 +0200 Subject: [PATCH 08/32] Improve error message when failing to print to stdout (#103) --- src/env/mod.rs | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/env/mod.rs b/src/env/mod.rs index 242437f..f647397 100644 --- a/src/env/mod.rs +++ b/src/env/mod.rs @@ -96,17 +96,20 @@ impl io::Write for &Stream { impl Stream { pub fn write_fmt(&mut self, args: fmt::Arguments<'_>) { - // This unwrap is not _really_ safe, but we are using this as stdout. - // The `println` macro also ignores errors and `push_str` of the - // fake stream also does not return an error. If this fails, it means - // we can't write to stdout anymore so a graceful exit will be very - // hard anyway. - self.writer - .lock() - .unwrap() - .deref_mut() - .write_fmt(args) - .unwrap(); + if let Err(e) = self.writer.lock().unwrap().deref_mut().write_fmt(args) { + // This can happen when output is piped to a command that + // terminates before we do. Note: If stderr is also broken this + // will panc, but then neither the panic nor this error would be + // printed out. + let prog = std::env::args_os() + .next() + .unwrap() + .to_string_lossy() + .to_string(); + eprintln!("{prog}: failed printing to stdout: {e}"); + // using exit(101) to replicate rust's default error exit code + std::process::exit(101); + } } pub fn is_terminal(&self) -> bool { From 28fc39d4016f06cf1d3b4934338d45938d56ad11 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:31:03 +0200 Subject: [PATCH 09/32] Format `keygen` and `key2ds` subcommand output using tabs like `ldns-keygen` and `ldns-key2ds`. (#48) Co-authored-by: Terts Diepraam Co-authored-by: Jannik Peters --- Cargo.lock | 8 ++++---- Cargo.toml | 7 +++++-- src/commands/key2ds.rs | 16 ++++++++-------- src/commands/keygen.rs | 4 ++-- src/lib.rs | 3 +-- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 44c0d01..a9794d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,7 +340,7 @@ dependencies = [ [[package]] name = "domain" version = "0.11.1-dev" -source = "git+https://github.com/NLnetLabs/domain.git?rev=a3e7ab0bc248bada2c2564bf77ddc58010d4ce25#a3e7ab0bc248bada2c2564bf77ddc58010d4ce25" +source = "git+https://github.com/NLnetLabs/domain.git?rev=17fb0e38120c9939ca28462af082d88ae8bc8b1d#17fb0e38120c9939ca28462af082d88ae8bc8b1d" dependencies = [ "arc-swap", "bumpalo", @@ -372,7 +372,7 @@ dependencies = [ [[package]] name = "domain-macros" version = "0.11.1-dev" -source = "git+https://github.com/NLnetLabs/domain.git?rev=a3e7ab0bc248bada2c2564bf77ddc58010d4ce25#a3e7ab0bc248bada2c2564bf77ddc58010d4ce25" +source = "git+https://github.com/NLnetLabs/domain.git?rev=17fb0e38120c9939ca28462af082d88ae8bc8b1d#17fb0e38120c9939ca28462af082d88ae8bc8b1d" dependencies = [ "proc-macro2", "quote", @@ -797,9 +797,9 @@ dependencies = [ [[package]] name = "openssl-src" -version = "300.5.0+3.5.0" +version = "300.5.1+3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f" +checksum = "735230c832b28c000e3bc117119e6466a663ec73506bc0a9907ea4187508e42a" dependencies = [ "cc", ] diff --git a/Cargo.toml b/Cargo.toml index a474ffe..e6df747 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,10 @@ static-openssl = ["openssl/vendored"] bytes = "1.8.0" chrono = "0.4.38" clap = { version = "4.3.4", features = ["cargo", "derive"] } -domain = { git = "https://github.com/NLnetLabs/domain.git", rev = "a3e7ab0bc248bada2c2564bf77ddc58010d4ce25", features = [ +# Until a release of domain is made that includes the features we need, pin +# to a specific commit of the domain main branch to ensure that the dnst main +# branch does not get broken if domain main is not stable. +domain = { git = "https://github.com/NLnetLabs/domain.git", rev = "17fb0e38120c9939ca28462af082d88ae8bc8b1d", features = [ "bytes", "net", "resolv", @@ -65,7 +68,7 @@ const_format = " 0.2.33" test_bin = "0.4.0" tempfile = "3.20.0" regex = "1.11.1" -domain = { git = "https://github.com/NLnetLabs/domain.git", rev = "a3e7ab0bc248bada2c2564bf77ddc58010d4ce25", features = [ +domain = { git = "https://github.com/NLnetLabs/domain.git", rev = "17fb0e38120c9939ca28462af082d88ae8bc8b1d", features = [ "unstable-stelline", ] } pretty_assertions = "1.4.1" diff --git a/src/commands/key2ds.rs b/src/commands/key2ds.rs index 9480dd6..03059f3 100644 --- a/src/commands/key2ds.rs +++ b/src/commands/key2ds.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use clap::builder::ValueParser; use clap::Parser; use domain::base::iana::{DigestAlgorithm, SecurityAlgorithm}; -use domain::base::zonefile_fmt::{DisplayKind, ZonefileFmt}; +use domain::base::zonefile_fmt::ZonefileFmt; use domain::base::Record; use domain::dnssec::validator::base::DnskeyExt; use domain::rdata::Ds; @@ -15,7 +15,7 @@ use lexopt::Arg; use crate::env::Env; use crate::error::Error; -use crate::Args; +use crate::{Args, DISPLAY_KIND}; use super::{Command, LdnsCommand}; @@ -197,7 +197,7 @@ impl Key2ds { let rr = Record::new(owner, class, ttl, ds); if self.write_to_stdout { - writeln!(env.stdout(), "{}", rr.display_zonefile(DisplayKind::Simple)); + writeln!(env.stdout(), "{}", rr.display_zonefile(DISPLAY_KIND)); } else { let owner = owner.fmt_with_dot(); let sec_alg = sec_alg.to_int(); @@ -227,7 +227,7 @@ impl Key2ds { let mut out_file = res.map_err(|e| format!("Could not create file \"{filename}\": {e}"))?; - writeln!(out_file, "{}", rr.display_zonefile(DisplayKind::Simple)) + writeln!(out_file, "{}", rr.display_zonefile(DISPLAY_KIND)) .map_err(|e| format!("Could not write to file \"{filename}\": {e}"))?; writeln!(env.stdout(), "{keyname}"); @@ -464,7 +464,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 15 2 52BD3BF40C8220BF1A3E2A3751C423BC4B69BCD7F328D38C4CD021A85DE65AD4\n"); } #[test] @@ -478,10 +478,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 15 2 B85F7D27C48A7B84D633C7A41C3022EA0F7FC80896227B61AE7BFC59BF5F0256\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 15 2 AA2030287A7C5C56CB3C0E9C64BE55616729C0C78DE2B83613D03B10C0F1EA93\n"); } #[test] @@ -495,7 +495,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 15 2 52BD3BF40C8220BF1A3E2A3751C423BC4B69BCD7F328D38C4CD021A85DE65AD4\n" ); assert_eq!(res.stderr, ""); } diff --git a/src/commands/keygen.rs b/src/commands/keygen.rs index 569ebf4..52a9d0e 100644 --- a/src/commands/keygen.rs +++ b/src/commands/keygen.rs @@ -16,7 +16,7 @@ use lexopt::Arg; use crate::env::Env; use crate::error::{Context, Error}; use crate::parse::parse_name; -use crate::{util, Args}; +use crate::{util, Args, DISPLAY_KIND}; use super::{parse_os, parse_os_with, Command, LdnsCommand}; @@ -369,7 +369,7 @@ impl Keygen { self.name.fmt_with_dot(), Ds::new(key_tag, algorithm, digest_alg, digest) .expect("we generated the digest, so don't expect it to be too long") - .display_zonefile(DisplayKind::Simple) + .display_zonefile(DISPLAY_KIND) ) }); diff --git a/src/lib.rs b/src/lib.rs index de0d8ef..4a15310 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,12 +9,11 @@ use commands::nsec3hash::Nsec3Hash; use commands::signzone::SignZone; use commands::update::Update; use commands::LdnsCommand; +use domain::base::zonefile_fmt::DisplayKind; use env::Env; use error::Error; use log::LogFormatter; -use domain::base::zonefile_fmt::DisplayKind; - pub use self::args::Args; pub mod args; From 5a4c09763f7ec5474da4bbf24a45f1b2ef7368eb Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Thu, 7 Aug 2025 14:16:10 +0200 Subject: [PATCH 10/32] Output digest in lower case for ldns-key2ds (#111) --- src/commands/key2ds.rs | 60 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/src/commands/key2ds.rs b/src/commands/key2ds.rs index 03059f3..e7aee8c 100644 --- a/src/commands/key2ds.rs +++ b/src/commands/key2ds.rs @@ -1,4 +1,5 @@ use std::ffi::OsString; +use std::fmt::Display; use std::fs::File; use std::io::{self, Write as _}; use std::path::PathBuf; @@ -7,7 +8,7 @@ use clap::builder::ValueParser; use clap::Parser; use domain::base::iana::{DigestAlgorithm, SecurityAlgorithm}; use domain::base::zonefile_fmt::ZonefileFmt; -use domain::base::Record; +use domain::base::{Record, RecordData, ToName}; use domain::dnssec::validator::base::DnskeyExt; use domain::rdata::Ds; use domain::zonefile::inplace::{Entry, ScannedRecordData}; @@ -197,7 +198,11 @@ impl Key2ds { let rr = Record::new(owner, class, ttl, ds); if self.write_to_stdout { - writeln!(env.stdout(), "{}", rr.display_zonefile(DISPLAY_KIND)); + if self.invoked_as_ldns { + let _ = write_rr_as_ldns(&env.stdout(), &rr); + } else { + writeln!(env.stdout(), "{}", rr.display_zonefile(DISPLAY_KIND)); + } } else { let owner = owner.fmt_with_dot(); let sec_alg = sec_alg.to_int(); @@ -227,8 +232,13 @@ impl Key2ds { let mut out_file = res.map_err(|e| format!("Could not create file \"{filename}\": {e}"))?; - writeln!(out_file, "{}", rr.display_zonefile(DISPLAY_KIND)) - .map_err(|e| format!("Could not write to file \"{filename}\": {e}"))?; + if self.invoked_as_ldns { + write_rr_as_ldns(out_file, &rr) + .map_err(|e| format!("Could not write to file \"{filename}\": {e}"))?; + } else { + writeln!(out_file, "{}", rr.display_zonefile(DISPLAY_KIND)) + .map_err(|e| format!("Could not write to file \"{filename}\": {e}"))?; + } writeln!(env.stdout(), "{keyname}"); } @@ -255,6 +265,21 @@ fn determine_hash_from_sec_alg(sec_alg: SecurityAlgorithm) -> Result( + mut w: W, + rr: &Record, +) -> Result<(), io::Error> { + writeln!( + w, + "{}\t{}\t{}\t{}\t{}", + rr.owner().fmt_with_dot(), + rr.ttl().as_secs(), + rr.class(), + rr.rtype(), + rr.data() + ) +} + #[cfg(test)] mod test { use domain::base::iana::DigestAlgorithm; @@ -524,6 +549,31 @@ mod test { assert_eq!(res.stderr, ""); } + #[test] + fn ldns_lowercase_digest() { + let dir = run_setup(); + + let res = FakeCmd::new(["ldns-key2ds", "-n", "key1.key"]) + .cwd(&dir) + .run(); + + assert_eq!(res.exit_code, 0); + assert_eq!( + res.stdout, + "example.test.\t3600\tIN\tDS\t60136 15 2 52bd3bf40c8220bf1a3e2a3751c423bc4b69bcd7f328d38c4cd021a85de65ad4\n" + ); + assert_eq!(res.stderr, ""); + + let res = FakeCmd::new(["ldns-key2ds", "key1.key"]).cwd(&dir).run(); + + assert_eq!(res.exit_code, 0, "{res:?}"); + assert_eq!(res.stdout, "Kexample.test.+015+60136\n"); + 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.\t3600\tIN\tDS\t60136 15 2 52bd3bf40c8220bf1a3e2a3751c423bc4b69bcd7f328d38c4cd021a85de65ad4\n"); + } + #[test] fn ldns_algorithm_fallback() { let dir = run_setup(); @@ -535,7 +585,7 @@ mod test { assert_eq!(res.exit_code, 0); assert_eq!( res.stdout, - ". 3600 IN DS 15147 3 1 DBCCC2CC359D4661AB39C72898EF58E9CDCD27AB\n" + ".\t3600\tIN\tDS\t15147 3 1 dbccc2cc359d4661ab39c72898ef58e9cdcd27ab\n" ); assert_eq!(res.stderr, ""); } From 3f84c1b26c0eb9e27dcf9e5a0cf9bcd3f53f8a94 Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Fri, 8 Aug 2025 12:38:36 +0200 Subject: [PATCH 11/32] Match ldns-signzone NSEC/NSEC3 RR ordering and NSEC3 formatting (#109) --- src/commands/signzone.rs | 143 +++++++++++++++++++++++++++++++++------ 1 file changed, 123 insertions(+), 20 deletions(-) diff --git a/src/commands/signzone.rs b/src/commands/signzone.rs index 58a07ce..29896c4 100644 --- a/src/commands/signzone.rs +++ b/src/commands/signzone.rs @@ -498,8 +498,8 @@ impl LdnsCommand for SignZone { hash_only: false, use_yyyymmddhhmmss_rrsig_format: true, preceed_zone_with_hash_list, - order_rrsigs_after_the_rtype_they_cover: extra_comments, - order_nsec3_rrs_by_unhashed_owner_name: extra_comments, + order_rrsigs_after_the_rtype_they_cover: true, + order_nsec3_rrs_by_unhashed_owner_name: true, zonefile_path, key_paths, invoked_as_ldns: true, @@ -970,7 +970,11 @@ impl SignZone { let mut nsec3_hashes: Option = None; - if self.use_nsec3 && (self.extra_comments || self.preceed_zone_with_hash_list) { + if self.use_nsec3 + && (self.extra_comments + || self.preceed_zone_with_hash_list + || self.order_nsec3_rrs_by_unhashed_owner_name) + { // Create a collection of NSEC3 hashes that can later be used for // debug output. let mut hash_provider = Nsec3HashMap::new(); @@ -1153,9 +1157,6 @@ impl SignZone { // 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. let mut owner_rrs; let owner_rrs_iter: AnyOwnerRrsIter = if self.order_nsec3_rrs_by_unhashed_owner_name && nsec3_hashes.is_some() { @@ -1248,12 +1249,22 @@ impl SignZone { // we skip that, and we skip RRSIGs as they are output only after // the RRset that they cover. if self.order_rrsigs_after_the_rtype_they_cover { - for rrset in owner_rrs - .rrsets() - .filter(|rrset| !matches!(rrset.rtype(), Rtype::SOA | Rtype::RRSIG)) - { + for rrset in owner_rrs.rrsets().filter(|rrset| { + !(matches!(rrset.rtype(), Rtype::SOA | Rtype::RRSIG) + // If run as ldns-signzone we want to list the NSEC RR + // at the end of the RRset of the apex. By default, + // the NSEC RR would preceed the DNSKEY RRset, so we + // need to filter it out here to manually reinsert it + // later. This is only necessary for the NSEC RR at + // the apex, as the ordering issue doesn't appear at + // other locations than the apex. + || (self.invoked_as_ldns + && rrset.rtype() == Rtype::NSEC + && rrset.owner() == apex)) + }) { for rr in rrset.iter() { self.write_rr(&mut writer, rr)?; + match rr.data() { ZoneRecordData::Nsec3(nsec3) if self.extra_comments => { nsec3.comment(&mut writer, rr, nsec3_cs)? @@ -1276,13 +1287,52 @@ impl SignZone { for covering_rrsigs in owner_rrs .rrsets() .filter(|this_rrset| this_rrset.rtype() == Rtype::RRSIG) - .map(|this_rrset| this_rrset.iter().filter(|rr| matches!(rr.data(), ZoneRecordData::Rrsig(rrsig) if rrsig.type_covered() == rrset.rtype()))) + .map(|this_rrset| { + this_rrset.iter().filter(|rr| { + matches!(rr.data(), ZoneRecordData::Rrsig(rrsig) + if rrsig.type_covered() == rrset.rtype() + && if self.invoked_as_ldns && rr.owner() == apex { + // Withhold an RRSIG that covers the NSEC of the apex + // as we' reinserting them at the end of the apex' RRsets + rrsig.type_covered() != Rtype::NSEC + } else { true } + ) + }) + }) { for covering_rrsig_rr in covering_rrsigs { self.writeln_rr(&mut writer, covering_rrsig_rr)?; } } } + + // If running as ldns-signzone, we've been withholding the NSEC and NSEC's RRSIG at + // the apex above to reinsert them after all other RRsets at the apex. By default, + // the DNSKEY RRset and it's RRSIG would take the rear of the RRsets at the apex. + // This doesn't apply, if we're using NSEC3. Additionally, the NSEC RRs at other + // places than the apex do not have the ordering issue. + if self.invoked_as_ldns && !self.use_nsec3 && owner_rrs.owner() == apex { + if let Some(nsec_rrset) = owner_rrs + .rrsets() + .find(|this_rrset| this_rrset.rtype() == Rtype::NSEC) + { + self.writeln_rr(&mut writer, nsec_rrset.first())?; + } + + if let Some(rrsig_rrset) = owner_rrs + .rrsets() + .find(|this_rrset| this_rrset.rtype() == Rtype::RRSIG) + { + for rr in rrsig_rrset.iter() { + if matches!(rr.data(), ZoneRecordData::Rrsig(rrsig) if rrsig.type_covered() == Rtype::NSEC) + { + self.writeln_rr(&mut writer, rr)?; + break; + } + } + } + } + if self.extra_comments { writer.write_str(";\n")?; } @@ -1327,6 +1377,13 @@ impl SignZone { } } + if self.invoked_as_ldns { + if let ZoneRecordData::Nsec3(nsec3) = rr.data() { + let rr = Record::new(rr.owner(), rr.class(), rr.ttl(), LdnsNsec3(nsec3)); + return writer.write_fmt(format_args!("{}", rr.display_zonefile(DISPLAY_KIND))); + } + } + writer.write_fmt(format_args!("{}", rr.display_zonefile(DISPLAY_KIND))) } @@ -2026,6 +2083,52 @@ impl RecordData for YyyyMmDdHhMMSsRrsig<'_, O, N> { } } +//------------ LdnsNsec3 ----------------------------------------------------- + +/// A wrapper around Nsec3 to print the Nsec3 data in the exact format used by +/// ldns-signzone with all its quirks. +struct LdnsNsec3<'a, O>(&'a Nsec3); + +impl> ZonefileFmt for LdnsNsec3<'_, O> { + fn fmt(&self, p: &mut impl Formatter) -> zonefile_fmt::Result { + // This block of code was copied from the `domain` crate impl of + // `Zonefilefmt` for domain::rdata::nsec3::Nsec3 and adapted for + // ldns output format. + p.block(|p| { + p.write_show(self.0.hash_algorithm())?; + p.write_token(self.0.flags())?; + p.write_comment(format_args!( + "flags: {}", + if self.0.opt_out() { + "opt-out" + } else { + "" + } + ))?; + p.write_token(self.0.iterations())?; + p.write_comment("iterations")?; + p.write_show(self.0.salt())?; + p.write_token(format!( + " {}", + domain::utils::base32::encode_display_hex(&self.0.next_owner()) + .to_string() + .to_lowercase() + ))?; + p.write_show(self.0.types())?; + // ldns-signzone ends its NSEC3 rtype bitmap with a trailing + // space. Adding an empty token, because the formatter will add + // a space as a delimiter. + p.write_token("") + }) + } +} + +impl RecordData for LdnsNsec3<'_, O> { + fn rtype(&self) -> Rtype { + Rtype::NSEC3 + } +} + //-------------- Nsec3HashMap ------------------------------------------------ #[derive(Debug)] @@ -2420,8 +2523,8 @@ mod test { hash_only: false, use_yyyymmddhhmmss_rrsig_format: true, preceed_zone_with_hash_list: false, - order_rrsigs_after_the_rtype_they_cover: false, - order_nsec3_rrs_by_unhashed_owner_name: false, + order_rrsigs_after_the_rtype_they_cover: true, + order_nsec3_rrs_by_unhashed_owner_name: true, zonefile_path: PathBuf::from("example.org.zone"), key_paths: Vec::from([PathBuf::from("anykey")]), invoked_as_ldns: true, @@ -2960,14 +3063,14 @@ m.root-servers.net.\t3600000\tIN\tAAAA\t2001:dc3::35 // (dnst) ldns-signzone -np -f - -e 20241127162422 -i 20241127162422 nsec3_optout1_example.org.zone ksk1 | grep NSEC3 let ldns_dnst_output_stripped: &str = "\ - example.org.\t3600\tIN\tRRSIG\tNSEC3PARAM 15 2 3600 20241127162422 20241127162422 38873 example.org. 0XdDm1l2Mm8dyhtzbyQb91CmyNONs8lc9d22FUGvpjfqo8T2h0xs04x5MIfP0DjmiVnNqIyPK6sipnDqf6tCDg==\n\ example.org.\t3600\tIN\tNSEC3PARAM\t1 1 1 -\n\ + example.org.\t3600\tIN\tRRSIG\tNSEC3PARAM 15 2 3600 20241127162422 20241127162422 38873 example.org. 0XdDm1l2Mm8dyhtzbyQb91CmyNONs8lc9d22FUGvpjfqo8T2h0xs04x5MIfP0DjmiVnNqIyPK6sipnDqf6tCDg==\n\ + 93u63bg57ppj6649al2n31l92iedkjd6.example.org.\t240\tIN\tNSEC3\t1 1 1 - k71ku6aicr5jpdjoe9j7cdnlk6d5c3ue A NS SOA RRSIG DNSKEY NSEC3PARAM \n\ 93u63bg57ppj6649al2n31l92iedkjd6.example.org.\t240\tIN\tRRSIG\tNSEC3 15 3 240 20241127162422 20241127162422 38873 example.org. z4ceUmbSZiSnluFj8CDJ7B9fukCR2flTWgca4GE2xrw48+fiieH/04xCKhJmDRJUJTVkKtIYpB4p0Q4m60M1Cg==\n\ - 93u63bg57ppj6649al2n31l92iedkjd6.example.org.\t240\tIN\tNSEC3\t1 1 1 - K71KU6AICR5JPDJOE9J7CDNLK6D5C3UE A NS SOA RRSIG DNSKEY NSEC3PARAM\n\ + k71ku6aicr5jpdjoe9j7cdnlk6d5c3ue.example.org.\t240\tIN\tNSEC3\t1 1 1 - ojicmhri4vp8po7h2kvej99sklqnj5p2 NS \n\ k71ku6aicr5jpdjoe9j7cdnlk6d5c3ue.example.org.\t240\tIN\tRRSIG\tNSEC3 15 3 240 20241127162422 20241127162422 38873 example.org. HUrf7tOm3simXqpZj1oZeKX/P3eWoTTKc3fsyqfuLD6sGssXrBfpv1/LINBR9eEBjJ9rFbQXILgweS6huBL/Ag==\n\ - k71ku6aicr5jpdjoe9j7cdnlk6d5c3ue.example.org.\t240\tIN\tNSEC3\t1 1 1 - OJICMHRI4VP8PO7H2KVEJ99SKLQNJ5P2 NS\n\ + ojicmhri4vp8po7h2kvej99sklqnj5p2.example.org.\t240\tIN\tNSEC3\t1 1 1 - 93u63bg57ppj6649al2n31l92iedkjd6 NS DS RRSIG \n\ ojicmhri4vp8po7h2kvej99sklqnj5p2.example.org.\t240\tIN\tRRSIG\tNSEC3 15 3 240 20241127162422 20241127162422 38873 example.org. NG/8jk3UHht1ZYNEjUZ4swaEHea1amF4l3jZ893oARi95oxtPVLKoinVbBbfVuoanicOgeZxUPpKWHMBR12XDA==\n\ - ojicmhri4vp8po7h2kvej99sklqnj5p2.example.org.\t240\tIN\tNSEC3\t1 1 1 - 93U63BG57PPJ6649AL2N31L92IEDKJD6 NS DS RRSIG\n\ "; let res = FakeCmd::new([ @@ -2996,14 +3099,14 @@ m.root-servers.net.\t3600000\tIN\tAAAA\t2001:dc3::35 fn ldns_signzone_disables_minus_b_when_output_is_to_stdout() { let expected_output = r###"example.org.\t239\tIN\tSOA\texample.net. hostmaster.example.net. 1234567890 28800 7200 604800 238 example.org.\t239\tIN\tRRSIG\tSOA 8 2 239 20241127162422 20241127162422 51331 example.org. XD5+Exk0KLfvLYA7y+Qs6jhF+JeESFONqZAjkSvznXdjod80W6cv9C77XeHqqod+5glGHlw9bXmVhuJ/5n056BbnDcMWF+AV4taFc/RrDcZb5A0tS6LnRWbpO9puKeLVK10FeAChCygct6/+GNiE12DDLnzKJFuyMuu+nLa2p88= -example.org.\t238\tIN\tRRSIG\tNSEC 8 2 238 20241127162422 20241127162422 51331 example.org. AT4PDLEolpApcrYi7mcTXrqCQ6psXeZNdmFub08m6BJRs2jeW07fM11Amft53FXKgqbT23WILkEM7Raai8E8qPJoSdDCys6zYXW/NCU9Cf/oXIKdD4nxQXXWbnX4GCMN4XJy382dYnxTDssQK6lNIKKi4OvGYIxVUPthaLKJFU0= +example.org.\t239\tIN\tDNSKEY\t257 3 8 AwEAAckp/oMmocs+pv4KsCkCciazIl2+SohAZ2/bH2viAMg3tHAPjw5YfPNErUBqMGvN4c23iBCnt9TktT5bVoQdpXyCJ+ZwmWrFxlXvXIqG8rpkwHi1xFoXWVZLrG9XYCqLVMq2cB+FgMIaX504XMGk7WQydtV1LAqLgP3B8JA2Fc1j ;{id = 51331 (ksk), size = 1024b} example.org.\t239\tIN\tRRSIG\tDNSKEY 8 2 239 20241127162422 20241127162422 51331 example.org. rLwqlu9fYkzAy0jM9crtw5du4rUaDVH9PI4m06lRwjSKhu1VQ1AHjRhlKy1OgUee/5LovXSRGcgNZi4wiTS5ZULTJw7UQTBRXaaNhVACENX/MoVw9SmYuDSTyvQboChmFmYSMch3Q/02VhgN+BT8F7+OdDVgsWqZUEKPVNixk/0= example.org.\t238\tIN\tNSEC\tsome.example.org. SOA RRSIG NSEC DNSKEY -example.org.\t239\tIN\tDNSKEY\t257 3 8 AwEAAckp/oMmocs+pv4KsCkCciazIl2+SohAZ2/bH2viAMg3tHAPjw5YfPNErUBqMGvN4c23iBCnt9TktT5bVoQdpXyCJ+ZwmWrFxlXvXIqG8rpkwHi1xFoXWVZLrG9XYCqLVMq2cB+FgMIaX504XMGk7WQydtV1LAqLgP3B8JA2Fc1j ;{id = 51331 (ksk), size = 1024b} +example.org.\t238\tIN\tRRSIG\tNSEC 8 2 238 20241127162422 20241127162422 51331 example.org. AT4PDLEolpApcrYi7mcTXrqCQ6psXeZNdmFub08m6BJRs2jeW07fM11Amft53FXKgqbT23WILkEM7Raai8E8qPJoSdDCys6zYXW/NCU9Cf/oXIKdD4nxQXXWbnX4GCMN4XJy382dYnxTDssQK6lNIKKi4OvGYIxVUPthaLKJFU0= some.example.org.\t240\tIN\tA\t1.2.3.4 some.example.org.\t240\tIN\tRRSIG\tA 8 3 240 20241127162422 20241127162422 51331 example.org. xdVbhbaMXEyMySCOKy2yYQgU2URAOnu+jLU5py+4R8R3yVVvdl6yMjzdUD3vyxprHitJ+xLrXU/wHSQvtjSwmxVL53ztu+9wrnrhQm6nqXGLW+iw58LepdLVRlppz2WlV0CJAlLIQPJ8rw4hND3NYLJojnO8OdrgpHL89ajD4II= -some.example.org.\t238\tIN\tRRSIG\tNSEC 8 3 238 20241127162422 20241127162422 51331 example.org. PP4tH4Y6JNymWSJebPd3zjvDrjyZXVBF8QTKxKAmbmtPacbWyIcRuI0L8+8Z1folAN2U5cUZmCaIbt5Ylaj6ab4UAYHiy0BrcF/zbNIeLRSTz4hOteencIooTDvqIqYuI9/xTVXcfJ+gVzzlIh2dJK2GW5O4+B1xR+CINLNJ/j8= some.example.org.\t238\tIN\tNSEC\texample.org. A RRSIG NSEC +some.example.org.\t238\tIN\tRRSIG\tNSEC 8 3 238 20241127162422 20241127162422 51331 example.org. PP4tH4Y6JNymWSJebPd3zjvDrjyZXVBF8QTKxKAmbmtPacbWyIcRuI0L8+8Z1folAN2U5cUZmCaIbt5Ylaj6ab4UAYHiy0BrcF/zbNIeLRSTz4hOteencIooTDvqIqYuI9/xTVXcfJ+gVzzlIh2dJK2GW5O4+B1xR+CINLNJ/j8= "###.replace("\\t", "\t"); let zone_file_path = From f6ae5d744cf8999fb7bfa0e327c52b140b7e05ea Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Fri, 8 Aug 2025 12:41:00 +0200 Subject: [PATCH 12/32] Add verbosity option (#110) Co-authored-by: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> --- src/args.rs | 15 ++++++++++++++- src/bin/ldns.rs | 34 +++++++++++++++++++++++++--------- src/commands/signzone.rs | 8 ++------ src/error.rs | 6 +++--- src/lib.rs | 24 +++++++++++++----------- 5 files changed, 57 insertions(+), 30 deletions(-) diff --git a/src/args.rs b/src/args.rs index 1b1cbaf..97c2262 100644 --- a/src/args.rs +++ b/src/args.rs @@ -4,12 +4,22 @@ use super::commands::Command; use super::error::Error; use clap::Parser; +use tracing::level_filters::LevelFilter; #[derive(Clone, Debug, Parser)] #[command(version, disable_help_subcommand = true)] pub struct Args { #[command(subcommand)] pub command: Command, + + /// Verbosity: 0-5 or a level name ("off", "error", "warn", "info", "debug" or "trace") + #[arg( + short = 'v', + long = "verbosity", + value_name = "level", + default_value_t = LevelFilter::from_level(tracing::Level::WARN), + )] + pub verbosity: LevelFilter, } impl Args { @@ -20,6 +30,9 @@ impl Args { impl From for Args { fn from(value: Command) -> Self { - Args { command: value } + Args { + command: value, + verbosity: LevelFilter::from_level(tracing::Level::WARN), + } } } diff --git a/src/bin/ldns.rs b/src/bin/ldns.rs index 4204530..30a2cd9 100644 --- a/src/bin/ldns.rs +++ b/src/bin/ldns.rs @@ -6,21 +6,37 @@ use std::process::ExitCode; +use dnst::env::Env; +use dnst::log::LogFormatter; use dnst::try_ldns_compatibility; +use tracing::level_filters::LevelFilter; fn main() -> ExitCode { let env = dnst::env::RealEnv; + run(env) +} + +fn run(env: impl Env) -> ExitCode { + let mut args = env.args_os(); + let argv0 = args.next().unwrap(); + let stderr = env.stderr(); + let subscriber = tracing_subscriber::FmtSubscriber::builder() + .with_ansi(stderr.is_terminal()) + .with_writer(stderr) + .with_max_level(LevelFilter::WARN) + .event_format(LogFormatter { + program: argv0.to_string_lossy().to_string(), + }); - let mut args = std::env::args_os(); - args.next().unwrap(); let args = try_ldns_compatibility(args).map(|args| args.expect("ldns commmand lacks ldns- prefix")); - - match args.and_then(|args| args.execute(&env)) { - Ok(()) => ExitCode::SUCCESS, - Err(err) => { - err.pretty_print(env); - ExitCode::FAILURE + tracing::subscriber::with_default(subscriber.finish(), || { + match args.and_then(|args| args.execute(&env)) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + err.pretty_print(env); + ExitCode::FAILURE + } } - } + }) } diff --git a/src/commands/signzone.rs b/src/commands/signzone.rs index 29896c4..f4d4d88 100644 --- a/src/commands/signzone.rs +++ b/src/commands/signzone.rs @@ -1612,12 +1612,8 @@ impl SignZone { Self::write_iterations_warning(env, "NSEC3 iterations larger than 0 increases performance cost while providing only moderate protection!"); } - fn write_iterations_warning(env: &impl Env, text: &str) { - warn!("{text}"); - writeln!( - env.stderr(), - "See: https://www.rfc-editor.org/rfc/rfc9276.html" - ); + fn write_iterations_warning(_env: &impl Env, text: &str) { + warn!("{text}\nSee: https://www.rfc-editor.org/rfc/rfc9276.html"); } /// Create the ZONEMD digest for the SIMPLE scheme. diff --git a/src/error.rs b/src/error.rs index ea382a4..948d6a8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -74,11 +74,11 @@ impl Error { PrimaryError::Other(error) => error, }; - error!("{msg}"); - let mut err = env.stderr(); + let mut buf = String::new(); for context in &self.0.context { - writeln!(err, "... while {context}"); + buf.push_str(&format!("... while {context}\n")); } + error!("{msg}\n{buf}"); } pub fn exit_code(&self) -> u8 { diff --git a/src/lib.rs b/src/lib.rs index 4a15310..d9de7cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ use domain::base::zonefile_fmt::DisplayKind; use env::Env; use error::Error; use log::LogFormatter; +use tracing::level_filters::LevelFilter; pub use self::args::Args; @@ -99,22 +100,23 @@ fn parse_args(env: impl Env) -> Result { pub fn run(env: impl Env) -> u8 { let stderr = env.stderr(); - let subscriber = tracing_subscriber::FmtSubscriber::builder() + let mut subscriber = tracing_subscriber::FmtSubscriber::builder() .with_ansi(stderr.is_terminal()) .with_writer(stderr) + .with_max_level(LevelFilter::WARN) .event_format(LogFormatter { program: env.args_os().next().unwrap().to_string_lossy().to_string(), - }) - .finish(); - - tracing::subscriber::with_default(subscriber, || { - let res = parse_args(&env).and_then(|args| args.execute(&env)); - match res { - Ok(()) => 0, - Err(err) => { + }); + let res = parse_args(&env); + if let Ok(args) = &res { + subscriber = subscriber.with_max_level(args.verbosity); + } + tracing::subscriber::with_default(subscriber.finish(), || { + res.and_then(|args| args.execute(&env)) + .map(|()| 0) + .unwrap_or_else(|err| { err.pretty_print(&env); err.exit_code() - } - } + }) }) } From e4fcc0431005f156fa07ad9495e1fd1de91c3187 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 8 Aug 2025 13:41:46 +0200 Subject: [PATCH 13/32] Make version argument handling more consistent. (#86) --- doc/manual/source/man/dnst-key2ds.rst | 4 ---- doc/manual/source/man/dnst-nsec3-hash.rst | 4 ---- doc/manual/source/man/dnst.rst | 16 ++++++++++++++-- src/commands/key2ds.rs | 2 -- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/doc/manual/source/man/dnst-key2ds.rst b/doc/manual/source/man/dnst-key2ds.rst index 452c857..6973534 100644 --- a/doc/manual/source/man/dnst-key2ds.rst +++ b/doc/manual/source/man/dnst-key2ds.rst @@ -46,7 +46,3 @@ Options Print the help text (short summary with ``-h``, long help with ``--help``). - -.. option:: -V, --version - - Print the version. diff --git a/doc/manual/source/man/dnst-nsec3-hash.rst b/doc/manual/source/man/dnst-nsec3-hash.rst index 809ec7b..c76378a 100644 --- a/doc/manual/source/man/dnst-nsec3-hash.rst +++ b/doc/manual/source/man/dnst-nsec3-hash.rst @@ -40,7 +40,3 @@ Options Print the help text (short summary with ``-h``, long help with ``--help``). - -.. option:: -V, --version - - Print the version. diff --git a/doc/manual/source/man/dnst.rst b/doc/manual/source/man/dnst.rst index 09b4bb7..d404f97 100644 --- a/doc/manual/source/man/dnst.rst +++ b/doc/manual/source/man/dnst.rst @@ -17,8 +17,20 @@ managing DNS servers and DNS zones. Please consult the manual pages for these individual commands for more information. -dnst Commands -------------- +Options +------- + +.. option:: -h, --help + + Print the help text (short summary with ``-h``, long help with + ``--help``). + +.. option:: -V, --version + + Print the version. + +Commands +-------- .. glossary:: diff --git a/src/commands/key2ds.rs b/src/commands/key2ds.rs index e7aee8c..c1e0cfc 100644 --- a/src/commands/key2ds.rs +++ b/src/commands/key2ds.rs @@ -21,7 +21,6 @@ use crate::{Args, DISPLAY_KIND}; use super::{Command, LdnsCommand}; #[derive(Clone, Debug, Parser, PartialEq, Eq)] -#[command(version)] pub struct Key2ds { /// ignore SEP flag (i.e. make DS records for any key) #[arg(long = "ignore-sep")] @@ -111,7 +110,6 @@ impl LdnsCommand for Key2ds { } keyfile = Some(val); } - 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()) From 26000cb8073ac4145f7afebcb8945b67024a07b4 Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Mon, 11 Aug 2025 10:05:39 +0200 Subject: [PATCH 14/32] Add verbosity option to man page (#113) --- doc/manual/source/man/dnst.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/manual/source/man/dnst.rst b/doc/manual/source/man/dnst.rst index d404f97..a5104b6 100644 --- a/doc/manual/source/man/dnst.rst +++ b/doc/manual/source/man/dnst.rst @@ -20,6 +20,11 @@ information. Options ------- +.. option:: -v, --verbosity + + Set the verbosity to 0-5 or a level name (``off``, ``error``, ``warn``, + ``info``, ``debug`` or ``trace``). Defaults to ``warn``. + .. option:: -h, --help Print the help text (short summary with ``-h``, long help with From 5eba1d4ee898347916733f90188a4d23eb71740a Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 11 Aug 2025 13:42:55 +0200 Subject: [PATCH 15/32] Make the OUTPUT FORMATTING help heading have consistent case with the rest of the help headings. (#112) --- src/commands/signzone.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/signzone.rs b/src/commands/signzone.rs index f4d4d88..c02372e 100644 --- a/src/commands/signzone.rs +++ b/src/commands/signzone.rs @@ -99,7 +99,7 @@ pub struct SignZone { /// /// Using this flag enables -O and -R automatically. #[arg( - help_heading = Some("OUTPUT FORMATTING"), + help_heading = Some("Output Formatting"), short = 'b', default_value_t = false )] @@ -245,7 +245,7 @@ pub struct SignZone { /// /// Requires -n. #[arg( - help_heading = Some("OUTPUT FORMATTING"), + help_heading = Some("Output Formatting"), short = 'L', default_value_t = false, requires = "nsec3" @@ -256,7 +256,7 @@ pub struct SignZone { /// /// Enabled automatically by -b. #[arg( - help_heading = Some("OUTPUT FORMATTING"), + help_heading = Some("Output Formatting"), short = 'O', default_value_t = false, default_value_if("extra_comments", "true", Some("true")), @@ -268,7 +268,7 @@ pub struct SignZone { /// /// Enabled automatically by -b. #[arg( - help_heading = Some("OUTPUT FORMATTING"), + help_heading = Some("Output Formatting"), short = 'R', default_value_t = false, default_value_if("extra_comments", "true", Some("true")), @@ -279,7 +279,7 @@ pub struct SignZone { /// /// Cannot be used with -Z or -H. #[arg( - help_heading = Some("OUTPUT FORMATTING"), + help_heading = Some("Output Formatting"), short = 'T', default_value_t = false, conflicts_with_all = ["allow_zonemd_without_signing", "hash_only"], From 7e5add5b8677a744fd954296cafa3b5816d95864 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Sun, 24 Aug 2025 11:16:25 +0200 Subject: [PATCH 16/32] Enable the Clap wrap_help feature to wrap help text more neatly. (#114) --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index e6df747..9dfe84f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ static-openssl = ["openssl/vendored"] [dependencies] bytes = "1.8.0" chrono = "0.4.38" -clap = { version = "4.3.4", features = ["cargo", "derive"] } +clap = { version = "4.3.4", features = ["cargo", "derive", "wrap_help"] } # Until a release of domain is made that includes the features we need, pin # to a specific commit of the domain main branch to ensure that the dnst main # branch does not get broken if domain main is not stable. From 018527f95f6f38be4b61d733b2e17e6c290606b3 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 25 Aug 2025 19:01:52 +0200 Subject: [PATCH 17/32] Add support for the KMIP cryptographic backend. (#99) - Adds KMIP server based key generation, signing and destruction, equivalent to the existing Ring/OpenSSL functionality. - Adds new kmip subcommands for managing KMIP server configurations. - Adds support for referring to KMIP keys by a new KMIP URL scheme. - Add a feature for the KMIP crypto backend just like the Ring and OpenSSL crypto backends. - Adds support for storing sensitive credentials in files separate to the KMIP server configuration. --- Cargo.lock | 320 +++- Cargo.toml | 9 +- src/commands/{keyset.rs => keyset/cmd.rs} | 953 +++++++---- src/commands/keyset/kmip.rs | 1840 +++++++++++++++++++++ src/commands/keyset/mod.rs | 6 + 5 files changed, 2811 insertions(+), 317 deletions(-) rename src/commands/{keyset.rs => keyset/cmd.rs} (67%) create mode 100644 src/commands/keyset/kmip.rs create mode 100644 src/commands/keyset/mod.rs diff --git a/Cargo.lock b/Cargo.lock index c040ac5..8b23f4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,22 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "bcder" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ffdaa8c6398acd07176317eb6c1f9082869dd1cc3fee7c72c6354866b928cc" +dependencies = [ + "bytes", + "smallvec", +] + [[package]] name = "bitflags" version = "2.9.1" @@ -213,7 +229,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -320,7 +336,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -332,14 +348,16 @@ dependencies = [ "clap", "const_format", "domain", + "indenter", "jiff", "lazy_static", "lexopt", "octseq", "pretty_assertions", + "rand 0.9.2", "rayon", "regex", - "ring", + "ring 0.17.14", "serde", "serde_json", "tempfile", @@ -353,23 +371,25 @@ dependencies = [ [[package]] name = "domain" version = "0.11.1-dev" -source = "git+https://github.com/NLnetLabs/domain.git?branch=crypto-and-keyset-fixes#0fa094bb9812b0ebcf8a2651e4f46d5923505d8e" +source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#ab61e4c9c51d38d3574c232040849bd0ef0d2923" dependencies = [ "arc-swap", + "bcder", "bumpalo", "bytes", "chrono", "domain-macros", "futures-util", "hashbrown", + "kmip-protocol", "libc", "log", "moka", "octseq", "openssl", "parking_lot", - "rand", - "ring", + "rand 0.8.5", + "ring 0.17.14", "rustversion", "secrecy", "serde", @@ -380,16 +400,18 @@ dependencies = [ "tokio-stream", "tracing", "tracing-subscriber", + "url", + "uuid", ] [[package]] name = "domain-macros" version = "0.11.1-dev" -source = "git+https://github.com/NLnetLabs/domain.git?branch=crypto-and-keyset-fixes#0fa094bb9812b0ebcf8a2651e4f46d5923505d8e" +source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#ab61e4c9c51d38d3574c232040849bd0ef0d2923" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -398,6 +420,28 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "enum-display-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f16ef37b2a9b242295d61a154ee91ae884afff6b8b933b486b12481cc58310ca" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "enum-flags" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3682d2328e61f5529088a02cd20bb0a9aeaeeeb2f26597436dd7d75d1340f8f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "errno" version = "0.3.13" @@ -473,7 +517,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -554,6 +598,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "iana-time-zone" version = "0.1.63" @@ -685,6 +735,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "io-uring" version = "0.7.8" @@ -729,7 +785,7 @@ checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -742,6 +798,44 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kmip-protocol" +version = "0.5.0" +source = "git+https://github.com/NLnetLabs/kmip-protocol?branch=next#d57571f186e5809d35b31003e02c496141deee55" +dependencies = [ + "cfg-if", + "enum-display-derive", + "enum-flags", + "hex", + "kmip-ttlv", + "log", + "maybe-async", + "r2d2", + "rustc_version", + "rustls", + "rustls-pemfile", + "serde", + "serde_bytes", + "serde_derive", + "tracing", + "trait-set", + "webpki", +] + +[[package]] +name = "kmip-ttlv" +version = "0.4.0" +source = "git+https://github.com/NLnetLabs/kmip-ttlv?branch=next#4ca144e19e69375a6ccd63cf40b0e61f89462f97" +dependencies = [ + "cfg-if", + "hex", + "maybe-async", + "rustc_version", + "serde", + "tracing", + "trait-set", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -810,6 +904,17 @@ dependencies = [ "regex-automata 0.1.10", ] +[[package]] +name = "maybe-async" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "memchr" version = "2.7.5" @@ -938,7 +1043,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1085,6 +1190,17 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + [[package]] name = "rand" version = "0.8.5" @@ -1092,8 +1208,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -1103,7 +1229,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -1115,6 +1251,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "rayon" version = "1.10.0" @@ -1188,6 +1333,21 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted 0.7.1", + "web-sys", + "winapi", +] + [[package]] name = "ring" version = "0.17.14" @@ -1198,7 +1358,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -1230,6 +1390,28 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustls" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" +dependencies = [ + "base64", + "log", + "ring 0.16.20", + "sct", + "webpki", +] + +[[package]] +name = "rustls-pemfile" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eebeaeb360c87bfb72e84abdb3447159c0eaececf1bef2aecd65a8be949d1c9" +dependencies = [ + "base64", +] + [[package]] name = "rustversion" version = "1.0.21" @@ -1242,6 +1424,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -1254,6 +1445,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" +dependencies = [ + "ring 0.16.20", + "untrusted 0.7.1", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -1278,6 +1479,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8437fd221bde2d4ca316d61b90e337e9e702b3820b87d63caa9ba6c02bd06d96" +dependencies = [ + "serde", +] + [[package]] name = "serde_derive" version = "1.0.219" @@ -1286,7 +1496,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1344,6 +1554,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -1356,6 +1572,17 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.104" @@ -1375,7 +1602,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1420,7 +1647,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1499,7 +1726,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1533,7 +1760,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1575,6 +1802,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "trait-set" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875c4c873cc824e362fa9a9419ffa59807244824275a44ad06fec9684fff08f2" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -1587,6 +1825,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -1676,7 +1920,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.104", "wasm-bindgen-shared", ] @@ -1698,7 +1942,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1712,6 +1956,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +dependencies = [ + "ring 0.16.20", + "untrusted 0.7.1", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1788,7 +2052,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1799,7 +2063,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2041,7 +2305,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", "synstructure", ] @@ -2062,7 +2326,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -2082,7 +2346,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", "synstructure", ] @@ -2122,5 +2386,5 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] diff --git a/Cargo.toml b/Cargo.toml index 5351d89..30c146e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,9 +17,10 @@ name = "ldns" path = "src/bin/ldns.rs" [features] -default = ["openssl", "ring"] +default = ["kmip", "openssl", "ring"] # Cryptographic backends +kmip = ["domain/kmip", "dep:indenter", "dep:rand"] openssl = ["domain/openssl"] ring = ["domain/ring"] @@ -27,7 +28,7 @@ ring = ["domain/ring"] 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 = "crypto-and-keyset-fixes", features = [ +domain = { git = "https://github.com/NLnetLabs/domain.git", branch = "patches-for-nameshed-prototype", features = [ "bytes", "net", "resolv", @@ -38,9 +39,11 @@ domain = { git = "https://github.com/NLnetLabs/domain.git", branch = "crypto-and "unstable-validator", "unstable-zonetree", ] } +indenter = { version = "0.3.4", optional = true } lexopt = "0.3.0" rayon = "1.10.0" octseq = "0.5.2" +rand = { version = "0.9.2", optional = true } ring = "0.17.8" tokio = "1.40.0" @@ -63,7 +66,7 @@ const_format = " 0.2.33" test_bin = "0.4.0" tempfile = "3.20.0" regex = "1.11.1" -domain = { git = "https://github.com/NLnetLabs/domain.git", branch = "crypto-and-keyset-fixes", features = [ +domain = { git = "https://github.com/NLnetLabs/domain.git", branch = "patches-for-nameshed-prototype", features = [ "unstable-stelline", ] } pretty_assertions = "1.4.1" diff --git a/src/commands/keyset.rs b/src/commands/keyset/cmd.rs similarity index 67% rename from src/commands/keyset.rs rename to src/commands/keyset/cmd.rs index 1848474..e6a5404 100644 --- a/src/commands/keyset.rs +++ b/src/commands/keyset/cmd.rs @@ -1,14 +1,24 @@ -use crate::env::Env; -use crate::error::Error; -use crate::util; +//! The keyset subcomnand. +use std::cmp::min; +use std::collections::HashMap; +use std::convert::From; +use std::fmt::{Debug, Display, Formatter}; +use std::fs::{remove_file, File}; +use std::io::Write; +use std::path::{absolute, Path, PathBuf}; +use std::time::Duration; +use std::time::SystemTime; + use bytes::Bytes; use clap::Subcommand; use domain::base::iana::Class; use domain::base::iana::{DigestAlgorithm, SecurityAlgorithm}; +use domain::base::name::FlattenInto; use domain::base::zonefile_fmt::{DisplayKind, ZonefileFmt}; use domain::base::{Name, Record, ToName, Ttl}; -use domain::crypto::sign; use domain::crypto::sign::{GenerateParams, KeyPair, SecretKeyBytes}; +#[cfg(feature = "kmip")] +use domain::crypto::{kmip::KeyUrl, sign::SignRaw}; use domain::dnssec::common::{display_as_bind, parse_from_bind}; use domain::dnssec::sign::keys::keyset::{Action, Key, KeySet, KeyType, RollType, UnixTime}; use domain::dnssec::sign::keys::SigningKey; @@ -16,21 +26,24 @@ use domain::dnssec::sign::records::Rrset; use domain::dnssec::sign::signatures::rrsigs::sign_rrset; use domain::dnssec::validator::base::DnskeyExt; use domain::rdata::dnssec::Timestamp; -use domain::rdata::{Cdnskey, Cds, Ds, ZoneRecordData}; +use domain::rdata::{Cdnskey, Cds, Dnskey, Ds, ZoneRecordData}; +#[cfg(feature = "kmip")] +use domain::utils::base32::encode_string_hex; use domain::zonefile::inplace::Zonefile; use domain::zonefile::inplace::{Entry, ScannedRecordData}; use jiff::{Span, SpanRelativeTo}; use serde::{Deserialize, Serialize}; -use std::cmp::min; -use std::collections::HashMap; -use std::fmt::{Display, Formatter}; -use std::fs::{remove_file, File}; -use std::io::Write; -use std::path::{absolute, Path, PathBuf}; -use std::time::Duration; -use std::time::SystemTime; +#[cfg(feature = "kmip")] +use tracing::warn; use url::Url; +use crate::env::Env; +use crate::error::Error; +use crate::util; + +#[cfg(feature = "kmip")] +use super::kmip::{format_key_label, kmip_command, KmipCommands, KmipState}; + const MAX_KEY_TAG_TRIES: u8 = 10; #[derive(Clone, Debug, clap::Args)] @@ -46,6 +59,7 @@ pub struct Keyset { type OptDuration = Option; +#[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, Subcommand)] enum Commands { Create { @@ -115,6 +129,12 @@ enum Commands { Show, Cron, + + #[cfg(feature = "kmip")] + Kmip { + #[command(subcommand)] + subcommand: KmipCommands, + }, } #[derive(Clone, Debug, Subcommand)] @@ -228,6 +248,8 @@ impl Keyset { cds_rrset: Vec::new(), ns_rrset: Vec::new(), cron_next: None, + #[cfg(feature = "kmip")] + kmip: Default::default(), }; const ONE_DAY: u64 = 86400; const FOUR_WEEKS: u64 = 2419200; @@ -314,6 +336,8 @@ impl Keyset { kss.keyset.keys(), &ksc.keys_dir, env, + #[cfg(feature = "kmip")] + &mut kss.kmip, )?; kss.keyset .add_key_csk( @@ -337,6 +361,8 @@ impl Keyset { kss.keyset.keys(), &ksc.keys_dir, env, + #[cfg(feature = "kmip")] + &mut kss.kmip, )?; kss.keyset .add_key_ksk( @@ -355,6 +381,8 @@ impl Keyset { kss.keyset.keys(), &ksc.keys_dir, env, + #[cfg(feature = "kmip")] + &mut kss.kmip, )?; kss.keyset .add_key_zsk( @@ -428,6 +456,8 @@ impl Keyset { kss.keyset.keys(), &ksc.keys_dir, env, + #[cfg(feature = "kmip")] + &mut kss.kmip, )?; kss.keyset .add_key_ksk( @@ -453,24 +483,8 @@ impl Keyset { Ok(actions) => actions, Err(e) => { // Remove the key files we just created. - if ksk_priv_url.scheme() == "file" { - remove_file(ksk_priv_url.path()).map_err::(|e| { - format!("unable to remove private key file {ksk_priv_url}: {e}\n") - .into() - })?; - } else { - panic!("unsupported URL scheme in {ksk_priv_url}"); - } - - if ksk_pub_url.scheme() == "file" { - remove_file(ksk_pub_url.path()).map_err::(|e| { - format!("unable to remove public key file {ksk_pub_url}: {e}\n") - .into() - })?; - } else { - panic!("unsupported URL scheme in {ksk_pub_url}"); - } - + remove_key(&mut kss, ksk_priv_url)?; + remove_key(&mut kss, ksk_pub_url)?; return Err(e); } }; @@ -526,6 +540,8 @@ impl Keyset { kss.keyset.keys(), &ksc.keys_dir, env, + #[cfg(feature = "kmip")] + &mut kss.kmip, )?; kss.keyset .add_key_zsk( @@ -551,22 +567,8 @@ impl Keyset { Ok(actions) => actions, Err(e) => { // Remove the key files we just created. - if zsk_priv_url.scheme() == "file" { - remove_file(zsk_priv_url.path()).map_err::(|e| { - format!("unable to remove private key file {zsk_priv_url}: {e}\n") - .into() - })?; - } else { - panic!("unsupported URL scheme in {zsk_priv_url}"); - } - if zsk_pub_url.scheme() == "file" { - remove_file(zsk_pub_url.path()).map_err::(|e| { - format!("unable to remove public key file {zsk_pub_url}: {e}\n") - .into() - })?; - } else { - panic!("unsupported URL scheme in {zsk_pub_url}"); - } + remove_key(&mut kss, zsk_priv_url)?; + remove_key(&mut kss, zsk_pub_url)?; return Err(e); } }; @@ -613,6 +615,8 @@ impl Keyset { kss.keyset.keys(), &ksc.keys_dir, env, + #[cfg(feature = "kmip")] + &mut kss.kmip, )?; new_urls.push(csk_priv_url.clone()); new_urls.push(csk_pub_url.clone()); @@ -642,6 +646,8 @@ impl Keyset { kss.keyset.keys(), &ksc.keys_dir, env, + #[cfg(feature = "kmip")] + &mut kss.kmip, )?; new_urls.push(ksk_priv_url.clone()); new_urls.push(ksk_pub_url.clone()); @@ -666,6 +672,8 @@ impl Keyset { kss.keyset.keys(), &ksc.keys_dir, env, + #[cfg(feature = "kmip")] + &mut kss.kmip, )?; new_urls.push(zsk_priv_url.clone()); new_urls.push(zsk_pub_url.clone()); @@ -698,13 +706,7 @@ impl Keyset { Err(e) => { // Remove the key files we just created. for u in new_urls { - if u.scheme() == "file" { - remove_file(u.path()).map_err::(|e| { - format!("unable to remove private key file {u}: {e}\n").into() - })?; - } else { - panic!("unsupported URL scheme in {u}"); - } + remove_key(&mut kss, u)?; } return Err(e); } @@ -751,6 +753,8 @@ impl Keyset { kss.keyset.keys(), &ksc.keys_dir, env, + #[cfg(feature = "kmip")] + &mut kss.kmip, )?; new_urls.push(csk_priv_url.clone()); new_urls.push(csk_pub_url.clone()); @@ -780,6 +784,8 @@ impl Keyset { kss.keyset.keys(), &ksc.keys_dir, env, + #[cfg(feature = "kmip")] + &mut kss.kmip, )?; new_urls.push(ksk_priv_url.clone()); new_urls.push(ksk_pub_url.clone()); @@ -804,6 +810,8 @@ impl Keyset { kss.keyset.keys(), &ksc.keys_dir, env, + #[cfg(feature = "kmip")] + &mut kss.kmip, )?; new_urls.push(zsk_priv_url.clone()); new_urls.push(zsk_pub_url.clone()); @@ -836,13 +844,7 @@ impl Keyset { Err(e) => { // Remove the key files we just created. for u in new_urls { - if u.scheme() == "file" { - remove_file(u.path()).map_err::(|e| { - format!("unable to private key file {u}: {e}\n").into() - })?; - } else { - panic!("unsupported scheme in {u}"); - } + remove_key(&mut kss, u)?; } return Err(e); } @@ -968,7 +970,7 @@ impl Keyset { // Remove old keys. if ksc.autoremove { - let files: Vec<_> = kss + let key_urls: Vec<_> = kss .keyset .keys() .iter() @@ -983,23 +985,26 @@ impl Keyset { }) .map(|(pubref, key)| (pubref.clone(), key.privref().map(|r| r.to_string()))) .collect(); - if !files.is_empty() { + if !key_urls.is_empty() { print!("Removing:"); - for f in files { - let (pubkey, privkey) = &f; - print!(" {pubkey}"); - kss.keyset.delete_key(pubkey).map_err::(|e| { - format!("unable to remove key {pubkey}: {e}\n").into() - })?; - remove_file(pubkey).map_err::(|e| { - format!("unable to remove file {pubkey}: {e}\n").into() + for u in key_urls { + let (pubref, privkey) = &u; + print!(" {pubref}"); + kss.keyset.delete_key(pubref).map_err::(|e| { + format!("unable to remove key {pubref}: {e}\n").into() })?; + if let Some(privkey) = privkey { - print!(" {privkey}"); - remove_file(privkey).map_err::(|e| { - format!("unable to remove file {privkey}: {e}\n").into() + let priv_url = Url::parse(privkey).map_err::(|e| { + format!("unable to parse {privkey} as URL: {e}").into() })?; + remove_key(&mut kss, priv_url)?; } + + let pub_url = Url::parse(pubref).map_err::(|e| { + format!("unable to parse {pubref} as URL: {e}").into() + })?; + remove_key(&mut kss, pub_url)?; } println!(); } @@ -1130,6 +1135,11 @@ impl Keyset { state_changed = true; } } + + #[cfg(feature = "kmip")] + Commands::Kmip { subcommand } => { + state_changed = kmip_command(env, subcommand, &mut kss)?; + } } let cron_next_dnskey = compute_cron_next(&kss.dnskey_rrset, &ksc.dnskey_remain_time); @@ -1173,6 +1183,31 @@ impl Keyset { } } +#[allow(unused_variables)] +fn remove_key(kss: &mut KeySetState, url: Url) -> Result<(), Error> { + match url.scheme() { + "file" => { + remove_file(url.path()).map_err::(|e| { + format!("unable to remove key file {}: {e}\n", url.path()).into() + })?; + } + + #[cfg(feature = "kmip")] + "kmip" => { + let key_url = KeyUrl::try_from(url)?; + let conn = kss.kmip.get_pool(key_url.server_id())?.get()?; + conn.destroy_key(key_url.key_id()) + .map_err(|e| format!("unable to remove key {key_url}: {e}"))?; + } + + _ => { + panic!("Unsupported URL scheme while removing key {url}"); + } + } + + Ok(()) +} + fn get_command(cmd: GetCommands, ksc: &KeySetConfig, kss: &KeySetState) { match cmd { GetCommands::UseCsk => { @@ -1327,9 +1362,9 @@ struct KeySetConfig { /// Persistent state for the keyset command. #[derive(Deserialize, Serialize)] -struct KeySetState { +pub struct KeySetState { /// Domain KeySet state. - keyset: KeySet, + pub keyset: KeySet, pub dnskey_rrset: Vec, pub ds_rrset: Vec, @@ -1337,6 +1372,11 @@ struct KeySetState { pub ns_rrset: Vec, cron_next: Option, + + /// KMIP related configuration. + #[cfg(feature = "kmip")] + #[serde(default)] + pub kmip: KmipState, } #[derive(Deserialize, Serialize)] @@ -1441,16 +1481,213 @@ fn new_keys( keys: &HashMap, keys_dir: &Path, env: &impl Env, + #[cfg(feature = "kmip")] kmip: &mut KmipState, ) -> Result<(Url, Url, SecurityAlgorithm, u16), Error> { // Generate the key. // TODO: Attempt repeated generation to avoid key tag collisions. // TODO: Add a high-level operation in 'domain' to select flags? let flags = if make_ksk { 257 } else { 256 }; - let mut retries = MAX_KEY_TAG_TRIES; + + // If a default KMIP server is configured, use that to generate keys + #[cfg(feature = "kmip")] + if let Some(kmip_conn_pool) = kmip.get_default_pool()? { + let (key_pair, dnskey) = loop { + // TODO: Fortanix DSM rejects attempts to create keys by names + // that are already taken. Should we be able to detect that case + // specifically and try again with a different name? Should we add + // a random element to each name? Should we keep track of used + // names and detect a collision ourselves when choosing a name? + // Is there some natural differentiator that can be used to name + // keys uniquely other than zone name? + // + // Elements to include in a key name: + // - Application, e.g. Nameshed or NS. + // - Namespace, e.g. prod or test or dev. + // - Key type, e.g. KSK or ZSK. + // - Zone name, e.g. example.com, but also a.b.c.d.f.com + // - Uniqifier, e.g. to differentiate pre-generated keys for + // the same zone. + // + // Max 32 characters seem to be wise as that is the lowest limit + // used amongst PKCS#11 HSM providers for a which a limit is + // known. + // + // Use an overridable naming template? E.g. support placeholders + // such as , and , with a default + // of: + // + // -- + // + // Where is 2 bytes long and is 3 bytes + // long, leaving 32 - '-' - 2 - '-' - 3 = 32 - 7 = 25 bytes for + // , which can be abbreviated if too long by replacing + // the middle with '...' and is a 0 padded positive + // integer in the range 00..99 giving 100 keys to roll the zone + // up to twice a week without needing to use 00 again. + // + // When overridden a user could include fixed namespace and + // application values, e.g.: + // + // NS-PROD--- + // + // Resulting in key names like: + // + // NS-PROD-example.com-001-ksk + // NS-DEV-some.lo...in-name-013-zsk + // (shrunk from NS-DEV-some.long-domain-name-013-zsk) + // 01234567890123456789012345678901 + // + // However, regarding , it may be that pre-generation + // should be accomplished differently, by generating the keys + // outside of dnst keyset and importing them. But it may still + // be useful to consider what to do if a key fails to generate, + // should we retry with an integer value at the end of the zone + // name (within the 32 byte limit - aside: should that limit also + // be configurable?), can we even tell that failure was due to a + // name collision? + // + // Alternate proposals are to use -- or even a random number then re-labeled post-generation + // to include the key tag (which requires the generated key to + // determine). The initial random number is to avoid conflcits if + // re-labeling fails. + // + // And for to be a hexified 16-bit random number that + // we can scan existing keys for to avoid conflict with a key that + // we might have generated before. + // + // And for name truncation to keep the last label (TLD) then remove + // next nearest labels until the name fits the limit, and add an + // extra '.' in to make it clear it was truncated, else keep the + // first n characters. + // + // And to make the max limit be configurable for HSMs that support + // longer than 32 bytes. We could also make the entire label a user + // overridable format/template string. + // + // For now we will do: + // + // 1. Configurable label length limit defaulting to 32 bytes. + // 2. Initial hexified random 32-byte label. + // 3. Relabel to: -<(partial) zone name>--. + + // Generate initial hexified random byte label. + + let server_id = kmip_conn_pool.server_id(); + let key_label_cfg = &mut kmip.servers.get_mut(server_id).unwrap().key_label_config; + + let mut rnadom_bytes = vec![0; key_label_cfg.max_label_bytes as usize]; + rand::fill(&mut rnadom_bytes[..]); + let public_key_random_label = encode_string_hex(&rnadom_bytes); + + let mut random_bytes = vec![0; key_label_cfg.max_label_bytes as usize]; + rand::fill(&mut random_bytes[..]); + let private_key_random_label = encode_string_hex(&random_bytes); + + let key_pair = domain::crypto::kmip::sign::generate( + public_key_random_label, + private_key_random_label, + algorithm.clone(), + flags, + kmip_conn_pool.clone(), + ) + .map_err::(|e| format!("KMIP key generation failed: {e}\n").into())?; + + let dnskey = key_pair.dnskey(); + + if !keys.iter().any(|(_, k)| k.key_tag() == dnskey.key_tag()) { + if key_label_cfg.supports_relabeling { + // Re-label the key now that we know the key tag. + let key_type = match make_ksk { + true => "ksk", + false => "zsk", + }; + + let prefix = &key_label_cfg.prefix; + let key_tag = dnskey.key_tag().to_string(); + let zone_name = name.to_string(); + let max_label_bytes = key_label_cfg.max_label_bytes as usize; + + let public_key_label = format_key_label( + prefix, + &zone_name, + &key_tag, + key_type, + "-pub", + max_label_bytes, + ); + + if let Err(err) = &public_key_label { + warn!("Failed to generate label for public key, key will have a hex label: {err}"); + } + + let private_key_label = format_key_label( + prefix, + &zone_name, + &key_tag, + key_type, + "-pri", + max_label_bytes, + ); + + if let Err(err) = &private_key_label { + warn!("Failed to generate label for private key, key will have a hex label: {err}"); + } + + if let (Ok(public_key_label), Ok(private_key_label)) = + (public_key_label, private_key_label) + { + let conn = kmip_conn_pool.get()?; + // If key generation succeeded then the most likely reason + // for the rename operation to fail is lack of support for + // key relabeling. + match conn.rename_key(key_pair.public_key_id(), public_key_label) { + Ok(_res) => { + // TODO: Inspect the response attributes to see if + // the Modify Attribute operation actually changed + // the attribute as requested? + + // If re-labelling the public key succeeded but + // re-labelling the private key fails, that is + // unexpected. Why would it succeed for one and + // fail for the other? + conn.rename_key(key_pair.private_key_id(), private_key_label) + .map_err::(|e| format!("KMIP key generation failed: failed to re-label private key with id {}: {e}", key_pair.private_key_id()).into())?; + } + Err(err) => { + // Assume that key re-labeling is not supported + // and disable future re-labeling attempts for + // this server. + warn!("KMIP post key generation re-labeling with server '{server_id}' failed, re-labeling will be disabled for this server: {err}"); + key_label_cfg.supports_relabeling = false; + } + } + } + } + + break (key_pair, dnskey); + } + + if retries <= 1 { + return Err("unable to generate key with unique key tag".into()); + } + retries -= 1; + }; + + return Ok(( + key_pair.public_key_url(), + key_pair.private_key_url(), + key_pair.algorithm(), + dnskey.key_tag(), + )); + } + + // Otherwise use Ring/OpenSSL based key generation. let (secret_key, public_key, key_tag) = loop { - let (secret_key, public_key) = sign::generate(algorithm.clone(), flags) - .map_err::(|e| format!("key generation failed: {e}\n").into())?; + let (secret_key, public_key) = + domain::crypto::sign::generate(algorithm.clone(), flags) + .map_err::(|e| format!("key generation failed: {e}\n").into())?; let key_tag = public_key.key_tag(); if !keys.iter().any(|(_, k)| k.key_tag() == key_tag) { @@ -1505,7 +1742,6 @@ fn new_keys( .map_err::(|e| format!("unable to parse {secret_key_url} as URL: {e}").into())?; let public_key_url = Url::parse(&public_key_url) .map_err::(|e| format!("unable to parse {public_key_url} as URL: {e}").into())?; - Ok((public_key_url, secret_key_url, algorithm, key_tag)) } @@ -1526,43 +1762,74 @@ fn update_dnskey_rrset( let pub_url = Url::parse(k).expect("valid URL expected"); if present { - let zonefile = if pub_url.scheme() == "file" { - let path = pub_url.path(); - let filename = env.in_cwd(&path); - let mut file = File::open(&filename).map_err::(|e| { - format!("unable to open public key file {}: {e}", filename.display()).into() - })?; - domain::zonefile::inplace::Zonefile::load(&mut file).map_err::(|e| { - format!("unable load zone from file {}: {e}", filename.display()).into() - })? - } else { - panic!("unsupported scheme in {pub_url}"); - }; - for entry in zonefile { - let entry = entry - .map_err::(|e| format!("bad entry in key file {k}: {e}\n").into())?; + match pub_url.scheme() { + "file" => { + let path = pub_url.path(); + let filename = env.in_cwd(&path); + let mut file = File::open(&filename).map_err::(|e| { + format!( + "update_dnskey_rrset: unable to open public key file {}: {e}", + filename.display() + ) + .into() + })?; + let zonefile = domain::zonefile::inplace::Zonefile::load(&mut file) + .map_err::(|e| { + format!("unable load zone from file {}: {e}", filename.display()).into() + })?; + for entry in zonefile { + let entry = entry.map_err::(|e| { + format!("bad entry in key file {k}: {e}\n").into() + })?; - // We only care about records in a zonefile - let Entry::Record(record) = entry else { - continue; - }; + // We only care about records in a zonefile + let Entry::Record(record) = entry else { + continue; + }; - // Of the records that we see, we only care about DNSKEY records - let ScannedRecordData::Dnskey(dnskey) = record.data() else { - continue; - }; + // Of the records that we see, we only care about DNSKEY records + let ScannedRecordData::Dnskey(dnskey) = record.data() else { + continue; + }; - let record = Record::new( - record - .owner() - .try_to_name::() - .expect("should not fail"), - record.class(), - record.ttl(), - dnskey.clone(), - ); + let record = Record::new( + record + .owner() + .try_to_name::() + .expect("should not fail"), + record.class(), + record.ttl(), + dnskey.clone(), + ); - dnskeys.push(record); + dnskeys.push(record); + } + } + + #[cfg(feature = "kmip")] + "kmip" => { + let kmip_key_url = KeyUrl::try_from(pub_url)?; + let flags = kmip_key_url.flags(); + let kmip_conn_pool = kss.kmip.get_pool(kmip_key_url.server_id())?; + let key = + domain::crypto::kmip::PublicKey::for_key_url(kmip_key_url, kmip_conn_pool) + .map_err(|err| { + format!("Failed to fetch public key for KMIP key URL: {err}") + })?; + let owner = kss.keyset.name().clone().flatten_into(); + // TODO: Where does this TTL come from? + let record = Record::new( + owner, + Class::IN, + Ttl::from_days(1), + key.dnskey(flags).convert(), + ); + dnskeys.push(record); + } + + _ => { + panic!("unsupported scheme in {pub_url}"); + } } } } @@ -1585,42 +1852,67 @@ fn update_dnskey_rrset( if dnskey_signer { let privref = v.privref().ok_or("missing private key")?; let priv_url = Url::parse(privref).expect("valid URL expected"); - let private_data = if priv_url.scheme() == "file" { - std::fs::read_to_string(priv_url.path()).map_err::(|e| { - format!("unable read from file {}: {e}", priv_url.path()).into() - })? - } else { - panic!("unsupported URL scheme in {priv_url}"); - }; - let secret_key = - SecretKeyBytes::parse_from_bind(&private_data).map_err::(|e| { - format!("unable to parse private key file {privref}: {e}").into() - })?; let pub_url = Url::parse(k).expect("valid URL expected"); - let public_data = if pub_url.scheme() == "file" { - std::fs::read_to_string(pub_url.path()).map_err::(|e| { - format!("unable read from file {}: {e}", pub_url.path()).into() - })? - } else { - panic!("unsupported URL scheme in {pub_url}"); - }; - let public_key = parse_from_bind(&public_data).map_err::(|e| { - format!("unable to parse public key file {k}: {e}").into() - })?; + let signing_key = match (priv_url.scheme(), pub_url.scheme()) { + ("file", "file") => { + let private_data = std::fs::read_to_string(priv_url.path()) + .map_err::(|e| { + format!("unable read from file {}: {e}", priv_url.path()).into() + })?; + let secret_key = SecretKeyBytes::parse_from_bind(&private_data) + .map_err::(|e| { + format!("unable to parse private key file {privref}: {e}").into() + })?; + let public_data = if pub_url.scheme() == "file" { + std::fs::read_to_string(pub_url.path()).map_err::(|e| { + format!("unable read from file {}: {e}", pub_url.path()).into() + })? + } else { + panic!("unsupported URL scheme in {pub_url}"); + }; + let public_key = parse_from_bind(&public_data).map_err::(|e| { + format!("unable to parse public key file {k}: {e}").into() + })?; - let key_pair = KeyPair::from_bytes(&secret_key, public_key.data()) - .map_err::(|e| { - format!("private key {privref} and public key {k} do not match: {e}").into() - })?; - let signing_key = SigningKey::new( - public_key.owner().clone(), - public_key.data().flags(), - key_pair, - ); - let sig = sign_rrset::<_, _, Bytes, _>(&signing_key, &rrset, inception, expiration) - .map_err::(|e| { - format!("error signing DNSKEY RRset with private key {privref}: {e}").into() - })?; + let key_pair = KeyPair::from_bytes(&secret_key, public_key.data()) + .map_err::(|e| { + format!("private key {privref} and public key {k} do not match: {e}") + .into() + })?; + SigningKey::new( + public_key.owner().clone(), + public_key.data().flags(), + key_pair, + ) + } + + #[cfg(feature = "kmip")] + ("kmip", "kmip") => { + let owner = kss.keyset.name().clone().flatten_into(); + let priv_key_url = KeyUrl::try_from(priv_url)?; + let pub_key_url = KeyUrl::try_from(pub_url)?; + let flags = priv_key_url.flags(); + let kmip_conn_pool = kss.kmip.get_pool(priv_key_url.server_id())?; + let key_pair = domain::crypto::kmip::sign::KeyPair::from_urls( + priv_key_url, + pub_key_url, + kmip_conn_pool, + ) + .map_err(|err| format!("Failed to retrieve KMIP key by URL: {err}"))?; + let key_pair = KeyPair::Kmip(key_pair); + SigningKey::new(owner, flags, key_pair) + } + + (priv_scheme, pub_scheme) => { + panic!("unsupported URL scheme combination: {priv_scheme} & {pub_scheme}"); + } + }; + + // TODO: Should there be a key not found error we can detect here so that we can retry if + // we believe that the key is simply not registered fully yet in the HSM? + let sig = sign_rrset(&signing_key, &rrset, inception, expiration).map_err::( + |e| format!("error signing DNSKEY RRset with private key {privref}: {e}").into(), + )?; sigs.push(sig); } } @@ -1656,72 +1948,53 @@ fn create_cds_rrset( if at_parent { let pub_url = Url::parse(k).expect("valid URL expected"); - let path = pub_url.path(); - let filename = env.in_cwd(&path); - let mut file = File::open(&filename).map_err::(|e| { - format!("unable to open public key file {}: {e}", filename.display()).into() - })?; - let zonefile = domain::zonefile::inplace::Zonefile::load(&mut file) - .map_err::(|e| { - format!("unable to read zone from file {}: {e}", filename.display()).into() - })?; - for entry in zonefile { - let entry = entry - .map_err::(|e| format!("bad entry in key file {k}: {e}\n").into())?; - - // We only care about records in a zonefile - let Entry::Record(record) = entry else { - continue; - }; - - // Of the records that we see, we only care about DNSKEY records - let ScannedRecordData::Dnskey(dnskey) = record.data() else { - continue; - }; - - let cdnskey = Cdnskey::new( - dnskey.flags(), - dnskey.protocol(), - dnskey.algorithm(), - dnskey.public_key().clone(), - ) - .expect("should not fail"); - let cdnskey_record = Record::new( - record - .owner() - .try_to_name::() - .expect("should not fail"), - record.class(), - record.ttl(), - cdnskey, - ); - - cdnskey_list.push(cdnskey_record); - - let key_tag = dnskey.key_tag(); - let sec_alg = dnskey.algorithm(); - - let digest = dnskey - .digest(&record.owner(), digest_alg) - .map_err::(|e| { - format!("error creating digest for DNSKEY record: {e}").into() + match pub_url.scheme() { + "file" => { + let path = pub_url.path(); + let filename = env.in_cwd(&path); + let mut file = File::open(&filename).map_err::(|e| { + format!("unable to open public key file {}: {e}", filename.display()).into() })?; + let zonefile = domain::zonefile::inplace::Zonefile::load(&mut file) + .map_err::(|e| { + format!("unable to read zone from file {}: {e}", filename.display()) + .into() + })?; + for entry in zonefile { + let entry = entry.map_err::(|e| { + format!("bad entry in key file {k}: {e}\n").into() + })?; - let cds = Cds::new(key_tag, sec_alg, digest_alg, digest.as_ref().to_vec()).expect( - "Infallible because the digest won't be too long since it's a valid digest", - ); + // We only care about records in a zonefile + let Entry::Record(record) = entry else { + continue; + }; - let cds_record = Record::new( - record - .owner() - .try_to_name::() - .expect("should not fail"), - record.class(), - record.ttl(), - cds, - ); + create_cds_rrset_helper( + digest_alg, + &mut cds_list, + &mut cdnskey_list, + record.flatten_into(), + )?; + } + } - cds_list.push(cds_record); + #[cfg(feature = "kmip")] + "kmip" => { + let key_url = KeyUrl::try_from(pub_url)?; + let flags = key_url.flags(); + let conn_pool = kss.kmip.get_pool(key_url.server_id())?; + let public_key = + domain::crypto::kmip::PublicKey::for_key_url(key_url, conn_pool) + .map_err(|err| format!("Failed to look up KMIP public key: {err}"))?; + let dnskey = public_key.dnskey(flags); + let dnskey = ZoneRecordData::Dnskey(dnskey.convert()); + let owner = kss.keyset.name().clone().flatten_into(); + let record = Record::new(owner, Class::IN, Ttl::from_days(1), dnskey); + create_cds_rrset_helper(digest_alg, &mut cds_list, &mut cdnskey_list, record)?; + } + + _ => panic!("unsupported scheme in {pub_url}"), } } @@ -1750,47 +2023,75 @@ fn create_cds_rrset( if dnskey_signer { let privref = v.privref().ok_or("missing private key")?; let priv_url = Url::parse(privref).expect("valid URL expected"); - let path = priv_url.path(); - let filename = env.in_cwd(&path); - let private_data = std::fs::read_to_string(&filename).map_err::(|e| { - format!( - "unable to read from private key file {}: {e}", - filename.display() - ) - .into() - })?; - let secret_key = - SecretKeyBytes::parse_from_bind(&private_data).map_err::(|e| { - format!( - "unable to parse private key file {}: {e}", - filename.display() - ) - .into() - })?; let pub_url = Url::parse(k).expect("valid URL expected"); - let path = pub_url.path(); - let filename = env.in_cwd(&path); - let public_data = std::fs::read_to_string(&filename).map_err::(|e| { - format!( - "unable to read from public key file {}: {e}", - filename.display() - ) - .into() - })?; - let public_key = parse_from_bind(&public_data).map_err::(|e| { - format!("unable to parse public key file {k}: {e}").into() - })?; + let signing_key = match (priv_url.scheme(), pub_url.scheme()) { + ("file", "file") => { + let path = priv_url.path(); + let filename = env.in_cwd(&path); + let private_data = + std::fs::read_to_string(&filename).map_err::(|e| { + format!( + "unable to read from private key file {}: {e}", + filename.display() + ) + .into() + })?; + let secret_key = SecretKeyBytes::parse_from_bind(&private_data) + .map_err::(|e| { + format!( + "unable to parse private key file {}: {e}", + filename.display() + ) + .into() + })?; + let path = pub_url.path(); + let filename = env.in_cwd(&path); + let public_data = + std::fs::read_to_string(&filename).map_err::(|e| { + format!( + "unable to read from public key file {}: {e}", + filename.display() + ) + .into() + })?; + let public_key = parse_from_bind(&public_data).map_err::(|e| { + format!("unable to parse public key file {k}: {e}").into() + })?; - let key_pair = KeyPair::from_bytes(&secret_key, public_key.data()) - .map_err::(|e| { - format!("private key {privref} and public key {k} do not match: {e}").into() - })?; - let signing_key = SigningKey::new( - public_key.owner().clone(), - public_key.data().flags(), - key_pair, - ); - let sig = sign_rrset::<_, _, Bytes, _>(&signing_key, &cds_rrset, inception, expiration) + let key_pair = KeyPair::from_bytes(&secret_key, public_key.data()) + .map_err::(|e| { + format!("private key {privref} and public key {k} do not match: {e}") + .into() + })?; + SigningKey::new( + public_key.owner().clone(), + public_key.data().flags(), + key_pair, + ) + } + + #[cfg(feature = "kmip")] + ("kmip", "kmip") => { + let owner = kss.keyset.name().clone().flatten_into(); + let priv_key_url = KeyUrl::try_from(priv_url)?; + let pub_key_url = KeyUrl::try_from(pub_url)?; + let flags = priv_key_url.flags(); + let kmip_conn_pool = kss.kmip.get_pool(priv_key_url.server_id())?; + let key_pair = domain::crypto::kmip::sign::KeyPair::from_urls( + priv_key_url, + pub_key_url, + kmip_conn_pool, + ) + .map_err(|err| format!("Failed to retrieve KMIP key by URL: {err}"))?; + let key_pair = KeyPair::Kmip(key_pair); + SigningKey::new(owner, flags, key_pair) + } + + (priv_scheme, pub_scheme) => { + panic!("unsupported URL scheme combination: {priv_scheme} & {pub_scheme}"); + } + }; + let sig = sign_rrset(&signing_key, &cds_rrset, inception, expiration) .map_err::(|e| { format!("error signing CDS RRset with private key {privref}: {e}").into() })?; @@ -1826,6 +2127,38 @@ fn create_cds_rrset( Ok(()) } +fn create_cds_rrset_helper( + digest_alg: DigestAlgorithm, + cds_list: &mut Vec, Cds>>>, + cdnskey_list: &mut Vec, Cdnskey>>>, + record: Record, ZoneRecordData>>, +) -> Result<(), Error> { + let owner = record.owner().clone(); + let ZoneRecordData::Dnskey(dnskey) = record.data() else { + return Ok(()); + }; + let dnskey: Dnskey> = dnskey.clone().convert(); + let cdnskey = Cdnskey::new( + dnskey.flags(), + dnskey.protocol(), + dnskey.algorithm(), + dnskey.public_key().clone(), + ) + .expect("should not fail"); + let cdnskey_record = Record::new(owner.clone(), record.class(), record.ttl(), cdnskey); + cdnskey_list.push(cdnskey_record); + let key_tag = dnskey.key_tag(); + let sec_alg = dnskey.algorithm(); + let digest = dnskey + .digest(&record.owner(), digest_alg) + .map_err::(|e| format!("error creating digest for DNSKEY record: {e}").into())?; + let cds = Cds::new(key_tag, sec_alg, digest_alg, digest.as_ref().to_vec()) + .expect("Infallible because the digest won't be too long since it's a valid digest"); + let cds_record = Record::new(owner, record.class(), record.ttl(), cds); + cds_list.push(cds_record); + Ok(()) +} + fn remove_cds_rrset(kss: &mut KeySetState) { kss.cds_rrset.truncate(0); } @@ -1835,7 +2168,8 @@ fn update_ds_rrset( digest_alg: DigestAlgorithm, env: &impl Env, ) -> Result<(), Error> { - let mut ds_list = Vec::new(); + #[allow(clippy::type_complexity)] + let mut ds_list: Vec>, Ds>>> = Vec::new(); for (k, v) in kss.keyset.keys() { let at_parent = match v.keytype() { KeyType::Ksk(key_state) => key_state.at_parent(), @@ -1846,46 +2180,93 @@ fn update_ds_rrset( if at_parent { let pub_url = Url::parse(k).expect("valid URL expected"); - let path = pub_url.path(); - let filename = env.in_cwd(&path); - let mut file = File::open(&filename).map_err::(|e| { - format!("unable to open public key file {}: {e}", filename.display()).into() - })?; - let zonefile = domain::zonefile::inplace::Zonefile::load(&mut file) - .map_err::(|e| { - format!("unable to read zone from file {}: {e}", filename.display()).into() - })?; - for entry in zonefile { - let entry = entry - .map_err::(|e| format!("bad entry in key file {k}: {e}\n").into())?; - - // We only care about records in a zonefile - let Entry::Record(record) = entry else { - continue; - }; - - // Of the records that we see, we only care about DNSKEY records - let ScannedRecordData::Dnskey(dnskey) = record.data() else { - continue; - }; - - let key_tag = dnskey.key_tag(); - let sec_alg = dnskey.algorithm(); - - let digest = dnskey - .digest(&record.owner(), digest_alg) - .map_err::(|e| { - format!("error creating digest for DNSKEY record: {e}").into() + match pub_url.scheme() { + "file" => { + let path = pub_url.path(); + let filename = env.in_cwd(&path); + let mut file = File::open(&filename).map_err::(|e| { + format!( + "update_ds_rrset: unable to open public key file {}: {e}", + filename.display() + ) + .into() })?; + let zonefile = domain::zonefile::inplace::Zonefile::load(&mut file) + .map_err::(|e| { + format!("unable to read zone from file {}: {e}", filename.display()) + .into() + })?; + for entry in zonefile { + let entry = entry.map_err::(|e| { + format!("bad entry in key file {k}: {e}\n").into() + })?; - let ds = Ds::new(key_tag, sec_alg, digest_alg, digest.as_ref().to_vec()).expect( - "Infallible because the digest won't be too long since it's a valid digest", - ); + // We only care about records in a zonefile + let Entry::Record(record) = entry else { + continue; + }; - let ds_record = - Record::new(record.owner().clone(), record.class(), record.ttl(), ds); + // Of the records that we see, we only care about DNSKEY records + let ScannedRecordData::Dnskey(dnskey) = record.data() else { + continue; + }; - ds_list.push(ds_record); + let digest = dnskey + .digest(&record.owner(), digest_alg) + .map_err::(|e| { + format!("error creating digest for DNSKEY record: {e}").into() + })?; + + let ds = Ds::new(dnskey.key_tag(), dnskey.algorithm(), digest_alg, digest.as_ref().to_vec()).expect( + "Infallible because the digest won't be too long since it's a valid digest", + ); + + let ds_record = Record::new( + record.owner().clone().flatten_into(), + record.class(), + record.ttl(), + ds, + ); + + ds_list.push(ds_record); + } + } + + #[cfg(feature = "kmip")] + "kmip" => { + let key_url = KeyUrl::try_from(pub_url)?; + let flags = key_url.flags(); + let conn_pool = kss.kmip.get_pool(key_url.server_id())?; + let public_key = + domain::crypto::kmip::PublicKey::for_key_url(key_url, conn_pool) + .map_err(|err| format!("Failed to look up KMIP public key: {err}"))?; + let dnskey = public_key.dnskey(flags); + let owner: Name> = kss.keyset.name().clone().flatten_into(); + let record = + Record::new(owner.clone(), Class::IN, Ttl::from_days(1), dnskey.clone()); + + let digest = dnskey + .digest(&record.owner(), digest_alg) + .map_err::(|e| { + format!("error creating digest for DNSKEY record: {e}").into() + })?; + + let ds = Ds::new( + dnskey.key_tag(), + dnskey.algorithm(), + digest_alg, + digest.as_ref().to_vec(), + ) + .expect( + "Infallible because the digest won't be too long since it's a valid digest", + ); + + let ds_record = Record::new(owner, record.class(), record.ttl(), ds); + + ds_list.push(ds_record); + } + + _ => panic!("unsupported scheme in {pub_url}"), } } } @@ -1940,7 +2321,7 @@ fn print_actions(actions: &[Action]) { } } -fn parse_duration(value: &str) -> Result { +pub fn parse_duration(value: &str) -> Result { let span: Span = value .parse() .map_err::(|e| format!("unable to parse {value} as lifetime: {e}\n").into())?; diff --git a/src/commands/keyset/kmip.rs b/src/commands/keyset/kmip.rs new file mode 100644 index 0000000..9aeaaed --- /dev/null +++ b/src/commands/keyset/kmip.rs @@ -0,0 +1,1840 @@ +//! KMIP support for the keyset subcommand. +//! +//! KMIP (OASIS Key Management Interoperability Protocol) is a specification +//! for communicating with HSMs (Hardware Security Modules) that implement +//! secure cryptographic key generation and signing of data using generated +//! keys. +//! +//! The functions and types in this module are used to extend `dnst keyset` to +//! support KMIP based cryptographic keys as well as the default Ring/OpenSSL +//! based keys. + +// Note: Currently this is only used by `dnst keyset` but one can imagine it +// also being used by `dnst keygen`, `dnst key2ds` and `dnst signzone`. It may +// make sense to move the pure KMIP content from here to say src/kmip.rs and +// only keep the `dnst keyset` specific KMIP content in this module. One would +// also then need a way to configure which KMIP server the other subcommands +// should use and might want to also at that point consider a `dnst`-wide +// config mechanism for KMIP servers, e.g. `dnst kmip` or `dnst cfg kmip` or +// something. + +use std::{ + collections::HashMap, + fmt::Formatter, + fs::{File, OpenOptions}, + io::{BufReader, BufWriter, Seek, SeekFrom, Write}, + ops::Not, + path::{Path, PathBuf}, + str::FromStr, + time::Duration, +}; + +use clap::{arg, Subcommand}; +use domain::{ + base::{name::ToLabelIter, Name, NameBuilder}, + crypto::kmip::{ClientCertificate, ConnectionSettings, KeyUrl}, + dep::kmip::client::pool::{ConnectionManager, KmipConnError, SyncConnPool}, +}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::{ + commands::keyset::{parse_duration, KeySetState}, + env::Env, + error::Error, +}; + +/// The default TCP port on which to connect to a KMIP server as defined by +/// IANA. +// TODO: Move this to the `kmip-protocol` crate? +pub const DEF_KMIP_PORT: u16 = 5696; + +//------------ KmipCommands -------------------------------------------------- + +/// Commands for configuring the use of KMIP compatible HSMs for key +/// generation and signing instead of or in addition to using and Ring/OpenSSL +/// based key generation and signing. +#[allow(clippy::large_enum_variant)] +#[derive(Clone, Debug, Subcommand)] +pub enum KmipCommands { + /// Disable use of KMIP for generating new keys. + /// + /// Existing KMIP keys will still work as normal, but any new keys will + /// be generated using Ring/OpenSSL whether or not KMIP servers are + /// configured. + /// + /// To re-enable KMIP use: kmip set-default-server. + Disable, + + /// Add a KMIP server to use for key generation & signing. + /// + /// If this is the first KMIP server to be configured it will be used to + /// generate new keys instead of using Ring/OpenSSL based key generation. + /// + /// If this is NOT the first KMIP server to be configured, the new server + /// will NOT be used to generate keys unless configured to do so by + /// using: kmip set-default-server. + AddServer { + /// An identifier to refer to the KMIP server by. + /// + /// This identifier is used in KMIP key URLs. The identifier serves + /// several purposes: + /// + /// 1. To make it easy at a glance to recognize which KMIP server a + /// given key was created on, by allowing operators to assign a + /// meaningful name to the server instead of whatever identity + /// strings the server associates with itself or by using hostnames + /// or IP addresses as identifiers. + /// + /// 2. To refer to additional configuration elsewhere to avoid + /// including sensitive and/or verbose KMIP server credential or + /// TLS client certificate/key authentication data in the URL, + /// and which would be repeated in every key created on the same + /// server. + /// + /// 3. To allow the actual location of the server and/or its access + /// credentials to be rotated without affecting the key URLs, e.g. + /// if a server is assigned a new IP address or if access + /// credentials change. + /// + /// The downside of this is that consumers of the key URL must also + /// possess the additional configuration settings and be able to fetch + /// them based on the same server identifier. + server_id: String, + + /// The hostname or IP address of the KMIP server. + ip_host_or_fqdn: String, + + /// TCP port to connect to the KMIP server on. + #[arg(help_heading = "Server", long = "port", default_value_t = DEF_KMIP_PORT)] + port: u16, + + /// Optional path to a JSON file to read/write username/password credentials from/to. + /// + /// The format of the file (at the time of writing) is like so: + /// { + /// "server_id": { + /// "username": "xxxx", + /// "password": "yyyy", + /// } + /// [, "another_server_id": { ... }] + /// } + #[arg(help_heading = "Client Credentials", long = "credential-store")] + credentials_store_path: Option, + + /// Optional username to authenticate to the KMIP server as. + #[arg( + help_heading = "Client Credentials", + long = "username", + requires = "credentials_store_path" + )] + username: Option, + + /// Optional password to authenticate to the KMIP server with. + #[arg( + help_heading = "Client Credentials", + long = "password", + requires = "username" + )] + password: Option, + + /// Optional path to a TLS certificate to authenticate to the KMIP + /// server with. + #[arg( + help_heading = "Client Certificate Authentication", + long = "client-cert", + requires = "client_key_path" + )] + client_cert_path: Option, + + /// Optional path to a private key for client certificate + /// authentication. + /// + /// The private key is needed to be able to prove to the KMIP server + /// that you are the owner of the provided TLS client certificate. + #[arg( + help_heading = "Client Certificate Authentication", + long = "client-key", + requires = "client_cert_path" + )] + client_key_path: Option, + + /// Whether or not to accept the KMIP server TLS certificate without + /// verifying it. + /// + /// Set to false if using a self-signed TLS certificate, e.g. in a + /// test environment. + #[arg(help_heading = "Server Certificate Verification", long = "insecure", default_value_t = false, action = clap::ArgAction::SetTrue)] + insecure: bool, + + /// Optional path to a TLS PEM certificate for the server. + #[arg(help_heading = "Server Certificate Verification", long = "server-cert")] + server_cert_path: Option, + + /// Optional path to a TLS PEM certificate for a Certificate Authority. + #[arg(help_heading = "Server Certificate Verification", long = "ca-cert")] + ca_cert_path: Option, + + /// TCP connect timeout. + // Note: This should be low otherwise the CLI user experience when + // running a command that interacts with a KMIP server, like `dnst + // init`, is that the command hangs if the KMIP server is not running + // or not reachable, until the timeout expires, and one would expect + // that under normal circumstances establishing a TCP connection to + // the KMIP server should be quite quick. + // Note: Does this also include time for TLS setup? + #[arg(help_heading = "Client Limits", long = "connect-timeout", value_parser = parse_duration, default_value = "3s")] + connect_timeout: Duration, + + /// TCP response read timeout. + // Note: This should be high otherwise for HSMs that are slow to + // respond, like the YubiHSM, we time out the connection while waiting + // for the response when generating keys. + #[arg(help_heading = "Client Limits", long = "read-timeout", value_parser = parse_duration, default_value = "30s")] + read_timeout: Duration, + + /// TCP request write timeout. + #[arg(help_heading = "Client Limits", long = "write-timeout", value_parser = parse_duration, default_value = "3s")] + write_timeout: Duration, + + /// Maximum KMIP response size to accept (in bytes). + #[arg( + help_heading = "Client Limits", + long = "max-response-bytes", + default_value_t = 8192 + )] + max_response_bytes: u32, + + /// Optional user supplied key label prefix. + /// + /// Can be used to denote the s/w that created the key, and/or to + /// indicate which installation/environment it belongs to, e.g. dev, + /// test, prod, etc. + #[arg(help_heading = "Key Labels", long = "key-label-prefix")] + key_label_prefix: Option, + + /// Maximum label length (in bytes) permitted by the HSM. + #[arg( + help_heading = "Key Labels", + long = "key-label-max-bytes", + default_value_t = 32 + )] + key_label_max_bytes: u8, + }, + + /// Modify an existing KMIP server configuration. + ModifyServer { + /// The identifier of the KMIP server. + server_id: String, + + /// Modify the hostname or IP address of the KMIP server. + #[arg(help_heading = "Server", long = "address")] + ip_host_or_fqdn: Option, + + /// Modify the TCP port to connect to the KMIP server on. + #[arg(help_heading = "Server", long = "port")] + port: Option, + + /// Disable use of username / password authentication. + /// + /// Note: This will remove any credentials from the credential-store + /// for this server id. + #[arg(help_heading = "Client Credentials", long = "no-credentials", action = clap::ArgAction::SetTrue)] + no_credentials: bool, + + /// Modify the path to a JSON file to read/write username/password + /// credentials from/to. + #[arg(help_heading = "Client Credentials", long = "credential-store")] + credentials_store_path: Option, + + /// Modifyt the username to authenticate to the KMIP server as. + #[arg(help_heading = "Client Credentials", long = "username")] + username: Option, + + /// Modify the password to authenticate to the KMIP server with. + #[arg(help_heading = "Client Credentials", long = "password")] + password: Option, + + /// Disable use of TLS client certificate authentication. + #[arg(help_heading = "Client Certificate Authentication", long = "no-client-auth", action = clap::ArgAction::SetTrue)] + no_client_auth: bool, + + /// Modify the path to the TLS certificate to authenticate to the KMIP + /// server with. + #[arg( + help_heading = "Client Certificate Authentication", + long = "client-cert" + )] + client_cert_path: Option, + + /// Modify the path to the private key for client certificate + /// authentication. + #[arg( + help_heading = "Client Certificate Authentication", + long = "client-key" + )] + client_key_path: Option, + + /// Modify whether or not to accept the KMIP server TLS certificate + /// without verifying it. + #[arg(help_heading = "Server Certificate Verification", long = "insecure")] + insecure: Option, + + /// Modify the path to a TLS PEM certificate for the server. + #[arg(help_heading = "Server Certificate Verification", long = "server-cert")] + server_cert_path: Option, + + /// Optional path to a TLS PEM certificate for a Certificate Authority. + #[arg(help_heading = "Server Certificate Verification", long = "ca-cert")] + ca_cert_path: Option, + + /// Modify the TCP connect timeout. + #[arg(help_heading = "Client Limits", long = "connect-timeout", value_parser = parse_duration)] + connect_timeout: Option, + + /// Modify the TCP response read timeout. + #[arg(help_heading = "Client Limits", long = "read-timeout", value_parser = parse_duration)] + read_timeout: Option, + + /// Modify the TCP request write timeout. + #[arg(help_heading = "Client Limits", long = "write-timeout", value_parser = parse_duration)] + write_timeout: Option, + + /// Modify the maximum KMIP response size to accept (in bytes). + #[arg(help_heading = "Client Limits", long = "max-response-bytes")] + max_response_bytes: Option, + + /// Optional user supplied key label prefix. + /// + /// Can be used to denote the s/w that created the key, and/or to + /// indicate which installation/environment it belongs to, e.g. dev, + /// test, prod, etc. + #[arg(help_heading = "Key Labels", long = "key-label-prefix")] + key_label_prefix: Option, + + /// Maximum label length (in bytes) permitted by the HSM. + #[arg(help_heading = "Key Labels", long = "key-label-max-bytes")] + key_label_max_bytes: Option, + }, + + /// Remove an existing non-default KMIP server. + /// + /// To remove the default KMIP server use `kmip disable` first. + RemoveServer { + /// The identifier of the KMIP server to remove. + server_id: String, + }, + + /// Set the default KMIP server to use for key generation. + SetDefaultServer { + /// The identifier of the KMIP server to use as the default. + server_id: String, + }, + + /// Get the details of an existing KMIP server. + GetServer { + /// The identifier of the KMIP server to get. + server_id: String, + }, + + /// List all configured KMIP servers. + ListServers, +} + +//------------ kmip_command() ------------------------------------------------ + +/// Process a `dnst keyset kmip` command. +pub fn kmip_command( + env: &impl Env, + cmd: KmipCommands, + kss: &mut KeySetState, +) -> Result { + match cmd { + KmipCommands::Disable => { + kss.kmip.default_server_id = None; + } + + KmipCommands::AddServer { + server_id, + ip_host_or_fqdn, + port, + credentials_store_path, + username, + password, + client_cert_path, + client_key_path, + insecure, + server_cert_path, + ca_cert_path, + connect_timeout, + read_timeout, + write_timeout, + max_response_bytes, + key_label_prefix, + key_label_max_bytes, + } => { + // Handle only the valid cases. Let Clap reject the invalid cases + // with a helpful error message, e.g. password without username is + // not allowed. + + let credentials = match (credentials_store_path, username, password) { + (Some(credentials_store_path), Some(username), password) => { + Some(KmipClientCredentialsConfig { + credentials_store_path, + credentials: Some(KmipClientCredentials { username, password }), + }) + } + (Some(credentials_store_path), _, _) => Some(KmipClientCredentialsConfig { + credentials_store_path, + credentials: None, + }), + _ => None, + }; + + let client_auth = match (client_cert_path, client_key_path) { + (Some(cert_path), Some(private_key_path)) => { + Some(KmipClientTlsCertificateAuthConfig { + cert_path, + private_key_path, + }) + } + _ => None, + }; + + let server_auth = KmipServerTlsCertificateVerificationConfig { + verify_certificate: insecure.not(), + server_cert_path, + ca_cert_path, + }; + + let limits = KmipClientLimits { + connect_timeout, + read_timeout, + write_timeout, + max_response_bytes, + }; + + let key_label_cfg = KeyLabelConfig { + max_label_bytes: key_label_max_bytes, + supports_relabeling: true, + prefix: key_label_prefix.unwrap_or_default(), + }; + + add_kmip_server( + &mut kss.kmip, + server_id, + ip_host_or_fqdn, + port, + credentials, + client_auth, + server_auth, + limits, + key_label_cfg, + )?; + } + + KmipCommands::ModifyServer { + server_id, + ip_host_or_fqdn, + port, + no_credentials, + credentials_store_path, + username, + password, + no_client_auth, + client_cert_path, + client_key_path, + insecure, + server_cert_path, + ca_cert_path, + connect_timeout, + read_timeout, + write_timeout, + max_response_bytes, + key_label_prefix, + key_label_max_bytes, + } => { + let mut crl_credentials_store_path = ChangeRemoveLeave::Leave; + let mut crl_username = ChangeRemoveLeave::Leave; + let mut crl_password = ChangeRemoveLeave::Leave; + let mut crl_client_cert_path = ChangeRemoveLeave::Leave; + let mut crl_client_key_path = ChangeRemoveLeave::Leave; + let mut crl_server_cert_path = ChangeRemoveLeave::Leave; + let mut crl_ca_cert_path = ChangeRemoveLeave::Leave; + + if no_credentials { + crl_credentials_store_path = ChangeRemoveLeave::Remove; + crl_username = ChangeRemoveLeave::Remove; + crl_password = ChangeRemoveLeave::Remove; + } else { + if let Some(v) = credentials_store_path { + crl_credentials_store_path = ChangeRemoveLeave::Change(v); + } + if let Some(v) = username { + crl_username = ChangeRemoveLeave::Change(v); + } + if let Some(v) = password { + crl_password = ChangeRemoveLeave::Change(v); + } + } + + if no_client_auth { + crl_client_cert_path = ChangeRemoveLeave::Remove; + crl_client_key_path = ChangeRemoveLeave::Remove; + } else { + if let Some(v) = client_cert_path { + crl_client_cert_path = ChangeRemoveLeave::Change(v); + } + if let Some(v) = client_key_path { + crl_client_key_path = ChangeRemoveLeave::Change(v); + } + } + + if let Some(v) = server_cert_path { + crl_server_cert_path = ChangeRemoveLeave::Change(v); + } + if let Some(v) = ca_cert_path { + crl_ca_cert_path = ChangeRemoveLeave::Change(v); + } + + modify_kmip_server( + &mut kss.kmip, + &server_id, + ip_host_or_fqdn, + port, + crl_credentials_store_path, + crl_username, + crl_password, + crl_client_cert_path, + crl_client_key_path, + insecure, + crl_server_cert_path, + crl_ca_cert_path, + connect_timeout, + read_timeout, + write_timeout, + max_response_bytes, + key_label_prefix, + key_label_max_bytes, + ) + .map_err(|err| { + Error::new(&format!( + "unable to modify configuration for KMIP server '{server_id}': {err}" + )) + })?; + } + + KmipCommands::RemoveServer { server_id } => { + remove_kmip_server(kss, server_id)?; + } + + KmipCommands::SetDefaultServer { server_id } => { + if !kss.kmip.servers.contains_key(&server_id) { + return Err(format!("KMIP server id '{server_id}' is not known").into()); + } + kss.kmip.default_server_id = Some(server_id); + } + + KmipCommands::GetServer { server_id } => { + let Some(server) = kss.kmip.servers.get(&server_id) else { + return Err(format!("KMIP server id '{server_id}' is not known").into()); + }; + + write!(env.stdout(), "{server}"); + + return Ok(false); + } + + KmipCommands::ListServers => { + write!(env.stdout(), "{}", &kss.kmip); + return Ok(false); + } + } + + Ok(true) +} + +//------------- remove_kmip_server() ----------------------------------------- + +/// Remove a KMIP server and its credentials. +/// +/// Removes the specified KMIP server from the configuration, and any +/// associated referenced credentials. +/// +/// Returns an error if: +/// - The KMIP server is the current default. +/// - The KMIP server is in use by any known keys. +/// - A referenced credentials file could not be updated to remove +/// credentials for the server being removed. +fn remove_kmip_server(kss: &mut KeySetState, server_id: String) -> Result<(), Error> { + if kss.kmip.default_server_id.as_ref() == Some(&server_id) { + return Err(format!( + "KMIP server '{server_id}' cannot be removed as it is the current default. Use kmip disable first." + ) + .into()); + } + + if kss.keyset.keys().iter().any(|(key_url_str, _)| { + if let Ok(url) = Url::parse(key_url_str) { + if let Ok(key_url) = KeyUrl::try_from(url) { + if key_url.server_id() == server_id { + return true; + } + } + } + false + }) { + return Err(format!( + "KMIP server '{server_id}' cannot be removed as there are still keys using it." + ) + .into()); + } + + let removed = kss.kmip.servers.remove(&server_id); + + if let Some(credentials_path) = removed.and_then(|s| s.client_credentials_path) { + let _ = remove_kmip_client_credentials(&server_id, &credentials_path)?; + } + + Ok(()) +} + +/// Remove credentials from a file, removing the file entirely if then empty. +fn remove_kmip_client_credentials( + server_id: &str, + credentials_path: &Path, +) -> Result { + let mut credentials_file = + KmipClientCredentialsFile::new(credentials_path, KmipServerCredentialsFileMode::ReadWrite)?; + + let removed_creds = credentials_file.remove(server_id).ok_or(Error::new(&format!("unable to remove credentials for KMIP server '{server_id}' from credentials file {}: server id does not exist in the file", credentials_path.display())))?; + + credentials_file.save()?; + + if credentials_file.is_empty() { + drop(credentials_file); + std::fs::remove_file(credentials_path).map_err(|e| { + Error::new(&format!( + "unable to remove empty credentials file {} for KMIP server '{server_id}': {e}", + credentials_path.display(), + )) + })?; + } + + Ok(removed_creds) +} + +//------------ add_kmip_server() --------------------------------------------- + +/// Adds a KMIP server to the configured set. +/// +/// Sensitive credentials must be referenced from separate files, we do not +/// allow them to be stored directly in the main configuration. +/// +/// To make it easier for users to store username/password credentials we +/// support writing them to the JSON file for the user using credentials +/// specified on the command line. We also support reading from a pre-existing +/// JSON credentials file, assuming a user was able to create one by hand. +/// +/// The format of the file (at the time of writing) is like so: +/// +/// { +/// "server_id": { +/// "username": "xxxx", +/// "password": "yyyy", +/// } +/// } +/// +/// Note: We do not (yet?) support protection against accidental leakage of +/// secrets in memory (e.g. via the secrecy crate) because the secrecy crate +/// SecretBox type cannot be cloned, thus would have to be both read from disk +/// for every request, and doing so would need to be supported all the way/ +/// down to the KMIP message wire serialization in the kmip-protocol crate, +/// plus the crate explicitly warns against creating a Serde Serialize impl +/// for SecretBox'd data and so requires you to manually impl that yourself. +#[allow(clippy::too_many_arguments)] +fn add_kmip_server( + kmip: &mut KmipState, + server_id: String, + ip_host_or_fqdn: String, + port: u16, + credentials: Option, + client_cert_auth: Option, + server_cert_verification: KmipServerTlsCertificateVerificationConfig, + client_limits: KmipClientLimits, + key_label_config: KeyLabelConfig, +) -> Result<(), Error> { + if kmip.servers.contains_key(&server_id) { + return Err(Error::new(&format!( + "unable to add KMIP server '{server_id}': server already exists!" + ))); + } + + let client_credentials_path = match credentials { + // No credentials supplied. + // Use unauthenticated access to the KMIP server. + None => None, + + Some(KmipClientCredentialsConfig { + credentials_store_path, + credentials, + }) => { + let mut credentials_file = KmipClientCredentialsFile::new( + &credentials_store_path, + KmipServerCredentialsFileMode::CreateReadWrite, + )?; + + if let Some(credentials) = credentials { + if credentials_file + .insert(server_id.clone(), credentials) + .is_some() + { + // Don't accidental change existing credentials. + return Err(Error::new(&format!("unable to add KMIP credentials to file {}: server '{server_id}' already exists.", credentials_store_path.display()))); + } + credentials_file.save()?; + } else { + // Only credentials path supplied. + // Check that it contains credentials for the specified server. + if !credentials_file.contains(&server_id) { + return Err(Error::new(&format!("unable to add KMIP server '{server_id}': credentials for server not found in {}", credentials_store_path.display()))); + } + } + + Some(credentials_store_path) + } + }; + + let settings = KmipServerConnectionConfig { + server_addr: ip_host_or_fqdn, + server_port: port, + server_cert_verification, + client_credentials_path, + client_cert_auth, + client_limits, + key_label_config, + }; + + kmip.servers.insert(server_id.clone(), settings); + + if kmip.servers.len() == 1 { + kmip.default_server_id = Some(server_id); + } + + Ok(()) +} + +//------------ ChangeRemoveLeave --------------------------------------------- + +/// Should a setting be changed, removed or left as-is? +enum ChangeRemoveLeave { + /// The setting should be changed to the given value. + Change(T), + + /// The setting should be removed as if it were never set by the user. + Remove, + + /// The setting should be left unchanged at its current value. + Leave, +} + +//------------ modify_kmip_server() ------------------------------------------ + +/// Modify the settings of a currently configured KMIP server. +#[allow(clippy::too_many_arguments)] +fn modify_kmip_server( + kmip: &mut KmipState, + server_id: &str, + ip_host_or_fqdn: Option, + port: Option, + credentials_store_path: ChangeRemoveLeave, + username: ChangeRemoveLeave, + password: ChangeRemoveLeave, + client_cert_path: ChangeRemoveLeave, + client_key_path: ChangeRemoveLeave, + server_insecure: Option, + server_cert_path: ChangeRemoveLeave, + ca_cert_path: ChangeRemoveLeave, + connect_timeout: Option, + read_timeout: Option, + write_timeout: Option, + max_response_bytes: Option, + key_label_prefix: Option, + key_label_max_bytes: Option, +) -> Result<(), Error> { + let Some(mut cfg) = kmip.servers.remove(server_id) else { + return Err("server does not exist!".into()); + }; + + cfg.server_addr = ip_host_or_fqdn.unwrap_or(cfg.server_addr); + cfg.server_port = port.unwrap_or(cfg.server_port); + + // Handle changed credentials. + cfg.client_credentials_path = match (credentials_store_path, username, password) { + (ChangeRemoveLeave::Leave, ChangeRemoveLeave::Leave, ChangeRemoveLeave::Leave) => { + // Nothing to do. + cfg.client_credentials_path + } + + (ChangeRemoveLeave::Remove, ChangeRemoveLeave::Change(_), _) + | (ChangeRemoveLeave::Remove, _, ChangeRemoveLeave::Change(_)) + | (ChangeRemoveLeave::Leave, ChangeRemoveLeave::Remove, ChangeRemoveLeave::Change(_)) => { + return Err("cannot remove credentials and change credentials at the same time".into()); + } + + (ChangeRemoveLeave::Change(_), ChangeRemoveLeave::Remove, _) => { + return Err("cannot move credentials and remove credentials at the same time".into()); + } + + (ChangeRemoveLeave::Remove, _, _) => { + // Remove any existing stored credentials. + if let Some(path) = &cfg.client_credentials_path { + let _ = remove_kmip_client_credentials(server_id, path)?; + } + None + } + + (ChangeRemoveLeave::Change(new_path), username, password) => { + // Change the file used to store credentials. If the credentials + // are not being changed, move them from the old file to the + // new file. Otherwise remove them from the old file and the new + // credentials to the new file. + + // Remove the old credentials file. + let creds = if let Some(p) = cfg.client_credentials_path { + let mut creds = remove_kmip_client_credentials(server_id, &p)?; + // Adjust credentials if needed. + match username { + ChangeRemoveLeave::Change(v) => creds.username = v, + ChangeRemoveLeave::Remove => unreachable!(), // Handled above + ChangeRemoveLeave::Leave => { /* Nothing to do */ } + } + match password { + ChangeRemoveLeave::Change(v) => creds.password = Some(v), + ChangeRemoveLeave::Remove => creds.password = None, + ChangeRemoveLeave::Leave => { /* Nothing to do */ } + } + creds + } else { + let username = match username { + ChangeRemoveLeave::Change(v) => v, + ChangeRemoveLeave::Remove => unreachable!(), // Handled above + ChangeRemoveLeave::Leave => { + return Err("cannot use existing username as none was found".into()) + } + }; + let password = match password { + ChangeRemoveLeave::Change(v) => Some(v), + ChangeRemoveLeave::Remove => None, + ChangeRemoveLeave::Leave => None, + }; + KmipClientCredentials { username, password } + }; + + // Open the new credentials file. + let mut new_creds_file = KmipClientCredentialsFile::new( + &new_path, + KmipServerCredentialsFileMode::CreateReadWrite, + )?; + + // Insert credentials and save them. + let _ = new_creds_file.insert(server_id.to_string(), creds); + new_creds_file.save()?; + Some(new_path) + } + + (ChangeRemoveLeave::Leave, _, _) if cfg.client_credentials_path.is_none() => { + return Err("cannot change client credentials that don't exist".into()); + } + + (ChangeRemoveLeave::Leave, username, password) => { + // Open the new credentials file. + let mut creds_file = KmipClientCredentialsFile::new( + cfg.client_credentials_path.as_ref().unwrap(), // SAFETY: Checked for is_none() above + KmipServerCredentialsFileMode::ReadWrite, + )?; + + let creds = if let Some(mut creds) = creds_file.remove(server_id) { + // Adjust credentials if needed. + match username { + ChangeRemoveLeave::Change(v) => creds.username = v, + ChangeRemoveLeave::Remove => unreachable!(), // Handled above + ChangeRemoveLeave::Leave => { /* Nothing to do */ } + } + match password { + ChangeRemoveLeave::Change(v) => creds.password = Some(v), + ChangeRemoveLeave::Remove => creds.password = None, + ChangeRemoveLeave::Leave => { /* Nothing to do */ } + } + creds + } else { + // Create new credentials. + let ChangeRemoveLeave::Change(username) = username else { + return Err( + "cannot change credentials that do not exist if no username is supplied" + .into(), + ); + }; + let password = match password { + ChangeRemoveLeave::Change(v) => Some(v), + ChangeRemoveLeave::Remove => None, + ChangeRemoveLeave::Leave => None, + }; + KmipClientCredentials { username, password } + }; + + // (re-)insert the credentials and save them. + let _ = creds_file.insert(server_id.to_string(), creds); + creds_file.save()?; + cfg.client_credentials_path + } + }; + + // Handle changed client certificate authentication. + cfg.client_cert_auth = match (client_cert_path, client_key_path) { + (ChangeRemoveLeave::Leave, ChangeRemoveLeave::Leave) => { + // Use the current values. + cfg.client_cert_auth + } + + (ChangeRemoveLeave::Remove, ChangeRemoveLeave::Remove) => { + // Forget the current values. + None + } + + (ChangeRemoveLeave::Remove, _) | (_, ChangeRemoveLeave::Remove) => { + return Err("cannot remove only one of the client certificate or client key.".into()); + } + + (cert_path, key_path) => { + // Adjust the settings as needed. + let cert_path = match cert_path { + ChangeRemoveLeave::Change(v) => v, + ChangeRemoveLeave::Remove => unreachable!(), // Handled above + ChangeRemoveLeave::Leave => cfg.client_cert_auth.as_ref().map(|v| v.cert_path.clone()).ok_or::("cannot configure client certicate authentication without a client certificate path".into())?, + }; + let private_key_path = match key_path { + ChangeRemoveLeave::Change(v) => v, + ChangeRemoveLeave::Remove => unreachable!(), // Handled above, + ChangeRemoveLeave::Leave => cfg + .client_cert_auth + .as_ref() + .map(|v| v.private_key_path.clone()) + .ok_or::( + "cannot configure client certificate authentication with a private key path" + .into(), + )?, + }; + + Some(KmipClientTlsCertificateAuthConfig { + cert_path, + private_key_path, + }) + } + }; + + // Handle changed server certificate verification. + if let Some(v) = server_insecure { + cfg.server_cert_verification.verify_certificate = v.not(); + } + match server_cert_path { + ChangeRemoveLeave::Change(v) => cfg.server_cert_verification.server_cert_path = Some(v), + ChangeRemoveLeave::Remove => cfg.server_cert_verification.server_cert_path = None, + ChangeRemoveLeave::Leave => { /* Nothing to do */ } + } + match ca_cert_path { + ChangeRemoveLeave::Change(v) => cfg.server_cert_verification.ca_cert_path = Some(v), + ChangeRemoveLeave::Remove => cfg.server_cert_verification.ca_cert_path = None, + ChangeRemoveLeave::Leave => { /* Nothing to do */ } + } + + if let Some(v) = connect_timeout { + cfg.client_limits.connect_timeout = v; + } + if let Some(v) = read_timeout { + cfg.client_limits.read_timeout = v; + } + if let Some(v) = write_timeout { + cfg.client_limits.write_timeout = v; + } + if let Some(v) = max_response_bytes { + cfg.client_limits.max_response_bytes = v; + } + + if let Some(v) = key_label_prefix { + cfg.key_label_config.prefix = v; + } + if let Some(v) = key_label_max_bytes { + cfg.key_label_config.max_label_bytes = v; + } + + kmip.servers.insert(server_id.to_string(), cfg); + + if kmip.servers.len() == 1 { + kmip.default_server_id = Some(server_id.to_string()); + } + + Ok(()) +} + +//------------ KmipClientCredentialsConfig ----------------------------------- + +/// Optional disk file based credentials for connecting to a KMIP server. +pub struct KmipClientCredentialsConfig { + pub credentials_store_path: PathBuf, + pub credentials: Option, +} + +//------------ KmipClientCredentials ----------------------------------------- + +/// Credentials for connecting to a KMIP server. +/// +/// Intended to be read from a JSON file stored separately to the main +/// configuration so that separate security policy can be applied to sensitive +/// credentials. +#[derive(Debug, Deserialize, Serialize)] +pub struct KmipClientCredentials { + /// KMIP username credential. + /// + /// Mandatory if the KMIP "Credential Type" is "Username and Password". + /// + /// See: https://docs.oasis-open.org/kmip/spec/v1.2/os/kmip-spec-v1.2-os.html#_Toc409613458 + pub username: String, + + /// KMIP password credential. + /// + /// Optional when KMIP "Credential Type" is "Username and Password". + /// + /// See: https://docs.oasis-open.org/kmip/spec/v1.2/os/kmip-spec-v1.2-os.html#_Toc409613458 + #[serde(skip_serializing_if = "Option::is_none", default)] + pub password: Option, +} + +//------------ KmipClientCredentialSet --------------------------------------- + +/// A set of KMIP server credentials. +#[derive(Debug, Default, Deserialize, Serialize)] +struct KmipClientCredentialsSet(HashMap); + +//------------ KmipClientCredentialsFileMode --------------------------------- + +/// The access mode to use when accessing a credentials file. +#[derive(Debug)] +pub enum KmipServerCredentialsFileMode { + /// Open an existing credentials file for reading. Saving will fail. + ReadOnly, + + /// Open an existing credentials file for reading and writing. + ReadWrite, + + /// Open or create the credentials file for reading and writing. + CreateReadWrite, +} + +//--- impl Display + +impl std::fmt::Display for KmipServerCredentialsFileMode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + KmipServerCredentialsFileMode::ReadOnly => write!(f, "read-only"), + KmipServerCredentialsFileMode::ReadWrite => write!(f, "read-write"), + KmipServerCredentialsFileMode::CreateReadWrite => write!(f, "create-read-write"), + } + } +} + +//------------ KmipServerCredentialsFile ------------------------------------- + +/// A KMIP server credential set file. +#[derive(Debug)] +pub struct KmipClientCredentialsFile { + /// The file from which the credentials were loaded, and will be saved + /// back to. + file: File, + + /// The path from which the file was loaded. Used for generating error + /// messages. + path: PathBuf, + + /// The actual set of loaded credentials. + credentials: KmipClientCredentialsSet, + + /// The read/write/create mode. + #[allow(dead_code)] + mode: KmipServerCredentialsFileMode, +} + +impl KmipClientCredentialsFile { + /// Load credentials from disk. + /// + /// Optionally: + /// - Create the file if missing. + /// - Keep the file open for writing back changes. See ['Self::save()`]. + pub fn new(path: &Path, mode: KmipServerCredentialsFileMode) -> Result { + let read; + let write; + let create; + + match mode { + KmipServerCredentialsFileMode::ReadOnly => { + read = true; + write = false; + create = false; + } + KmipServerCredentialsFileMode::ReadWrite => { + read = true; + write = true; + create = false; + } + KmipServerCredentialsFileMode::CreateReadWrite => { + read = true; + write = true; + create = true; + } + } + + let file = OpenOptions::new() + .read(read) + .write(write) + .create(create) + .truncate(false) + .open(path) + .map_err::(|e| { + format!( + "unable to open KMIP credentials file {} in {mode} mode: {e}", + path.display() + ) + .into() + })?; + + // Determine the length of the file as JSON parsing fails if the file + // is completely empty. + let len = file.metadata().map(|m| m.len()).map_err::(|e| { + format!( + "unable to query metadata of KMIP credentials file {}: {e}", + path.display() + ) + .into() + })?; + + // Buffer reading as apparently JSON based file reading is extremely + // slow without buffering, even for small files. + let mut reader = BufReader::new(&file); + + // Load or create the credential set. + let credentials: KmipClientCredentialsSet = if len > 0 { + serde_json::from_reader(&mut reader).map_err::(|e| { + format!( + "error loading KMIP credentials file {:?}: {e}\n", + path.display() + ) + .into() + })? + } else { + KmipClientCredentialsSet::default() + }; + + // Save the path for use in generating error messages. + let path = path.to_path_buf(); + + Ok(KmipClientCredentialsFile { + file, + path, + credentials, + mode, + }) + } + + /// Write the credential set back to the file it was loaded from. + pub fn save(&mut self) -> Result<(), Error> { + // Ensure that writing happens at the start of the file. + self.file.seek(SeekFrom::Start(0))?; + + // Use a buffered writer as writing JSON to a file directly is + // apparently very slow, even for small files. + // + // Enclose the use of the BufWriter in a block so that it is + // definitely no longer using the file when we next act on it. + { + let mut writer = BufWriter::new(&self.file); + serde_json::to_writer_pretty(&mut writer, &self.credentials).map_err::( + |e| { + format!( + "error writing KMIP credentials file {}: {e}", + self.path.display() + ) + .into() + }, + )?; + + // Ensure that the BufWriter is flushed as advised by the + // BufWriter docs. + writer.flush()?; + } + + // Truncate the file to the length of data we just wrote.. + let pos = self.file.stream_position()?; + self.file.set_len(pos)?; + + // Ensure that any write buffers are flushed. + self.file.flush()?; + + Ok(()) + } + + /// Does this credential set include credentials for the specified KMIP + /// server. + pub fn contains(&self, server_id: &str) -> bool { + self.credentials.0.contains_key(server_id) + } + + #[allow(dead_code)] + fn get(&self, server_id: &str) -> Option<&KmipClientCredentials> { + self.credentials.0.get(server_id) + } + + /// Add credentials for the specified KMIP server, replacing any that + /// previously existed for the same server.- + /// + /// Returns any previous configuration if found. + pub fn insert( + &mut self, + server_id: String, + credentials: KmipClientCredentials, + ) -> Option { + self.credentials.0.insert(server_id, credentials) + } + + /// Remove any existing configuration for the specified KMIP server. + /// + /// Returns any previous configuration if found. + pub fn remove(&mut self, server_id: &str) -> Option { + self.credentials.0.remove(server_id) + } + + pub fn is_empty(&self) -> bool { + self.credentials.0.is_empty() + } +} + +//------------ KmipClientTlsCertificateAuthConfig ---------------------------- + +/// Configuration for KMIP TLS client certificate based authentication. +/// +/// Both certificate and key file must be present and must be in PEM format. +// Note: We only support PEM format, not PKCS#12, because the underlying +// kmip-protocol TLS "drivers" for rustls and OpenSSL both don't actually +// support PKCS#12 even though taking it as config input. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct KmipClientTlsCertificateAuthConfig { + /// Path to the PEM format client certificate file. + pub cert_path: PathBuf, + + /// Path to the PEM format client private key file. + pub private_key_path: PathBuf, +} + +//------------ KmipServerTlsCertificateVerificationConfig -------------------- + +/// Configuration for KMIP TLS certificate verification. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct KmipServerTlsCertificateVerificationConfig { + /// Whether or not to enable server certificate verification. + #[serde(default)] + pub verify_certificate: bool, + + /// Path to the server certificate file in PEM format. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub server_cert_path: Option, + + /// Path to the server CA certificate file in PEM format. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub ca_cert_path: Option, +} + +//--- impl Default + +impl Default for KmipServerTlsCertificateVerificationConfig { + fn default() -> Self { + Self { + verify_certificate: true, + server_cert_path: None, + ca_cert_path: None, + } + } +} + +//------------ KmipClientLimits ---------------------------------------------- + +/// Limits to be imposed on the KMIP client when commmunicating with a KMIP +/// server. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct KmipClientLimits { + /// TCP connect timeout + pub connect_timeout: Duration, + + /// TCP read timeout + pub read_timeout: Duration, + + /// TCP write timeout + pub write_timeout: Duration, + + /// Maximum number of HSM response bytes to accept + pub max_response_bytes: u32, +} + +impl std::fmt::Display for KmipClientLimits { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "Connect Timeout: {} seconds", + self.connect_timeout.as_secs() + )?; + writeln!( + f, + "Read Timeout: {} seconds", + self.read_timeout.as_secs() + )?; + writeln!( + f, + "Write Timeout: {} seconds", + self.write_timeout.as_secs() + )?; + writeln!( + f, + "Max Response Size: {} bytes", + self.max_response_bytes + ) + } +} + +//------------ KeyLabelConfig ------------------------------------------------ + +/// Whether and how to relabel KMIP keys with human readable labels. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct KeyLabelConfig { + /// Maximum label length. + pub max_label_bytes: u8, + + /// Supports re-labeling. + /// + /// Defaults to true, will be changed to false if relabeling fails to + /// avoid further attempts to relabel. + pub supports_relabeling: bool, + + /// Optional user supplied key label prefix. + /// + /// E.g. to denote the s/w that created the key, and/or to indicate which + /// installation/environment it belongs to, e.g. dev, test, prod, etc. + pub prefix: String, +} + +impl std::fmt::Display for KeyLabelConfig { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Prefix: {}", self.prefix)?; + writeln!(f, "Max Bytes: {}", self.max_label_bytes,)?; + writeln!( + f, + "Supports Re-Labeling: {}", + self.supports_relabeling + )?; + Ok(()) + } +} + +//------------ KmipServerConnectionConfig ------------------------------------ + +/// Settings for connecting to a KMIP HSM server. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct KmipServerConnectionConfig { + /// IP address, hostname or FQDN of the KMIP server. + pub server_addr: String, + + /// The TCP port number on which the KMIP server listens. + pub server_port: u16, + + /// KMIP server TLS certificate verification configuration. + pub server_cert_verification: KmipServerTlsCertificateVerificationConfig, + + /// The credentials to authenticate with the KMIP server. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub client_credentials_path: Option, + + /// KMIP client TLS certificate authentication configuration. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub client_cert_auth: Option, + + /// Limits to be applied by the KMIP client + pub client_limits: KmipClientLimits, + + /// Key labeling configuration. + pub key_label_config: KeyLabelConfig, +} + +//--- impl Display + +/// Displays in multi-line tabulated format like so: +/// +/// ```text +/// Address: 127.0.0.1:5696 +/// Server Certificate Verification: Disabled +/// Server Certificate: None +/// Certificate Authority Certificate: None +/// Client Credentials: /tmp/x.creds +/// Client Certificate Authentication: Disabled +/// Client Limits: +/// Connect Timeout: 10 seconds +/// Read Timeout: 10 seconds +/// Write Timeout: 10 seconds +/// Max Response Size: 8192 bytes +/// ``` +impl std::fmt::Display for KmipServerConnectionConfig { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + use std::fmt::Write; + + fn opt_path_to_string(p: &Option) -> String { + match p { + Some(p) => p.display().to_string(), + None => "None".to_string(), + } + } + + writeln!( + f, + "Address: {}:{}", + self.server_addr, self.server_port + )?; + let enabled = match self.server_cert_verification.verify_certificate { + true => "Enabled", + false => "Disabled", + }; + writeln!(f, "Server Certificate Verification: {enabled}")?; + writeln!( + f, + "Server Certificate: {}", + opt_path_to_string(&self.server_cert_verification.server_cert_path) + )?; + writeln!( + f, + "Certificate Authority Certificate: {}", + opt_path_to_string(&self.server_cert_verification.ca_cert_path) + )?; + writeln!( + f, + "Client Credentials: {}", + opt_path_to_string(&self.client_credentials_path) + )?; + match &self.client_cert_auth { + Some(cfg) => { + writeln!(f, "Client Certificate Authentication: Enabled")?; + writeln!( + f, + " Client Certificate: {}", + cfg.cert_path.display() + )?; + writeln!( + f, + " Private Key: {}", + cfg.private_key_path.display() + )?; + } + None => { + writeln!(f, "Client Certificate Authentication: Disabled")?; + } + } + + { + writeln!(f, "Client Limits:")?; + let mut indented = indenter::indented(f); + write!(indented, "{}", self.client_limits)?; + } + + { + writeln!(f, "Key Label Config:")?; + let mut indented = indenter::indented(f); + write!(indented, "{}", self.key_label_config)?; + } + + Ok(()) + } +} + +impl KmipServerConnectionConfig { + /// Load KMIP connection configuration data into memory. + /// + /// Load and parse the various credential data that can optionally + /// be associated with KMIP connection settings from the separate + /// files on disk where they are stored, and return a populated + /// `ConnectionSettings` object containing the resulting data. + /// + /// TODO: Currently lacks support for configuring timeouts and other + /// limits that the KMIP client can enforce. By default there are no such + /// limits. + pub fn load(&self, server_id: &str) -> Result { + let client_cert = self.load_client_cert()?; + let server_cert = self.load_server_cert()?; + let ca_cert = self.load_ca_cert()?; + let (username, password) = self.load_credentials(server_id)?; + Ok(ConnectionSettings { + host: self.server_addr.clone(), + port: self.server_port, + username, + password, + insecure: self.server_cert_verification.verify_certificate.not(), + client_cert, + server_cert, + ca_cert, + connect_timeout: Some(self.client_limits.connect_timeout), + read_timeout: Some(self.client_limits.read_timeout), + write_timeout: Some(self.client_limits.write_timeout), + max_response_bytes: Some(self.client_limits.max_response_bytes), + }) + } + + /// Load and parse PEM TLS client certificate and key files. + /// + /// TLS client certificate and key files can be used to authenticate + /// against KMIP servers that are configured to require such + /// authentication. + fn load_client_cert(&self) -> Result, Error> { + match &self.client_cert_auth { + Some(cfg) => Ok(Some(ClientCertificate::SeparatePem { + cert_bytes: Self::load_binary_file(&cfg.cert_path)?, + key_bytes: Self::load_binary_file(&cfg.private_key_path)?, + })), + None => Ok(None), + } + } + + /// Load and parse a PEM format TLS server certificate. + /// + /// The certificate contains a public key which can be used to verify the + /// identity of the remote KMIP server. + fn load_server_cert(&self) -> Result>, Error> { + Ok(match &self.server_cert_verification.server_cert_path { + Some(p) => Some(Self::load_binary_file(p)?), + None => None, + }) + } + + /// Load and parse a PEM format TLS certificate authority certificate. + /// + /// The certificate can be used to verify the issuing authority of the + /// TLS server certificate, thereby verifying not just that the server is + /// the owner of the certificate but that the certificate was issued by a + /// trusted party. + fn load_ca_cert(&self) -> Result>, Error> { + Ok(match &self.server_cert_verification.ca_cert_path { + Some(p) => Some(Self::load_binary_file(p)?), + None => None, + }) + } + + /// Load credentials from disk for authenticating with a KMIP server. + /// + /// Currently supports only one credential type: + /// - Username and optional password. + /// + /// In the case of Nameshed-HSM-Relay the username is the PKCS#11 slot + /// label and the password is the PKCS#11 user PIN. + fn load_credentials(&self, server_id: &str) -> Result<(Option, Option), Error> { + if let Some(p) = &self.client_credentials_path { + let mut file = + KmipClientCredentialsFile::new(p, KmipServerCredentialsFileMode::ReadOnly)?; + if let Some(creds) = file.remove(server_id) { + return Ok((Some(creds.username), creds.password)); + } + } + Ok((None, None)) + } + + /// Load an arbitrary file as unparsed bytes into memory. + /// + /// TODO: Lmiit how many bytes we will read? + fn load_binary_file(path: &Path) -> Result, Error> { + use std::{fs::File, io::Read}; + + let mut bytes = Vec::new(); + File::open(path)?.read_to_end(&mut bytes)?; + + Ok(bytes) + } +} + +//--- Conversions + +impl From for Error { + fn from(err: KmipConnError) -> Self { + Error::new(&format!("KMIP connection error: {err}")) + } +} + +//------------ KmipState ----------------------------------------------------- + +/// KMIP related state. +/// +/// Part of [`KeySetState`]. +#[derive(Default, Deserialize, Serialize)] +pub struct KmipState { + /// KMIP servers to use, keyed by user chosen HSM id. + pub servers: HashMap, + + /// Which KMIP server should new keys be created in, if any? + #[serde(skip_serializing_if = "Option::is_none", default)] + pub default_server_id: Option, + + /// The current set of KMIP server pools. + #[serde(skip)] + pub pools: HashMap, +} + +impl KmipState { + /// Get the default KMIP server pool, if any. + /// + /// Requires KeySetConfig::default_kmip_server to be set. The pool will be + /// created if needed. + /// + /// Returns Ok(None) if no default KMIP server is set. + pub fn get_default_pool(&mut self) -> Result, Error> { + if self.default_server_id.is_some() { + let id = self.default_server_id.clone().unwrap(); + return self.get_pool(&id).map(Some); + } + Ok(None) + } + + /// Get the server pool for a specific KMIP server ID. + /// + /// Requires the server ID to exist in KeySetConfig::kmip_servers. + /// The pool will be created if needed. + /// + /// Returns Ok(pool) or Err if the server ID is not known or the pool + /// cannot be created. + pub fn get_pool(&mut self, id: &str) -> Result { + match self.pools.get(id) { + Some(pool) => Ok(pool.clone()), + None => { + let Some(srv_conn_settings) = self.servers.get(id) else { + return Err(format!("No KMIP server config exists for server '{id}").into()); + }; + let conn_settings = srv_conn_settings.load(id).map_err(|err| { + format!("Unable to prepare KMIP connection settings for server '{id}': {err}") + })?; + // TODO: Should the timeouts used here be configurable and/or set to some + // other value? + let pool = ConnectionManager::create_connection_pool( + id.to_string(), + conn_settings.into(), + 1, + Some(Duration::from_secs(60)), + Some(Duration::from_secs(60)), + ) + .map_err(|err| format!("Failed to create KMIP connection pool: {err}"))?; + + self.pools.insert(id.to_string(), pool.clone()); + Ok(pool) + } + } + } +} + +//--- impl Display + +/// Displays in muti-line tabulated format like so: +/// +/// ```text +/// Servers: +/// ID: my_server_x [DEFAULT] +/// Address: 127.0.0.1:5696 +/// Server Certificate Verification: Disabled +/// Server Certificate: None +/// Certificate Authority Certificate: None +/// Client Certificate Authentication: Disabled +/// ID: my_server +/// Address: 127.0.0.1:5696 +/// Server Certificate Verification: Disabled +/// Server Certificate: None +/// Certificate Authority Certificate: None +/// Client Certificate Authentication: Enabled +/// Client Certificate: /blah +/// Private Key: /tmp/tmp +/// ``` +impl std::fmt::Display for KmipState { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Servers:")?; + for (server_id, cfg) in &self.servers { + let default = match Some(server_id) == self.default_server_id.as_ref() { + true => " [DEFAULT]", + false => "", + }; + use std::fmt::Write; + let mut indented = indenter::indented(f); + writeln!(indented, "ID: {server_id}{default}")?; + + let mut twice_indented = indenter::indented(&mut indented); + write!(twice_indented, "{cfg}")?; + } + Ok(()) + } +} + +/// Construct from parts a KMIP key label. +pub fn format_key_label( + prefix: &str, + zone_name: &str, + key_tag: &str, + key_type: &str, + suffix: &str, + max_label_bytes: usize, +) -> Result { + let mut public_key_label = format!("{prefix}{zone_name}-{key_tag}-{key_type}{suffix}"); + if public_key_label.len() > max_label_bytes { + let diff = public_key_label.len() - max_label_bytes; + let max_zone_name_len = zone_name.len().saturating_sub(diff); + if max_zone_name_len < 8 { + return Err(format!("Insufficient space to include a useful (partial) zone name in generated KMIP key label: {max_zone_name_len} < 8").into()); + } + // If the name is a valid DNS name, truncate it by + // keeping the right most label (the TLD) but removing + // labels one by one prior to that until the name is + // short enough. + let zone_name = truncate_zone_name(zone_name.to_string(), max_zone_name_len); + public_key_label = format!("{prefix}{zone_name}-{key_tag}-{key_type}{suffix}"); + } + Ok(public_key_label) +} + +/// Trnucate a zone name to a maximum length. +/// +/// First attempt to truncate by removing labels under the TLD label, falling +/// back to truncating to N bytes from the start if needed. +fn truncate_zone_name(mut zone_name: String, max_zone_name_len: usize) -> String { + if zone_name.len() <= max_zone_name_len { + return zone_name; + } + if max_zone_name_len > 0 { + if let Ok(dns_name) = Name::>::from_str(&zone_name) { + // We can only shorten names that have at least + // three labels. + let num_labels = dns_name.iter_labels().count(); + if num_labels >= 3 { + let mut end_name = NameBuilder::new_vec(); + + // Append prior labels until the current + // length + '.' + the final label + '.' would + // be too long. + let mut labels = dns_name.iter_labels().rev(); + + // Keep the root and TLD labels. + end_name + .append_label(labels.next().unwrap().as_slice()) + .unwrap(); + end_name + .append_label(labels.next().unwrap().as_slice()) + .unwrap(); + + // Append labels from the left as long as they fit. + let mut labels = dns_name.iter_labels(); + let mut start_name = NameBuilder::new_vec(); + for _ in 0..num_labels - 2 { + let label = labels.next().unwrap(); + // Minus one to allow space for the '..' that will be used + // instead of '.' to signify that label based truncation + // occurred. + if start_name.len() + label.len() + end_name.len() < (max_zone_name_len - 1) { + start_name.append_label(label.as_slice()).unwrap(); + } + } + + if !start_name.is_empty() { + // Build final name + let mut zone_name = start_name.finish().to_string(); + zone_name.push_str(".."); + zone_name.push_str(&end_name.into_name().unwrap().to_string()); + zone_name.push('.'); + + if zone_name.len() <= max_zone_name_len { + return zone_name; + } + } + } + } + } + + zone_name.truncate(max_zone_name_len); + zone_name +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_zone_name() { + // Name already shorter than the truncation length + assert_eq!(&truncate_zone_name("".to_string(), 5), ""); + assert_eq!(&truncate_zone_name("nl.".to_string(), 5), "nl."); + + // Names longer than the truncation length but the labels under the + // TLD are too long to allow shortening by dropping of labels, instead + // shortening is done by brute truncation. + assert_eq!(&truncate_zone_name("nlnetlabs.nl.".to_string(), 5), "nlnet"); + assert_eq!( + &truncate_zone_name("a.b.c.d.nlnetlabs.nl.".to_string(), 5), + "a.b.c" + ); + + // Names longer than the truncation length and has labels under the + // TLD that are short enough to permit truncation by dropping of labels + // in the middle. A double dot (..) indicates that truncation occurred. + assert_eq!( + &truncate_zone_name("a.b.c.d.nlnetlabs.nl.".to_string(), 10), + "a.b.c..nl." + ); + assert_eq!( + &truncate_zone_name("a.b.c.d.nlnetlabs.nl.".to_string(), 12), + "a.b.c.d..nl." + ); + assert_eq!( + &truncate_zone_name("a.b.c.d.nlnetlabs.nl.".to_string(), 19), + "a.b.c.d..nl." + ); + assert_eq!( + &truncate_zone_name("a.b.c.d.nlnetlabs.nl.".to_string(), 20), + "a.b.c.d..nl." + ); + + // Name is equal to the truncation length so no truncation needed. + assert_eq!( + &truncate_zone_name("a.b.c.d.nlnetlabs.nl.".to_string(), 21), + "a.b.c.d.nlnetlabs.nl." + ); + } + + #[test] + fn test_format_key_label() { + assert_eq!( + format_key_label("", "a.b.c.d.nlnetlabs.nl.", "12345", "ksk", "", 20).unwrap(), + "a.b.c..nl.-12345-ksk" + ); + assert_eq!( + format_key_label("", "a.b.c.d.nlnetlabs.nl.", "12345", "ksk", "", 31).unwrap(), + "a.b.c.d.nlnetlabs.nl.-12345-ksk" + ); + assert_eq!( + format_key_label( + "prefix-", + "a.b.c.d.nlnetlabs.nl.", + "12345", + "ksk", + "-suffix", + 45 + ) + .unwrap(), + "prefix-a.b.c.d.nlnetlabs.nl.-12345-ksk-suffix" + ); + + // Max len too short to hold the generated label. + assert!(format_key_label("", "a.b.c.d.nlnetlabs.nl.", "12345", "ksk", "", 10).is_err()); + } +} diff --git a/src/commands/keyset/mod.rs b/src/commands/keyset/mod.rs new file mode 100644 index 0000000..65035ae --- /dev/null +++ b/src/commands/keyset/mod.rs @@ -0,0 +1,6 @@ +pub mod cmd; + +#[cfg(feature = "kmip")] +pub mod kmip; + +pub use cmd::*; From 6fad1196858378e070d0e81218977b6334ea33ac Mon Sep 17 00:00:00 2001 From: Philip-NLnetLabs Date: Fri, 29 Aug 2025 10:34:11 +0200 Subject: [PATCH 18/32] Add support for automatic key rolls (#108) --- Cargo.lock | 267 ++- Cargo.toml | 1 + src/commands/keyset/cmd.rs | 4034 ++++++++++++++++++++++++++++++------ 3 files changed, 3520 insertions(+), 782 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b23f4a..c94b388 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,9 +49,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -79,22 +79,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -105,9 +105,9 @@ checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" [[package]] name = "async-lock" -version = "3.4.0" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" dependencies = [ "event-listener", "event-listener-strategy", @@ -153,9 +153,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.9.1" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" [[package]] name = "bumpalo" @@ -171,18 +171,18 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.29" +version = "1.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" dependencies = [ "shlex", ] [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "chrono" @@ -200,9 +200,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.41" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57" dependencies = [ "clap_builder", "clap_derive", @@ -210,9 +210,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.41" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41" dependencies = [ "anstream", "anstyle", @@ -222,14 +222,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.41" +version = "4.5.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -336,7 +336,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -348,6 +348,7 @@ dependencies = [ "clap", "const_format", "domain", + "futures", "indenter", "jiff", "lazy_static", @@ -371,7 +372,7 @@ dependencies = [ [[package]] name = "domain" version = "0.11.1-dev" -source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#ab61e4c9c51d38d3574c232040849bd0ef0d2923" +source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#187e30479a782124dfedd9dbd3102e1b64dbf171" dependencies = [ "arc-swap", "bcder", @@ -407,11 +408,11 @@ dependencies = [ [[package]] name = "domain-macros" version = "0.11.1-dev" -source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#ab61e4c9c51d38d3574c232040849bd0ef0d2923" +source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#187e30479a782124dfedd9dbd3102e1b64dbf171" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -454,9 +455,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -496,19 +497,61 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-macro" version = "0.3.31" @@ -517,9 +560,15 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + [[package]] name = "futures-task" version = "0.3.31" @@ -532,9 +581,13 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -542,9 +595,9 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.5" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d18470a76cb7f8ff746cf1f7470914f900252ec36bbc40b569d74b1258446827" +checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" dependencies = [ "cc", "cfg-if", @@ -716,9 +769,9 @@ dependencies = [ [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -743,9 +796,9 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "io-uring" -version = "0.7.8" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ "bitflags", "cfg-if", @@ -785,7 +838,7 @@ checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -850,9 +903,9 @@ checksum = "9fa0e2a1fcbe2f6be6c42e342259976206b383122fc152e872795338b5a3f3a7" [[package]] name = "libc" -version = "0.2.174" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "linux-raw-sys" @@ -912,7 +965,7 @@ checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1043,7 +1096,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1095,9 +1148,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" @@ -1168,9 +1221,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -1262,9 +1315,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -1272,9 +1325,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -1282,23 +1335,23 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.13" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ "bitflags", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata 0.4.10", + "regex-syntax 0.8.6", ] [[package]] @@ -1312,13 +1365,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", ] [[package]] @@ -1329,9 +1382,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "ring" @@ -1364,9 +1417,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc_version" @@ -1379,15 +1432,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1414,9 +1467,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -1496,14 +1549,14 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "itoa", "memchr", @@ -1534,9 +1587,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "slab" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" @@ -1546,12 +1599,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1585,9 +1638,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -1602,7 +1655,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1613,15 +1666,15 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1647,7 +1700,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1702,9 +1755,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.46.1" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", @@ -1715,7 +1768,7 @@ dependencies = [ "slab", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1726,7 +1779,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1760,7 +1813,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1839,13 +1892,14 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -1862,9 +1916,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" dependencies = [ "getrandom 0.3.3", "js-sys", @@ -1920,7 +1974,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-shared", ] @@ -1942,7 +1996,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2052,7 +2106,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2063,7 +2117,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2124,7 +2178,7 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -2145,10 +2199,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ + "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -2305,7 +2360,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] @@ -2326,7 +2381,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2346,7 +2401,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] @@ -2369,9 +2424,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ "yoke", "zerofrom", @@ -2386,5 +2441,5 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] diff --git a/Cargo.toml b/Cargo.toml index 30c146e..a4b7e98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ serde = "1.0.217" tracing = "0.1.41" tracing-subscriber = "0.3.19" url = "2.5.4" +futures = "0.3.31" [dev-dependencies] const_format = " 0.2.33" diff --git a/src/commands/keyset/cmd.rs b/src/commands/keyset/cmd.rs index e6a5404..74be707 100644 --- a/src/commands/keyset/cmd.rs +++ b/src/commands/keyset/cmd.rs @@ -1,51 +1,134 @@ -//! The keyset subcomnand. -use std::cmp::min; -use std::collections::HashMap; -use std::convert::From; -use std::fmt::{Debug, Display, Formatter}; -use std::fs::{remove_file, File}; -use std::io::Write; -use std::path::{absolute, Path, PathBuf}; -use std::time::Duration; -use std::time::SystemTime; - -use bytes::Bytes; -use clap::Subcommand; -use domain::base::iana::Class; -use domain::base::iana::{DigestAlgorithm, SecurityAlgorithm}; -use domain::base::name::FlattenInto; -use domain::base::zonefile_fmt::{DisplayKind, ZonefileFmt}; -use domain::base::{Name, Record, ToName, Ttl}; -use domain::crypto::sign::{GenerateParams, KeyPair, SecretKeyBytes}; -#[cfg(feature = "kmip")] -use domain::crypto::{kmip::KeyUrl, sign::SignRaw}; -use domain::dnssec::common::{display_as_bind, parse_from_bind}; -use domain::dnssec::sign::keys::keyset::{Action, Key, KeySet, KeyType, RollType, UnixTime}; -use domain::dnssec::sign::keys::SigningKey; -use domain::dnssec::sign::records::Rrset; -use domain::dnssec::sign::signatures::rrsigs::sign_rrset; -use domain::dnssec::validator::base::DnskeyExt; -use domain::rdata::dnssec::Timestamp; -use domain::rdata::{Cdnskey, Cds, Dnskey, Ds, ZoneRecordData}; -#[cfg(feature = "kmip")] -use domain::utils::base32::encode_string_hex; -use domain::zonefile::inplace::Zonefile; -use domain::zonefile::inplace::{Entry, ScannedRecordData}; -use jiff::{Span, SpanRelativeTo}; -use serde::{Deserialize, Serialize}; -#[cfg(feature = "kmip")] -use tracing::warn; -use url::Url; +//! Key management utility. +#![warn(missing_docs)] +#![warn(clippy::missing_docs_in_private_items)] use crate::env::Env; use crate::error::Error; use crate::util; +use bytes::Bytes; +use clap::Subcommand; +use domain::base::iana::{Class, DigestAlgorithm, OptRcode, SecurityAlgorithm}; +use domain::base::name::FlattenInto; +use domain::base::zonefile_fmt::{DisplayKind, ZonefileFmt}; +use domain::base::{ + MessageBuilder, Name, ParseRecordData, ParsedName, Record, Rtype, Serial, ToName, Ttl, +}; +use domain::crypto::sign::{GenerateParams, KeyPair, SecretKeyBytes}; +#[cfg(feature = "kmip")] +use domain::crypto::{kmip::KeyUrl, sign::SignRaw}; +use domain::dnssec::common::{display_as_bind, parse_from_bind}; +use domain::dnssec::sign::keys::keyset::{ + self, Action, Key, KeySet, KeyState, KeyType, RollState, RollType, UnixTime, +}; +use domain::dnssec::sign::keys::SigningKey; +use domain::dnssec::sign::records::Rrset; +use domain::dnssec::sign::signatures::rrsigs::sign_rrset; +use domain::dnssec::validator::base::DnskeyExt; +use domain::net::client::dgram_stream; +use domain::net::client::protocol::{TcpConnect, UdpConnect}; +use domain::net::client::request::{ + ComposeRequest, RequestMessage, RequestMessageMulti, SendRequest, SendRequestMulti, +}; +use domain::net::client::stream; +use domain::rdata::dnssec::Timestamp; +use domain::rdata::{AllRecordData, Cdnskey, Cds, Dnskey, Ds, Rrsig, Soa, ZoneRecordData}; +use domain::resolv::lookup::lookup_host; +use domain::resolv::StubResolver; +#[cfg(feature = "kmip")] +use domain::utils::base32::encode_string_hex; +use domain::zonefile::inplace::{Entry, ScannedRecordData, Zonefile}; +use futures::future::join_all; +use jiff::{Span, SpanRelativeTo}; +use serde::{Deserialize, Serialize}; +use std::cmp::max; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::convert::From; +use std::fmt::{Debug, Display, Formatter}; +//use std::fs::{remove_file, File}; +use std::fs::{remove_file, File}; +use std::io::{self, Write}; +use std::net::{IpAddr, SocketAddr}; +use std::path::{absolute, Path, PathBuf}; +use std::process::Command; +use std::sync::Mutex; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::net::TcpStream; +#[cfg(feature = "kmip")] +use tracing::{debug, error, warn}; +#[cfg(not(feature = "kmip"))] +use tracing::{debug, error, warn}; +use url::Url; #[cfg(feature = "kmip")] use super::kmip::{format_key_label, kmip_command, KmipCommands, KmipState}; +/// Maximum tries to generate new key with a key tag that does not conclict +/// with the key tags of existing keys. const MAX_KEY_TAG_TRIES: u8 = 10; +/// Wait this amount before retrying for network errors, DNS errors, etc. +const DEFAULT_WAIT: Duration = Duration::from_secs(10 * 60); + +// Types to simplify some HashSet types. +/// Type for a Name that uses a Vec. +type NameVecU8 = Name>; +/// Type for a record that uses ZoneRecordData and a Vec. +type RecordZoneRecordData = Record, NameVecU8>>; +/// Type for a DNSKEY record. +type RecordDnskey = Record>>; + +// Automatic key rolls +// +// Keyset supports four types of automatic key rolls: +// 1) A KSK roll. Roll one (or more) KSKs to a new KSK. +// 2) A ZSK roll. Roll one (or more) ZSKs to a new ZSK. +// 3) A CSK roll. Roll any KSK, ZSK, or CSK to a single new CSK or roll +// one (or more CSKs) plus any KSK or ZSK to a new KSK plus a new ZSK. +// This depends on the value of the use_csk config variable. +// 4) An algorithm roll. Roll any KSK, ZSK, or CSK to a new CSK (if use_csk +// is true) or to a new KSK and a new ZSK (if use_csk is false) with an +// algorithm that is different from the one in the old keys. +// +// For each roll type automation can be enable for four different types of +// steps: +// 1) Start. When automation is enabled for this step, keyset checks if keys +// are expired, no conflicting rolls are currently in progress and no +// conditions (use of CSK, the need for a algorithm roll) prevents this +// type of roll. +// 2) Report. In the complete key roll, these are two steps: +// propagation1_complete and propagation2_complete. When automation is +// enabled, keyset goes through the list of actions and takes care of +// the Report actions (ReportDnskeyPropagated, ReportDsPropagated, +// ReportRrsigPropagated). Keyset checks nameservers for the zone +// (or the parent zone in the case of ReportDsPropagated) to make sure +// that new information has propagated to all listed nameservers. +// The maximum TTL is passed to Keyset::propagation1_complete (or +// Keyset::propagation2_complete). +// 3) Expire. This corresponds to the steps cache_expired1 and +// cache_expired2. When enabled, this step wait until time equal to the +// TTL amount that was reported in propagation1_complete or +// propagation2_complete to have passed before continuing to the next step. +// 4) Done. When enabled this step takes care of any Wait actions +// (WaitDnskeyPropagated, WaitDsPropagated, WaitRrsigPropagated). This +// is very similar to the Report step except no TTL value is reported. +// After this step, the key roll is considered done though some old date +// may still exist in caches. +// +// For each key roll type, automation for each step can be enabled or disabled +// individually. This give a total of sixteen flags. +// +// The function auto_start handles the Start step. The other steps are +// handled by auto_report_expire_done. The current state for automatic report +// and done handling is kept in a field called 'internal' in the KeySetState +// structure. +// +// At every change to the config or the state file, the next time +// 'dnst keyset cron' should be called is computed and stored in the +// state file. The function cron_next_auto_start provides timestamps for +// automatic start of key rolls, cron_next_auto_report_expire_done does +// the same for the report, expire, and done steps. + +/// Command line arguments of the keyset utility. #[derive(Clone, Debug, clap::Args)] pub struct Keyset { /// Keyset config @@ -57,11 +140,16 @@ pub struct Keyset { cmd: Commands, } +/// Type for an optional Duration. A separate type is needed because CLAP +/// treats Option special. type OptDuration = Option; +/// The subcommands of the keyset utility. #[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, Subcommand)] enum Commands { + /// Create empty state for a DNS zone. This will create both the config + /// file as well as the state file. Create { /// Domain name #[arg(short = 'n')] @@ -72,66 +160,115 @@ enum Commands { keyset_state: PathBuf, }, + /// Init creates keys for an empty state file. Init, + + /// The following should be move to ksk, zsk, etc. subcommands. StartKskRoll, + /// XXX StartZskRoll, + /// XXX StartCskRoll, + /// XXX StartAlgorithmRoll, + /// XXX KskPropagation1Complete { + /// XXX ttl: u32, }, + /// XXX KskPropagation2Complete { + /// XXX ttl: u32, }, + /// XXX ZskPropagation1Complete { + /// XXX ttl: u32, }, + /// XXX ZskPropagation2Complete { + /// XXX ttl: u32, }, + /// XXX CskPropagation1Complete { + /// XXX ttl: u32, }, + /// XXX CskPropagation2Complete { + /// XXX ttl: u32, }, + /// XXX AlgorithmPropagation1Complete { + /// XXX ttl: u32, }, + /// XXX AlgorithmPropagation2Complete { + /// XXX ttl: u32, }, + /// XXX KskCacheExpired1, + /// XXX KskCacheExpired2, + /// XXX ZskCacheExpired1, + /// XXX ZskCacheExpired2, + /// XXX CskCacheExpired1, + /// XXX CskCacheExpired2, + /// XXX AlgorithmCacheExpired1, + /// XXX AlgorithmCacheExpired2, + /// XXX KskRollDone, + /// XXX ZskRollDone, + /// XXX CskRollDone, + /// XXX AlgorithmRollDone, + /// Report status, such as key rolls that are in progress, expired + /// keys, when to call the 'cron' subcommand next. Status, + /// Report actions that are associated with the current state of + /// any key rolls. Actions, + /// List all keys in the current state. Keys, + /// Get various config and state values. Get { + /// The specific get subcommand. #[command(subcommand)] subcommand: GetCommands, }, + /// Set config values. Set { + /// The specific set subcommand. #[command(subcommand)] subcommand: SetCommands, }, + /// Show all config variables. Show, + + /// Execute any automatic steps such a refreshing signatures or + /// automatic steps in key rolls. Cron, + /// Kmip command. #[cfg(feature = "kmip")] Kmip { + /// Kmip subcommands. #[command(subcommand)] subcommand: KmipCommands, }, @@ -139,92 +276,198 @@ enum Commands { #[derive(Clone, Debug, Subcommand)] enum GetCommands { + /// Get the state of the use_csk config variable. UseCsk, + /// Get the state of the autoremove config variable. Autoremove, - KskAlgorithm, - ZskAlgorithm, - CskAlgorithm, + /// Get the state of the algorithm config variable. + Algorithm, + /// Get the state of the ds_algorithm config variable. DsAlgorithm, + /// Get the state of the dnskey_lifetime config variable. DnskeyLifetime, + /// Get the state of the cds_lifetime config variable. CdsLifetime, + /// Get the current DNSKEY RRset including signatures. Dnskey, + /// Get the current CDS and CDNSKEY RRsets including signatures. Cds, + /// Get the current DS records that canbe added to the parent zone. Ds, } #[derive(Clone, Debug, Subcommand)] enum SetCommands { + /// Set the use_csk config variable. UseCsk { + /// The value of the config variable. #[arg(action = clap::ArgAction::Set)] boolean: bool, }, + /// Set the autoremove config variable. Autoremove { + /// The value of the config variable. #[arg(action = clap::ArgAction::Set)] boolean: bool, }, - KskAlgorithm { + /// Set the algorithm config variable. + Algorithm { + /// The number of bits of a new RSA key. At the moment RSA is the + /// only public key algorithm that needs a bits argument. #[arg(short = 'b')] bits: Option, + /// The algorithm to use for new keys. algorithm: String, }, - ZskAlgorithm { - #[arg(short = 'b')] - bits: Option, - algorithm: String, + /// Set the config values for automatic KSK rolls. + AutoKsk { + /// Whether to automatically start a key roll. + #[arg(action = clap::ArgAction::Set)] + start: bool, + /// Whether to automatically handle report actions. + #[arg(action = clap::ArgAction::Set)] + report: bool, + /// Whether to automatically handle cache expiration actions. + #[arg(action = clap::ArgAction::Set)] + expire: bool, + /// Whether to automatically handle done actions. + #[arg(action = clap::ArgAction::Set)] + done: bool, }, - CskAlgorithm { - #[arg(short = 'b')] - bits: Option, - - algorithm: String, + /// Set the config values for automatic ZSK rolls. + AutoZsk { + /// Whether to automatically start a key roll. + #[arg(action = clap::ArgAction::Set)] + start: bool, + /// Whether to automatically handle report actions. + #[arg(action = clap::ArgAction::Set)] + report: bool, + /// Whether to automatically handle cache expiration actions. + #[arg(action = clap::ArgAction::Set)] + expire: bool, + /// Whether to automatically handle done actions. + #[arg(action = clap::ArgAction::Set)] + done: bool, }, + /// Set the config values for automatic CSK rolls. + AutoCsk { + /// Whether to automatically start a key roll. + #[arg(action = clap::ArgAction::Set)] + start: bool, + /// Whether to automatically handle report actions. + #[arg(action = clap::ArgAction::Set)] + report: bool, + /// Whether to automatically handle cache expiration actions. + #[arg(action = clap::ArgAction::Set)] + expire: bool, + /// Whether to automatically handle done actions. + #[arg(action = clap::ArgAction::Set)] + done: bool, + }, + /// Set the config values for automatic algorithm rolls. + AutoAlgorithm { + /// Whether to automatically start a key roll. + #[arg(action = clap::ArgAction::Set)] + start: bool, + /// Whether to automatically handle report actions. + #[arg(action = clap::ArgAction::Set)] + report: bool, + /// Whether to automatically handle cache expiration actions. + #[arg(action = clap::ArgAction::Set)] + expire: bool, + /// Whether to automatically handle done actions. + #[arg(action = clap::ArgAction::Set)] + done: bool, + }, + /// Set the hash algorithm to use for creating DS records. DsAlgorithm { + /// The hash algorithm. #[arg(value_parser = DsAlgorithm::new)] algorithm: DsAlgorithm, }, + /// Set the amount inception times of signatures over the DNSKEY RRset + /// are backdated. + /// + /// Note that positive values are subtract from the current time. DnskeyInceptionOffset { + /// The offset. #[arg(value_parser = parse_duration)] duration: Duration, }, + /// Set how much time the expiration times of signatures over the DNSKEY + /// RRset are in the future. DnskeyLifetime { + /// The lifetime. #[arg(value_parser = parse_duration)] duration: Duration, }, + /// Set how much time the DNSKEY signatures still have to be valid. + /// + /// New signatures will be generated when the time until the expiration + /// time is less than that. DnskeyRemainTime { + /// The required remaining time. #[arg(value_parser = parse_duration)] duration: Duration, }, + /// Set the amount inception times of signatures over the CDS and + /// CDNSKEY RRsets are backdated. + /// + /// Note that positive values are subtract from the current time. CdsInceptionOffset { + /// The offset. #[arg(value_parser = parse_duration)] duration: Duration, }, + /// Set how much time the expiration times of signatures over the CDS + /// and CDNSKEY RRsets are in the future. CdsLifetime { + /// The lifetime. #[arg(value_parser = parse_duration)] duration: Duration, }, + /// Set how much time the CDS/CDNSKEY signatures still have to be valid. + /// + /// New signatures will be generated when the time until the expiration + /// time is less than that. CdsRemainTime { + /// The required remaining time. #[arg(value_parser = parse_duration)] duration: Duration, }, + /// How long a KSK is valid from the time it was first 'published'. KskValidity { + /// The amount of time the key is valid. #[arg(value_parser = parse_opt_duration)] opt_duration: OptDuration, }, + /// How long a ZSK is valid from the time it was first 'published'. ZskValidity { + /// The amount of time the key is valid. #[arg(value_parser = parse_opt_duration)] opt_duration: OptDuration, }, + /// How long a CSK is valid from the time it was first 'published'. CskValidity { + /// The amount of time the key is valid. #[arg(value_parser = parse_opt_duration)] opt_duration: OptDuration, }, + + /// Set the command to run when the DS records at the parent need updating. + UpdateDsCommand { + /// Command and arguments. + args: Vec, + }, } impl Keyset { + /// execute the keyset command. pub fn execute(self, env: impl Env) -> Result<(), Error> { - let runtime = tokio::runtime::Runtime::new().unwrap(); + let runtime = + tokio::runtime::Runtime::new().expect("tokio::runtime::Runtime::new should not fail"); runtime.block_on(self.run(&env)) } @@ -248,6 +491,8 @@ impl Keyset { cds_rrset: Vec::new(), ns_rrset: Vec::new(), cron_next: None, + internal: HashMap::new(), + #[cfg(feature = "kmip")] kmip: Default::default(), }; @@ -257,12 +502,14 @@ impl Keyset { state_file: state_file.clone(), keys_dir, use_csk: false, - ksk_generate_params: KeyParameters::RsaSha256(2048), - zsk_generate_params: KeyParameters::RsaSha256(2048), - csk_generate_params: KeyParameters::RsaSha256(2048), + algorithm: KeyParameters::EcdsaP256Sha256, ksk_validity: None, zsk_validity: None, csk_validity: None, + auto_ksk: { Default::default() }, + auto_zsk: { Default::default() }, + auto_csk: { Default::default() }, + auto_algorithm: { Default::default() }, dnskey_inception_offset: Duration::from_secs(ONE_DAY), dnskey_signature_lifetime: Duration::from_secs(FOUR_WEEKS), dnskey_remain_time: Duration::from_secs(FOUR_WEEKS / 2), @@ -271,6 +518,7 @@ impl Keyset { cds_remain_time: Duration::from_secs(FOUR_WEEKS / 2), ds_algorithm: DsAlgorithm::Sha256, autoremove: false, + update_ds_command: Vec::new(), }; let json = serde_json::to_string_pretty(&kss).expect("should not fail"); let mut file = File::create(&state_file).map_err::(|e| { @@ -316,6 +564,7 @@ impl Keyset { let mut config_changed = false; let mut state_changed = false; + let mut run_update_ds_command = false; match self.cmd { Commands::Create { .. } => unreachable!(), @@ -326,531 +575,52 @@ impl Keyset { return Err("already initialized\n".into()); } - // Check for CSK. - let actions = if ksc.use_csk { - // Generate CSK. - let (csk_pub_name, csk_priv_name, algorithm, key_tag) = new_keys( - kss.keyset.name(), - ksc.csk_generate_params.to_generate_params(), - true, - kss.keyset.keys(), - &ksc.keys_dir, - env, - #[cfg(feature = "kmip")] - &mut kss.kmip, - )?; - kss.keyset - .add_key_csk( - csk_pub_name.to_string(), - Some(csk_priv_name.to_string()), - algorithm, - key_tag, - UnixTime::now(), - true, - ) - .expect("should not happen"); + let (new_stored, _) = new_csk_or_ksk_zsk(&ksc, &mut kss, env)?; - kss.keyset - .start_roll(RollType::AlgorithmRoll, &[], &[csk_pub_name.as_str()]) - .expect("should not happen") - } else { - let (ksk_pub_url, ksk_priv_url, algorithm, key_tag) = new_keys( - kss.keyset.name(), - ksc.ksk_generate_params.to_generate_params(), - true, - kss.keyset.keys(), - &ksc.keys_dir, - env, - #[cfg(feature = "kmip")] - &mut kss.kmip, - )?; - kss.keyset - .add_key_ksk( - ksk_pub_url.to_string(), - Some(ksk_priv_url.to_string()), - algorithm, - key_tag, - UnixTime::now(), - true, - ) - .expect("should not happen"); - let (zsk_pub_url, zsk_priv_url, algorithm, key_tag) = new_keys( - kss.keyset.name(), - ksc.zsk_generate_params.to_generate_params(), - false, - kss.keyset.keys(), - &ksc.keys_dir, - env, - #[cfg(feature = "kmip")] - &mut kss.kmip, - )?; - kss.keyset - .add_key_zsk( - zsk_pub_url.to_string(), - Some(zsk_priv_url.to_string()), - algorithm, - key_tag, - UnixTime::now(), - true, - ) - .expect("should not happen"); + let new: Vec<_> = new_stored.iter().map(|v| v.as_ref()).collect(); + let actions = kss + .keyset + .start_roll(RollType::AlgorithmRoll, &[], &new) + .expect("should not happen"); - let new = [ksk_pub_url.as_ref(), zsk_pub_url.as_ref()]; - kss.keyset - .start_roll(RollType::AlgorithmRoll, &[], &new) - .expect("should not happen") - }; - - handle_actions(&actions, &ksc, &mut kss, env)?; + handle_actions( + &actions, + &ksc, + &mut kss, + env, + true, + &mut run_update_ds_command, + )?; + kss.internal + .insert(RollType::AlgorithmRoll, Default::default()); print_actions(&actions); state_changed = true; } Commands::StartKskRoll => { - if kss.keyset.keys().is_empty() { - // Avoid KSK roll without init. - return Err("not yet initialized\n".into()); - } - - // Check for CSK. - if ksc.use_csk { - return Err("wrong key roll, use start-csk-roll\n".into()); - } - - // Refuse if we can find a CSK key. - if kss - .keyset - .keys() - .iter() - .any(|(_, key)| matches!(key.keytype(), KeyType::Csk(_, _))) - { - return Err("cannot start key roll, found CSK\n".into()); - } - - // Find existing KSKs. Do we complain if there is none? - let old_stored: Vec<_> = kss - .keyset - .keys() - .iter() - .filter(|(_, key)| { - if let KeyType::Ksk(keystate) = key.keytype() { - !keystate.old() - || keystate.signer() - || keystate.present() - || keystate.at_parent() - } else { - false - } - }) - .map(|(name, _)| name.clone()) - .collect(); - let old: Vec<_> = old_stored.iter().map(|name| name.as_ref()).collect(); - - // Collect algorithms. Maybe this needs to be in the library. - - // Create a new KSK - let (ksk_pub_url, ksk_priv_url, algorithm, key_tag) = new_keys( - kss.keyset.name(), - ksc.ksk_generate_params.to_generate_params(), - true, - kss.keyset.keys(), - &ksc.keys_dir, - env, - #[cfg(feature = "kmip")] - &mut kss.kmip, - )?; - kss.keyset - .add_key_ksk( - ksk_pub_url.to_string(), - Some(ksk_priv_url.to_string()), - algorithm, - key_tag, - UnixTime::now(), - true, - ) - .map_err::(|e| { - format!("unable to add KSK {ksk_pub_url}: {e}\n").into() - })?; - - let new = [ksk_pub_url.as_ref()]; - - // Start the key roll - let actions = match kss - .keyset - .start_roll(RollType::KskRoll, &old, &new) - .map_err::(|e| format!("cannot start roll: {e}\n").into()) - { - Ok(actions) => actions, - Err(e) => { - // Remove the key files we just created. - remove_key(&mut kss, ksk_priv_url)?; - remove_key(&mut kss, ksk_pub_url)?; - return Err(e); - } - }; - handle_actions(&actions, &ksc, &mut kss, env)?; + let actions = + start_ksk_roll(&ksc, &mut kss, env, true, &mut run_update_ds_command)?; print_actions(&actions); state_changed = true; } Commands::StartZskRoll => { - if kss.keyset.keys().is_empty() { - // Avoid ZSK roll without init. - return Err("not yet initialized\n".into()); - } - - // Check for CSK. - if ksc.use_csk { - return Err("wrong key roll, use start-csk-roll\n".into()); - } - - // Refuse if we can find a CSK key. - if kss - .keyset - .keys() - .iter() - .any(|(_, key)| matches!(key.keytype(), KeyType::Csk(_, _))) - { - return Err("cannot start key roll, found CSK\n".into()); - } - - // Find existing ZSKs. Do we complain if there is none? - let old_stored: Vec<_> = kss - .keyset - .keys() - .iter() - .filter(|(_, key)| { - if let KeyType::Zsk(keystate) = key.keytype() { - !keystate.old() || keystate.signer() || keystate.present() - } else { - false - } - }) - .map(|(name, _)| name.clone()) - .collect(); - let old: Vec<_> = old_stored.iter().map(|name| name.as_ref()).collect(); - - // Collect algorithms. Maybe this needs to be in the library. - - // Create a new ZSK - let (zsk_pub_url, zsk_priv_url, algorithm, key_tag) = new_keys( - kss.keyset.name(), - ksc.zsk_generate_params.to_generate_params(), - false, - kss.keyset.keys(), - &ksc.keys_dir, - env, - #[cfg(feature = "kmip")] - &mut kss.kmip, - )?; - kss.keyset - .add_key_zsk( - zsk_pub_url.to_string(), - Some(zsk_priv_url.to_string()), - algorithm, - key_tag, - UnixTime::now(), - true, - ) - .map_err::(|e| { - format!("unable to add ZSK {zsk_pub_url}: {e}\n").into() - })?; - - let new = [zsk_pub_url.as_ref()]; - - // Start the key roll - let actions = match kss - .keyset - .start_roll(RollType::ZskRoll, &old, &new) - .map_err::(|e| format!("cannot start roll: {e}\n").into()) - { - Ok(actions) => actions, - Err(e) => { - // Remove the key files we just created. - remove_key(&mut kss, zsk_priv_url)?; - remove_key(&mut kss, zsk_pub_url)?; - return Err(e); - } - }; - handle_actions(&actions, &ksc, &mut kss, env)?; + let actions = + start_zsk_roll(&ksc, &mut kss, env, true, &mut run_update_ds_command)?; print_actions(&actions); state_changed = true; } Commands::StartCskRoll => { - // Find existing KSKs, ZSKs and CSKs. Do we complain if there - // are none? - let old_stored: Vec<_> = kss - .keyset - .keys() - .iter() - .filter(|(_, key)| match key.keytype() { - KeyType::Ksk(keystate) - | KeyType::Zsk(keystate) - | KeyType::Csk(keystate, _) => { - // Assume that for a CSK it is sufficient to check - // one of the key states. Also assume that we - // can check at_parent for a ZSK. - !keystate.old() - || keystate.signer() - || keystate.present() - || keystate.at_parent() - } - KeyType::Include(_) => false, - }) - .map(|(name, _)| name.clone()) - .collect(); - let old: Vec<_> = old_stored.iter().map(|name| name.as_ref()).collect(); - - // Collect algorithms. Maybe this needs to be in the library. - - let (new_stored, new_urls) = if ksc.use_csk { - let mut new_urls = Vec::new(); - - // Create a new CSK - let (csk_pub_url, csk_priv_url, algorithm, key_tag) = new_keys( - kss.keyset.name(), - ksc.csk_generate_params.to_generate_params(), - true, - kss.keyset.keys(), - &ksc.keys_dir, - env, - #[cfg(feature = "kmip")] - &mut kss.kmip, - )?; - new_urls.push(csk_priv_url.clone()); - new_urls.push(csk_pub_url.clone()); - kss.keyset - .add_key_csk( - csk_pub_url.to_string(), - Some(csk_priv_url.to_string()), - algorithm, - key_tag, - UnixTime::now(), - true, - ) - .map_err::(|e| { - format!("unable to add CSK {csk_pub_url}: {e}\n").into() - })?; - - let new = vec![csk_pub_url]; - (new, new_urls) - } else { - let mut new_urls = Vec::new(); - - // Create a new KSK - let (ksk_pub_url, ksk_priv_url, algorithm, key_tag) = new_keys( - kss.keyset.name(), - ksc.ksk_generate_params.to_generate_params(), - true, - kss.keyset.keys(), - &ksc.keys_dir, - env, - #[cfg(feature = "kmip")] - &mut kss.kmip, - )?; - new_urls.push(ksk_priv_url.clone()); - new_urls.push(ksk_pub_url.clone()); - kss.keyset - .add_key_ksk( - ksk_pub_url.to_string(), - Some(ksk_priv_url.to_string()), - algorithm, - key_tag, - UnixTime::now(), - true, - ) - .map_err::(|e| { - format!("unable to add KSK {ksk_pub_url}: {e}\n").into() - })?; - - // Create a new ZSK - let (zsk_pub_url, zsk_priv_url, algorithm, key_tag) = new_keys( - kss.keyset.name(), - ksc.zsk_generate_params.to_generate_params(), - false, - kss.keyset.keys(), - &ksc.keys_dir, - env, - #[cfg(feature = "kmip")] - &mut kss.kmip, - )?; - new_urls.push(zsk_priv_url.clone()); - new_urls.push(zsk_pub_url.clone()); - kss.keyset - .add_key_zsk( - zsk_pub_url.to_string(), - Some(zsk_priv_url.to_string()), - algorithm, - key_tag, - UnixTime::now(), - true, - ) - .map_err::(|e| { - format!("unable to add ZSK {zsk_pub_url}: {e}\n").into() - })?; - - let new = vec![ksk_pub_url, zsk_pub_url]; - (new, new_urls) - }; - - let new: Vec<_> = new_stored.iter().map(|v| v.as_ref()).collect(); - - // Start the key roll - let actions = match kss - .keyset - .start_roll(RollType::CskRoll, &old, &new) - .map_err::(|e| format!("cannot start roll: {e}\n").into()) - { - Ok(actions) => actions, - Err(e) => { - // Remove the key files we just created. - for u in new_urls { - remove_key(&mut kss, u)?; - } - return Err(e); - } - }; - - handle_actions(&actions, &ksc, &mut kss, env)?; + let actions = + start_csk_roll(&ksc, &mut kss, env, true, &mut run_update_ds_command)?; print_actions(&actions); state_changed = true; } Commands::StartAlgorithmRoll => { - // Find existing KSKs, ZSKs and CSKs. Do we complain if there - // are none? - let old_stored: Vec<_> = kss - .keyset - .keys() - .iter() - .filter(|(_, key)| match key.keytype() { - KeyType::Ksk(keystate) - | KeyType::Zsk(keystate) - | KeyType::Csk(keystate, _) => { - // Assume that for a CSK it is sufficient to check - // one of the key states. Also assume that we - // can check at_parent for a ZSK. - !keystate.old() - || keystate.signer() - || keystate.present() - || keystate.at_parent() - } - KeyType::Include(_) => false, - }) - .map(|(name, _)| name.clone()) - .collect(); - let old: Vec<_> = old_stored.iter().map(|name| name.as_ref()).collect(); - - let (new_stored, new_urls) = if ksc.use_csk { - let mut new_urls = Vec::new(); - - // Create a new CSK - let (csk_pub_url, csk_priv_url, algorithm, key_tag) = new_keys( - kss.keyset.name(), - ksc.csk_generate_params.to_generate_params(), - true, - kss.keyset.keys(), - &ksc.keys_dir, - env, - #[cfg(feature = "kmip")] - &mut kss.kmip, - )?; - new_urls.push(csk_priv_url.clone()); - new_urls.push(csk_pub_url.clone()); - kss.keyset - .add_key_csk( - csk_pub_url.to_string(), - Some(csk_priv_url.to_string()), - algorithm, - key_tag, - UnixTime::now(), - true, - ) - .map_err::(|e| { - format!("unable to add CSK {csk_pub_url}: {e}\n").into() - })?; - - let new = vec![csk_pub_url]; - (new, new_urls) - } else { - let mut new_urls = Vec::new(); - - // Create a new KSK - let (ksk_pub_url, ksk_priv_url, algorithm, key_tag) = new_keys( - kss.keyset.name(), - ksc.ksk_generate_params.to_generate_params(), - true, - kss.keyset.keys(), - &ksc.keys_dir, - env, - #[cfg(feature = "kmip")] - &mut kss.kmip, - )?; - new_urls.push(ksk_priv_url.clone()); - new_urls.push(ksk_pub_url.clone()); - kss.keyset - .add_key_ksk( - ksk_pub_url.to_string(), - Some(ksk_priv_url.to_string()), - algorithm, - key_tag, - UnixTime::now(), - true, - ) - .map_err::(|e| { - format!("unable to add KSK {ksk_pub_url}: {e}\n").into() - })?; - - // Create a new ZSK - let (zsk_pub_url, zsk_priv_url, algorithm, key_tag) = new_keys( - kss.keyset.name(), - ksc.zsk_generate_params.to_generate_params(), - false, - kss.keyset.keys(), - &ksc.keys_dir, - env, - #[cfg(feature = "kmip")] - &mut kss.kmip, - )?; - new_urls.push(zsk_priv_url.clone()); - new_urls.push(zsk_pub_url.clone()); - kss.keyset - .add_key_zsk( - zsk_pub_url.to_string(), - Some(zsk_priv_url.to_string()), - algorithm, - key_tag, - UnixTime::now(), - true, - ) - .map_err::(|e| { - format!("unable to add ZSK {zsk_pub_url}: {e}\n").into() - })?; - - let new = vec![ksk_pub_url, zsk_pub_url]; - (new, new_urls) - }; - - let new: Vec<_> = new_stored.iter().map(|v| v.as_ref()).collect(); - - // Start the key roll - let actions = match kss - .keyset - .start_roll(RollType::AlgorithmRoll, &old, &new) - .map_err::(|e| format!("cannot start roll: {e}\n").into()) - { - Ok(actions) => actions, - Err(e) => { - // Remove the key files we just created. - for u in new_urls { - remove_key(&mut kss, u)?; - } - return Err(e); - } - }; - - handle_actions(&actions, &ksc, &mut kss, env)?; + let actions = + start_algorithm_roll(&ksc, &mut kss, env, true, &mut run_update_ds_command)?; print_actions(&actions); state_changed = true; @@ -900,7 +670,14 @@ impl Keyset { // Handle error - handle_actions(&actions, &ksc, &mut kss, env)?; + handle_actions( + &actions, + &ksc, + &mut kss, + env, + true, + &mut run_update_ds_command, + )?; // Report actions print_actions(&actions); @@ -939,7 +716,14 @@ impl Keyset { // Handle error - handle_actions(&actions, &ksc, &mut kss, env)?; + handle_actions( + &actions, + &ksc, + &mut kss, + env, + true, + &mut run_update_ds_command, + )?; // Report actions print_actions(&actions); @@ -949,66 +733,14 @@ impl Keyset { | Commands::ZskRollDone | Commands::CskRollDone | Commands::AlgorithmRollDone => { - let actions = match self.cmd { - Commands::KskRollDone => kss.keyset.roll_done(RollType::KskRoll), - Commands::ZskRollDone => kss.keyset.roll_done(RollType::ZskRoll), - Commands::CskRollDone => kss.keyset.roll_done(RollType::CskRoll), - Commands::AlgorithmRollDone => kss.keyset.roll_done(RollType::AlgorithmRoll), + let r = match self.cmd { + Commands::KskRollDone => RollType::KskRoll, + Commands::ZskRollDone => RollType::ZskRoll, + Commands::CskRollDone => RollType::CskRoll, + Commands::AlgorithmRollDone => RollType::AlgorithmRoll, _ => unreachable!(), }; - - let actions = match actions { - Ok(actions) => actions, - Err(err) => { - return Err(format!("Error reporting done: {err}\n").into()); - } - }; - - if !actions.is_empty() { - return Err("List of actions after reporting done\n".into()); - } - - // Remove old keys. - if ksc.autoremove { - let key_urls: Vec<_> = kss - .keyset - .keys() - .iter() - .filter(|(_, key)| { - let state = match key.keytype() { - KeyType::Ksk(state) => state, - KeyType::Zsk(state) => state, - KeyType::Csk(state, _) => state, - KeyType::Include(state) => state, - }; - state.old() && !state.signer() && !state.present() && !state.at_parent() - }) - .map(|(pubref, key)| (pubref.clone(), key.privref().map(|r| r.to_string()))) - .collect(); - if !key_urls.is_empty() { - print!("Removing:"); - for u in key_urls { - let (pubref, privkey) = &u; - print!(" {pubref}"); - kss.keyset.delete_key(pubref).map_err::(|e| { - format!("unable to remove key {pubref}: {e}\n").into() - })?; - - if let Some(privkey) = privkey { - let priv_url = Url::parse(privkey).map_err::(|e| { - format!("unable to parse {privkey} as URL: {e}").into() - })?; - remove_key(&mut kss, priv_url)?; - } - - let pub_url = Url::parse(pubref).map_err::(|e| { - format!("unable to parse {pubref} as URL: {e}").into() - })?; - remove_key(&mut kss, pub_url)?; - } - println!(); - } - } + do_done(&mut kss, r, ksc.autoremove)?; state_changed = true; } Commands::Status => { @@ -1029,14 +761,69 @@ impl Keyset { println!("{label} {pubref} has expired"); } } + if let Some(cron_next) = &kss.cron_next { + println!("Next time to run the 'cron' subcommand {cron_next}"); + } + + let mut first = true; + for (r, s) in kss.keyset.rollstates() { + let auto_state = kss.internal.get(r).expect("should exit"); + match s { + // Nothing to report. + RollState::CacheExpire1(_) | RollState::CacheExpire2(_) => (), + + RollState::Propagation1 => { + let auto_state = + auto_state.propagation1.lock().expect("should not fail"); + if auto_state.dnskey.is_none() + && auto_state.ds.is_none() + && auto_state.rrsig.is_none() + { + continue; + } + if first { + first = false; + println!("Automatic key roll state:"); + } + show_automatic_roll_state(*r, s, &auto_state, true); + } + RollState::Propagation2 => { + let auto_state = + auto_state.propagation2.lock().expect("should not fail"); + if auto_state.dnskey.is_none() + && auto_state.ds.is_none() + && auto_state.rrsig.is_none() + { + continue; + } + if first { + first = false; + println!("Automatic key roll state:"); + } + show_automatic_roll_state(*r, s, &auto_state, true); + } + RollState::Done => { + let auto_state = auto_state.done.lock().expect("should not fail"); + if auto_state.dnskey.is_none() + && auto_state.ds.is_none() + && auto_state.rrsig.is_none() + { + continue; + } + if first { + first = false; + println!("Automatic key roll state:"); + } + show_automatic_roll_state(*r, s, &auto_state, false); + } + } + } } Commands::Actions => { for roll in kss.keyset.rollstates().keys() { - let actions = kss.keyset.actions(roll.clone()); + let actions = kss.keyset.actions(*roll); println!("{roll:?} actions:"); - for a in actions { - println!("\t{a:?}"); - } + print_actions(&actions); } } Commands::Keys => { @@ -1105,12 +892,29 @@ impl Keyset { Commands::Show => { println!("state-file: {:?}", ksc.state_file); println!("use-csk: {}", ksc.use_csk); - println!("ksk-algorithm: {}", ksc.ksk_generate_params); - println!("zsk-algorithm: {}", ksc.zsk_generate_params); - println!("csk-algorithm: {}", ksc.csk_generate_params); + println!("algorithm: {}", ksc.algorithm); println!("ksk-validity: {:?}", ksc.ksk_validity); println!("zsk-validity: {:?}", ksc.zsk_validity); println!("csk-validity: {:?}", ksc.csk_validity); + println!( + "auto-ksk: start {}, report {}, expire {}, done {}", + ksc.auto_ksk.start, ksc.auto_ksk.report, ksc.auto_ksk.expire, ksc.auto_ksk.done, + ); + println!( + "auto-zsk: start {}, report {}, expire {}, done {}", + ksc.auto_zsk.start, ksc.auto_zsk.report, ksc.auto_zsk.expire, ksc.auto_zsk.done, + ); + println!( + "auto-csk: start {}, report {}, expire {}, done {}", + ksc.auto_csk.start, ksc.auto_csk.report, ksc.auto_csk.expire, ksc.auto_csk.done, + ); + println!( + "auto-algorithm: start {}, report {}, expire {}, done {}", + ksc.auto_algorithm.start, + ksc.auto_algorithm.report, + ksc.auto_algorithm.expire, + ksc.auto_algorithm.done, + ); println!("dnskey-inception-offset: {:?}", ksc.dnskey_inception_offset); println!( "dnskey-signature-lifetime: {:?}", @@ -1122,18 +926,192 @@ impl Keyset { println!("cds-remain-time: {:?}", ksc.cds_remain_time); println!("ds-algorithm: {:?}", ksc.ds_algorithm); println!("autoremove: {:?}", ksc.autoremove); + println!("update_ds_command: {:?}", ksc.update_ds_command); } Commands::Cron => { if sig_renew(&kss.dnskey_rrset, &ksc.dnskey_remain_time) { println!("DNSKEY RRSIG(s) need to be renewed"); - update_dnskey_rrset(&mut kss, &ksc, env)?; + update_dnskey_rrset(&mut kss, &ksc, env, false)?; state_changed = true; } if sig_renew(&kss.cds_rrset, &ksc.cds_remain_time) { println!("CDS/CDNSKEY RRSIGs need to be renewed"); - create_cds_rrset(&mut kss, &ksc, ksc.ds_algorithm.to_digest_algorithm(), env)?; + create_cds_rrset( + &mut kss, + &ksc, + ksc.ds_algorithm.to_digest_algorithm(), + env, + false, + )?; state_changed = true; } + + let need_algorithm_roll = algorithm_roll_needed(&ksc, &kss); + + if ksc.use_csk || need_algorithm_roll { + // Start a CSK or algorithm roll if the KSK has expired. + // All other rolls are a conflict. + auto_start( + &ksc.ksk_validity, + if need_algorithm_roll { + &ksc.auto_algorithm + } else { + &ksc.auto_csk + }, + &ksc, + &mut kss, + env, + &mut state_changed, + |_| true, + |keytype| { + if let KeyType::Ksk(keystate) = keytype { + Some(keystate) + } else { + None + } + }, + if need_algorithm_roll { + start_algorithm_roll + } else { + start_csk_roll + }, + &mut run_update_ds_command, + )?; + + // The same for the ZSK. + auto_start( + &ksc.zsk_validity, + if need_algorithm_roll { + &ksc.auto_algorithm + } else { + &ksc.auto_csk + }, + &ksc, + &mut kss, + env, + &mut state_changed, + |_| true, + |keytype| { + if let KeyType::Zsk(keystate) = keytype { + Some(keystate) + } else { + None + } + }, + if need_algorithm_roll { + start_algorithm_roll + } else { + start_csk_roll + }, + &mut run_update_ds_command, + )?; + } else { + auto_start( + &ksc.ksk_validity, + &ksc.auto_ksk, + &ksc, + &mut kss, + env, + &mut state_changed, + |r| r != RollType::ZskRoll && r != RollType::ZskDoubleSignatureRoll, + |keytype| { + if let KeyType::Ksk(keystate) = keytype { + Some(keystate) + } else { + None + } + }, + start_ksk_roll, + &mut run_update_ds_command, + )?; + + auto_start( + &ksc.zsk_validity, + &ksc.auto_zsk, + &ksc, + &mut kss, + env, + &mut state_changed, + |r| r != RollType::KskRoll && r != RollType::KskDoubleDsRoll, + |keytype| { + if let KeyType::Zsk(keystate) = keytype { + Some(keystate) + } else { + None + } + }, + start_zsk_roll, + &mut run_update_ds_command, + )?; + } + + auto_start( + &ksc.csk_validity, + if need_algorithm_roll { + &ksc.auto_algorithm + } else { + &ksc.auto_csk + }, + &ksc, + &mut kss, + env, + &mut state_changed, + |_| true, + |keytype| { + if let KeyType::Csk(keystate, _) = keytype { + Some(keystate) + } else { + None + } + }, + if need_algorithm_roll { + start_algorithm_roll + } else { + start_csk_roll + }, + &mut run_update_ds_command, + )?; + + auto_report_expire_done( + &ksc.auto_ksk, + &[RollType::KskRoll, RollType::KskDoubleDsRoll], + &ksc, + &mut kss, + env, + &mut state_changed, + &mut run_update_ds_command, + ) + .await?; + auto_report_expire_done( + &ksc.auto_zsk, + &[RollType::ZskRoll, RollType::ZskDoubleSignatureRoll], + &ksc, + &mut kss, + env, + &mut state_changed, + &mut run_update_ds_command, + ) + .await?; + auto_report_expire_done( + &ksc.auto_csk, + &[RollType::CskRoll], + &ksc, + &mut kss, + env, + &mut state_changed, + &mut run_update_ds_command, + ) + .await?; + auto_report_expire_done( + &ksc.auto_algorithm, + &[RollType::AlgorithmRoll], + &ksc, + &mut kss, + env, + &mut state_changed, + &mut run_update_ds_command, + ) + .await?; } #[cfg(feature = "kmip")] @@ -1142,17 +1120,136 @@ impl Keyset { } } - let cron_next_dnskey = compute_cron_next(&kss.dnskey_rrset, &ksc.dnskey_remain_time); - let cron_next_cds = compute_cron_next(&kss.cds_rrset, &ksc.cds_remain_time); - let cron_next = if let Some(cron_next_dnskey) = cron_next_dnskey { - if let Some(cron_next_cds) = cron_next_cds { - Some(min(cron_next_dnskey, cron_next_cds)) - } else { - Some(cron_next_dnskey) - } + if !config_changed && !state_changed { + // No need to update cron_next if nothing has changed. + return Ok(()); + } + + let mut cron_next = Vec::new(); + + cron_next.push(compute_cron_next( + &kss.dnskey_rrset, + &ksc.dnskey_remain_time, + )); + + cron_next.push(compute_cron_next(&kss.cds_rrset, &ksc.cds_remain_time)); + + let need_algorithm_roll = algorithm_roll_needed(&ksc, &kss); + + if ksc.use_csk || need_algorithm_roll { + cron_next_auto_start( + ksc.ksk_validity, + if need_algorithm_roll { + &ksc.auto_algorithm + } else { + &ksc.auto_csk + }, + &kss, + |_| true, + |keytype| { + if let KeyType::Ksk(keystate) = keytype { + Some(keystate) + } else { + None + } + }, + &mut cron_next, + ); + cron_next_auto_start( + ksc.zsk_validity, + if need_algorithm_roll { + &ksc.auto_algorithm + } else { + &ksc.auto_csk + }, + &kss, + |_| true, + |keytype| { + if let KeyType::Zsk(keystate) = keytype { + Some(keystate) + } else { + None + } + }, + &mut cron_next, + ); } else { - cron_next_cds - }; + cron_next_auto_start( + ksc.ksk_validity, + &ksc.auto_ksk, + &kss, + |r| r != RollType::ZskRoll && r != RollType::ZskDoubleSignatureRoll, + |keytype| { + if let KeyType::Ksk(keystate) = keytype { + Some(keystate) + } else { + None + } + }, + &mut cron_next, + ); + cron_next_auto_start( + ksc.zsk_validity, + &ksc.auto_zsk, + &kss, + |r| r != RollType::KskRoll && r != RollType::KskDoubleDsRoll, + |keytype| { + if let KeyType::Zsk(keystate) = keytype { + Some(keystate) + } else { + None + } + }, + &mut cron_next, + ); + } + + cron_next_auto_start( + ksc.csk_validity, + if need_algorithm_roll { + &ksc.auto_algorithm + } else { + &ksc.auto_csk + }, + &kss, + |_| true, + |keytype| { + if let KeyType::Csk(keystate, _) = keytype { + Some(keystate) + } else { + None + } + }, + &mut cron_next, + ); + + cron_next_auto_report_expire_done( + &ksc.auto_ksk, + &[RollType::KskRoll, RollType::KskDoubleDsRoll], + &kss, + &mut cron_next, + )?; + cron_next_auto_report_expire_done( + &ksc.auto_zsk, + &[RollType::ZskRoll, RollType::ZskDoubleSignatureRoll], + &kss, + &mut cron_next, + )?; + cron_next_auto_report_expire_done( + &ksc.auto_csk, + &[RollType::CskRoll], + &kss, + &mut cron_next, + )?; + cron_next_auto_report_expire_done( + &ksc.auto_algorithm, + &[RollType::AlgorithmRoll], + &kss, + &mut cron_next, + )?; + + let cron_next = cron_next.iter().filter_map(|e| e.clone()).min(); + if cron_next != kss.cron_next { kss.cron_next = cron_next; state_changed = true; @@ -1179,10 +1276,27 @@ impl Keyset { format!("unable to write to file {}: {e}", ksc.state_file.display()).into() })?; } + + // Now check if we need to run the update_ds_command. Make sure that + // all locks are released before running the command. The command + // may want to call back into keyset to retreive the DS + // (or CDS/CDNSKEY) records. + if run_update_ds_command && !ksc.update_ds_command.is_empty() { + let output = Command::new(&ksc.update_ds_command[0]) + .args(&ksc.update_ds_command[1..]) + .output()?; + if !output.status.success() { + println!("update command failed with: {}", output.status); + io::stdout().write_all(&output.stdout)?; + io::stderr().write_all(&output.stderr)?; + } + } + Ok(()) } } +/// Remove a key from the filesystem or the HSM. #[allow(unused_variables)] fn remove_key(kss: &mut KeySetState, url: Url) -> Result<(), Error> { match url.scheme() { @@ -1208,6 +1322,7 @@ fn remove_key(kss: &mut KeySetState, url: Url) -> Result<(), Error> { Ok(()) } +/// Implement the get subcommand. fn get_command(cmd: GetCommands, ksc: &KeySetConfig, kss: &KeySetState) { match cmd { GetCommands::UseCsk => { @@ -1216,14 +1331,8 @@ fn get_command(cmd: GetCommands, ksc: &KeySetConfig, kss: &KeySetState) { GetCommands::Autoremove => { println!("{}", ksc.autoremove); } - GetCommands::KskAlgorithm => { - println!("{}", ksc.ksk_generate_params); - } - GetCommands::ZskAlgorithm => { - println!("{}", ksc.zsk_generate_params); - } - GetCommands::CskAlgorithm => { - println!("{}", ksc.csk_generate_params); + GetCommands::Algorithm => { + println!("{}", ksc.algorithm); } GetCommands::DsAlgorithm => { println!("{}", ksc.ds_algorithm); @@ -1260,6 +1369,7 @@ fn get_command(cmd: GetCommands, ksc: &KeySetConfig, kss: &KeySetState) { } } +/// Implement the set subcommand. fn set_command( cmd: SetCommands, ksc: &mut KeySetConfig, @@ -1272,14 +1382,64 @@ fn set_command( SetCommands::Autoremove { boolean } => { ksc.autoremove = boolean; } - SetCommands::KskAlgorithm { algorithm, bits } => { - ksc.ksk_generate_params = KeyParameters::new(&algorithm, bits)?; + SetCommands::Algorithm { algorithm, bits } => { + ksc.algorithm = KeyParameters::new(&algorithm, bits)?; } - SetCommands::ZskAlgorithm { algorithm, bits } => { - ksc.zsk_generate_params = KeyParameters::new(&algorithm, bits)?; + SetCommands::AutoKsk { + start, + report, + expire, + done, + } => { + ksc.auto_ksk = AutoConfig { + start, + report, + expire, + done, + }; + *config_changed = true; } - SetCommands::CskAlgorithm { algorithm, bits } => { - ksc.csk_generate_params = KeyParameters::new(&algorithm, bits)?; + SetCommands::AutoZsk { + start, + report, + expire, + done, + } => { + ksc.auto_zsk = AutoConfig { + start, + report, + expire, + done, + }; + *config_changed = true; + } + SetCommands::AutoCsk { + start, + report, + expire, + done, + } => { + ksc.auto_csk = AutoConfig { + start, + report, + expire, + done, + }; + *config_changed = true; + } + SetCommands::AutoAlgorithm { + start, + report, + expire, + done, + } => { + ksc.auto_algorithm = AutoConfig { + start, + report, + expire, + done, + }; + *config_changed = true; } SetCommands::DsAlgorithm { algorithm } => { ksc.ds_algorithm = algorithm; @@ -1311,6 +1471,9 @@ fn set_command( SetCommands::CskValidity { opt_duration } => { ksc.csk_validity = opt_duration; } + SetCommands::UpdateDsCommand { args } => { + ksc.update_ds_command = args; + } } *config_changed = true; Ok(()) @@ -1319,45 +1482,73 @@ fn set_command( /// Config for the keyset command. #[derive(Deserialize, Serialize)] struct KeySetConfig { + /// Filename of the state file. state_file: PathBuf, + + /// Directory where new key file should be created. keys_dir: PathBuf, + /// Whether to use a CSK (if true) or a KSK and a ZSK. use_csk: bool, /// Algorithm and other parameters for key generation. - ksk_generate_params: KeyParameters, - zsk_generate_params: KeyParameters, - csk_generate_params: KeyParameters, + algorithm: KeyParameters, + /// Validity of KSKs. ksk_validity: Option, + /// Validity of ZSKs. zsk_validity: Option, + /// Validity of CSKs. csk_validity: Option, - // ksk validity - // auto-ksk - // DNSKEY inception offset + /// Configuration variable for automatic KSK rolls. + auto_ksk: AutoConfig, + /// Configuration variable for automatic ZSK rolls. + auto_zsk: AutoConfig, + /// Configuration variable for automatic CSK rolls. + auto_csk: AutoConfig, + /// Configuration variable for automatic algorithm rolls. + auto_algorithm: AutoConfig, + + /// DNSKEY signature inception offset (positive values are subtracted + ///from the current time). dnskey_inception_offset: Duration, - // DNSKEY sig lifetime + /// DNSKEY signature lifetime dnskey_signature_lifetime: Duration, - // DNSKEY resign + /// The required remaining signature lifetime. dnskey_remain_time: Duration, - // CDS/CDNSKEY inception offset + /// CDS/CDNSKEY signature inception offset cds_inception_offset: Duration, - // CDS/CDNSKEY sig lifetime + /// CDS/CDNSKEY signature lifetime cds_signature_lifetime: Duration, - // CDS/CDNSKEY resign + /// The required remaining signature lifetime. cds_remain_time: Duration, - // DS hash algorithm + /// The DS hash algorithm. ds_algorithm: DsAlgorithm, /// Automatically remove keys that are no long in use. autoremove: bool, + + /// Command to run when the DS records at the parent need updating. + update_ds_command: Vec, +} + +#[derive(Default, Deserialize, Serialize)] +struct AutoConfig { + /// Whether to start a key roll automatically. + start: bool, + /// Whether to handle the Report actions automatically. + report: bool, + /// Whether to handle the cache expire step automatically. + expire: bool, + /// Whether to handle the done step automatically. + done: bool, } /// Persistent state for the keyset command. @@ -1366,30 +1557,51 @@ pub struct KeySetState { /// Domain KeySet state. pub keyset: KeySet, + /// DNSKEY RRset plus signatures to include in the signed zone. pub dnskey_rrset: Vec, + + /// DS records to add to the parent zone. pub ds_rrset: Vec, + + /// CDS and CDNSKEY RRsets plus signatures to include in the signed zone. pub cds_rrset: Vec, + + /// Place holder for NS records. Maybe the four _rrset fields should be + /// combined. Though for extensibility there needs to be a field that + /// informs the signer which Rtypes need special treatment. pub ns_rrset: Vec, + /// Next time to call the cron subcommand. cron_next: Option, /// KMIP related configuration. #[cfg(feature = "kmip")] #[serde(default)] pub kmip: KmipState, + + /// Internal state for automatic key rolls. + internal: HashMap, } #[derive(Deserialize, Serialize)] enum KeyParameters { + /// The RSASHA256 algorithm with the key length in bits. RsaSha256(usize), + /// The RSASHA512 w algorithmith the key length in bits. RsaSha512(usize), + /// The ECDSAP256SHA256 algorithm. EcdsaP256Sha256, + /// The ECDSAP384SHA384 algorithm. EcdsaP384Sha384, + /// The ED25519 algorithm. Ed25519, + /// The ED448 algorithm. Ed448, } impl KeyParameters { + /// Generate a new KeyParameter object from the algorithm name and + /// the key length (when required). fn new(algorithm: &str, bits: Option) -> Result { if algorithm == "RSASHA256" { let bits = bits.ok_or::("bits option expected\n".into())?; @@ -1410,6 +1622,7 @@ impl KeyParameters { } } + /// Return the GenerateParams equivalent of a KeyParameters object. fn to_generate_params(&self) -> GenerateParams { match self { KeyParameters::RsaSha256(size) => GenerateParams::RsaSha256 { @@ -1439,14 +1652,18 @@ impl Display for KeyParameters { } } +/// The hash algorithm to use for DS records. // Do we want Deserialize and Serialize for DigestAlgorithm? #[derive(Clone, Debug, Deserialize, Serialize)] enum DsAlgorithm { + /// Hash the public key using SHA-256. Sha256, + /// Hash the public key using SHA-384. Sha384, } impl DsAlgorithm { + /// Create a new DsAlgorithm based on the hash algorithm name. fn new(digest: &str) -> Result { if digest == "SHA-256" { Ok(DsAlgorithm::Sha256) @@ -1457,6 +1674,7 @@ impl DsAlgorithm { } } + /// Return the equivalent DigestAlgorithm for a DsAlgorithm object. fn to_digest_algorithm(&self) -> DigestAlgorithm { match self { DsAlgorithm::Sha256 => DigestAlgorithm::SHA256, @@ -1474,6 +1692,27 @@ impl Display for DsAlgorithm { } } +/// State needed for automatic key rolls. +#[derive(Default, Deserialize, Serialize)] +struct RollStateReports { + /// State for the propagation1-complete step. + propagation1: Mutex, + /// State for the propagation2-complete step. + propagation2: Mutex, + /// State for the done step. + done: Mutex, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +struct ReportState { + /// State for DNSKEY propagation checks. + dnskey: Option, + /// State for DS propagation checks. + ds: Option, + /// State for RRSIG propagation checks. + rrsig: Option, +} + fn new_keys( name: &Name>, algorithm: GenerateParams, @@ -1745,10 +1984,15 @@ fn new_keys( Ok((public_key_url, secret_key_url, algorithm, key_tag)) } +/// Update the DNSKEY RRset and signures in the KeySetState. +/// +/// Collect all keys where present() returns true and sign the DNSKEY RRset +/// with all KSK and CSK (KSK state) where signer() returns true. fn update_dnskey_rrset( kss: &mut KeySetState, ksc: &KeySetConfig, env: &impl Env, + verbose: bool, ) -> Result<(), Error> { let mut dnskeys = Vec::new(); for (k, v) in kss.keyset.keys() { @@ -1926,15 +2170,25 @@ fn update_dnskey_rrset( kss.dnskey_rrset .push(r.display_zonefile(DisplayKind::Simple).to_string()); } - println!("Got DNSKEY RRset: {:?}", kss.dnskey_rrset); + if verbose { + println!("Got DNSKEY RRset:"); + for r in &kss.dnskey_rrset { + println!("\t{r}"); + } + } Ok(()) } +/// Create the CDS and CDNSKEY RRsets plus signatures. +/// +/// The CDS and CDNSKEY RRsets contain the keys where at_parent() returns +/// true. The RRsets are signed with all keys that sign the DNSKEY RRset. fn create_cds_rrset( kss: &mut KeySetState, ksc: &KeySetConfig, digest_alg: DigestAlgorithm, env: &impl Env, + verbose: bool, ) -> Result<(), Error> { let mut cds_list = Vec::new(); let mut cdnskey_list = Vec::new(); @@ -2123,10 +2377,16 @@ fn create_cds_rrset( .push(r.display_zonefile(DisplayKind::Simple).to_string()); } - println!("Got CDS/CDNSKEY RRset: {:?}", kss.cds_rrset); + if verbose { + println!("Got CDS/CDNSKEY RRset:"); + for r in &kss.cds_rrset { + println!("\t{r}"); + } + } Ok(()) } +/// Create CDS and CDNSKEY RRsets. fn create_cds_rrset_helper( digest_alg: DigestAlgorithm, cds_list: &mut Vec, Cds>>>, @@ -2159,14 +2419,20 @@ fn create_cds_rrset_helper( Ok(()) } +/// Remove the CDS and CDNSKEY RRsets and signatures. fn remove_cds_rrset(kss: &mut KeySetState) { kss.cds_rrset.truncate(0); } +/// Update the DS RRset. +/// +/// The DS records are generated from all keys where at_parent() returns true. +/// This RRset is not signed. fn update_ds_rrset( kss: &mut KeySetState, digest_alg: DigestAlgorithm, env: &impl Env, + verbose: bool, ) -> Result<(), Error> { #[allow(clippy::type_complexity)] let mut ds_list: Vec>, Ds>>> = Vec::new(); @@ -2277,25 +2543,42 @@ fn update_ds_rrset( .push(r.display_zonefile(DisplayKind::Simple).to_string()); } - println!("Got DS RRset: {:?}", kss.ds_rrset); + if verbose { + println!("Got DS RRset:"); + for r in &kss.ds_rrset { + println!("\t{r}"); + } + } Ok(()) } +/// Handle the actions that result from key roll steps that always need to +/// be handled independent of automation. +/// +/// Those are the actions that update the DNSKEY RRset, DS records and the +/// CDS and CDNSKEY RRsets. fn handle_actions( actions: &[Action], ksc: &KeySetConfig, kss: &mut KeySetState, env: &impl Env, + verbose: bool, + run_update_ds_command: &mut bool, ) -> Result<(), Error> { for action in actions { match action { - Action::UpdateDnskeyRrset => update_dnskey_rrset(kss, ksc, env)?, - Action::CreateCdsRrset => { - create_cds_rrset(kss, ksc, ksc.ds_algorithm.to_digest_algorithm(), env)? - } + Action::UpdateDnskeyRrset => update_dnskey_rrset(kss, ksc, env, verbose)?, + Action::CreateCdsRrset => create_cds_rrset( + kss, + ksc, + ksc.ds_algorithm.to_digest_algorithm(), + env, + verbose, + )?, Action::RemoveCdsRrset => remove_cds_rrset(kss), Action::UpdateDsRrset => { - update_ds_rrset(kss, ksc.ds_algorithm.to_digest_algorithm(), env)? + *run_update_ds_command = true; + update_ds_rrset(kss, ksc.ds_algorithm.to_digest_algorithm(), env, verbose)? } Action::UpdateRrsig => (), Action::ReportDnskeyPropagated => (), @@ -2309,18 +2592,70 @@ fn handle_actions( Ok(()) } +/// Print a list of actions. +/// +/// TODO: make this list user friendly. fn print_actions(actions: &[Action]) { if actions.is_empty() { println!("No actions"); } else { - print!("Actions:"); + println!("Actions:"); + let mut report_count = 0; for a in actions { - print!(" {a:?}"); + println!("\t{a:?}:"); + match a { + Action::CreateCdsRrset => { + println!("\t\tsign the zone with the CDS and CDNSKEY RRsets") + } + Action::RemoveCdsRrset => { + println!("\t\tsign the zone with empty CDS and CDNSKEY RRsets") + } + Action::UpdateDnskeyRrset => { + println!("\t\tsign the zone with the new DNSKEY RRset from the state file") + } + Action::UpdateDsRrset => { + println!("\t\tupdate the DS RRset at the parent to match the CDNSKEY RRset") + } + Action::UpdateRrsig => println!("\t\tsign the zone with the new zone signing keys"), + Action::ReportDnskeyPropagated => { + println!("\t\tverify that the new DNSKEY RRset has propagated to all"); + println!("\t\tnameservers and report (at least) the TTL of the DNSKEY RRset"); + report_count += 1; + } + Action::ReportDsPropagated => { + println!("\t\tverify that all nameservers of the parent zone have a new"); + println!("\t\tDS RRset that matches the keys in the CNDSKEY RRset and"); + println!("\t\treport (at least) the TTL of the DNSKEY RRset"); + report_count += 1; + } + Action::ReportRrsigPropagated => { + println!("\t\tverify that the new RRSIG records have propagated to all"); + println!("\t\tnameservers and report (at least) the maximum TTL among"); + println!("\t\tthe RRSIG records"); + report_count += 1; + } + Action::WaitDnskeyPropagated => { + println!("\t\tverify that the new DNSKEY RRset has propagated to all"); + println!("\t\tnameservers"); + } + Action::WaitDsPropagated => { + println!("\t\tverify that all nameservers of the parent zone have a new"); + println!("\t\tDS RRset that matches the keys in the CNDSKEY RRset"); + } + Action::WaitRrsigPropagated => { + println!("\t\tverify that the new RRSIG records have propagated to all"); + println!("\t\tnameservers"); + } + } + println!(); + } + if report_count > 1 { + println!("\tNote: with multiple Report actions, report the maximum of the TTLs."); } - println!(); } } +/// Parse a duration from a string with suffixes like 'm', 'h', 'w', etc. pub fn parse_duration(value: &str) -> Result { let span: Span = value .parse() @@ -2331,6 +2666,8 @@ pub fn parse_duration(value: &str) -> Result { Duration::try_from(signeddur).map_err(|e| format!("unable to convert duration: {e}\n").into()) } +/// Parse an optional duration from a string but also allow 'off' to signal +/// no duration. fn parse_opt_duration(value: &str) -> Result, Error> { if value == "off" { return Ok(None); @@ -2339,6 +2676,10 @@ fn parse_opt_duration(value: &str) -> Result, Error> { Ok(Some(duration)) } +/// Check whether signatures need to be renewed. +/// +/// The input is an RRset plus signatures in zonefile format plus a +/// duration how long the signatures are required to remain valid. fn sig_renew(rrset: &[String], remain_time: &Duration) -> bool { let mut zonefile = Zonefile::new(); for r in rrset { @@ -2363,6 +2704,8 @@ fn sig_renew(rrset: &[String], remain_time: &Duration) -> bool { false } +/// Return where a key has expired. Return a label for the type of +/// key as well to help user friendly output. fn key_expired(key: &Key, ksc: &KeySetConfig) -> (bool, &'static str) { let Some(timestamp) = key.timestamps().published() else { return (false, ""); @@ -2375,7 +2718,7 @@ fn key_expired(key: &Key, ksc: &KeySetConfig) -> (bool, &'static str) { KeyType::Csk(keystate, _) => (keystate, "CSK", ksc.csk_validity), KeyType::Include(_) => return (false, ""), // Does not expire. }; - if keystate.old() && !keystate.present() && !keystate.signer() && !keystate.at_parent() { + if keystate.stale() { // Old key. return (false, ""); } @@ -2386,10 +2729,13 @@ fn key_expired(key: &Key, ksc: &KeySetConfig) -> (bool, &'static str) { (timestamp.elapsed() > validity, label) } +/// Create a PathBuf for the parent directory of a PathBuf. fn make_parent_dir(filename: PathBuf) -> PathBuf { filename.parent().unwrap_or(Path::new("/")).to_path_buf() } +/// Compute when the cron subcommand should be called to refresh signatures +/// for an RRset. fn compute_cron_next(rrset: &[String], remain_time: &Duration) -> Option { let mut zonefile = Zonefile::new(); for r in rrset { @@ -2414,5 +2760,2341 @@ fn compute_cron_next(rrset: &[String], remain_time: &Duration) -> Option>, + /// Rtype to check. + rtype: Rtype, + /// The ttl to use to compute a new 'next' wait time if the check fails. + ttl: Ttl, + }, + /// For NSEC3 record, it is not possible to directly check if they got new + /// signatures. Instead, wait for a new version of the zone and check the + /// entire zone. + WaitNextSerial { + /// Try again after this time. + next: UnixTime, + /// Wait until the zone version is new than this serial. + serial: Serial, + /// The ttl to use to compute a new 'next' wait time if the check fails. + ttl: Ttl, + }, +} + +/// Handle the actions for the Done state automatically. Actions for this +/// state cannot have report actions, but there can be wait actions. +async fn auto_wait_actions( + actions: &[Action], + kss: &KeySetState, + report_state: &Mutex, + state_changed: &mut bool, +) -> AutoActionsResult { + for a in actions { + match a { + Action::CreateCdsRrset + | Action::RemoveCdsRrset + | Action::UpdateDnskeyRrset + | Action::UpdateDsRrset + | Action::UpdateRrsig => (), + Action::WaitDnskeyPropagated => { + // Note, an extra scope here to make clippy happy. Otherwise + // clippy thinks that the lock is used across an await point. + { + let report_state_locked = report_state.lock().expect("lock() should not fail"); + if let Some(dnskey_status) = &report_state_locked.dnskey { + match dnskey_status { + AutoReportActionsResult::Wait(next) => { + if *next > UnixTime::now() { + return AutoActionsResult::Wait(next.clone()); + } + } + AutoReportActionsResult::Report(_) => continue, + } + } + + drop(report_state_locked); + } + + let result = report_dnskey_propagated(kss).await; + + let mut report_state_locked = report_state.lock().expect("lock() should not fail"); + report_state_locked.dnskey = Some(result.clone()); + drop(report_state_locked); + *state_changed = true; + + match result { + AutoReportActionsResult::Wait(next) => return AutoActionsResult::Wait(next), + AutoReportActionsResult::Report(_) => (), + } + } + Action::WaitDsPropagated => { + // Clippy problem + { + let report_state_locked = report_state.lock().expect("lock() should not fail"); + if let Some(ds_status) = &report_state_locked.ds { + match ds_status { + AutoReportActionsResult::Wait(next) => { + if *next > UnixTime::now() { + return AutoActionsResult::Wait(next.clone()); + } + } + AutoReportActionsResult::Report(_) => continue, + } + } + drop(report_state_locked); + } + + let result = report_ds_propagated(kss).await.unwrap_or_else(|e| { + warn!("Check DS propagation failed: {e}"); + AutoReportActionsResult::Wait(UnixTime::now() + DEFAULT_WAIT) + }); + + let mut report_state_locked = report_state.lock().expect("lock() should not fail"); + report_state_locked.ds = Some(result.clone()); + drop(report_state_locked); + *state_changed = true; + + match result { + AutoReportActionsResult::Wait(next) => return AutoActionsResult::Wait(next), + AutoReportActionsResult::Report(_) => (), + } + } + Action::WaitRrsigPropagated => { + // Clippy problem + let opt_rrsig_status = { + let report_state_locked = report_state.lock().expect("lock() should not fail"); + // Make a copy of the state. We need to release the lock + // before calling await. + let opt_rrsig_status = report_state_locked.rrsig.clone(); + drop(report_state_locked); + opt_rrsig_status + }; + + if let Some(rrsig_status) = opt_rrsig_status { + match rrsig_status { + AutoReportRrsigResult::Wait(next) => { + if next > UnixTime::now() { + return AutoActionsResult::Wait(next.clone()); + } + } + AutoReportRrsigResult::Report(_) => continue, + AutoReportRrsigResult::WaitSoa { + next, + serial, + ttl, + report_ttl, + } => { + if next > UnixTime::now() { + return AutoActionsResult::Wait(next.clone()); + } + let res = check_soa(serial, kss).await.unwrap_or_else(|e| { + warn!("Check SOA propagation failed: {e}"); + false + }); + dbg!(format!("got {res} for {serial}")); + if res { + dbg!("Setting rrsig to Report"); + let mut report_state_locked = + report_state.lock().expect("lock() should not fail"); + report_state_locked.rrsig = + Some(AutoReportRrsigResult::Report(report_ttl)); + drop(report_state_locked); + *state_changed = true; + continue; + } else { + let next = UnixTime::now() + ttl.into(); + dbg!("Setting rrsig to WaitSoa"); + let mut report_state_locked = + report_state.lock().expect("lock() should not fail"); + report_state_locked.rrsig = Some(AutoReportRrsigResult::WaitSoa { + next: next.clone(), + serial, + ttl, + report_ttl, + }); + drop(report_state_locked); + *state_changed = true; + return AutoActionsResult::Wait(next); + } + } + AutoReportRrsigResult::WaitRecord { + next, + name, + rtype, + ttl, + } => { + if next > UnixTime::now() { + return AutoActionsResult::Wait(next.clone()); + } + let res = check_record(&name, &rtype, kss).await.unwrap_or_else(|e| { + warn!("record check failed: {e}"); + false + }); + if !res { + let next = UnixTime::now() + ttl.into(); + let mut report_state_locked = + report_state.lock().expect("lock() should not fail"); + report_state_locked.rrsig = + Some(AutoReportRrsigResult::WaitRecord { + next: next.clone(), + name: name.clone(), + rtype, + ttl, + }); + drop(report_state_locked); + *state_changed = true; + return AutoActionsResult::Wait(next); + } + + // This record has the right signatures. Check + // the zone. + } + AutoReportRrsigResult::WaitNextSerial { next, serial, ttl } => { + if next > UnixTime::now() { + return AutoActionsResult::Wait(next.clone()); + } + let res = check_next_serial(serial, kss).await.unwrap_or_else(|e| { + warn!("next serial check failed: {e}"); + false + }); + if !res { + let next = UnixTime::now() + ttl.into(); + let mut report_state_locked = + report_state.lock().expect("lock() should not fail"); + report_state_locked.rrsig = + Some(AutoReportRrsigResult::WaitNextSerial { + next: next.clone(), + serial, + ttl, + }); + drop(report_state_locked); + *state_changed = true; + return AutoActionsResult::Wait(next); + } + + // A new serial. Check the zone. + } + } + } + + let result = report_rrsig_propagated(kss).await.unwrap_or_else(|e| { + warn!("Check RRSIG propagation failed: {e}"); + AutoReportRrsigResult::Wait(UnixTime::now() + DEFAULT_WAIT) + }); + + let mut report_state_locked = report_state.lock().expect("lock() should not fail"); + report_state_locked.rrsig = Some(result.clone()); + drop(report_state_locked); + *state_changed = true; + + match result { + AutoReportRrsigResult::Wait(next) + | AutoReportRrsigResult::WaitRecord { next, .. } + | AutoReportRrsigResult::WaitNextSerial { next, .. } + | AutoReportRrsigResult::WaitSoa { next, .. } => { + return AutoActionsResult::Wait(next) + } + AutoReportRrsigResult::Report(_) => (), + } + } + // These actions are not compatible with the 'done' state because + // the 'done' state does not report anything, it can only wait. + Action::ReportDnskeyPropagated + | Action::ReportDsPropagated + | Action::ReportRrsigPropagated => unreachable!(), + } + } + AutoActionsResult::Ok +} + +/// Handle automatic report actions. +async fn auto_report_actions( + actions: &[Action], + kss: &KeySetState, + report_state: &Mutex, + state_changed: &mut bool, +) -> AutoReportActionsResult { + assert!(!actions.is_empty()); + let mut max_ttl = Ttl::from_secs(0); + for a in actions { + match a { + Action::ReportDnskeyPropagated => { + // Clippy problem + { + let report_state_locked = report_state.lock().expect("lock() should not fail"); + if let Some(dnskey_status) = &report_state_locked.dnskey { + match dnskey_status { + AutoReportActionsResult::Wait(next) => { + if *next > UnixTime::now() { + return dnskey_status.clone(); + } + } + AutoReportActionsResult::Report(ttl) => { + max_ttl = max(max_ttl, *ttl); + continue; + } + } + } + drop(report_state_locked); + } + + let result = report_dnskey_propagated(kss).await; + + let mut report_state_locked = report_state.lock().expect("lock() should not fail"); + report_state_locked.dnskey = Some(result.clone()); + drop(report_state_locked); + *state_changed = true; + + match result { + AutoReportActionsResult::Wait(_) => return result, + AutoReportActionsResult::Report(ttl) => { + max_ttl = max(max_ttl, ttl); + } + } + } + Action::ReportDsPropagated => { + // Clippy problem + { + let report_state_locked = report_state.lock().expect("lock() should not fail"); + if let Some(ds_status) = &report_state_locked.ds { + match ds_status { + AutoReportActionsResult::Wait(next) => { + if *next > UnixTime::now() { + return ds_status.clone(); + } + } + AutoReportActionsResult::Report(ttl) => { + max_ttl = max(max_ttl, *ttl); + continue; + } + } + } + drop(report_state_locked); + } + + let result = report_ds_propagated(kss).await.unwrap_or_else(|e| { + warn!("Check DS propagation failed: {e}"); + AutoReportActionsResult::Wait(UnixTime::now() + DEFAULT_WAIT) + }); + + let mut report_state_locked = report_state.lock().expect("lock() should not fail"); + report_state_locked.ds = Some(result.clone()); + drop(report_state_locked); + *state_changed = true; + + match result { + AutoReportActionsResult::Wait(_) => return result, + AutoReportActionsResult::Report(ttl) => { + max_ttl = max(max_ttl, ttl); + } + } + } + Action::ReportRrsigPropagated => { + // Clippy problem + let opt_rrsig_status = { + let report_state_locked = report_state.lock().expect("lock() should not fail"); + // Make a copy of the state. We need to release the lock + // before calling await. + let opt_rrsig_status = report_state_locked.rrsig.clone(); + drop(report_state_locked); + opt_rrsig_status + }; + + if let Some(rrsig_status) = opt_rrsig_status { + match rrsig_status { + AutoReportRrsigResult::Wait(next) => { + if next > UnixTime::now() { + return AutoReportActionsResult::Wait(next.clone()); + } + } + AutoReportRrsigResult::Report(ttl) => { + max_ttl = max(max_ttl, ttl); + continue; + } + AutoReportRrsigResult::WaitSoa { + next, + serial, + ttl, + report_ttl, + } => { + if next > UnixTime::now() { + return AutoReportActionsResult::Wait(next.clone()); + } + let res = check_soa(serial, kss).await.unwrap_or_else(|e| { + warn!("Check SOA propagation failed: {e}"); + false + }); + dbg!(format!("got {res} for {serial}")); + if res { + dbg!("Setting rrsig to Report"); + let mut report_state_locked = + report_state.lock().expect("lock() should not fail"); + report_state_locked.rrsig = + Some(AutoReportRrsigResult::Report(report_ttl)); + drop(report_state_locked); + *state_changed = true; + max_ttl = max(max_ttl, report_ttl); + continue; + } else { + let next = UnixTime::now() + ttl.into(); + dbg!("Setting rrsig to WaitSoa"); + let mut report_state_locked = + report_state.lock().expect("lock() should not fail"); + report_state_locked.rrsig = Some(AutoReportRrsigResult::WaitSoa { + next: next.clone(), + serial, + ttl, + report_ttl, + }); + drop(report_state_locked); + *state_changed = true; + return AutoReportActionsResult::Wait(next); + } + } + AutoReportRrsigResult::WaitRecord { + next, + name, + rtype, + ttl, + } => { + if next > UnixTime::now() { + return AutoReportActionsResult::Wait(next.clone()); + } + let res = check_record(&name, &rtype, kss).await.unwrap_or_else(|e| { + warn!("record check failed: {e}"); + false + }); + if !res { + let next = UnixTime::now() + ttl.into(); + let mut report_state_locked = + report_state.lock().expect("lock() should not fail"); + report_state_locked.rrsig = + Some(AutoReportRrsigResult::WaitRecord { + next: next.clone(), + name: name.clone(), + rtype, + ttl, + }); + drop(report_state_locked); + *state_changed = true; + return AutoReportActionsResult::Wait(next); + } + + // This record has the right signatures. Check + // the zone. + } + AutoReportRrsigResult::WaitNextSerial { next, serial, ttl } => { + if next > UnixTime::now() { + return AutoReportActionsResult::Wait(next.clone()); + } + let res = check_next_serial(serial, kss).await.unwrap_or_else(|e| { + warn!("next serial check failed: {e}"); + false + }); + if !res { + let next = UnixTime::now() + ttl.into(); + let mut report_state_locked = + report_state.lock().expect("lock() should not fail"); + report_state_locked.rrsig = + Some(AutoReportRrsigResult::WaitNextSerial { + next: next.clone(), + serial, + ttl, + }); + drop(report_state_locked); + *state_changed = true; + return AutoReportActionsResult::Wait(next); + } + + // A new serial. Check the zone. + } + } + } + + let result = report_rrsig_propagated(kss).await.unwrap_or_else(|e| { + warn!("Check RRSIG propagation failed: {e}"); + AutoReportRrsigResult::Wait(UnixTime::now() + DEFAULT_WAIT) + }); + + let mut report_state_locked = report_state.lock().expect("lock() should not fail"); + report_state_locked.rrsig = Some(result.clone()); + drop(report_state_locked); + *state_changed = true; + + match result { + AutoReportRrsigResult::Wait(next) + | AutoReportRrsigResult::WaitRecord { next, .. } + | AutoReportRrsigResult::WaitNextSerial { next, .. } + | AutoReportRrsigResult::WaitSoa { next, .. } => { + return AutoReportActionsResult::Wait(next) + } + AutoReportRrsigResult::Report(ttl) => { + max_ttl = max(max_ttl, ttl); + } + } + } + Action::UpdateDnskeyRrset + | Action::CreateCdsRrset + | Action::RemoveCdsRrset + | Action::UpdateDsRrset + | Action::UpdateRrsig => (), + + // These actions should not occur here. Actions in this functions + // need to be no-ops or report a TTL. Wait actions are not + // compatible with this. + Action::WaitDnskeyPropagated + | Action::WaitDsPropagated + | Action::WaitRrsigPropagated => unreachable!(), + } + } + AutoReportActionsResult::Report(max_ttl) +} + +/// Check whether automatic actions are done or not. If not, return until +/// when to wait to try again. +fn check_auto_actions(actions: &[Action], report_state: &Mutex) -> AutoActionsResult { + for a in actions { + match a { + Action::UpdateDnskeyRrset + | Action::CreateCdsRrset + | Action::RemoveCdsRrset + | Action::UpdateDsRrset + | Action::UpdateRrsig => (), + Action::ReportDnskeyPropagated | Action::WaitDnskeyPropagated => { + let report_state_locked = report_state.lock().expect("lock() should not fail"); + if let Some(dnskey_status) = &report_state_locked.dnskey { + match dnskey_status { + AutoReportActionsResult::Wait(next) => { + return AutoActionsResult::Wait(next.clone()) + } + AutoReportActionsResult::Report(_) => continue, + } + } + drop(report_state_locked); + + // No status, request cron + return AutoActionsResult::Wait(UnixTime::now()); + } + Action::ReportDsPropagated | Action::WaitDsPropagated => { + let report_state_locked = report_state.lock().expect("lock() should not fail"); + if let Some(ds_status) = &report_state_locked.ds { + match ds_status { + AutoReportActionsResult::Wait(next) => { + return AutoActionsResult::Wait(next.clone()) + } + AutoReportActionsResult::Report(_) => continue, + } + } + drop(report_state_locked); + + // No status, request cron + return AutoActionsResult::Wait(UnixTime::now()); + } + Action::ReportRrsigPropagated | Action::WaitRrsigPropagated => { + let report_state_locked = report_state.lock().expect("lock() should not fail"); + if let Some(rrsig_status) = &report_state_locked.rrsig { + match rrsig_status { + AutoReportRrsigResult::Wait(next) + | AutoReportRrsigResult::WaitRecord { next, .. } + | AutoReportRrsigResult::WaitNextSerial { next, .. } + | AutoReportRrsigResult::WaitSoa { next, .. } => { + return AutoActionsResult::Wait(next.clone()) + } + AutoReportRrsigResult::Report(_) => continue, + } + } + drop(report_state_locked); + + // No status, request cron + return AutoActionsResult::Wait(UnixTime::now()); + } + } + } + AutoActionsResult::Ok +} + +/// Execute the done action. +fn do_done(kss: &mut KeySetState, roll_type: RollType, autoremove: bool) -> Result<(), Error> { + let actions = kss.keyset.roll_done(roll_type); + + let actions = match actions { + Ok(actions) => actions, + Err(err) => { + return Err(format!("Error reporting done: {err}\n").into()); + } + }; + + if !actions.is_empty() { + return Err("List of actions after reporting done\n".into()); + } + + // Sometimes there is no space for a RemoveCdsRrset action. Just remove + // it anyhow. + remove_cds_rrset(kss); + + kss.internal.remove(&roll_type); + + // Remove old keys. + if autoremove { + let key_urls: Vec<_> = kss + .keyset + .keys() + .iter() + .filter(|(_, key)| { + let state = match key.keytype() { + KeyType::Ksk(state) => state, + KeyType::Zsk(state) => state, + KeyType::Csk(state, _) => state, + KeyType::Include(state) => state, + }; + state.stale() + }) + .map(|(pubref, key)| (pubref.clone(), key.privref().map(|r| r.to_string()))) + .collect(); + if !key_urls.is_empty() { + print!("Removing:"); + for u in key_urls { + let (pubref, privref) = &u; + kss.keyset.delete_key(pubref).map_err::(|e| { + format!("unable to remove key {pubref}: {e}\n").into() + })?; + if let Some(privref) = privref { + let priv_url = Url::parse(privref).map_err::(|e| { + format!("unable to parse {privref} as URL: {e}").into() + })?; + remove_key(kss, priv_url)?; + } + let pub_url = Url::parse(pubref).map_err::(|e| { + format!("unable to parse {pubref} as URL: {e}").into() + })?; + remove_key(kss, pub_url)?; + } + println!(); + } + } + Ok(()) +} + +/// Start a KSK roll. +fn start_ksk_roll( + ksc: &KeySetConfig, + kss: &mut KeySetState, + env: &impl Env, + verbose: bool, + run_update_ds_command: &mut bool, +) -> Result, Error> { + //let roll_type = RollType::KskRoll; + let roll_type = RollType::KskDoubleDsRoll; + + assert!(!kss.keyset.keys().is_empty()); + + // Check for CSK. + if ksc.use_csk { + return Err("wrong key roll, use start-csk-roll\n".into()); + } + + // Refuse if we can find a CSK key. + if kss.keyset.keys().iter().any(|(_, key)| { + if let KeyType::Csk(keystate, _) = key.keytype() { + !keystate.stale() + } else { + false + } + }) { + return Err(format!("cannot start {roll_type:?} roll, found CSK\n").into()); + } + + // Find existing KSKs. Do we complain if there is none? + let old_stored: Vec<_> = kss + .keyset + .keys() + .iter() + .filter(|(_, key)| { + if let KeyType::Ksk(keystate) = key.keytype() { + !keystate.stale() + } else { + false + } + }) + .map(|(name, _)| name.clone()) + .collect(); + let old: Vec<_> = old_stored.iter().map(|name| name.as_ref()).collect(); + + // Create a new KSK + let (ksk_pub_url, ksk_priv_url, algorithm, key_tag) = new_keys( + kss.keyset.name(), + ksc.algorithm.to_generate_params(), + true, + kss.keyset.keys(), + &ksc.keys_dir, + env, + #[cfg(feature = "kmip")] + &mut kss.kmip, + )?; + kss.keyset + .add_key_ksk( + ksk_pub_url.to_string(), + Some(ksk_priv_url.to_string()), + algorithm, + key_tag, + UnixTime::now(), + true, + ) + .map_err::(|e| format!("unable to add KSK {ksk_pub_url}: {e}\n").into())?; + + let new = [ksk_pub_url.as_ref()]; + + // Start the key roll + let actions = match kss + .keyset + .start_roll(roll_type, &old, &new) + .map_err::(|e| format!("cannot start {roll_type:?}: {e}\n").into()) + { + Ok(actions) => actions, + Err(e) => { + // Remove the keys we just created. + remove_key(kss, ksk_priv_url)?; + remove_key(kss, ksk_pub_url)?; + return Err(e); + } + }; + handle_actions(&actions, ksc, kss, env, verbose, run_update_ds_command)?; + kss.internal.insert(roll_type, Default::default()); + Ok(actions) +} + +/// Start a ZSK roll. +fn start_zsk_roll( + ksc: &KeySetConfig, + kss: &mut KeySetState, + env: &impl Env, + verbose: bool, + run_update_ds_command: &mut bool, +) -> Result, Error> { + let roll_type = RollType::ZskRoll; + + assert!(!kss.keyset.keys().is_empty()); + + // Check for CSK. + if ksc.use_csk { + return Err("wrong key roll, use start-csk-roll\n".into()); + } + + // Refuse if we can find a CSK key. + if kss.keyset.keys().iter().any(|(_, key)| { + if let KeyType::Csk(keystate, _) = key.keytype() { + !keystate.stale() + } else { + false + } + }) { + return Err(format!("cannot start {roll_type:?} roll, found CSK\n").into()); + } + + // Find existing ZSKs. Do we complain if there is none? + let old_stored: Vec<_> = kss + .keyset + .keys() + .iter() + .filter(|(_, key)| { + if let KeyType::Zsk(keystate) = key.keytype() { + !keystate.stale() + } else { + false + } + }) + .map(|(name, _)| name.clone()) + .collect(); + let old: Vec<_> = old_stored.iter().map(|name| name.as_ref()).collect(); + + // Collect algorithms. Maybe this needs to be in the library. + + // Create a new ZSK + let (zsk_pub_url, zsk_priv_url, algorithm, key_tag) = new_keys( + kss.keyset.name(), + ksc.algorithm.to_generate_params(), + false, + kss.keyset.keys(), + &ksc.keys_dir, + env, + #[cfg(feature = "kmip")] + &mut kss.kmip, + )?; + kss.keyset + .add_key_zsk( + zsk_pub_url.to_string(), + Some(zsk_priv_url.to_string()), + algorithm, + key_tag, + UnixTime::now(), + true, + ) + .map_err::(|e| format!("unable to add ZSK {zsk_pub_url}: {e}\n").into())?; + + let new = [zsk_pub_url.as_ref()]; + + // Start the key roll + let actions = match kss + .keyset + .start_roll(roll_type, &old, &new) + .map_err::(|e| format!("cannot start {roll_type:?}: {e}\n").into()) + { + Ok(actions) => actions, + Err(e) => { + // Remove the keys we just created. + remove_key(kss, zsk_priv_url)?; + remove_key(kss, zsk_pub_url)?; + return Err(e); + } + }; + + handle_actions(&actions, ksc, kss, env, verbose, run_update_ds_command)?; + kss.internal.insert(roll_type, Default::default()); + Ok(actions) +} + +/// Start a CSK roll. +fn start_csk_roll( + ksc: &KeySetConfig, + kss: &mut KeySetState, + env: &impl Env, + verbose: bool, + run_update_ds_command: &mut bool, +) -> Result, Error> { + let roll_type = RollType::CskRoll; + + assert!(!kss.keyset.keys().is_empty()); + + // Find existing KSKs, ZSKs and CSKs. Do we complain if there + // are none? + let old_stored: Vec<_> = kss + .keyset + .keys() + .iter() + .filter(|(_, key)| match key.keytype() { + KeyType::Ksk(keystate) | KeyType::Zsk(keystate) | KeyType::Csk(keystate, _) => { + // Assume that for a CSK it is sufficient to check + // one of the key states. Also assume that we + // can check at_parent for a ZSK. + !keystate.stale() + } + KeyType::Include(_) => false, + }) + .map(|(name, _)| name.clone()) + .collect(); + let old: Vec<_> = old_stored.iter().map(|name| name.as_ref()).collect(); + + // Collect algorithms. Maybe this needs to be in the library. + + let (new_stored, new_urls) = new_csk_or_ksk_zsk(ksc, kss, env)?; + + let new: Vec<_> = new_stored.iter().map(|v| v.as_ref()).collect(); + + // Start the key roll + let actions = match kss + .keyset + .start_roll(roll_type, &old, &new) + .map_err::(|e| format!("cannot start {roll_type:?}: {e}\n").into()) + { + Ok(actions) => actions, + Err(e) => { + // Remove the key files we just created. + for u in new_urls { + remove_key(kss, u)?; + } + return Err(e); + } + }; + + handle_actions(&actions, ksc, kss, env, verbose, run_update_ds_command)?; + kss.internal.insert(roll_type, Default::default()); + Ok(actions) +} + +/// Start an algorithm roll. +fn start_algorithm_roll( + ksc: &KeySetConfig, + kss: &mut KeySetState, + env: &impl Env, + verbose: bool, + run_update_ds_command: &mut bool, +) -> Result, Error> { + let roll_type = RollType::AlgorithmRoll; + + assert!(!kss.keyset.keys().is_empty()); + + // Find existing KSKs, ZSKs and CSKs. Do we complain if there + // are none? + let old_stored: Vec<_> = kss + .keyset + .keys() + .iter() + .filter(|(_, key)| match key.keytype() { + KeyType::Ksk(keystate) | KeyType::Zsk(keystate) | KeyType::Csk(keystate, _) => { + // Assume that for a CSK it is sufficient to check + // one of the key states. Also assume that we + // can check at_parent for a ZSK. + !keystate.stale() + } + KeyType::Include(_) => false, + }) + .map(|(name, _)| name.clone()) + .collect(); + let old: Vec<_> = old_stored.iter().map(|name| name.as_ref()).collect(); + + let (new_stored, new_urls) = new_csk_or_ksk_zsk(ksc, kss, env)?; + let new: Vec<_> = new_stored.iter().map(|v| v.as_ref()).collect(); + + // Start the key roll + let actions = match kss + .keyset + .start_roll(roll_type, &old, &new) + .map_err::(|e| format!("cannot start roll: {e}\n").into()) + { + Ok(actions) => actions, + Err(e) => { + // Remove the key files we just created. + for u in new_urls { + remove_key(kss, u)?; + } + return Err(e); + } + }; + + handle_actions(&actions, ksc, kss, env, verbose, run_update_ds_command)?; + kss.internal.insert(roll_type, Default::default()); + Ok(actions) +} + +/// Check whether a new DNSKEY RRset has propagated. +/// +/// Compile a list of nameservers for the zone and their addresses and +/// query each address for the DNSKEY RRset. The function +/// check_dnskey_for_address does the actual work. +async fn report_dnskey_propagated(kss: &KeySetState) -> AutoReportActionsResult { + // Convert the DNSKEY RRset plus RRSIGs into a HashSet. + // Find the address of all name servers of zone + // Ask each nameserver for the DNSKEY RRset. Check if it matches the + // one we want. + // If it doesn't match, wait the TTL of the RRset to try again. + // On error, wait a default time. + let mut target_dnskey: HashSet = HashSet::new(); + for dnskey_rr in &kss.dnskey_rrset { + let mut zonefile = Zonefile::new(); + zonefile.extend_from_slice(dnskey_rr.as_bytes()); + zonefile.extend_from_slice(b"\n"); + if let Ok(Some(Entry::Record(rec))) = zonefile.next_entry() { + target_dnskey.insert(rec.flatten_into()); + } + } + + let zone = kss.keyset.name(); + let addresses = match addresses_for_zone(zone).await { + Ok(a) => a, + Err(e) => { + warn!("Getting nameserver addresses for {zone} failed: {e}"); + return AutoReportActionsResult::Wait(UnixTime::now() + DEFAULT_WAIT); + } + }; + + // addresses_for_zone returns at least one address. + assert!(!addresses.is_empty()); + + let futures: Vec<_> = addresses + .iter() + .map(|a| check_dnskey_for_address(zone, a, target_dnskey.clone())) + .collect(); + let res: Vec<_> = join_all(futures).await; + + // Be paranoid. The variable max_ttl is set to None initially to make + // sure that we only return a value if something has been assigned + // during the loop. + let mut max_ttl = None; + for r in res { + let r = match r { + Ok(r) => r, + Err(e) => { + warn!("DNSKEY check failed: {e}"); + return AutoReportActionsResult::Wait(UnixTime::now() + DEFAULT_WAIT); + } + }; + match r { + // It doesn't really matter how long we have to wait. + AutoReportActionsResult::Wait(_) => return r, + AutoReportActionsResult::Report(ttl) => { + max_ttl = Some(max(max_ttl.unwrap_or(Ttl::from_secs(0)), ttl)); + } + } + } + + // We can only get here with Some(Ttl) because there is at least one + // address. + let max_ttl = max_ttl.expect("cannot be None"); + AutoReportActionsResult::Report(max_ttl) +} + +/// Check whether the parent zone has a DS RRset that matches the keys +/// with 'at_parent' equal to true. +/// +/// Compile a list of nameservers for the parent zone and their addresses and +/// query each address for the DS RRset. The function +/// check_ds_for_address does the actual work. The CDNSKEY RRset is +/// used as the reference for the DS RRset. +async fn report_ds_propagated(kss: &KeySetState) -> Result { + // Convert the CDNSKEY RRset into a HashSet. + // Find the name of the parent zone. + // Find the address of all name servers of the parent zone. + // Ask each nameserver for the DS RRset. Check if it matches the + // one we want. + // If it doesn't match, wait the TTL of the RRset to try again. + // On error, wait a default time. + + let mut target_dnskey: HashSet = HashSet::new(); + for cdnskey_rr in &kss.cds_rrset { + let mut zonefile = Zonefile::new(); + zonefile.extend_from_slice(cdnskey_rr.as_bytes()); + zonefile.extend_from_slice(b"\n"); + if let Ok(Some(Entry::Record(r))) = zonefile.next_entry() { + if let ZoneRecordData::Cdnskey(cdnskey) = r.data() { + let dnskey = Dnskey::>::new( + cdnskey.flags(), + cdnskey.protocol(), + cdnskey.algorithm(), + cdnskey.public_key().to_vec(), + ) + .expect("should not fail"); + let record = Record::new(r.owner().to_name(), r.class(), r.ttl(), dnskey); + target_dnskey.insert(record); + } + } + } + + let zone = kss.keyset.name(); + let parent_zone = parent_zone(zone).await?; + let addresses = addresses_for_zone(&parent_zone).await?; + + // addresses_for_zone returns at least one address. + assert!(!addresses.is_empty()); + + let futures: Vec<_> = addresses + .iter() + .map(|a| check_ds_for_address(zone, a, target_dnskey.clone())) + .collect(); + let res: Vec<_> = join_all(futures).await; + let mut max_ttl = None; + for r in res { + let r = r?; + match r { + // It doesn't really matter how long we have to wait. + AutoReportActionsResult::Wait(_) => return Ok(r), + AutoReportActionsResult::Report(ttl) => { + max_ttl = Some(max(max_ttl.unwrap_or(Ttl::from_secs(0)), ttl)); + } + } + } + + // We can only get here with Some(Ttl) because there is at least one + // address. + let max_ttl = max_ttl.expect("cannot be None"); + Ok(AutoReportActionsResult::Report(max_ttl)) +} + +/// Report whether all RRSIGs (except for the ones that are copied from +/// keyset state) have been updated. +/// +/// The basic process is to send an AXFR query to the primary nameserver and +/// check the zone. If the zone checks out, very that all of the nameservers +/// of the zone have the checked SOA serial or newer. If a (name, rtype) tuple +/// is found with the wrong signatures then keep checking that name, rtype +/// combination until the right signatures are found. Then go back to checking +/// the entire zone. NSEC3 is special because it is not possible to directly +/// query for NSEC3 records. In that case, wait for high SOA serial and check +/// the entire zone again. +async fn report_rrsig_propagated(kss: &KeySetState) -> Result { + // This function assume a single signer. Multi-signer is not supported + // at all, but any kind of active-passive or active-active setup would also + // need changes. With more than one signer, each signer needs to be + // checked explicitly. Then for all nameservers it needs to be checked + // that their SOA versions are at least as high as all of the signers. + // Check the zone. If the zone checks out, make sure that all nameservers + // have at least the version of the zone that was checked. + + let result = check_zone(kss).await?; + let (serial, ttl, report_ttl) = match result { + // check_zone never returns Report or Wait. + AutoReportRrsigResult::Report(_) | AutoReportRrsigResult::Wait(_) => unreachable!(), + AutoReportRrsigResult::WaitSoa { + serial, + ttl, + report_ttl, + .. + } => (serial, ttl, report_ttl), + AutoReportRrsigResult::WaitRecord { .. } | AutoReportRrsigResult::WaitNextSerial { .. } => { + return Ok(result) + } + }; + + Ok( + if check_soa(serial, kss).await.unwrap_or_else(|e| { + warn!("Check SOA propagation failed: {e}"); + false + }) { + AutoReportRrsigResult::Report(report_ttl) + } else { + AutoReportRrsigResult::WaitSoa { + next: UnixTime::now() + ttl.into(), + serial, + ttl, + report_ttl, + } + }, + ) +} + +/// Check whether the zone has signatures from the right keys. +/// +/// Collect the ZSK algorithm and key tags into a HashSet +/// Get the primary nameserver from the SOA record (this should become +/// a configuration option for the nameserver and any TSIG key to use). +/// Transfer the zone. +/// Assume the signer is correct. +/// Convert the RRSIGs into a HashMap with (name, type) as key and a HashSet +/// of (algorithm, key tag) as value. +/// Convert the other records into a BtreeMap with name as key and +/// a HashSet of type as the value. Check that each name and type has a +/// corresponding complete RRSIG set. +/// Ignore delegated records +async fn check_zone(kss: &KeySetState) -> Result { + let expected_set = get_expected_zsk_key_tags(kss); + + let zone = kss.keyset.name(); + + let resolver = StubResolver::new(); + let answer = resolver.query((zone, Rtype::SOA)).await?; + let Some(Ok((mname, mut serial))) = answer + .answer()? + .limit_to_in::>() + .map(|r| r.map(|r| (r.data().mname().clone(), r.data().serial()))) + .next() + else { + let rcode = answer.opt_rcode(); + return if rcode != OptRcode::NOERROR { + Err(format!("Unable to resolve {zone}/SOA: {rcode}").into()) + } else { + Err(format!("No result for {zone}/SOA").into()) + }; + }; + + let addresses = addresses_for_name(&resolver, mname).await?; + + 'addr: for a in &addresses { + let tcp_conn = match TcpStream::connect((*a, 53_u16)).await { + Ok(conn) => conn, + Err(e) => { + warn!("DNS TCP connection to {a} failed: {e}"); + continue; + } + }; + + let (tcp, transport) = stream::Connection::>, _>::new(tcp_conn); + tokio::spawn(transport.run()); + + let msg = MessageBuilder::new_vec(); + let mut msg = msg.question(); + msg.push((zone, Rtype::AXFR)).expect("should not fail"); + let req = RequestMessageMulti::new(msg).expect("should not fail"); + + // Send a request message. + let mut request = SendRequestMulti::send_request(&tcp, req.clone()); + + let mut treemap = BTreeMap::new(); + let mut sigmap = HashMap::new(); + + let mut first_soa = false; + let mut max_ttl = Ttl::from_secs(0); + loop { + // Get the reply + let reply = match request.get_response().await { + Ok(reply) => reply, + Err(e) => { + warn!("reading AXFR response from {a} failed: {e}"); + continue 'addr; + } + }; + let Some(reply) = reply else { + return Err(format!("Unexpected end of AXFR for {zone}").into()); + }; + let rcode = reply.opt_rcode(); + if rcode != OptRcode::NOERROR { + warn!("AXFR for {zone} from {a} failed: {rcode}"); + continue 'addr; + } + + let answer = reply.answer()?; + for r in answer { + let r = r?; + if !first_soa { + let Some(soa_record) = r.to_record::>()? else { + // Bad start of zone transfer. + return Err(format!( + "Wrong start of AXFR for {zone}, expected SOA found {}", + r.rtype() + ) + .into()); + }; + + first_soa = true; + serial = soa_record.data().serial(); + } else if r.rtype() == Rtype::SOA { + // The end. + let res = check_rrsigs(treemap, sigmap, zone, expected_set); + return match res { + CheckRrsigsResult::Done => Ok(AutoReportRrsigResult::WaitSoa { + next: UnixTime::now(), + serial, + ttl: r.ttl(), + report_ttl: max_ttl, + }), + CheckRrsigsResult::WaitRecord { name, rtype } => { + Ok(AutoReportRrsigResult::WaitRecord { + next: UnixTime::now() + r.ttl().into(), + name, + rtype, + ttl: r.ttl(), + }) + } + CheckRrsigsResult::WaitNextSerial => { + Ok(AutoReportRrsigResult::WaitNextSerial { + next: UnixTime::now() + r.ttl().into(), + serial, + ttl: r.ttl(), + }) + } + }; + } + + let owner = r.owner().to_name(); + if let Some(rrsig_record) = r.to_record::>()? { + let key = (owner, rrsig_record.data().type_covered()); + let value = ( + rrsig_record.data().algorithm(), + rrsig_record.data().key_tag(), + ); + let alg_kt_map = sigmap.entry(key).or_insert_with(HashSet::new); + alg_kt_map.insert(value); + max_ttl = max(max_ttl, r.ttl()); + } else { + let key = owner; + let rtype_map = treemap.entry(key).or_insert_with(HashSet::new); + rtype_map.insert(r.rtype()); + } + } + } + } + + Err(format!("AXFR for {zone} failed for all addresses {addresses:?}").into()) +} + +/// Return the set of addresses of the nameservers of a zone. +async fn addresses_for_zone(zone: &impl ToName) -> Result, Error> { + // Paranoid solution: + // Find nameserver addresses for the parent zone. + // Iterate over those addresses and try to get a delegation. + // Record all nameservers and glue addresses returned in the delegations. + // Add offical address for those nameservers. + // Iterate over the address and ask for the apex NS RRset. Add those + // and address offical address for those nameservers. + // Return the set of addresses. + // + // Current method, ask a resolver for the apex NS RRset. Loop over the + // set and ask for addresses. Return the list of addresses. + + let mut nameservers = Vec::new(); + let resolver = StubResolver::new(); + let answer = resolver.query((zone, Rtype::NS)).await?; + let rcode = answer.opt_rcode(); + if rcode != OptRcode::NOERROR { + return Err(format!("{}/NS query failed: {rcode}", zone.to_name::>()).into()); + } + for r in answer.answer()?.limit_to_in::>() { + let r = r?; + let AllRecordData::Ns(ns) = r.data() else { + continue; + }; + if *r.owner() != zone { + continue; + } + nameservers.push(ns.nsdname().clone()); + } + if nameservers.is_empty() { + return Err(format!("{} has no NS records", zone.to_name::>()).into()); + } + + let mut futures = Vec::new(); + for n in nameservers { + futures.push(addresses_for_name(&resolver, n)); + } + + let mut set = HashSet::new(); + for a in join_all(futures).await.into_iter() { + set.extend(match a { + Ok(a) => a, + Err(e) => { + return Err(e); + } + }); + } + Ok(set) +} + +/// Return the IPv4 and IPv6 addresses associated with a name. +async fn addresses_for_name( + resolver: &StubResolver, + name: impl ToName, +) -> Result, Error> { + let res = lookup_host(&resolver, &name).await?; + let res: Vec<_> = res.iter().collect(); + if res.is_empty() { + return Err(format!("no IP addresses found for {}", name.to_name::>()).into()); + } + Ok(res) +} + +/// Check whether a nameserver at a specific address has the right DNSKEY +/// RRset plus signatures. +async fn check_dnskey_for_address( + zone: &Name>, + address: &IpAddr, + mut target_dnskey: HashSet, +) -> Result { + let records = lookup_name_rtype_at_address(zone, Rtype::DNSKEY, address).await?; + + let mut max_ttl = Ttl::from_secs(0); + + for r in records { + if let AllRecordData::Dnskey(dnskey) = r.data() { + if r.owner() != zone { + continue; + } + max_ttl = max(max_ttl, r.ttl()); + let target_r = target_dnskey.iter().find(|target_r| { + if let ZoneRecordData::Dnskey(target_dnskey) = target_r.data() { + target_dnskey == dnskey + } else { + false + } + }); + if let Some(record) = target_r { + // Clone record to release target_dnskey. + let record = record.clone(); + // Found one, remove it from the set. + target_dnskey.remove(&record); + } else { + // The current record is not found in the target set. Wait + // until the TTL has expired. + debug!("Check DNSKEY RRset: DNSKEY record not expected"); + return Ok(AutoReportActionsResult::Wait( + UnixTime::now() + r.ttl().into_duration(), + )); + } + continue; + } + if let AllRecordData::Rrsig(rrsig) = r.data() { + if r.owner() != zone || rrsig.type_covered() != Rtype::DNSKEY { + continue; + } + max_ttl = max(max_ttl, r.ttl()); + let target_r = target_dnskey.iter().find(|target_r| { + if let ZoneRecordData::Rrsig(target_rrsig) = target_r.data() { + target_rrsig == rrsig + } else { + false + } + }); + if let Some(record) = target_r { + // Clone record to release target_dnskey. + let record = record.clone(); + // Found one, remove it from the set. + target_dnskey.remove(&record); + } else { + // The current record is not found in the target set. Wait + // until the TTL has expired. + debug!("Check DNSKEY RRset: RRSIG record not expected"); + return Ok(AutoReportActionsResult::Wait( + UnixTime::now() + r.ttl().into_duration(), + )); + } + continue; + } + } + if let Some(record) = target_dnskey.iter().next() { + // Not all DNSKEY records were found. + warn!("Not all required DNSKEY records were found for {zone}"); + Ok(AutoReportActionsResult::Wait( + UnixTime::now() + record.ttl().into(), + )) + } else { + Ok(AutoReportActionsResult::Report(max_ttl)) + } +} + +/// Check whether a nameserver at a specific address has the right DS RRset. +async fn check_ds_for_address( + zone: &Name>, + address: &IpAddr, + mut target_dnskey: HashSet, +) -> Result { + let records = lookup_name_rtype_at_address::>(zone, Rtype::DS, address).await?; + + let mut max_ttl = Ttl::from_secs(0); + + for r in records { + if r.owner() != zone { + continue; + } + max_ttl = max(max_ttl, r.ttl()); + let target_r = target_dnskey.iter().find(|target_r| { + let digest = target_r + .data() + .digest(zone, r.data().digest_type()) + .expect("should not fail"); + r.data().algorithm() == target_r.data().algorithm() + && r.data().digest() == digest.as_ref() + }); + if let Some(record) = target_r { + // Clone record to release target_dnskey. + let record = record.clone(); + // Found one, remove it from the set. + target_dnskey.remove(&record); + } else { + // The current record is not found in the target set. Wait + // until the TTL has expired. + debug!("Check DS RRset: DS record not expected"); + return Ok(AutoReportActionsResult::Wait( + UnixTime::now() + r.ttl().into_duration(), + )); + } + continue; + } + let dnskey = target_dnskey.iter().next(); + if let Some(dnskey) = dnskey { + debug!("Check DS RRset: expected DS record not present"); + let ttl = dnskey.ttl(); + Ok(AutoReportActionsResult::Wait( + UnixTime::now() + ttl.into_duration(), + )) + } else { + Ok(AutoReportActionsResult::Report(max_ttl)) + } +} + +/// Check whether a nameserver at a specific address has the right SOA serial +/// or a newer one. +async fn check_soa_for_address( + zone: &Name>, + address: &IpAddr, + serial: Serial, +) -> Result { + let records = lookup_name_rtype_at_address::>(zone, Rtype::SOA, address).await?; + + if records.is_empty() { + return Ok(AutoReportActionsResult::Wait( + UnixTime::now() + DEFAULT_WAIT, + )); + } + + if let Some(ttl) = records + .iter() + .filter_map(|r| { + if r.data().serial() < serial { + Some(r.ttl()) + } else { + None + } + }) + .next() + { + return Ok(AutoReportActionsResult::Wait(UnixTime::now() + ttl.into())); + } + // Return a dummy TTL. The caller knows the real TTL to report. + Ok(AutoReportActionsResult::Report(Ttl::from_secs(0))) +} + +/// Lookup a name, rtype pair at an address. +/// +/// Extract records of type T from the answer. +async fn lookup_name_rtype_at_address( + name: &Name>, + rtype: Rtype, + address: &IpAddr, +) -> Result, T>>, Error> +where + for<'a> T: ParseRecordData<'a, Bytes>, +{ + let server_addr = SocketAddr::new(*address, 53); + let udp_connect = UdpConnect::new(server_addr); + let tcp_connect = TcpConnect::new(server_addr); + let (udptcp_conn, transport) = dgram_stream::Connection::new(udp_connect, tcp_connect); + tokio::spawn(transport.run()); + + let mut msg = MessageBuilder::new_vec(); + msg.header_mut().set_rd(true); + let mut msg = msg.question(); + msg.push((name, rtype)).expect("should not fail"); + let mut req = RequestMessage::new(msg).expect("should not fail"); + req.set_dnssec_ok(true); + let mut request = udptcp_conn.send_request(req.clone()); + let response = request.get_response().await.map_err::(|e| { + format!("{name}/{rtype} request to {address} failed: {e}").into() + })?; + + let mut res = Vec::new(); + for r in response.answer()?.limit_to_in::() { + let r = r?; + res.push(r); + } + Ok(res) +} + +/// Return the name of the parent zone. +async fn parent_zone(name: &Name>) -> Result>, Error> { + let parent = name + .parent() + .ok_or_else::(|| format!("unable to get parent of {name}").into())?; + + let resolver = StubResolver::new(); + let answer = resolver.query((&parent, Rtype::SOA)).await?; + let rcode = answer.opt_rcode(); + if rcode != OptRcode::NOERROR { + return Err(format!("{parent}/SOA query failed: {rcode}").into()); + } + if let Some(Ok(owner)) = answer + .answer()? + .limit_to_in::>() + .map(|r| r.map(|r| r.owner().to_name::>())) + .next() + { + return Ok(owner); + } + + // Try the authority section. + if let Some(Ok(owner)) = answer + .authority()? + .limit_to_in::>() + .map(|r| r.map(|r| r.owner().to_name::>())) + .next() + { + return Ok(owner); + } + + Err(format!("{parent}/SOA query failed").into()) +} + +/// This function automatically starts a key roll when the conditions are right. +/// +/// First the conficting_roll function is invoked to make sure there are no +/// rolls in progress that would conflict. Then match_keytype is used to +/// select key that could participate in this roll. The published time of +/// each key is compared to the validity parameter to see if the key +/// needs to be replaced. No key roll will happen is validity is equal to +/// None. The start_roll parameter starts the key roll. +#[allow(clippy::too_many_arguments)] +fn auto_start( + validity: &Option, + auto: &AutoConfig, + ksc: &KeySetConfig, + kss: &mut KeySetState, + env: Env, + state_changed: &mut bool, + conficting_roll: impl Fn(RollType) -> bool, + match_keytype: impl Fn(KeyType) -> Option, + start_roll: impl Fn( + &KeySetConfig, + &mut KeySetState, + Env, + bool, + &mut bool, + ) -> Result, Error>, + run_update_ds_command: &mut bool, +) -> Result<(), Error> { + if let Some(validity) = validity { + if auto.start { + // If there is no conficting roll, and this + // flag is set, and the lifetime has expired then + // start a roll. + if !kss + .keyset + .rollstates() + .iter() + .any(|(r, _)| conficting_roll(*r)) + { + let next = kss + .keyset + .keys() + .iter() + .filter_map(|(_, k)| { + if let Some(keystate) = match_keytype(k.keytype()) { + if !keystate.stale() { + k.timestamps() + .published() + .map(|published| published + *validity) + } else { + None + } + } else { + None + } + }) + .min(); + if let Some(next) = next { + if next < UnixTime::now() { + start_roll(ksc, kss, env, false, run_update_ds_command)?; + *state_changed = true; + } + } + } + } + } + Ok(()) +} + +/// Handle automation for the report, expire and done steps. +/// +/// The auto parameter has the flags that control whether automation is +/// enabled or disabled for a step. The roll_list parameters are the +/// roll types that are covered by the auto parameter. +/// This function calls two function (auto_report_actions and +/// auto_wait_actions) to handle, repectively, the Report and Wait actions. +async fn auto_report_expire_done( + auto: &AutoConfig, + roll_list: &[RollType], + ksc: &KeySetConfig, + kss: &mut KeySetState, + env: &impl Env, + state_changed: &mut bool, + run_update_ds_command: &mut bool, +) -> Result<(), Error> { + if auto.report { + // If there is currently a roll in one of the + // propagation states and this flags is set and all + // actions have comleted report the ttl. + for r in roll_list { + if let Some(state) = kss.keyset.rollstates().get(r) { + let report_state = kss.internal.get(r).expect("should not fail"); + let report_state = match state { + RollState::Propagation1 => &report_state.propagation1, + RollState::Propagation2 => &report_state.propagation2, + _ => continue, + }; + let actions = kss.keyset.actions(*r); + match auto_report_actions(&actions, kss, report_state, state_changed).await { + AutoReportActionsResult::Wait(_) => continue, + AutoReportActionsResult::Report(ttl) => { + let actions = match state { + RollState::Propagation1 => { + kss.keyset.propagation1_complete(*r, ttl.as_secs()) + } + RollState::Propagation2 => { + kss.keyset.propagation2_complete(*r, ttl.as_secs()) + } + _ => unreachable!(), + }; + + let actions = match actions { + Ok(actions) => actions, + Err(err) => { + return Err(format!( + "Error reporting propagation complete: {err}\n" + ) + .into()); + } + }; + + handle_actions(&actions, ksc, kss, env, false, run_update_ds_command)?; + *state_changed = true; + } + } + } + } + } + if auto.expire { + // If there is currently a roll in one of the cache + // expire states and this flag is set, move to the next + // state + for r in roll_list { + if let Some(state) = kss.keyset.rollstates().get(r) { + let actions = match state { + RollState::CacheExpire1(_) => kss.keyset.cache_expired1(*r), + RollState::CacheExpire2(_) => kss.keyset.cache_expired2(*r), + _ => continue, + }; + if let Err(keyset::Error::Wait(_)) = actions { + // To early. + continue; + } + let actions = actions.map_err::(|e| { + format!("cache_expired[12] failed for state {r:?}: {e}").into() + })?; + handle_actions(&actions, ksc, kss, env, false, run_update_ds_command)?; + // Report actions + *state_changed = true; + } + } + } + if auto.done { + // If there is current a roll in the done state and all + // actions have completed then call do_done to end the key roll. + for r in roll_list { + if let Some(RollState::Done) = kss.keyset.rollstates().get(r) { + let report_state = &kss.internal.get(r).expect("should not fail").done; + let actions = kss.keyset.actions(*r); + match auto_wait_actions(&actions, kss, report_state, state_changed).await { + AutoActionsResult::Ok => { + do_done(kss, *r, ksc.autoremove)?; + *state_changed = true; + } + AutoActionsResult::Wait(_) => continue, + } + } + } + } + Ok(()) +} + +/// This function computes when the next key roll should happen. +/// +/// It has the same logic as auto_start but instead of starting a key roll, +/// it (optionally) adds a timestamp to the cron_next vector. Should this +/// be merged with auto_start? +fn cron_next_auto_start( + validity: Option, + auto: &AutoConfig, + kss: &KeySetState, + conflicting_roll: impl Fn(RollType) -> bool, + match_keytype: impl Fn(KeyType) -> Option, + cron_next: &mut Vec>, +) { + if let Some(validity) = validity { + if auto.start { + // If there is no KSK, CSK, or Algorithm roll, and this + // flag is set, compute the remaining KSK lifetime + + // The only roll types that are compatible with a KSK roll + // are the two ZSK rolls. + if !kss + .keyset + .rollstates() + .iter() + .any(|(r, _)| conflicting_roll(*r)) + { + let next = kss + .keyset + .keys() + .iter() + .filter_map(|(_, k)| { + if let Some(keystate) = match_keytype(k.keytype()) { + if !keystate.stale() { + k.timestamps().published() + } else { + None + } + } else { + None + } + }) + .map(|published| published + validity) + .min(); + cron_next.push(next); + } + } + } +} + +/// This function computes when next to try to move to the next state. +/// +/// For the Report and Wait actions that involves checking when propagation +/// should be tested again. For the expire step it computes when the +/// keyset object in the domain library accepts the cache_expired1 or +/// cache_expired2 methods. +fn cron_next_auto_report_expire_done( + auto: &AutoConfig, + roll_list: &[RollType], + kss: &KeySetState, + cron_next: &mut Vec>, +) -> Result<(), Error> { + if auto.report { + // If there is currently a roll in one of the propagation + // states and this flags is set take when to check again for + // actions to complete + for r in roll_list { + if let Some(state) = kss.keyset.rollstates().get(r) { + let report_state = kss.internal.get(r).expect("should not fail"); + let report_state = match state { + RollState::Propagation1 => &report_state.propagation1, + RollState::Propagation2 => &report_state.propagation2, + _ => continue, + }; + let actions = kss.keyset.actions(*r); + match check_auto_actions(&actions, report_state) { + AutoActionsResult::Ok => { + // All actions are ready. Request cron. + cron_next.push(Some(UnixTime::now())); + } + AutoActionsResult::Wait(next) => cron_next.push(Some(next)), + } + } + } + } + + if auto.expire { + // If there is currently a roll in one of the cache expire + // states and this flag is set, use the remaining time until caches + // are expired. Try to issue the cache_expire[12] method on a + // clone of keyset. + let mut keyset = kss.keyset.clone(); + for r in roll_list { + if let Some(state) = keyset.rollstates().get(r) { + let actions = match state { + RollState::CacheExpire1(_) => keyset.cache_expired1(*r), + RollState::CacheExpire2(_) => keyset.cache_expired2(*r), + _ => continue, + }; + if let Err(keyset::Error::Wait(remain)) = actions { + cron_next.push(Some(UnixTime::now() + remain)); + continue; + } + let _ = actions.map_err::(|e| { + format!("cache_expired[12] failed for state {r:?}: {e}").into() + })?; + + // Time to call cron. Report the current time. + cron_next.push(Some(UnixTime::now())); + } + } + } + + if auto.done { + // If there is current a roll in the done state and all + // and this flag is set, take when the check again for actions to + // complete + for r in roll_list { + if let Some(RollState::Done) = kss.keyset.rollstates().get(r) { + let report_state = kss.internal.get(r).expect("should not fail"); + match check_auto_actions(&kss.keyset.actions(*r), &report_state.done) { + AutoActionsResult::Ok => { + // All actions are ready. Request cron. + cron_next.push(Some(UnixTime::now())); + } + AutoActionsResult::Wait(next) => { + cron_next.push(Some(next)); + } + } + } + } + } + + Ok(()) +} + +/// The result of checking whether all RRSIG records are present. +#[derive(PartialEq)] +enum CheckRrsigsResult { + /// The required RRSIGs are present. + Done, + /// Wait for a specific name, rtype combination to get updated signatures. + WaitRecord { + /// The name to check. + name: Name>, + /// And the Rtype. + rtype: Rtype, + }, + /// Wait for the next version of the zone. + WaitNextSerial, +} + +/// Type for the key of the signature HashMap. +type SigmapKey = (Name>, Rtype); +/// Type for the value of the signature HashMap. +type SigmapValue = HashSet<(SecurityAlgorithm, u16)>; + +/// Check if all authoritive records have the right signatures. +/// +/// A zone is not authoritative for names below a delegation. At a delegation, +/// a zone is authoritative for DS and NSEC records. +fn check_rrsigs( + treemap: BTreeMap>, HashSet>, + sigmap: HashMap, + zone: &Name>, + expected_set: HashSet<(SecurityAlgorithm, u16)>, +) -> CheckRrsigsResult { + let mut delegation = None; + let mut result = CheckRrsigsResult::Done; + for (key, rtype_map) in treemap { + if let Some(name) = &delegation { + if key.ends_with(name) { + // Ignore anything below a delegation. + continue; + } + delegation = None; + } + if rtype_map.contains(&Rtype::NS) && key != zone { + delegation = Some(key.clone()); + } + for rtype in rtype_map { + if delegation.is_some() { + // NS is not signed. A and AAAA are glue. + if rtype == Rtype::NS || rtype == Rtype::A || rtype == Rtype::AAAA { + continue; + } else if rtype == Rtype::DS || rtype == Rtype::NSEC { + // DS records are signed. Just keep going. + } else { + error!("Weird type {rtype} in delegation {}", &key); + continue; + } + } + if (rtype == Rtype::DNSKEY || rtype == Rtype::CDS || rtype == Rtype::CDNSKEY) + && key == zone + { + // These rtypes are signed with the KSKs + continue; + } + let set = if let Some(set) = sigmap.get(&(key.clone(), rtype)) { + set.clone() + } else { + warn!("RRSIG not found for {key}/{rtype}"); + HashSet::new() + }; + if set != expected_set { + // NSEC3 records are special because we cannot directly query + // for them. For 'normal' records, return WaitRecord. + // For NSEC3 we need to wait for a new version of the zone, + // so we return WaitNextSerial. However, WaitRecord is more + // efficient. Therefore, if the mismatch is at an NSEC3 then + // remember this by setting result to WaitNextSerial but + // keep checking. + if rtype != Rtype::NSEC3 { + warn!( + "RRSIG mismatch for {key}/{rtype}: found {:?} expected {:?}", + set, expected_set + ); + let name = key.to_name::>(); + return CheckRrsigsResult::WaitRecord { name, rtype }; + } + if result == CheckRrsigsResult::Done { + warn!( + "RRSIG mismatch for {key}/{rtype}: found {:?} expected {:?}", + set, expected_set + ); + } + result = CheckRrsigsResult::WaitNextSerial; + } + } + } + + // All authoritative records have signatures with the right algorithms and + // key tags. Or an NSEC3 failure was found. + result +} + +/// Check if a name, Rtype pair has the right signatures. +async fn check_record( + name: &Name>, + rtype: &Rtype, + kss: &KeySetState, +) -> Result { + let expected = get_expected_zsk_key_tags(kss); + let addresses = get_primary_addresses(kss.keyset.name()).await?; + for address in &addresses { + let server_addr = SocketAddr::new(*address, 53); + let udp_connect = UdpConnect::new(server_addr); + let tcp_connect = TcpConnect::new(server_addr); + let (udptcp_conn, transport) = dgram_stream::Connection::new(udp_connect, tcp_connect); + tokio::spawn(transport.run()); + + let mut msg = MessageBuilder::new_vec(); + msg.header_mut().set_rd(true); + let mut msg = msg.question(); + msg.push((name, *rtype)).expect("should not fail"); + let mut req = RequestMessage::new(msg).expect("should not fail"); + req.set_dnssec_ok(true); + let mut request = udptcp_conn.send_request(req.clone()); + let response = match request.get_response().await { + Ok(r) => r, + Err(e) => { + warn!("{name}/{rtype} request to {server_addr} failed: {e}"); + continue; + } + }; + + let mut alg_tag_set = HashSet::new(); + + for r in response.answer()?.limit_to_in::>() { + let r = r?; + if r.data().type_covered() != *rtype { + continue; + } + alg_tag_set.insert((r.data().algorithm(), r.data().key_tag())); + } + return Ok(alg_tag_set == expected); + } + Err(format!("lookup of {name}/{rtype} failed for all addresses {addresses:?}").into()) +} + +/// Check if the zone has move to the next serial. +async fn check_next_serial(serial: Serial, kss: &KeySetState) -> Result { + let zone = kss.keyset.name(); + let addresses = get_primary_addresses(zone).await?; + for address in &addresses { + let server_addr = SocketAddr::new(*address, 53); + let udp_connect = UdpConnect::new(server_addr); + let tcp_connect = TcpConnect::new(server_addr); + let (udptcp_conn, transport) = dgram_stream::Connection::new(udp_connect, tcp_connect); + tokio::spawn(transport.run()); + + let mut msg = MessageBuilder::new_vec(); + msg.header_mut().set_rd(true); + let mut msg = msg.question(); + msg.push((zone, Rtype::SOA)).expect("should not fail"); + let req = RequestMessage::new(msg).expect("should not fail"); + let mut request = udptcp_conn.send_request(req.clone()); + let response = match request.get_response().await { + Ok(r) => r, + Err(e) => { + warn!("{zone}/SOA request to {server_addr} failed: {e}"); + continue; + } + }; + + if let Some(r) = response.answer()?.limit_to_in::>().next() { + let r = r?; + return Ok(r.data().serial() > serial); + } + warn!("No SOA record in reply to SOA query for zone {zone}"); + return Ok(false); + } + Err(format!("lookup of {zone}/SOA failed for all addresses {addresses:?}").into()) +} + +/// Check if all addresses of all nameservers of the zone to see if they +/// have at least the SOA serial passed as parameter. +async fn check_soa(serial: Serial, kss: &KeySetState) -> Result { + // Find the address of all name servers of zone + // Ask each nameserver for the SOA record. + // Check that it's version is at least the version we checked. + // If it doesn't match, wait the TTL of the SOA record to try again. + // On error, wait a default time. + + let zone = kss.keyset.name(); + + let addresses = addresses_for_zone(zone).await?; + let futures: Vec<_> = addresses + .iter() + .map(|a| check_soa_for_address(zone, a, serial)) + .collect(); + let res: Vec<_> = join_all(futures).await; + + for r in res { + let r = r?; + match r { + // It doesn't really matter how long we have to wait. + AutoReportActionsResult::Wait(_) => return Ok(false), + AutoReportActionsResult::Report(_) => (), + } + } + + Ok(true) +} + +/// Get the expected key tags. +/// +/// Instead of validating signatures against the keys that sign the zone, +/// the signatures are of only checked for key tags. +fn get_expected_zsk_key_tags(kss: &KeySetState) -> HashSet<(SecurityAlgorithm, u16)> { + kss.keyset + .keys() + .iter() + .filter_map(|(_, k)| match k.keytype() { + KeyType::Ksk(_) | KeyType::Include(_) => None, + KeyType::Zsk(keystate) => Some((keystate, k.algorithm(), k.key_tag())), + KeyType::Csk(_, keystate) => Some((keystate, k.algorithm(), k.key_tag())), + }) + .filter_map(|(ks, a, kt)| if ks.signer() { Some((a, kt)) } else { None }) + .collect() +} + +/// Get the addresses of the primary nameserver of a zone. +async fn get_primary_addresses(zone: &Name>) -> Result, Error> { + let resolver = StubResolver::new(); + let answer = resolver.query((zone, Rtype::SOA)).await?; + let Some(Ok(mname)) = answer + .answer()? + .limit_to_in::>() + .map(|r| r.map(|r| r.data().mname().clone())) + .next() + else { + let rcode = answer.opt_rcode(); + return if rcode != OptRcode::NOERROR { + Err(format!("Unable to resolve {zone}/SOA: {rcode}").into()) + } else { + Err(format!("No result for {zone}/SOA").into()) + }; + }; + + addresses_for_name(&resolver, mname).await +} + +/// Check if an algorithm roll is needed. +/// +/// An algorithm roll is needed if the algorithm listed in config is +/// different from the set of algorithms in the collection of active keys. +fn algorithm_roll_needed(ksc: &KeySetConfig, kss: &KeySetState) -> bool { + // Collect the algorithms in all active keys. Check if the algorithm + // for new keys is the same. + let curr_algs: HashSet<_> = kss + .keyset + .keys() + .iter() + .filter_map(|(_, k)| { + if let Some(keystate) = match k.keytype() { + KeyType::Ksk(keystate) => Some(keystate), + KeyType::Zsk(keystate) => Some(keystate), + KeyType::Csk(keystate, _) => Some(keystate), + KeyType::Include(_) => None, + } { + if !keystate.stale() { + Some(k.algorithm()) + } else { + None + } + } else { + None + } + }) + .collect(); + let new_algs = HashSet::from([ksc.algorithm.to_generate_params().algorithm()]); + curr_algs != new_algs +} + +/// Show the automatic roll state for one state in a roll. +fn show_automatic_roll_state( + roll: RollType, + state: &RollState, + auto_state: &ReportState, + report: bool, +) { + println!("Roll {roll:?}, state {state:?}:"); + if let Some(status) = &auto_state.dnskey { + match status { + AutoReportActionsResult::Wait(retry) => { + println!("\tWait until the new DNSKEY RRset has propagated to all nameservers."); + println!("\tTry again after {retry}"); + } + AutoReportActionsResult::Report(ttl) => { + println!("\tThe new DNSKEY RRset has propagated to all nameservers."); + if report { + println!("\tReport (at least) TTL {}", ttl.as_secs()); + } + } + } + } + if let Some(status) = &auto_state.ds { + match status { + AutoReportActionsResult::Wait(retry) => { + println!("\tWait until the new DS RRset has propagated to all nameservers"); + println!("\tof the parent zone. Try again after {retry}"); + } + AutoReportActionsResult::Report(ttl) => { + println!("\tThe new DS RRset has propagated to all nameservers."); + if report { + println!("\tReport (at least) TTL {}", ttl.as_secs()); + } + } + } + } + if let Some(status) = &auto_state.rrsig { + match status { + AutoReportRrsigResult::Wait(next) => { + println!("\tSomething went wrong transferring the zone to be verified."); + println!("\tTry again after {next}"); + } + AutoReportRrsigResult::WaitRecord { + name, rtype, next, .. + } => { + println!("\tWait until {name}/{rtype} is signed with the right keys."); + println!("\tTry again after {next}"); + } + AutoReportRrsigResult::WaitNextSerial { serial, next, .. } => { + println!("\tWait for a zone with serial higher than {serial}"); + println!("\tTry again after {next}"); + } + AutoReportRrsigResult::WaitSoa { serial, next, .. } => { + println!("\tWait until the zone with at least serial {serial} has propagated"); + println!("\tto all nameservers. Try again after {next}"); + } + AutoReportRrsigResult::Report(ttl) => { + println!("\tThe new RRSIG records have propagated to all nameservers."); + if report { + println!("\tReport (at least) TTL {}", ttl.as_secs()); + } + } + } + } +} + +/// Create a new CSK key or KSK and ZSK keys if use_csk is false. +fn new_csk_or_ksk_zsk( + ksc: &KeySetConfig, + kss: &mut KeySetState, + env: &impl Env, +) -> Result<(Vec, Vec), Error> { + let (new_stored, new_urls) = if ksc.use_csk { + let mut new_urls = Vec::new(); + + // Create a new CSK + let (csk_pub_url, csk_priv_url, algorithm, key_tag) = new_keys( + kss.keyset.name(), + ksc.algorithm.to_generate_params(), + true, + kss.keyset.keys(), + &ksc.keys_dir, + env, + #[cfg(feature = "kmip")] + &mut kss.kmip, + )?; + new_urls.push(csk_priv_url.clone()); + new_urls.push(csk_pub_url.clone()); + kss.keyset + .add_key_csk( + csk_pub_url.to_string(), + Some(csk_priv_url.to_string()), + algorithm, + key_tag, + UnixTime::now(), + true, + ) + .map_err::(|e| format!("unable to add CSK {csk_pub_url}: {e}\n").into())?; + + let new = vec![csk_pub_url]; + (new, new_urls) + } else { + let mut new_urls = Vec::new(); + + // Create a new KSK + let (ksk_pub_url, ksk_priv_url, algorithm, key_tag) = new_keys( + kss.keyset.name(), + ksc.algorithm.to_generate_params(), + true, + kss.keyset.keys(), + &ksc.keys_dir, + env, + #[cfg(feature = "kmip")] + &mut kss.kmip, + )?; + new_urls.push(ksk_priv_url.clone()); + new_urls.push(ksk_pub_url.clone()); + kss.keyset + .add_key_ksk( + ksk_pub_url.to_string(), + Some(ksk_priv_url.to_string()), + algorithm, + key_tag, + UnixTime::now(), + true, + ) + .map_err::(|e| format!("unable to add KSK {ksk_pub_url}: {e}\n").into())?; + + // Create a new ZSK + let (zsk_pub_url, zsk_priv_url, algorithm, key_tag) = new_keys( + kss.keyset.name(), + ksc.algorithm.to_generate_params(), + false, + kss.keyset.keys(), + &ksc.keys_dir, + env, + #[cfg(feature = "kmip")] + &mut kss.kmip, + )?; + new_urls.push(zsk_priv_url.clone()); + new_urls.push(zsk_pub_url.clone()); + kss.keyset + .add_key_zsk( + zsk_pub_url.to_string(), + Some(zsk_priv_url.to_string()), + algorithm, + key_tag, + UnixTime::now(), + true, + ) + .map_err::(|e| format!("unable to add ZSK {zsk_pub_url}: {e}\n").into())?; + + let new = vec![ksk_pub_url, zsk_pub_url]; + (new, new_urls) + }; + Ok((new_stored, new_urls)) +} + +/* +Test for RRSIG check +- records before the zone +- records after the zone +- DNSKEY/CDS/CDNSKEY + - at apex + - not at apex +- delegations + - with DS/NSEC + - with A/AAAA at the delegations + - other records at the delegations + - below delegation +- bad sig NSEC3 +- bad sig not NSEC3 +*/ From dff760d05fd6805eaaf285a29b53b9895bedb828 Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Fri, 29 Aug 2025 19:37:47 +0200 Subject: [PATCH 19/32] Update ploutos to v8 (#118) --- .github/workflows/pkg.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pkg.yml b/.github/workflows/pkg.yml index 33fc900..18d5cd5 100644 --- a/.github/workflows/pkg.yml +++ b/.github/workflows/pkg.yml @@ -12,7 +12,7 @@ on: jobs: package: - uses: NLnetLabs/ploutos/.github/workflows/pkg-rust.yml@v7 + uses: NLnetLabs/ploutos/.github/workflows/pkg-rust.yml@v8 # secrets: # DOCKER_HUB_ID: ${{ secrets.DOCKER_HUB_ID }} # DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }} @@ -30,4 +30,4 @@ jobs: deb_extra_build_packages: libssl-dev rpm_extra_build_packages: make openssl-devel - rpm_scriptlets_path: pkg/rpm/scriptlets.toml \ No newline at end of file + rpm_scriptlets_path: pkg/rpm/scriptlets.toml From 0607c299bd67e49239ff97cb7cb47d69807d8acf Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Fri, 29 Aug 2025 20:46:09 +0200 Subject: [PATCH 20/32] Update Cargo.lock (#119) --- Cargo.lock | 379 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 235 insertions(+), 144 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a9794d7..f1dbeef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" @@ -49,9 +49,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.18" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -64,37 +64,37 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.8" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -105,9 +105,9 @@ checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" [[package]] name = "async-lock" -version = "3.4.0" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" dependencies = [ "event-listener", "event-listener-strategy", @@ -116,9 +116,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "backtrace" @@ -132,20 +132,20 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] name = "bitflags" -version = "2.9.1" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" [[package]] name = "bumpalo" -version = "3.17.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytes" @@ -155,18 +155,18 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.25" +version = "1.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0fc897dc1e865cc67c0e05a836d9d3f1df3cbe442aa4a9473b18e12624a4951" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" dependencies = [ "shlex", ] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "chrono" @@ -184,9 +184,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.39" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" +checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57" dependencies = [ "clap_builder", "clap_derive", @@ -194,21 +194,22 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.39" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51" +checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] name = "clap_derive" -version = "4.5.32" +version = "4.5.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" dependencies = [ "heck", "proc-macro2", @@ -218,15 +219,15 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "concurrent-queue" @@ -387,19 +388,19 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "errno" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -476,9 +477,9 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.5" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d18470a76cb7f8ff746cf1f7470914f900252ec36bbc40b569d74b1258446827" +checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" dependencies = [ "cc", "cfg-if", @@ -496,7 +497,7 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi 0.11.1+wasi-snapshot-preview1", ] [[package]] @@ -508,7 +509,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.3+wasi-0.2.4", ] [[package]] @@ -556,6 +557,17 @@ dependencies = [ "cc", ] +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -570,9 +582,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a194df1107f33c79f4f93d02c80798520551949d59dfad22b6157048a88cca93" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" dependencies = [ "jiff-static", "log", @@ -583,9 +595,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c6e1db7ed32c6c71b759497fae34bf7933636f75a251b9e736555da426f6442" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", @@ -616,9 +628,9 @@ checksum = "9fa0e2a1fcbe2f6be6c42e342259976206b383122fc152e872795338b5a3f3a7" [[package]] name = "libc" -version = "0.2.172" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "linux-raw-sys" @@ -666,15 +678,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.4" +version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "miniz_oxide" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", ] @@ -686,7 +698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -797,9 +809,9 @@ dependencies = [ [[package]] name = "openssl-src" -version = "300.5.1+3.5.1" +version = "300.5.2+3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "735230c832b28c000e3bc117119e6466a663ec73506bc0a9907ea4187508e42a" +checksum = "d270b79e2926f5150189d475bc7e9d2c69f9c4697b185fa917d5a32b792d21b4" dependencies = [ "cc", ] @@ -849,7 +861,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -872,9 +884,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "portable-atomic" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "portable-atomic-util" @@ -912,9 +924,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -930,9 +942,9 @@ dependencies = [ [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" @@ -966,9 +978,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -976,9 +988,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -986,23 +998,23 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.12" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ "bitflags", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata 0.4.10", + "regex-syntax 0.8.6", ] [[package]] @@ -1016,13 +1028,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", ] [[package]] @@ -1033,9 +1045,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "ring" @@ -1053,9 +1065,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc_version" @@ -1068,22 +1080,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "scoped-tls" @@ -1155,27 +1167,24 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1186,9 +1195,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.101" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -1203,15 +1212,25 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.60.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix", + "windows-sys 0.60.2", ] [[package]] @@ -1242,12 +1261,11 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", ] [[package]] @@ -1283,18 +1301,20 @@ dependencies = [ [[package]] name = "tokio" -version = "1.45.1" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "pin-project-lite", + "slab", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1333,9 +1353,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", @@ -1344,9 +1364,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", "valuable", @@ -1407,9 +1427,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" dependencies = [ "getrandom 0.3.3", "js-sys", @@ -1430,17 +1450,17 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.3+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] @@ -1525,9 +1545,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.61.1" +version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", "windows-core", @@ -1593,9 +1613,9 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-numerics" @@ -1631,7 +1651,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1640,7 +1660,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", ] [[package]] @@ -1649,14 +1678,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", ] [[package]] @@ -1674,42 +1720,84 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -1717,13 +1805,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "windows_x86_64_msvc" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "wit-bindgen" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814" [[package]] name = "yansi" @@ -1733,18 +1824,18 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "zerocopy" -version = "0.8.25" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.25" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", From 0e1ea6efa4c64030110e6e6241b9dd1b0819ac09 Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Mon, 1 Sep 2025 13:37:21 +0200 Subject: [PATCH 21/32] Disable help command until opening man pages is implemented (#120) * Disable help command until opening man pages is implemented * Bump minimal version because rayon dependency requires rustc 1.80 --- Cargo.toml | 2 +- src/commands/mod.rs | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9dfe84f..74692c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ description = "Rust reimplementation of important ldns programs." categories = ["command-line-utilities"] license = "BSD-3-Clause" keywords = ["DNS", "domain", "ldns"] -rust-version = "1.79" +rust-version = "1.80" [[bin]] name = "ldns" diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 3551512..f4bede9 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -74,9 +74,6 @@ pub enum Command { #[command(name = "update")] Update(self::update::Update), - /// Show the manual pages - Help(self::help::Help), - /// Report a string to stdout /// /// This is used for printing version information and some other @@ -94,7 +91,7 @@ impl Command { Self::Notify(notify) => notify.execute(env), Self::SignZone(signzone) => signzone.execute(env), Self::Update(update) => update.execute(env), - Self::Help(help) => help.execute(), + // Self::Help(help) => help.execute(env), Self::Report(s) => { writeln!(env.stdout(), "{s}"); Ok(()) From 1f8f2594afb7e20c0d8d7ab0e9207beef31faf9c Mon Sep 17 00:00:00 2001 From: Philip Homburg Date: Tue, 2 Sep 2025 16:41:19 +0200 Subject: [PATCH 22/32] Restore white space. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 005e444..bbb2609 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,7 @@ on: # Jobs # ---------------------------------------------------------------------------- jobs: + # Check Formatting # ---------------- # From 5bf2f3fb4d67b76c6888809d67a4e1cb42b3839e Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:07:10 +0200 Subject: [PATCH 23/32] Getting ready to publish an 0.1.0-rc2 version of dnst with keyset support to the proposed channel at packages.nlnetlabs.nl. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 3b68b09..e87279d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dnst" -version = "0.1.1-dev" +version = "0.1.0-rc2" edition = "2021" default-run = "dnst" readme = "README.md" From 64b9ce9742aeccca21753395e0fe34966f022a93 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:10:01 +0200 Subject: [PATCH 24/32] Add missed Cargo.lock change due to rc2 version bump. --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 739283e..68699cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -343,7 +343,7 @@ dependencies = [ [[package]] name = "dnst" -version = "0.1.1-dev" +version = "0.1.0-rc2" dependencies = [ "bytes", "chrono", From 1d6b2f22693cb3020da7d13d0ee419aeefc8f4d3 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:10:13 +0200 Subject: [PATCH 25/32] Mark keyset as experimental. --- src/commands/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b9f9d00..2335206 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -75,7 +75,7 @@ pub enum Command { #[command(name = "update")] Update(self::update::Update), - /// Maintain a set of DNSSEC keys + /// Maintain a set of DNSSEC keys. EXPERIMENTAL. #[command(name = "keyset")] Keyset(self::keyset::Keyset), From 6a1e4cbcd06e904fee05a7164a6f670c70d385fb Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 3 Sep 2025 11:02:32 +0200 Subject: [PATCH 26/32] Extend O/S's supported for packaging. --- pkg/rules/packages-to-build.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/rules/packages-to-build.yml b/pkg/rules/packages-to-build.yml index 31358c0..05517a0 100644 --- a/pkg/rules/packages-to-build.yml +++ b/pkg/rules/packages-to-build.yml @@ -10,8 +10,10 @@ image: - "debian:buster" # debian/10 - "debian:bullseye" # debian/11 - "debian:bookworm" # debian/12 - - 'rockylinux:8' # compatible with EOL centos:8 - - 'rockylinux:9' + - "debian:trixie" # debian/13 + - 'almalinux:8' # compatible with EOL centos:8 + - 'almalinux:9' + - 'almalinux:10' target: - 'x86_64' include: From 6915b816a65dbafa1ed27f0cd84781362dbb7f0d Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Wed, 3 Sep 2025 12:20:51 +0200 Subject: [PATCH 27/32] Reimplement `dnst update` with better UI (#115) --- doc/manual/source/man/dnst-update.rst | 152 ++- src/commands/mod.rs | 9 +- src/commands/update.rs | 1625 ++++++++++++++++++++++--- src/lib.rs | 4 +- 4 files changed, 1578 insertions(+), 212 deletions(-) diff --git a/doc/manual/source/man/dnst-update.rst b/doc/manual/source/man/dnst-update.rst index f82bc52..05f0421 100644 --- a/doc/manual/source/man/dnst-update.rst +++ b/doc/manual/source/man/dnst-update.rst @@ -4,50 +4,152 @@ dnst update Synopsis -------- -:program:`dnst update` ```` ``[ZONE]`` ```` -``[ ]`` +:program:`dnst update` ``[OPTIONS]`` ```` ```` + +:program:`dnst update` ``[OPTIONS]`` ```` :subcmd:`add` ```` ``[RDATA]...`` + +:program:`dnst update` ``[OPTIONS]`` ```` :subcmd:`delete` ```` ``[RDATA]...`` + +:program:`dnst update` ``[OPTIONS]`` ```` :subcmd:`clear` ```` Description ----------- **dnst update** sends an RFC 2136 Dynamic Update message to the name servers -for a zone to update an IP address (or delete all existing IP addresses) for a -domain name. +for a zone to add, update, or delete arbitrary Resource Records for a domain +name. The message to be sent can be optionally authenticated using a given TSIG key. +**dnst update [...] add** adds the given RRs to the domain. + +**dnst update [...] delete** deletes the given RRs from the domain. It can be +used to delete individual RRs or a whole RRset. + +**dnst update [...] clear** clears (deletes) all RRs of any type from the +domain name. + Arguments --------- .. option:: - The domain name to update the IP address of. + The domain name of the RR(s) to update. -.. option:: +.. option:: - The zone to send the update to (if omitted, derived from SOA record). - -.. option:: - - The IP address to update the domain with (``none`` to remove any - existing IP addresses) - -.. option:: - - TSIG key name. - -.. option:: - - TSIG algorithm (e.g. "hmac-sha256"). - -.. option:: - - Base64 encoded TSIG key data. + Which action to take: add, delete, or clear. Options: -------- +.. option:: -c, --class + + Class + + Defaults to IN. + +.. option:: -t, --ttl + + TTL in seconds or with unit suffix (s, m, h, d). + + Is only used by the :subcmd:`add` command and is otherwise ignored. + + Defaults to 3600. + +.. option:: -s, --server + + The name server to send the update to. + + By default, the update will be sent to the list of name servers fetched + from the zone's NS RRset as per RFC 2136. + +.. option:: -z, --zone + + The zone the domain name belongs to (to skip a SOA query) + +.. option:: -y, --tsig + + TSIG credentials for the UPDATE packet + +.. option:: --rrset-exists + + Require that at least one RR with the given NAME and TYPE exists. + This option can be provided multiple times, with format `` + `` each, to build up a list of RRs. + + If the domain name is relative, it will be relative to the zone's apex. + + [aliases: --rrset] + +.. option:: --rrset-exists-exact + + Require that an RRset exists and contains exactly the RRs with the given + NAME, TYPE, and RDATA. This option can be provided multiple times, each + with one RR in zonefile format, to build up one or more RRsets that is + required to exist. CLASS and TTL can be omitted. + + If the domain name is relative, it will be relative to the zone's apex. + + [aliases: --rrset-exact] + +.. option:: --rrset-non-existent + + RRset does not exist. This option can be provided multiple times, with + format `` `` each, to build up a list of RRs that + specify that no RRs with a specified NAME and TYPE can exist. + + If the domain name is relative, it will be relative to the zone's apex. + + [aliases: --rrset-empty] + +.. option:: --name-in-use + + Name is in use. This option can be provided multiple times, with format + ```` each, to collect a list of NAMEs that must own at least + one RR. + + Note that this prerequisite is NOT satisfied by empty nonterminals. + + If the domain name is relative, it will be relative to the zone's apex. + + [aliases: --name-used] + +.. option:: --name-not-in-use + + Name is not in use. This option can be provided multiple times, with + format ```` each, to collect a list of NAMEs that must NOT + own any RRs. + + Note that this prerequisite IS satisfied by empty nonterminals. + + If the domain name is relative, it will be relative to the zone's apex. + + [aliases: --name-unused] + .. option:: -h, --help Print the help text (short summary with ``-h``, long help with - ``--help``). + ``--help``). Can also be used on the individual sub commands. + +Arguments for :subcmd:`add` and :subcmd:`delete` +------------------------------------------------------ + +.. option:: + + The RR type to add or delete. + +.. option:: [RDATA]... + + One or more RDATA arguments for :subcmd:`add`, and zero or more for + :subcmd:`delete`. + + Each argument corresponds to a single RR's RDATA, so beware of (shell and + DNS) quoting rules. + + Each RDATA argument will be parsed as if it was read from a zone file. + + | Examples: + | :code:`dnst update some.example.com add AAAA ::1 2001:db8::` + | :code:`dnst update some.example.com add TXT '"Spacious String" "Another + string for the same TXT record"' '"This is another TXT RR"'` diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f4bede9..e7b9909 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -74,6 +74,13 @@ pub enum Command { #[command(name = "update")] Update(self::update::Update), + /// Send an UPDATE packet ldns compatibility variant + /// + /// This variant is not a dnst command and only used to provide + /// a separate implementation from the dnst update command. + #[command(skip)] + LdnsUpdate(self::update::LdnsUpdate), + /// Report a string to stdout /// /// This is used for printing version information and some other @@ -91,7 +98,7 @@ impl Command { Self::Notify(notify) => notify.execute(env), Self::SignZone(signzone) => signzone.execute(env), Self::Update(update) => update.execute(env), - // Self::Help(help) => help.execute(env), + Self::LdnsUpdate(ldnsupdate) => ldnsupdate.execute(env), Self::Report(s) => { writeln!(env.stdout(), "{s}"); Ok(()) diff --git a/src/commands/update.rs b/src/commands/update.rs index a31a638..a4278b8 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -1,30 +1,52 @@ +// Relevant RFCs: +// +// - [RFC2136]: Dynamic Updates in the Domain Name System (DNS UPDATE) +// - [RFC3007]: Secure Domain Name System (DNS) Dynamic Update +// +// [RFC2136]: https://www.rfc-editor.org/rfc/rfc2136.html +// [RFC3007]: https://www.rfc-editor.org/rfc/rfc3007.html +// +// Important notes: +// +// - No duplicate protection through update ordering with marker RRs + +use std::cmp::Ordering; use std::ffi::OsString; use std::net::{IpAddr, SocketAddr}; +use std::str::FromStr; +use std::time::Duration; +use clap::Subcommand; use domain::base::iana::{Class, Opcode, Rcode}; +use domain::base::message_builder::{AnswerBuilder, AuthorityBuilder}; +use domain::base::name::{FlattenInto, UncertainName}; +use domain::base::opt::AllOptData; 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::rdata::{Aaaa, AllRecordData, Ns, Soa, ZoneRecordData, A}; use domain::resolv::stub::conf::{ResolvConf, ServerConf, Transport}; use domain::tsig::Key; use domain::utils::base64; +use domain::zonefile::inplace::{Entry, Zonefile}; +use tracing::{debug, info, trace}; use crate::env::Env; -use crate::error::Error; +use crate::error::{Context, 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; +type ParsedRecord = Record>, ZoneRecordData, Name>>>; +type NameTypeTuple = (Name>, Rtype); + +const SERIAL_BITS: u32 = 32; +const SOA_SERIAL_CUTOFF: u32 = 1u32 << (SERIAL_BITS - 1); + +//------------ Update -------------------------------------------------------- #[derive(Clone, Debug, clap::Args, PartialEq, Eq)] pub struct Update { @@ -32,27 +54,1173 @@ pub struct 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, + /// Update action + #[command(subcommand)] + action: UpdateAction, - /// Zone to update - #[arg(long = "zone")] + /// Class + #[arg(short = 'c', long = "class", default_value_t = Class::IN)] + class: Class, + + /// TTL in seconds or with unit suffix (s, m, h, d). + /// + /// Is only used by the `add` command and ignored otherwise. + #[arg(short = 't', long = "ttl", value_parser = Update::parse_ttl, default_value = "3600")] + ttl: Ttl, + + /// Name server to send the update to + #[arg(short = 's', long = "server", value_name = "IP")] + nameservers: Option, + + /// Zone the domain name belongs to (to skip SOA query) + #[arg(short = 'z', long = "zone", value_name = "ZONE")] zone: Option>>, /// TSIG credentials for the UPDATE packet - #[arg(short = 'y', long = "tsig", value_name = "name:key[:algo]")] + #[arg(short = 'y', long = "tsig", value_name = "NAME:KEY[:ALGO]")] tsig: Option, + + /// Require that at least one RR with the given NAME and TYPE exists. + /// This option can be provided multiple times, with format " + /// " each, to build up a list of RRs. + /// + /// If the domain name is relative, it will be relative to the zone's apex. + #[arg( + long = "rrset-exists", + visible_alias = "rrset", + value_name = "DOMAIN_NAME_AND_TYPE" + )] + rrset_exists: Option>, + + /// Require that an RRset exists and contains exactly the RRs with the given + /// NAME, TYPE, and RDATA. This option can be provided multiple times, each + /// with one RR in zonefile format, to build up one or more RRsets that is + /// required to exist. CLASS and TTL can be omitted. + /// + /// If the domain name is relative, it will be relative to the zone's apex. + #[arg( + long = "rrset-exists-exact", + visible_alias = "rrset-exact", + value_name = "RESOURCE_RECORD" + )] + rrset_exists_exact: Option>, + + /// RRset does not exist. This option can be provided multiple times, with + /// format " " each, to build up a list of RRs that + /// specify that no RRs with a specified NAME and TYPE can exist. + /// + /// If the domain name is relative, it will be relative to the zone's apex. + #[arg( + long = "rrset-non-existent", + visible_alias = "rrset-empty", + value_name = "DOMAIN_NAME_AND_TYPE" + )] + rrset_non_existent: Option>, + + /// Name is in use. This option can be provided multiple times, with format + /// "" each, to collect a list of NAMEs that must own at least + /// one RR. + /// + /// Note that this prerequisite is NOT satisfied by empty nonterminals. + /// + /// If the domain name is relative, it will be relative to the zone's apex. + #[arg( + long = "name-in-use", + visible_alias = "name-used", + value_name = "DOMAIN_NAME" + )] + name_in_use: Option>, + + /// Name is not in use. This option can be provided multiple times, with + /// format "" each, to collect a list of NAMEs that must NOT + /// own any RRs. + /// + /// Note that this prerequisite IS satisfied by empty nonterminals. + /// + /// If the domain name is relative, it will be relative to the zone's apex. + #[arg( + long = "name-not-in-use", + visible_alias = "name-unused", + value_name = "DOMAIN_NAME" + )] + name_not_in_use: 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)) +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> { + // 1. Know apex and name servers for zone + // 2. If update ordering desired, fetch existing SOA RR from primary + // - If updating SOA, must update in serial in positive direction and preserve other + // fields, unless intent to change them; serial must never be 0 + // (not yet implemented) + // 3. Order nameserver list, listing primary first + // 4. Create UPDATE and send to first server in list + // 5. If response != SERVFAIL | NOTIMP, then return success + // 6. If response == SERVFAIL | NOTIMP, OR no response in software + // dependent timeout, OR ICMP error, THEN delete unusable server + // from list and try the next one. Repeat 4,5,6 until success, or + // list empty; return. + + // 1. If not provided, determine zone apex and fetch name servers + info!("Determining primary name server name, zone apex, and soa record"); + let (apex, mname, soa) = match &self.zone { + Some(zone) => { + let tmp = update_helpers::find_mname_and_soa(env, zone) + .await + .context("fetching the SOA record")?; + (zone.clone(), tmp.0, tmp.1) + } + None => update_helpers::find_mname_and_zone_and_soa(env, &self.domain) + .await + .context("fetching the SOA record and determining the zone apex")?, + }; + + // // mname is only used to put the primary first in the later fetched + // // list of name servers, therefore, if we have IP addresses to sent + // // the update to, we don't need to put the primary first and can let + // // the user determine the ordering, and therefore can skip the SOA + // // query. + // let (apex, mname) = match (&self.zone, self.nameservers.is_empty()) { + // (Some(zone), true) => ( + // zone.clone(), + // Some(update_helpers::find_mname(env, &zone).await?), + // ), + // (Some(zone), false) => (zone.clone(), None), + // (None, _) => { + // let (a, b) = update_helpers::find_mname_and_zone(env, &self.domain).await?; + // (a, Some(b)) + // } + // }; + + info!("Parsing prerequisite arguments"); + // Parse prerequisites before fetching nameservers, in case parsing fails + let prerequisites = self + .parse_prerequisites(apex.clone().flatten_into()) + .context("parsing the prerequisite arguments")?; + + // 1. (Cont.) If not provided, determine zone apex and fetch name servers + let nsnames = if self.nameservers.is_none() { + info!("Fetching authoritative name servers for {apex}"); + Some( + // 3. Order nameserver list, listing primary first + update_helpers::determine_nsnames(env, &apex, &mname) + .await + .context("fetching the authoritative nameservers for the zone")?, + ) + } else { + None + }; + + info!("Creating DNS UPDATE message"); + // 4. Create UPDATE and send to first server in list + let msg = self.create_update_message(&apex, prerequisites, soa)?; + + info!("Sending DNS UPDATE message"); + self.send_update(env, msg, nsnames).await?; + + Ok(()) + } + + fn parse_prerequisite_name_type( + args: &Vec, + origin: &Name>, + ) -> Result, Error> { + let mut records = Vec::new(); + for arg in args { + if let Some((name, typ)) = arg.split_once(' ') { + let typ = Rtype::from_str(typ).map_err(|e| -> Error { + format!("Invalid resource record type '{typ}': {e}").into() + })?; + let uncertain = UncertainName::>::from_str(name).map_err(|e| -> Error { + format!("Invalid domain name '{name}': {e}").into() + })?; + let name = uncertain + .chain(origin) + .map_err(|_| -> Error { + format!("Combining {name}.{origin} is too long for a domain name").into() + })? + .to_name(); + records.push((name, typ)) + } else { + return Err(format!( + "Invalid prerequisite argument format. Expected format ' ', was '{arg}'." + ) + .into()); + } + } + Ok(records) + } + + fn parse_prerequisite_name( + args: &Vec, + origin: &Name>, + ) -> Result>>, Error> { + let mut names = Vec::new(); + for name in args { + let uncertain = UncertainName::>::from_str(name) + .map_err(|e| -> Error { format!("Invalid domain name '{name}': {e}").into() })?; + let name = uncertain + .chain(origin) + .map_err(|_| -> Error { + format!("Combining {name}.{origin} is too long for a domain name").into() + })? + .to_name(); + names.push(name) + } + Ok(names) + } + + fn parse_prerequisite_rrset_exists_exact( + args: &Vec, + origin: &Name>, + class: Class, + ) -> Result, Error> { + let mut records = Vec::new(); + for arg in args { + let mut zonefile = Zonefile::new(); + zonefile.extend_from_slice(arg.as_bytes()); + zonefile.extend_from_slice(b"\n"); + zonefile.set_default_class(class); + zonefile.set_origin(origin.clone().flatten_into()); + if let Ok(Some(Entry::Record(mut record))) = zonefile.next_entry() { + record.set_ttl(Ttl::from_secs(0)); + records.push(record.flatten_into()); + } else { + return Err( + format!("Provided argument is not a valid resource record: {arg}").into(), + ); + } + } + Ok(records) + } + + fn parse_ttl(arg: &str) -> Result { + Ok(Ttl::from_secs( + if let Some(ttl) = arg.strip_suffix('s') { + ttl.parse() + } else if let Some(ttl) = arg.strip_suffix('m') { + ttl.parse::().map(|t| t * 60) + } else if let Some(ttl) = arg.strip_suffix('h') { + ttl.parse::().map(|t| t * 3600) + } else if let Some(ttl) = arg.strip_suffix('d') { + ttl.parse::().map(|t| t * 86400) + } else { + arg.parse() + } + .map_err(|err| Error::from(format!("Invalid TTL: {err}")))?, + )) + } + + /// Create the packet of the update message to send to the name servers + fn create_update_message( + &self, + zone: &Name>, + prerequisites: UpdatePrerequisites, + soa: Soa>>, + ) -> Result, Error> { + // UPDATE message sections: + // Zone Section (= Question), + // Prerequisite Section (= Answer) + // Update Section (= Authority) + // Additional (= Additional) + + let mut message = MessageBuilder::new_vec(); + + let header = message.header_mut(); + header.set_opcode(Opcode::UPDATE); + header.set_qr(false); + + debug!("Adding '{zone}' to zone section"); + let mut zone_section = message.question(); + zone_section + .push(Question::new(zone, Rtype::SOA, Class::IN)) + .unwrap(); + + debug!("Adding prerequisites to prerequisites section"); + let mut prereq_section = zone_section.answer(); + Self::insert_prerequisites(prerequisites, &mut prereq_section)?; + + let mut update_section = prereq_section.authority(); + + debug!("Adding update messages to update section"); + match self.action { + UpdateAction::Add { rtype, ref rdata } => { + self.insert_add_updates(rtype, rdata, &soa, &mut update_section)?; + } + UpdateAction::Delete { rtype, ref rdata } => { + self.insert_delete_updates(rtype, rdata, &mut update_section)?; + } + UpdateAction::Clear => { + debug!("Inserting update message: clear all RRsets on domain"); + update_section + .push(Self::create_all_rrset_deletion(self.domain.clone())) + .map_err(|e| { + format!("Failed to add RRset deletion RR to UPDATE message: {e}") + })? + } + } + + // TODO: Providing additional data is not yet implemented + // let mut additional_section = update_section.additional(); + + Ok(update_section.finish()) + } + + fn parse_rdata( + rtype: Rtype, + rdata: &str, + ) -> Result, Name>>, Error> { + // TODO: add from_rtype_and_str to ZoneRecordData to skip this + // workaround with zonefile? + let mut zonefile = Zonefile::new(); + // The origin, ttl and class are irrelevant here and only needed to + // parse the rdata + let rr = format!(". 1 IN {rtype} {rdata}\n"); + zonefile.extend_from_slice(rr.as_bytes()); + match zonefile.next_entry() { + Ok(Some(Entry::Record(record))) => Ok(record.data().clone().flatten_into()), + Ok(_) => unreachable!("We always create a record"), + Err(e) => { + Err(format!("Failed to parse rdata for {rtype} {rdata} -- Error: {e}").into()) + } + } + } + + fn create_rr_addition( + domain: Name>, + class: Class, + ttl: Ttl, + rdata: ZoneRecordData, Name>>, + ) -> ParsedRecord { + // From [RFC2136] Section 2.5.1: + // RRs are added to the Update Section whose NAME, TYPE, TTL, RDLENGTH + // and RDATA are those being added, and CLASS is the same as the zone + // class. + ParsedRecord::new(domain, class, ttl, rdata) + } + + fn create_rr_deletion( + domain: Name>, + rdata: ZoneRecordData, Name>>, + ) -> ParsedRecord { + // From [RFC2136] Section 2.5.4: + // The NAME, TYPE, RDLENGTH and RDATA must match the RR being deleted. + // TTL must be specified as zero (0) [...]. + // CLASS must be specified as NONE to distinguish this from an addition. + ParsedRecord::new(domain, Class::NONE, Ttl::from_secs(0), rdata) + } + + fn create_rrset_deletion(domain: Name>, rtype: Rtype) -> ParsedRecord { + // From [RFC2136] Section 2.5.2: + // - One RR is added to the Update Section whose NAME and TYPE are those + // of the RRset to be deleted. + // - TTL must be specified as zero (0) [...]. + // - CLASS must be specified as ANY. + // - RDLENGTH must be zero (0) and RDATA must therefore be empty. + let rdata = ZoneRecordData::Unknown( + UnknownRecordData::from_octets(rtype, Vec::new()) + .expect("Failed to create empty rdata"), + ); + ParsedRecord::new(domain, Class::ANY, Ttl::from_secs(0), rdata) + } + + fn create_all_rrset_deletion(domain: Name>) -> ParsedRecord { + // From [RFC2136] Section 2.5.3: + // - One RR is added to the Update Section whose NAME is that of the + // name to be cleansed of RRsets. + // - TYPE must be specified as ANY. + // - TTL must be specified as zero (0) [...] + // - CLASS must be specified as ANY. + // - RDLENGTH must be zero (0) and RDATA must therefore be empty. + let rdata = ZoneRecordData::Unknown( + UnknownRecordData::from_octets(Rtype::ANY, Vec::new()) + .expect("Failed to create empty rdata"), + ); + ParsedRecord::new(domain, Class::ANY, Ttl::from_secs(0), rdata) + } + + fn parse_prerequisites(&self, origin: Name>) -> Result { + let mut prerequisites = UpdatePrerequisites { + rrset_exists: None, + rrset_exists_exact: None, + rrset_non_existent: None, + name_in_use: None, + name_not_in_use: None, + }; + if let Some(ref v) = self.rrset_exists { + prerequisites.rrset_exists = Some(Self::parse_prerequisite_name_type(v, &origin)?) + } + if let Some(ref v) = self.rrset_exists_exact { + prerequisites.rrset_exists_exact = Some(Self::parse_prerequisite_rrset_exists_exact( + v, &origin, self.class, + )?) + } + if let Some(ref v) = self.rrset_non_existent { + prerequisites.rrset_non_existent = Some(Self::parse_prerequisite_name_type(v, &origin)?) + } + if let Some(ref v) = self.name_in_use { + prerequisites.name_in_use = Some(Self::parse_prerequisite_name(v, &origin)?) + } + if let Some(ref v) = self.name_not_in_use { + prerequisites.name_not_in_use = Some(Self::parse_prerequisite_name(v, &origin)?) + } + Ok(prerequisites) + } + + fn insert_prerequisites( + prerequisites: UpdatePrerequisites, + prereq_section: &mut AnswerBuilder>, + ) -> Result<(), Error> { + if let Some(rrset_exists) = prerequisites.rrset_exists { + for (domain, rtype) in rrset_exists { + debug!("Adding prerequisite (RRset exists): RR with name '{domain}' and type '{rtype}'"); + prereq_section + .push(Self::create_prereq_rrset_exists(domain, rtype)) + .map_err(|e| -> Error { + format!("Failed to add RR to UPDATE message: {e}").into() + })? + } + } + if let Some(rrset_exists_exact) = prerequisites.rrset_exists_exact { + for rr in rrset_exists_exact { + debug!("Adding prerequisite (RRset exists exact): RR '{rr}'"); + // The ttl is set while parsing the record, but in case that + // changes, here an extra check. + debug_assert!(rr.ttl() == Ttl::from_secs(0)); + // From [RFC2136] Section 2.4.2 - RRset Exists (Value Dependent) + // - [...] an entire RRset whose preexistence is required. + // - NAME and TYPE are that of the RRset being denoted. + // - CLASS is that of the zone. + // - TTL must be specified as zero (0) [...] + prereq_section.push(rr).map_err(|e| -> Error { + format!("Failed to add RR to UPDATE message: {e}").into() + })? + } + } + if let Some(rrset_non_existent) = prerequisites.rrset_non_existent { + for (domain, rtype) in rrset_non_existent { + debug!("Adding prerequisite (RRset does not exist): RR with name '{domain}' and type '{rtype}'"); + prereq_section + .push(Self::create_prereq_rrset_non_existent(domain, rtype)) + .map_err(|e| -> Error { + format!("Failed to add RR to UPDATE message: {e}").into() + })? + } + } + if let Some(name_in_use) = prerequisites.name_in_use { + for domain in name_in_use { + debug!("Adding prerequisite (Name in use): with name '{domain}'"); + prereq_section + .push(Self::create_prereq_name_in_use(domain)) + .map_err(|e| -> Error { + format!("Failed to add RR to UPDATE message: {e}").into() + })? + } + } + if let Some(name_not_in_use) = prerequisites.name_not_in_use { + for domain in name_not_in_use { + debug!("Adding prerequisite (Name not in use): with name '{domain}'"); + prereq_section + .push(Self::create_prereq_name_not_in_use(domain)) + .map_err(|e| -> Error { + format!("Failed to add RR to UPDATE message: {e}").into() + })? + } + } + Ok(()) + } + + fn create_prereq_rrset_exists(domain: Name>, rtype: Rtype) -> ParsedRecord { + // From [RFC2136] Section 2.4.1 - RRset Exists (Value Independent): + // - [...] a single RR whose NAME and TYPE are equal to that of the zone + // RRset whose existence is required. + // - RDLENGTH is zero and RDATA is therefore empty. + // - CLASS must be specified as ANY [...] + // - TTL is specified as zero (0). + let rdata = ZoneRecordData::Unknown( + UnknownRecordData::from_octets(rtype, Vec::new()) + .expect("Failed to create empty rdata"), + ); + ParsedRecord::new(domain, Class::ANY, Ttl::from_secs(0), rdata) + } + + fn create_prereq_rrset_non_existent(domain: Name>, rtype: Rtype) -> ParsedRecord { + // From [RFC2136] Section 2.4.3 - RRset Does Not Exist + // - [...] a single RR whose NAME and TYPE are equal to that of the + // RRset whose nonexistence is required. + // - The RDLENGTH of this record is zero (0), and RDATA field is + // therefore empty. + // - CLASS must be specified as NONE [...] + // - TTL must be specified as zero (0). + let rdata = ZoneRecordData::Unknown( + UnknownRecordData::from_octets(rtype, Vec::new()) + .expect("Failed to create empty rdata"), + ); + ParsedRecord::new(domain, Class::NONE, Ttl::from_secs(0), rdata) + } + + fn create_prereq_name_in_use(domain: Name>) -> ParsedRecord { + // From [RFC2136] Section 2.4.4 - Name Is In Use + // - [...] a single RR whose NAME is equal to that of the name whose + // ownership of an RR is required. + // - RDLENGTH is zero and RDATA is therefore empty. + // - CLASS must be specified as ANY [...] + // - TYPE must be specified as ANY [...] + // - TTL is specified as zero (0). + let rdata = ZoneRecordData::Unknown( + UnknownRecordData::from_octets(Rtype::ANY, Vec::new()) + .expect("Failed to create empty rdata"), + ); + ParsedRecord::new(domain, Class::ANY, Ttl::from_secs(0), rdata) + } + + fn create_prereq_name_not_in_use(domain: Name>) -> ParsedRecord { + // From [RFC2136] Section 2.4.5 - Name Is Not In Use + // - [...] a single RR whose NAME is equal to that of the name whose + // nonownership of any RRs is required. + // - RDLENGTH is zero and RDATA is therefore empty. + // - CLASS must be specified as NONE. + // - TYPE must be specified as ANY. + // - TTL must be specified as zero (0). + let rdata = ZoneRecordData::Unknown( + UnknownRecordData::from_octets(Rtype::ANY, Vec::new()) + .expect("Failed to create empty rdata"), + ); + ParsedRecord::new(domain, Class::NONE, Ttl::from_secs(0), rdata) + } + + /// 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: Option>>>, + ) -> 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()?; + + async fn connect_and_send_request( + env: &impl Env, + socket: SocketAddr, + msg: &Message>, + tsig_key: &Option, + ) -> Result, domain::net::client::request::Error> { + // TODO: Use TCP directly or at least implement TCP fallback + + // // Using TCP directly to skip check whether the request fits + // // in a UDP packet. + // let tcp_connect = TcpConnect::new(socket); + // let (tcp_connection, transport) = multi_stream::Connection::new(tcp_connect); + // tokio::spawn(transport.run()); + + // let connection: Box> = if let Some(k) = &tsig_key { + // Box::new(tsig::Connection::new(k.clone(), tcp_connection)) + // } else { + // Box::new(tcp_connection) + // }; + + let local: SocketAddr = if socket.is_ipv4() { + ([0u8; 4], 0).into() + } else { + ([0u16; 8], 0).into() + }; + let dgram_connection = dgram::Connection::new(env.dgram(local, 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 msg_dig = Update::update_to_dig_fmt(msg); + trace!("Sending UPDATE:\n{msg_dig}"); + connection + .send_request(RequestMessage::new(msg.clone()).unwrap()) + .get_response() + .await + } + + if let Some(nsnames) = nsnames { + for name in nsnames { + let found_ips = resolver.lookup_host(&name).await?; + for socket in found_ips.port_iter(53) { + let resp = match connect_and_send_request(&env, socket, &msg, &tsig_key).await { + Ok(resp) => resp, + Err(err) => { + writeln!(env.stderr(), "{name} @ {socket}: {err}"); + continue; + } + }; + + let mut buf = String::with_capacity(4096); + Self::answer_to_dig_fmt(&resp, &mut buf, socket); + trace!("Got Response:\n{buf}"); + + let rcode = resp.header().rcode(); + if rcode == Rcode::SERVFAIL || rcode == Rcode::NOTIMP { + writeln!(env.stderr(), "Skipping {name} @ {socket}: {rcode}"); + continue; + } else if rcode != Rcode::NOERROR { + writeln!(env.stdout(), "UPDATE response was {rcode}"); + } + return Ok(()); + } + } + } else { + // This is always the case if we reach this branch, but just in case + // of later changes we can leave this check in place + if let Some(ip) = &self.nameservers { + let socket = SocketAddr::new(*ip, 53); + let resp = match connect_and_send_request(&env, socket, &msg, &tsig_key).await { + Ok(resp) => resp, + Err(err) => { + return Err(format!("Unable to send update to {socket}: {err}").into()); + } + }; + + let mut buf = String::with_capacity(4096); + Self::answer_to_dig_fmt(&resp, &mut buf, socket); + trace!("Got Response:\n{buf}"); + + let rcode = resp.header().rcode(); + if rcode == Rcode::SERVFAIL || rcode == Rcode::NOTIMP { + return Err( + format!("Name server {socket} was unable to handle the update. Got response: {rcode}").into() + ); + } else 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 successful responses"); + + Ok(()) + } + + /// Compares two 32-bit serial numbers as defined in [RFC1982]. Returns + /// Ordering::Less if a < b, Ordering::Equal if a == b, and + /// Ordering::Greater if a > b. The result is undefined if a != b but + /// neither is greater or smaller (see [RFC1982] Section 3.2.). + /// The maximumm increment for a SOA serial is 2147483647 as per [RFC1982]. + /// + /// [RFC1982]: https://www.rfc-editor.org/rfc/rfc1982.html + fn compare_serial(a: u32, b: u32) -> Ordering { + // Code from nsd:util.c:compare_serial + if a == b { + Ordering::Equal + } else if (a < b && b - a < SOA_SERIAL_CUTOFF) || (a > b && a - b > SOA_SERIAL_CUTOFF) { + Ordering::Less + } else { + Ordering::Greater + } + } + + /// Verify that the SOA serial of the new rdata is within the bounds of an + /// allowed increment. + fn verify_soa_serial_increment(old: &Soa>>, new: &Soa>>) -> bool { + // The new soa serial should be greater than the old serial + Self::compare_serial(new.serial().into_int(), old.serial().into_int()) == Ordering::Greater + } + + fn insert_add_updates( + &self, + rtype: Rtype, + rdata: &Vec, + soa: &Soa>>, + update_section: &mut AuthorityBuilder>, + ) -> Result<(), Error> { + if rdata.is_empty() { + return Err("Provide at least one RDATA item to add".into()); + } + for r in rdata { + debug!("Parsing RDATA '{r}'"); + let rdata = Self::parse_rdata(rtype, r)?; + if let ZoneRecordData::Soa(new_soa) = &rdata { + if !Self::verify_soa_serial_increment(soa, new_soa) { + let old_serial = soa.serial(); + let new_serial = new_soa.serial(); + return Err( + format!( + "SOA serial is only allowed to be incremented (and maximally by {SOA_SERIAL_CUTOFF} at once), got old serial = {old_serial} and new serial = {new_serial}").into() + ); + } + } + debug!("Inserting update message: add {rtype} RR with RDATA '{rdata}'"); + update_section + .push(Self::create_rr_addition( + self.domain.clone(), + self.class, + self.ttl, + rdata, + )) + .map_err(|e| format!("Failed to add RR to UPDATE message: {e}"))? + } + Ok(()) + } + + fn insert_delete_updates( + &self, + rtype: Rtype, + rdata: &Vec, + update_section: &mut AuthorityBuilder>, + ) -> Result<(), Error> { + if rdata.is_empty() { + debug!("Inserting update message: delete the {rtype} RRset"); + update_section + .push(Self::create_rrset_deletion(self.domain.clone(), rtype)) + .map_err(|e| format!("Failed to add RRset deletion RR to UPDATE message: {e}"))? + } else { + for r in rdata { + debug!("Parsing RDATA '{r}'"); + let rdata = Self::parse_rdata(rtype, r)?; + debug!("Inserting update message: delete {rtype} RR with RDATA '{rdata}'"); + update_section + .push(Self::create_rr_deletion(self.domain.clone(), rdata)) + .map_err(|e| format!("Failed to add RR deletion RR to UPDATE message: {e}"))? + } + } + Ok(()) + } + + fn update_to_dig_fmt(msg: &Message>) -> String { + fn write_record_item_additional( + target: &mut String, + item: &domain::base::ParsedRecord>, + ) { + let parsed = item.to_any_record::>(); + + if parsed.is_err() { + target.push_str("; "); + } + + let data = match parsed { + Ok(item) => item.data().to_string(), + Err(_) => "".into(), + }; + + target.push_str(&format!( + "{} {} {} {} {}\n", + item.owner(), + item.ttl().as_secs(), + item.class(), + item.rtype(), + data + )) + } + + fn write_record_item_update( + target: &mut String, + item: &domain::base::ParsedRecord>, + class: Class, + ) { + let parsed = item.to_any_record::>(); + + let data = match parsed { + Ok(item) => item.data().to_string(), + Err(_) => "".into(), + }; + + target.push_str("; "); + match ( + item.class(), + item.ttl().as_secs(), + item.rtype(), + item.rdlen(), + ) { + (Class::ANY, 0, Rtype::ANY, 0) => { + target.push_str(&format!("Clearing domain: {}\n", item.owner())) + } + (Class::ANY, 0, _, 0) => target.push_str(&format!( + "Deleting RRset: {} {} {}\n", + item.owner(), + class, + item.rtype() + )), + (Class::NONE, 0, _, _) => target.push_str(&format!( + "Deleting RR: {} {} {} {}\n", + item.owner(), + class, + item.rtype(), + data + )), + (_, _, _, _) => { + target.push_str("Adding RR: "); + target.push_str(&format!( + "{} {} {} {} {}\n", + item.owner(), + item.ttl().as_secs(), + class, + item.rtype(), + data + )); + } + } + } + + fn write_record_item_prerequisite( + target: &mut String, + item: &domain::base::ParsedRecord>, + class: Class, + ) { + let parsed = item.to_any_record::>(); + + let data = match parsed { + Ok(item) => item.data().to_string(), + Err(_) => "".into(), + }; + + target.push_str("; "); + match ( + item.class(), + item.ttl().as_secs(), + item.rtype(), + item.rdlen(), + ) { + (Class::ANY, 0, Rtype::ANY, 0) => { + target.push_str(&format!("Name is in use: {}\n", item.owner())) + } + (Class::NONE, 0, Rtype::ANY, 0) => { + target.push_str(&format!("Name is NOT in use: {}\n", item.owner(),)) + } + (Class::ANY, 0, _, 0) => target.push_str(&format!( + "RRset exists (regardless of content): {} {}\n", + item.owner(), + item.rtype() + )), + (Class::NONE, 0, _, 0) => target.push_str(&format!( + "RRset does not exist: {} {}\n", + item.owner(), + item.rtype() + )), + (_, 0, _, _) => target.push_str(&format!( + "RRset exists with exact members: {} {} {} {}\n", + item.owner(), + class, + item.rtype(), + data + )), + (_, _, _, _) => unreachable!("Prerequisites all have a TTL of 0"), + } + } + + let mut target = String::with_capacity(4096); + + // Header + let counts = msg.header_counts(); + + target.push_str(&format!( + "; ZONE: {}, PREREQUISITE: {}, UPDATE: {}, ADDITIONAL: {}\n", + counts.qdcount(), + counts.ancount(), + counts.nscount(), + counts.arcount() + )); + + let opt = msg.opt(); // We need it further down ... + + if let Some(opt) = opt.as_ref() { + target.push_str("\n;; OPT PSEUDOSECTION:\n"); + target.push_str(&format!( + "; EDNS: version {}, flags: {}; udp: {}\n", + opt.version(), + if opt.dnssec_ok() { "do" } else { "" }, + opt.udp_payload_size() + )); + } + + let mut class = None; + + // Zone section + let questions = msg.question(); + target.push_str(";; ZONE SECTION:\n"); + // Should only be one... + for item in questions { + let item = item.expect("we created this message, it should be valid"); + target.push_str(&format!("; {item}\n")); + class = Some(item.qclass()); + } + + // There is always a zone section in an update and therefore always + // a class + let class = class.expect("There is always a zone section"); + + // Prerequisite section + let answer = questions + .answer() + .expect("We created this message outselves, it should be correct"); + if counts.ancount() > 0 { + target.push_str("\n;; PREREQUISITE SECTION:\n"); + for item in answer { + let item = item.expect("we created this message, it should be valid"); + write_record_item_prerequisite(&mut target, &item, class); + } + } + + // Update section + let authority = answer + .next_section() + .expect("we created this message, it should be valid") + .unwrap(); + if counts.nscount() > 0 { + target.push_str("\n;; UPDATE SECTION:\n"); + for item in authority { + let item = item.expect("we created this message, it should be valid"); + write_record_item_update(&mut target, &item, class); + } + } + + // Additional + let additional = authority + .next_section() + .expect("we created this message, it should be valid") + .unwrap(); + if counts.arcount() > 0 || (opt.is_none() && counts.arcount() > 0) { + target.push_str("\n;; ADDITIONAL SECTION:\n"); + for item in additional { + let item = item.expect("we created this message, it should be valid"); + if item.rtype() != Rtype::OPT { + write_record_item_additional(&mut target, &item); + } + } + } + + target + } + + // Code from dnsi:src/output/dig.rs with io:Write replaced with push_str + fn answer_to_dig_fmt(msg: &Message, target: &mut String, socket: SocketAddr) { + fn write_record_item(target: &mut String, item: &domain::base::ParsedRecord) { + let parsed = item.to_any_record::>(); + + if parsed.is_err() { + target.push_str("; "); + } + + let data = match parsed { + Ok(item) => item.data().to_string(), + Err(_) => "".into(), + }; + + target.push_str(&format!( + "{} {} {} {} {}\n", + item.owner(), + item.ttl().as_secs(), + item.class(), + item.rtype(), + data + )) + } + // Header + let header = msg.header(); + let counts = msg.header_counts(); + + target.push_str(&format!( + ";; ->>HEADER<<- opcode: {}, rcode: {}, id: {}\n", + header.opcode(), + header.rcode(), + header.id() + )); + target.push_str(&format!(";; flags: {}", header.flags())); + target.push_str(&format!( + "; QUERY: {}, ANSWER: {}, AUTHORITY: {}, ADDITIONAL: {}\n", + counts.qdcount(), + counts.ancount(), + counts.nscount(), + counts.arcount() + )); + + let opt = msg.opt(); // We need it further down ... + + if let Some(opt) = opt.as_ref() { + target.push_str("\n;; OPT PSEUDOSECTION:\n"); + target.push_str(&format!( + "; EDNS: version {}, flags: {}; udp: {}\n", + opt.version(), + if opt.dnssec_ok() { "do" } else { "" }, + opt.udp_payload_size() + )); + for option in opt.opt().iter::>() { + use AllOptData::*; + + match option { + Ok(opt) => match opt { + Nsid(nsid) => target.push_str(&format!("; NSID: {nsid}\n")), + Dau(dau) => target.push_str(&format!("; DAU: {dau}\n")), + Dhu(dhu) => target.push_str(&format!("; DHU: {dhu}\n")), + N3u(n3u) => target.push_str(&format!("; N3U: {n3u}\n")), + Expire(expire) => target.push_str(&format!("; EXPIRE: {expire}\n")), + TcpKeepalive(opt) => target.push_str(&format!( + "; TCP KEEPALIVE: {}\n", + opt.timeout().map_or("".to_string(), |t| format!( + "{:.1} secs", + Duration::from(t).as_secs_f64() + )) + )), + Padding(padding) => target.push_str(&format!("; PADDING: {padding}\n")), + ClientSubnet(opt) => target.push_str(&format!("; CLIENTSUBNET: {opt}\n")), + Cookie(cookie) => target.push_str(&format!("; COOKIE: {cookie}\n")), + Chain(chain) => target.push_str(&format!("; CHAIN: {chain}\n")), + KeyTag(keytag) => target.push_str(&format!("; KEYTAG: {keytag}\n")), + ExtendedError(extendederror) => { + target.push_str(&format!("; EDE: {extendederror}\n")) + } + Other(other) => { + target.push_str(&format!("; {}\n", other.code())); + } + _ => target.push_str("Unknown OPT\n"), + }, + Err(err) => { + target.push_str(&format!("; ERROR: bad option: {err}.\n")); + } + } + } + } + + // Question + let questions = msg.question(); + if counts.qdcount() > 0 { + target.push_str(";; QUESTION SECTION:\n"); + for item in questions { + let item = item.expect("we created this message, it should be valid"); + target.push_str(&format!(";{item}\n")); + } + } + + // Answer + let section = questions + .answer() + .expect("we created this message, it should be valid"); + if counts.ancount() > 0 { + target.push_str("\n;; ANSWER SECTION:\n"); + for item in section { + let item = item.expect("we created this message, it should be valid"); + write_record_item(target, &item); + } + } + + // Authority + let section = section + .next_section() + .expect("we created this message, it should be valid") + .unwrap(); + if counts.nscount() > 0 { + target.push_str("\n;; AUTHORITY SECTION:\n"); + for item in section { + let item = item.expect("we created this message, it should be valid"); + write_record_item(target, &item); + } + } + + // Additional + let section = section + .next_section() + .expect("we created this message, it should be valid") + .unwrap(); + if counts.arcount() > 1 || (opt.is_none() && counts.arcount() > 0) { + target.push_str("\n;; ADDITIONAL SECTION:\n"); + for item in section { + let item = item.expect("we created this message, it should be valid"); + if item.rtype() != Rtype::OPT { + write_record_item(target, &item); + } + } + } + + target.push_str(&format!(";; SERVER: {}#{}\n", socket.ip(), socket.port(),)); + target.push_str(&format!(";; MSG SIZE rcvd: {}\n", msg.as_slice().len())); + } +} + +//------------ UpdateAction -------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Eq, Subcommand)] +enum UpdateAction { + /// Add RRs to a domain + Add { + /// RRtype + #[arg(value_name = "RRTYPE")] + rtype: Rtype, + + /// RDATA (One or more). Each argument corresponds to a single RR's + /// RDATA, so beware of (shell and DNS) quoting rules. + /// + /// Each RDATA argument will be parsed as if it was read from a zone file. + /// + /// Examples: + /// $ dnst update some.example.com add TXT \ + /// '"Spacious String" "Another string for the same TXT record"' \ + /// '"This is another TXT RR"' + #[arg(value_name = "RDATA", verbatim_doc_comment)] + rdata: Vec, + }, + + /// Delete specific RRs or a complete RRsets from a domain + Delete { + /// RRtype + #[arg(value_name = "RRTYPE")] + rtype: Rtype, + + /// RDATA (Optional. Delete whole RRset, if none provided). Each + /// argument corresponds to a single RR's RDATA, so beware of (shell + /// and DNS) quoting rules. + /// + /// Each RDATA argument will be parsed as if it was read from a zone file. + /// + /// For quoting example see `dnst update add --help` + #[arg(value_name = "RDATA")] + rdata: Vec, + }, + + /// Clear domain, aka delete all RRsets on the domain + Clear, +} + +//------------ UpdatePrerequisites ------------------------------------------- + +#[derive(Clone, Debug)] +struct UpdatePrerequisites { + rrset_exists: Option>, + rrset_exists_exact: Option>, + rrset_non_existent: Option>, + name_in_use: Option>>>, + name_not_in_use: Option>>>, +} + +//------------ LdnsUpdate ---------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LdnsUpdate { + /// Domain name to update + domain: Name>, + + /// IP address to associate with the given domain name. + /// Use `none` to delete the records for the domain name. + ip: Option, + + /// Zone to update + zone: Option>>, + + /// TSIG credentials for the UPDATE packet + tsig: Option, } const LDNS_HELP: &str = "\ @@ -67,7 +1235,7 @@ This command exists for compatibility purposes. For a more modern version of this command try `dnst update`\ "; -impl LdnsCommand for Update { +impl LdnsCommand for LdnsUpdate { const NAME: &'static str = "update"; const HELP: &'static str = LDNS_HELP; const COMPATIBLE_VERSION: &'static str = "1.8.4"; @@ -118,7 +1286,7 @@ impl LdnsCommand for Update { None => None, }; - Ok(Args::from(Command::Update(Self { + Ok(Args::from(Command::LdnsUpdate(Self { domain, ip, zone, @@ -134,7 +1302,7 @@ impl LdnsCommand for Update { } } -impl Update { +impl LdnsUpdate { pub fn execute(self, env: impl Env) -> Result<(), Error> { let runtime = tokio::runtime::Runtime::new().unwrap(); runtime.block_on(self.run(&env)) @@ -152,142 +1320,20 @@ impl Update { let soa_zone; let soa_mname; if let Some(zone) = &self.zone { - soa_mname = self.find_mname(env, zone).await?; + (soa_mname, _) = update_helpers::find_mname_and_soa(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?; + (soa_zone, soa_mname, _) = + update_helpers::find_mname_and_zone_and_soa(env, &name).await?; }; - let nsnames = self.determine_nsnames(env, &soa_zone, &soa_mname).await?; + let nsnames = update_helpers::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(); @@ -319,14 +1365,13 @@ impl Update { )) .unwrap(); } else { - update_section - .push(Record::new( - &self.domain, - Class::ANY, - Ttl::from_secs(0), - UnknownRecordData::from_octets(Rtype::A, &[]).unwrap(), - )) - .unwrap(); + let tmp = Record::new( + &self.domain, + Class::ANY, + Ttl::from_secs(0), + UnknownRecordData::from_octets(Rtype::A, &[]).unwrap(), + ); + update_section.push(tmp).unwrap(); update_section .push(Record::new( @@ -405,14 +1450,171 @@ impl Update { } } +//------------ update_helpers ------------------------------------------------ + +mod update_helpers { + use super::*; + + /// Find the MNAME by sending a SOA query for the zone + pub async fn find_mname_and_soa( + env: &impl Env, + zone: &Name>, + ) -> Result<(Name>, Soa>>), Error> { + let resolver = env.stub_resolver().await; + + debug!("Querying resolver for SOA of {zone}"); + let response = resolver + .query(Question::new(&zone, Rtype::SOA, Class::IN)) + .await?; + + debug!("Reading response from resolver"); + let mut answer = response.answer()?.limit_to::>(); + if let Some(Ok(soa)) = answer.next() { + Ok(( + soa.data().mname().to_name(), + soa.data().clone().flatten_into(), + )) + } else { + Err(format!("No SOA record found for {zone}").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 IP addresses for the MNAME + /// 3. Send a SOA query to that IP address and use the owner as zone + /// and the MNAME from that response. + pub async fn find_mname_and_zone_and_soa( + env: &impl Env, + name: &Name>, + ) -> Result<(Name>, Name>, Soa>>), Error> { + let resolver = env.stub_resolver().await; + + debug!("Querying resolver for SOA of {name}"); + // Step 1 - first find a name server that should know *something* + let response = resolver + .query(Question::new(&name, Rtype::SOA, Class::IN)) + .await?; + + debug!("Reading response from resolver"); + // 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(); + + debug!("Querying for the IP address of {soa_mname}"); + // 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(format!("No A or AAAA record found for {soa_mname}").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, + )]; + // Querying the SOA RR again, but from the primary directly, makes + // sure that we have an up-to-date SOA record and not a cached + // version. This would be relevant if we'd want to implement update + // ordering, or want to update the SOA serial. + let resolver = env.stub_resolver_from_conf(conf).await; + + debug!("Querying primary name server directly for SOA of {name}"); + let response = resolver + .query(Question::new(&name, Rtype::SOA, Class::IN)) + .await?; + + debug!("Reading response from primary name server"); + // 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, soa.data().clone().flatten_into())) + } + + /// Send an NS query to find all name servers for the given zone + /// + /// The name server with the given MNAME is put at the start of the list. + // async fn determine_nsnames( + pub async fn determine_nsnames( + env: &impl Env, + zone: &Name>, + mname: &Name>, + ) -> Result>>, Error> { + debug!("Querying {mname} for NS RRset of {zone}"); + let response = env + .stub_resolver() + .await + .query(Question::new(&zone, Rtype::NS, Class::IN)) + .await?; + + debug!("Reading response from {mname}"); + 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) + } +} + +//------------ test ---------------------------------------------------------- + #[cfg(test)] mod test { + use std::str::FromStr; + + use domain::base::iana::Class; + use domain::base::{Name, Rtype, Ttl}; use domain::{tsig::Algorithm, utils::base64}; + use crate::commands::update::{LdnsUpdate, UpdateAction}; use crate::{commands::Command, env::fake::FakeCmd}; use super::{TSigInfo, Update}; + #[track_caller] + fn parse_ldns(cmd: FakeCmd) -> LdnsUpdate { + let res = cmd.parse().unwrap(); + let Command::LdnsUpdate(x) = res.command else { + panic!("Not an Update!"); + }; + x + } + #[track_caller] fn parse(cmd: FakeCmd) -> Update { let res = cmd.parse().unwrap(); @@ -422,6 +1624,9 @@ mod test { x } + // TODO: Add tests triggering the runtime checks + // TODO: Add stelline tests + #[test] fn dnst_parse() { let cmd = FakeCmd::new(["dnst", "update"]); @@ -432,40 +1637,81 @@ mod test { cmd.args(["--zone", "example.test", "ns.example.test"]) .parse() .unwrap_err(); - cmd.args(["foo.test", "bar.test", "none"]) + cmd.args(["example.test", "bar.test", "none"]) .parse() .unwrap_err(); + // Error when missing rtype to add (missing rdata is a runtime error) + cmd.args(["example.test", "add"]).parse().unwrap_err(); + // Error when missing rtype + cmd.args(["example.test", "delete"]).parse().unwrap_err(); let base = Update { - domain: "foo.test".parse().unwrap(), - ip: None, + domain: "example.test".parse().unwrap(), zone: None, tsig: None, + // This is not actually a default, but I need to add something here + action: UpdateAction::Add { + rtype: Rtype::A, + rdata: vec![String::from("127.0.0.1")], + }, + class: Class::IN, + ttl: Ttl::from_secs(3600), + nameservers: Default::default(), + rrset_exists: None, + rrset_exists_exact: None, + rrset_non_existent: None, + name_in_use: None, + name_not_in_use: None, }; - let res = parse(cmd.args(["foo.test", "none"])); + let res = parse(cmd.args(["example.test", "add", "A", "127.0.0.1"])); assert_eq!(res, base); - let res = parse(cmd.args(["foo.test", "1.1.1.1"])); + let res = parse(cmd.args(["example.test", "add", "A", "127.0.0.1", "127.0.0.2"])); assert_eq!( res, Update { - ip: Some("1.1.1.1".parse().unwrap()), + action: UpdateAction::Add { + rtype: Rtype::A, + rdata: vec!["127.0.0.1".into(), "127.0.0.2".into()], + }, ..base.clone() } ); - let res = parse(cmd.args(["foo.test", "1.1.1.1", "--zone", "bar.test"])); + let res = parse(cmd.args(["example.test", "delete", "AAAA", "::1"])); assert_eq!( res, Update { - ip: Some("1.1.1.1".parse().unwrap()), - zone: Some("bar.test".parse().unwrap()), + action: UpdateAction::Delete { + rtype: Rtype::AAAA, + rdata: vec!["::1".into()], + }, ..base.clone() } ); - let res = parse(cmd.args(["foo.test", "none", "--tsig", "somekey:1234"])); + let res = parse(cmd.args(["example.test", "clear"])); + assert_eq!( + res, + Update { + action: UpdateAction::Clear, + ..base.clone() + } + ); + + let res = parse(cmd.args([ + "example.test", + "--tsig", + "somekey:1234", + "--ttl", + "60", + "--server", + "127.0.0.9", + "add", + "TXT", + "Hallo", + ])); assert_eq!( res, Update { @@ -474,16 +1720,34 @@ mod test { key: base64::decode("1234").unwrap(), algorithm: Algorithm::Sha256, }), + ttl: Ttl::from_secs(60), + nameservers: Some([127, 0, 0, 9].into()), + action: UpdateAction::Add { + rtype: Rtype::TXT, + rdata: vec!["Hallo".into()] + }, ..base.clone() } ); + + let res = parse(cmd.args(["example.test", "--zone", "test", "add", "A", "127.0.0.1"])); + assert_eq!( + res, + Update { + zone: Some(Name::from_str("test").unwrap()), + ..base.clone() + } + ); + + // Parsing the prerequisites arguments here doesn't make much sense as + // they only get validated at runtime } #[test] fn ldns_parse() { let cmd = FakeCmd::new(["ldns-update"]); - let base = Update { + let base = LdnsUpdate { domain: "foo.test".parse().unwrap(), ip: None, zone: None, @@ -492,22 +1756,22 @@ mod test { cmd.args(["foo.test"]).parse().unwrap_err(); - let res = parse(cmd.args(["foo.test", "none"])); + let res = parse_ldns(cmd.args(["foo.test", "none"])); assert_eq!(res, base.clone()); - let res = parse(cmd.args(["foo.test", "1.1.1.1"])); + let res = parse_ldns(cmd.args(["foo.test", "1.1.1.1"])); assert_eq!( res, - Update { + LdnsUpdate { ip: Some("1.1.1.1".parse().unwrap()), ..base.clone() } ); - let res = parse(cmd.args(["foo.test", "base.test", "1.1.1.1"])); + let res = parse_ldns(cmd.args(["foo.test", "base.test", "1.1.1.1"])); assert_eq!( res, - Update { + LdnsUpdate { ip: Some("1.1.1.1".parse().unwrap()), zone: Some("base.test".parse().unwrap()), ..base.clone() @@ -599,15 +1863,8 @@ mod test { SCENARIO_END "; - let cmd = FakeCmd::new([ - "dnst", - "update", - "foo.test", - "none", - "--zone", - "zone.foo.test", - ]) - .stelline(rpl.as_bytes(), "update.rpl"); + let cmd = FakeCmd::new(["ldns-update", "foo.test", "zone.foo.test", "none"]) + .stelline(rpl.as_bytes(), "update.rpl"); let res = cmd.run(); assert_eq!(res.exit_code, 0); @@ -617,7 +1874,7 @@ mod test { ); assert_eq!(res.stderr, ""); - let cmd = FakeCmd::new(["dnst", "update", "foo.test", "none"]) + let cmd = FakeCmd::new(["ldns-update", "foo.test", "none"]) .stelline(rpl.as_bytes(), "update.rpl"); let res = cmd.run(); diff --git a/src/lib.rs b/src/lib.rs index d9de7cb..cfe694e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,7 +7,7 @@ use commands::keygen::Keygen; use commands::notify::Notify; use commands::nsec3hash::Nsec3Hash; use commands::signzone::SignZone; -use commands::update::Update; +use commands::update::LdnsUpdate; use commands::LdnsCommand; use domain::base::zonefile_fmt::DisplayKind; use env::Env; @@ -49,7 +49,7 @@ pub fn try_ldns_compatibility>( "keygen" => Keygen::parse_ldns_args(args_iter), "nsec3-hash" => Nsec3Hash::parse_ldns_args(args_iter), "signzone" => SignZone::parse_ldns_args(args_iter), - "update" => Update::parse_ldns_args(args_iter), + "update" => LdnsUpdate::parse_ldns_args(args_iter), _ => Err(format!("Unrecognized ldns command 'ldns-{binary_name}'").into()), }?; From f50b65740180b7dce9946b03497b7aaf04db723b Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 3 Sep 2025 13:20:02 +0200 Subject: [PATCH 28/32] Test missing/incorrect O/S variants. --- pkg/rules/packages-to-test.yml | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/pkg/rules/packages-to-test.yml b/pkg/rules/packages-to-test.yml index 1441b68..37a33f4 100644 --- a/pkg/rules/packages-to-test.yml +++ b/pkg/rules/packages-to-test.yml @@ -6,9 +6,11 @@ pkg: image: - "ubuntu:focal" # ubuntu/20.04 - "ubuntu:jammy" # ubuntu/22.04 + - "ubuntu:noble". # ubuntu/24.04 - "debian:buster" # debian/10 - "debian:bullseye" # debian/11 - "debian:bookworm" # debian/12 + - "debian:trixie" # debian/13 published_pkg: - 'ldnsutils' # correct for Ubuntu/Debian target: @@ -26,24 +28,36 @@ test-mode: - 'upgrade-from-published' include: - pkg: 'dnst' - image: 'rockylinux:8' + image: 'almalinux:8' target: 'x86_64' test-mode: 'fresh-install' - pkg: 'dnst' - image: 'rockylinux:8' + image: 'almalinux:8' target: 'x86_64' test-mode: 'upgrade-from-published' published_pkg: 'ldns-utils' rpm_yum_extra_args: --enablerepo powertools - pkg: 'dnst' - image: 'rockylinux:9' + image: 'almalinux:9' target: 'x86_64' test-mode: 'fresh-install' - pkg: 'dnst' - image: 'rockylinux:9' + image: 'almalinux:9' + target: 'x86_64' + test-mode: 'upgrade-from-published' + published_pkg: 'ldns-utils' + rpm_yum_extra_args: --enablerepo crb + + - pkg: 'dnst' + image: 'almalinux:10' + target: 'x86_64' + test-mode: 'fresh-install' + + - pkg: 'dnst' + image: 'almalinux:10' target: 'x86_64' test-mode: 'upgrade-from-published' published_pkg: 'ldns-utils' From 4f11b90956f5800ad03d49c27d7fe27c39321dc7 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 3 Sep 2025 16:01:18 +0200 Subject: [PATCH 29/32] Permit KMIP servers to be added in an inactive state. (#122) --- src/commands/keyset/kmip.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/commands/keyset/kmip.rs b/src/commands/keyset/kmip.rs index 9aeaaed..78da433 100644 --- a/src/commands/keyset/kmip.rs +++ b/src/commands/keyset/kmip.rs @@ -68,12 +68,15 @@ pub enum KmipCommands { /// Add a KMIP server to use for key generation & signing. /// - /// If this is the first KMIP server to be configured it will be used to - /// generate new keys instead of using Ring/OpenSSL based key generation. + /// If this is the first KMIP server to be configured it will be set + /// as the default KMIP server which will be used to generate new keys + /// instead of using Ring/OpenSSL based key generation. /// - /// If this is NOT the first KMIP server to be configured, the new server - /// will NOT be used to generate keys unless configured to do so by - /// using: kmip set-default-server. + /// If this is NOT the first KMIP server to be configured, the default + /// KMIP server will be left as-is, either unset or set to an existing + /// KMIP server. + /// + /// Use 'kmip set-default-server' to change the default KMIP server. AddServer { /// An identifier to refer to the KMIP server by. /// @@ -109,6 +112,10 @@ pub enum KmipCommands { #[arg(help_heading = "Server", long = "port", default_value_t = DEF_KMIP_PORT)] port: u16, + /// Add the server but don't make it the default. + #[arg(help_heading = "Server", long = "pending", default_value_t = false, action = clap::ArgAction::SetTrue)] + pending: bool, + /// Optional path to a JSON file to read/write username/password credentials from/to. /// /// The format of the file (at the time of writing) is like so: @@ -358,6 +365,7 @@ pub fn kmip_command( server_id, ip_host_or_fqdn, port, + pending, credentials_store_path, username, password, @@ -425,6 +433,7 @@ pub fn kmip_command( server_id, ip_host_or_fqdn, port, + pending, credentials, client_auth, server_auth, @@ -658,6 +667,7 @@ fn add_kmip_server( server_id: String, ip_host_or_fqdn: String, port: u16, + pending: bool, credentials: Option, client_cert_auth: Option, server_cert_verification: KmipServerTlsCertificateVerificationConfig, @@ -717,7 +727,7 @@ fn add_kmip_server( kmip.servers.insert(server_id.clone(), settings); - if kmip.servers.len() == 1 { + if !pending && kmip.servers.len() == 1 { kmip.default_server_id = Some(server_id); } From e8dd8e2708862469c9ec6b4f5a9cd71aa75df859 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 3 Sep 2025 22:10:03 +0200 Subject: [PATCH 30/32] Delete errant character that broke the YML syntax. --- pkg/rules/packages-to-test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/rules/packages-to-test.yml b/pkg/rules/packages-to-test.yml index 37a33f4..cbc78fa 100644 --- a/pkg/rules/packages-to-test.yml +++ b/pkg/rules/packages-to-test.yml @@ -6,11 +6,11 @@ pkg: image: - "ubuntu:focal" # ubuntu/20.04 - "ubuntu:jammy" # ubuntu/22.04 - - "ubuntu:noble". # ubuntu/24.04 + - "ubuntu:noble" # ubuntu/24.04 - "debian:buster" # debian/10 - "debian:bullseye" # debian/11 - "debian:bookworm" # debian/12 - - "debian:trixie" # debian/13 + - "debian:trixie" # debian/13 published_pkg: - 'ldnsutils' # correct for Ubuntu/Debian target: @@ -71,4 +71,4 @@ include: # mode: 'upgrade-from-published' # - pkg: 'routinator' # image: 'debian:bookworm' -# mode: 'upgrade-from-published' \ No newline at end of file +# mode: 'upgrade-from-published' From c19673fedd837e39a7f4701ce4b41546c96a2ed4 Mon Sep 17 00:00:00 2001 From: Philip-NLnetLabs Date: Fri, 5 Sep 2025 11:38:52 +0200 Subject: [PATCH 31/32] Keyset import (#121) * Restructure roll commands. * Import public keys. * Import a public/private key pair from files. * Add a default TTL to config. Use that for DNSKEY/CDS/CDNSKEY/DS RRsets. * Cargo.lock. * Support for importing KMIP keys. * Import public/private keys in decoupled state * Add --private-key option to importing a public/private key pair from files. * Add remove-key command. --- Cargo.lock | 4 +- src/commands/keyset/cmd.rs | 1060 ++++++++++++++++++++++++----------- src/commands/keyset/kmip.rs | 2 +- 3 files changed, 728 insertions(+), 338 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 68699cf..c729642 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -375,7 +375,7 @@ dependencies = [ [[package]] name = "domain" version = "0.11.1-dev" -source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#8d0025ebc865899e8fff584f8d5f106103101b4f" +source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#6221dcc0e9611169f7438e57a9b617594d02612c" dependencies = [ "arc-swap", "bcder", @@ -411,7 +411,7 @@ dependencies = [ [[package]] name = "domain-macros" version = "0.11.1-dev" -source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#8d0025ebc865899e8fff584f8d5f106103101b4f" +source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#6221dcc0e9611169f7438e57a9b617594d02612c" dependencies = [ "proc-macro2", "quote", diff --git a/src/commands/keyset/cmd.rs b/src/commands/keyset/cmd.rs index 74be707..0ac810e 100644 --- a/src/commands/keyset/cmd.rs +++ b/src/commands/keyset/cmd.rs @@ -15,7 +15,7 @@ use domain::base::{ }; use domain::crypto::sign::{GenerateParams, KeyPair, SecretKeyBytes}; #[cfg(feature = "kmip")] -use domain::crypto::{kmip::KeyUrl, sign::SignRaw}; +use domain::crypto::{kmip, kmip::KeyUrl, sign::SignRaw}; use domain::dnssec::common::{display_as_bind, parse_from_bind}; use domain::dnssec::sign::keys::keyset::{ self, Action, Key, KeySet, KeyState, KeyType, RollState, RollType, UnixTime, @@ -36,15 +36,15 @@ use domain::resolv::lookup::lookup_host; use domain::resolv::StubResolver; #[cfg(feature = "kmip")] use domain::utils::base32::encode_string_hex; -use domain::zonefile::inplace::{Entry, ScannedRecordData, Zonefile}; +use domain::zonefile::inplace::{Entry, Zonefile}; use futures::future::join_all; use jiff::{Span, SpanRelativeTo}; use serde::{Deserialize, Serialize}; use std::cmp::max; use std::collections::{BTreeMap, HashMap, HashSet}; use std::convert::From; +use std::ffi::OsStr; use std::fmt::{Debug, Display, Formatter}; -//use std::fs::{remove_file, File}; use std::fs::{remove_file, File}; use std::io::{self, Write}; use std::net::{IpAddr, SocketAddr}; @@ -69,6 +69,9 @@ const MAX_KEY_TAG_TRIES: u8 = 10; /// Wait this amount before retrying for network errors, DNS errors, etc. const DEFAULT_WAIT: Duration = Duration::from_secs(10 * 60); +/// The default TTL for creating a new config file. +const DEFAULT_TTL: Ttl = Ttl::from_secs(3600); + // Types to simplify some HashSet types. /// Type for a Name that uses a Vec. type NameVecU8 = Name>; @@ -163,78 +166,52 @@ enum Commands { /// Init creates keys for an empty state file. Init, - /// The following should be move to ksk, zsk, etc. subcommands. - StartKskRoll, - /// XXX - StartZskRoll, - /// XXX - StartCskRoll, - /// XXX - StartAlgorithmRoll, - /// XXX - KskPropagation1Complete { - /// XXX - ttl: u32, + /// Command for KSK rolls. + Ksk { + /// The specific key roll subcommand. + #[command(subcommand)] + subcommand: RollCommands, }, - /// XXX - KskPropagation2Complete { - /// XXX - ttl: u32, + /// Command for ZSK rolls. + Zsk { + /// The specific key roll subcommand. + #[command(subcommand)] + subcommand: RollCommands, }, - /// XXX - ZskPropagation1Complete { - /// XXX - ttl: u32, + /// Command for CSK rolls. + Csk { + /// The specific key roll subcommand. + #[command(subcommand)] + subcommand: RollCommands, }, - /// XXX - ZskPropagation2Complete { - /// XXX - ttl: u32, + /// Command for algorithm rolls. + Algorithm { + /// The specific key roll subcommand. + #[command(subcommand)] + subcommand: RollCommands, }, - /// XXX - CskPropagation1Complete { - /// XXX - ttl: u32, + + /// Command for importing existing keys. + Import { + /// The specific import subcommand. + #[command(subcommand)] + subcommand: ImportCommands, }, - /// XXX - CskPropagation2Complete { - /// XXX - ttl: u32, + + /// Remove a key from the key set. + RemoveKey { + /// Force a key to be removed even if the key is not stale. + #[arg(long)] + force: bool, + + /// Continue when removing the underlying keys fails. + #[arg(long = "continue")] + continue_flag: bool, + + /// The key to remove. + key: String, }, - /// XXX - AlgorithmPropagation1Complete { - /// XXX - ttl: u32, - }, - /// XXX - AlgorithmPropagation2Complete { - /// XXX - ttl: u32, - }, - /// XXX - KskCacheExpired1, - /// XXX - KskCacheExpired2, - /// XXX - ZskCacheExpired1, - /// XXX - ZskCacheExpired2, - /// XXX - CskCacheExpired1, - /// XXX - CskCacheExpired2, - /// XXX - AlgorithmCacheExpired1, - /// XXX - AlgorithmCacheExpired2, - /// XXX - KskRollDone, - /// XXX - ZskRollDone, - /// XXX - CskRollDone, - /// XXX - AlgorithmRollDone, + /// Report status, such as key rolls that are in progress, expired /// keys, when to call the 'cron' subcommand next. Status, @@ -463,6 +440,130 @@ enum SetCommands { }, } +#[derive(Clone, Debug, Subcommand)] +enum RollCommands { + /// Start a key roll. + StartRoll, + /// Report that the first propagation step has completed. + Propagation1Complete { + /// The TTL that is required to be reported by the Report actions. + ttl: u32, + }, + /// Cached information from before Propagation1Complete should have + /// expired by now. + CacheExpired1, + /// Report that the second propagation step has completed. + Propagation2Complete { + /// The TTL that is required to be reported by the Report actions. + ttl: u32, + }, + /// Cached information from before Propagation2Complete should have + /// expired by now. + CacheExpired2, + /// Report that the final changes have propagated and the the roll is done. + RollDone, +} + +#[derive(Clone, Debug, Subcommand)] +enum ImportCommands { + /// Import a public key. + PublicKey { + /// The file name of the public key. + path: PathBuf, + }, + + /// Command for KSK imports. + Ksk { + /// The specific key import subcommand. + #[command(subcommand)] + subcommand: ImportKeyCommands, + }, + /// Command for ZSK imports. + Zsk { + /// The specific key import subcommand. + #[command(subcommand)] + subcommand: ImportKeyCommands, + }, + /// Command for CSK imports. + Csk { + /// The specific key import subcommand. + #[command(subcommand)] + subcommand: ImportKeyCommands, + }, +} + +#[derive(Clone, Debug, Subcommand)] +enum ImportKeyCommands { + /// Import public/private key pair from file. + File { + /// Take ownership of the imported keys. + /// + /// When the key is removed from the key set, the underlying keys + /// are also removed. The default is decoupled when the underlying + /// keys are not removed. + #[arg(long)] + coupled: bool, + + /// Explicitly pass the name of the file that holds the private key. + /// + /// Otherwise the name is derived from the name of the file that holds + /// the public key. + #[arg(long)] + private_key: Option, + + /// Pathname of the public key. + path: PathBuf, + }, + #[cfg(feature = "kmip")] + /// Import a KMIP public/private key pair. + Kmip { + /// Take ownership of the imported keys. + /// + /// When the key is removed from the key set, the underlying keys + /// are also removed. The default is decoupled when the underlying + /// keys are not removed. + #[arg(long)] + coupled: bool, + + /// The identifier of the KMIP server. + server: String, + + /// The KMIP identifier of the public key. + public_id: String, + + /// The KMIP identifier of the private key. + private_id: String, + + /// The key's DNSSEC security algorithm. + algorithm: SecurityAlgorithm, + + /// Value to put in the DNSKEY flags field. + flags: u16, + }, +} + +#[derive(Debug)] +enum KeyVariant { + /// Apply command to KSKs. + Ksk, + /// Apply command to ZSKs. + Zsk, + /// Apply command to CSKs. + Csk, +} + +// We cannot use RollType because that name is already in use. +enum RollVariant { + /// Apply the subcommand to a KSK roll. + Ksk, + /// Apply the subcommand to a ZSK roll. + Zsk, + /// Apply the subcommand to a CSK roll. + Csk, + /// Apply the subcommand to an algorithm roll. + Algorithm, +} + impl Keyset { /// execute the keyset command. pub fn execute(self, env: impl Env) -> Result<(), Error> { @@ -517,6 +618,7 @@ impl Keyset { cds_signature_lifetime: Duration::from_secs(FOUR_WEEKS), cds_remain_time: Duration::from_secs(FOUR_WEEKS / 2), ds_algorithm: DsAlgorithm::Sha256, + default_ttl: DEFAULT_TTL, autoremove: false, update_ds_command: Vec::new(), }; @@ -597,152 +699,66 @@ impl Keyset { print_actions(&actions); state_changed = true; } - Commands::StartKskRoll => { - let actions = - start_ksk_roll(&ksc, &mut kss, env, true, &mut run_update_ds_command)?; + Commands::Ksk { subcommand } => roll_command( + subcommand, + RollVariant::Ksk, + &ksc, + &mut kss, + env, + &mut state_changed, + &mut run_update_ds_command, + )?, + Commands::Zsk { subcommand } => roll_command( + subcommand, + RollVariant::Zsk, + &ksc, + &mut kss, + env, + &mut state_changed, + &mut run_update_ds_command, + )?, + Commands::Csk { subcommand } => roll_command( + subcommand, + RollVariant::Csk, + &ksc, + &mut kss, + env, + &mut state_changed, + &mut run_update_ds_command, + )?, + Commands::Algorithm { subcommand } => roll_command( + subcommand, + RollVariant::Algorithm, + &ksc, + &mut kss, + env, + &mut state_changed, + &mut run_update_ds_command, + )?, - print_actions(&actions); + Commands::Import { subcommand } => { + import_command(subcommand, &ksc, &mut kss, env, &mut state_changed)? + } + + Commands::RemoveKey { + key, + force, + continue_flag, + } => { + remove_key_command(key, force, continue_flag, &mut kss)?; + if force { + // If the key was in use then the DNSKEY RRset may be + // affected. Avoid introducing a DNSKEY RRset when there + // was none. + if !kss.dnskey_rrset.is_empty() { + update_dnskey_rrset(&ksc, &mut kss, env, true)?; + } + + // What about CDS/CDNSKEY/DS? + } state_changed = true; } - Commands::StartZskRoll => { - let actions = - start_zsk_roll(&ksc, &mut kss, env, true, &mut run_update_ds_command)?; - print_actions(&actions); - state_changed = true; - } - Commands::StartCskRoll => { - let actions = - start_csk_roll(&ksc, &mut kss, env, true, &mut run_update_ds_command)?; - - print_actions(&actions); - state_changed = true; - } - Commands::StartAlgorithmRoll => { - let actions = - start_algorithm_roll(&ksc, &mut kss, env, true, &mut run_update_ds_command)?; - - print_actions(&actions); - state_changed = true; - } - Commands::KskPropagation1Complete { ttl } - | Commands::KskPropagation2Complete { ttl } - | Commands::ZskPropagation1Complete { ttl } - | Commands::ZskPropagation2Complete { ttl } - | Commands::CskPropagation1Complete { ttl } - | Commands::CskPropagation2Complete { ttl } - | Commands::AlgorithmPropagation1Complete { ttl } - | Commands::AlgorithmPropagation2Complete { ttl } => { - let actions = match self.cmd { - Commands::KskPropagation1Complete { ttl: _ } => { - kss.keyset.propagation1_complete(RollType::KskRoll, ttl) - } - Commands::KskPropagation2Complete { ttl: _ } => { - kss.keyset.propagation2_complete(RollType::KskRoll, ttl) - } - Commands::ZskPropagation1Complete { ttl: _ } => { - kss.keyset.propagation1_complete(RollType::ZskRoll, ttl) - } - Commands::ZskPropagation2Complete { ttl: _ } => { - kss.keyset.propagation2_complete(RollType::ZskRoll, ttl) - } - Commands::CskPropagation1Complete { ttl: _ } => { - kss.keyset.propagation1_complete(RollType::CskRoll, ttl) - } - Commands::CskPropagation2Complete { ttl: _ } => { - kss.keyset.propagation2_complete(RollType::CskRoll, ttl) - } - Commands::AlgorithmPropagation1Complete { ttl: _ } => kss - .keyset - .propagation1_complete(RollType::AlgorithmRoll, ttl), - Commands::AlgorithmPropagation2Complete { ttl: _ } => kss - .keyset - .propagation2_complete(RollType::AlgorithmRoll, ttl), - _ => unreachable!(), - }; - - let actions = match actions { - Ok(actions) => actions, - Err(err) => { - return Err(format!("Error reporting propagation complete: {err}\n").into()); - } - }; - - // Handle error - - handle_actions( - &actions, - &ksc, - &mut kss, - env, - true, - &mut run_update_ds_command, - )?; - - // Report actions - print_actions(&actions); - state_changed = true; - } - Commands::KskCacheExpired1 - | Commands::KskCacheExpired2 - | Commands::ZskCacheExpired1 - | Commands::ZskCacheExpired2 - | Commands::CskCacheExpired1 - | Commands::CskCacheExpired2 - | Commands::AlgorithmCacheExpired1 - | Commands::AlgorithmCacheExpired2 => { - let actions = match self.cmd { - Commands::KskCacheExpired1 => kss.keyset.cache_expired1(RollType::KskRoll), - Commands::KskCacheExpired2 => kss.keyset.cache_expired2(RollType::KskRoll), - Commands::ZskCacheExpired1 => kss.keyset.cache_expired1(RollType::ZskRoll), - Commands::ZskCacheExpired2 => kss.keyset.cache_expired2(RollType::ZskRoll), - Commands::CskCacheExpired1 => kss.keyset.cache_expired1(RollType::CskRoll), - Commands::CskCacheExpired2 => kss.keyset.cache_expired2(RollType::CskRoll), - Commands::AlgorithmCacheExpired1 => { - kss.keyset.cache_expired1(RollType::AlgorithmRoll) - } - Commands::AlgorithmCacheExpired2 => { - kss.keyset.cache_expired2(RollType::AlgorithmRoll) - } - _ => unreachable!(), - }; - - let actions = match actions { - Ok(actions) => actions, - Err(err) => { - return Err(format!("Error reporting cache expired: {err}\n").into()); - } - }; - - // Handle error - - handle_actions( - &actions, - &ksc, - &mut kss, - env, - true, - &mut run_update_ds_command, - )?; - - // Report actions - print_actions(&actions); - state_changed = true; - } - Commands::KskRollDone - | Commands::ZskRollDone - | Commands::CskRollDone - | Commands::AlgorithmRollDone => { - let r = match self.cmd { - Commands::KskRollDone => RollType::KskRoll, - Commands::ZskRollDone => RollType::ZskRoll, - Commands::CskRollDone => RollType::CskRoll, - Commands::AlgorithmRollDone => RollType::AlgorithmRoll, - _ => unreachable!(), - }; - do_done(&mut kss, r, ksc.autoremove)?; - state_changed = true; - } Commands::Status => { for (roll, state) in kss.keyset.rollstates().iter() { println!("{roll:?}: {state:?}"); @@ -835,6 +851,7 @@ impl Keyset { }); for (pubref, key) in keys { println!("\t{} {}", pubref, key.privref().unwrap_or_default(),); + println!("\t\tDecoupled: {}", key.decoupled(),); let (keytype, state, opt_state) = match key.keytype() { KeyType::Ksk(keystate) => ("KSK", keystate, None), KeyType::Zsk(keystate) => ("ZSK", keystate, None), @@ -925,13 +942,14 @@ impl Keyset { println!("cds-signature-lifetime: {:?}", ksc.cds_signature_lifetime); println!("cds-remain-time: {:?}", ksc.cds_remain_time); println!("ds-algorithm: {:?}", ksc.ds_algorithm); + println!("default-ttl: {:?}", ksc.default_ttl); println!("autoremove: {:?}", ksc.autoremove); println!("update_ds_command: {:?}", ksc.update_ds_command); } Commands::Cron => { if sig_renew(&kss.dnskey_rrset, &ksc.dnskey_remain_time) { println!("DNSKEY RRSIG(s) need to be renewed"); - update_dnskey_rrset(&mut kss, &ksc, env, false)?; + update_dnskey_rrset(&ksc, &mut kss, env, false)?; state_changed = true; } if sig_renew(&kss.cds_rrset, &ksc.cds_remain_time) { @@ -1322,6 +1340,70 @@ fn remove_key(kss: &mut KeySetState, url: Url) -> Result<(), Error> { Ok(()) } +/// Execute the key roll subcommands. +fn roll_command( + cmd: RollCommands, + roll_variant: RollVariant, + ksc: &KeySetConfig, + kss: &mut KeySetState, + env: &impl Env, + state_changed: &mut bool, + run_update_ds_command: &mut bool, +) -> Result<(), Error> { + let actions = match cmd { + RollCommands::StartRoll => { + let actions = match roll_variant { + RollVariant::Ksk => start_ksk_roll(ksc, kss, env, true, run_update_ds_command)?, + RollVariant::Zsk => start_zsk_roll(ksc, kss, env, true, run_update_ds_command)?, + RollVariant::Csk => start_csk_roll(ksc, kss, env, true, run_update_ds_command)?, + RollVariant::Algorithm => { + start_algorithm_roll(ksc, kss, env, true, run_update_ds_command)? + } + }; + + print_actions(&actions); + *state_changed = true; + return Ok(()); + } + RollCommands::Propagation1Complete { ttl } => { + let roll = roll_variant_to_roll(roll_variant); + kss.keyset.propagation1_complete(roll, ttl) + } + RollCommands::CacheExpired1 => { + let roll = roll_variant_to_roll(roll_variant); + kss.keyset.cache_expired1(roll) + } + RollCommands::Propagation2Complete { ttl } => { + let roll = roll_variant_to_roll(roll_variant); + kss.keyset.propagation2_complete(roll, ttl) + } + RollCommands::CacheExpired2 => { + let roll = roll_variant_to_roll(roll_variant); + kss.keyset.cache_expired2(roll) + } + RollCommands::RollDone => { + let roll = roll_variant_to_roll(roll_variant); + do_done(kss, roll, ksc.autoremove)?; + *state_changed = true; + return Ok(()); + } + }; + + let actions = match actions { + Ok(actions) => actions, + Err(err) => { + return Err(format!("Error reporting propagation complete: {err}\n").into()); + } + }; + + handle_actions(&actions, ksc, kss, env, true, run_update_ds_command)?; + + // Report actions + print_actions(&actions); + *state_changed = true; + Ok(()) +} + /// Implement the get subcommand. fn get_command(cmd: GetCommands, ksc: &KeySetConfig, kss: &KeySetState) { match cmd { @@ -1532,6 +1614,9 @@ struct KeySetConfig { /// The DS hash algorithm. ds_algorithm: DsAlgorithm, + /// The TTL to use when creating DNSKEY/CDS/CDNSKEY records. + default_ttl: Ttl, + /// Automatically remove keys that are no long in use. autoremove: bool, @@ -1723,7 +1808,6 @@ fn new_keys( #[cfg(feature = "kmip")] kmip: &mut KmipState, ) -> Result<(Url, Url, SecurityAlgorithm, u16), Error> { // Generate the key. - // TODO: Attempt repeated generation to avoid key tag collisions. // TODO: Add a high-level operation in 'domain' to select flags? let flags = if make_ksk { 257 } else { 256 }; let mut retries = MAX_KEY_TAG_TRIES; @@ -1989,8 +2073,8 @@ fn new_keys( /// Collect all keys where present() returns true and sign the DNSKEY RRset /// with all KSK and CSK (KSK state) where signer() returns true. fn update_dnskey_rrset( - kss: &mut KeySetState, ksc: &KeySetConfig, + kss: &mut KeySetState, env: &impl Env, verbose: bool, ) -> Result<(), Error> { @@ -2010,44 +2094,22 @@ fn update_dnskey_rrset( "file" => { let path = pub_url.path(); let filename = env.in_cwd(&path); - let mut file = File::open(&filename).map_err::(|e| { - format!( - "update_dnskey_rrset: unable to open public key file {}: {e}", - filename.display() - ) - .into() - })?; - let zonefile = domain::zonefile::inplace::Zonefile::load(&mut file) + + let public_data = + std::fs::read_to_string(&filename).map_err::(|e| { + format!("unable read from file {}: {e}", filename.display()).into() + })?; + let mut public_key = parse_from_bind::>(&public_data) .map_err::(|e| { - format!("unable load zone from file {}: {e}", filename.display()).into() - })?; - for entry in zonefile { - let entry = entry.map_err::(|e| { - format!("bad entry in key file {k}: {e}\n").into() + format!( + "unable to parse public key file {}: {e}", + filename.display() + ) + .into() })?; - // We only care about records in a zonefile - let Entry::Record(record) = entry else { - continue; - }; - - // Of the records that we see, we only care about DNSKEY records - let ScannedRecordData::Dnskey(dnskey) = record.data() else { - continue; - }; - - let record = Record::new( - record - .owner() - .try_to_name::() - .expect("should not fail"), - record.class(), - record.ttl(), - dnskey.clone(), - ); - - dnskeys.push(record); - } + public_key.set_ttl(ksc.default_ttl); + dnskeys.push(public_key); } #[cfg(feature = "kmip")] @@ -2060,12 +2122,11 @@ fn update_dnskey_rrset( .map_err(|err| { format!("Failed to fetch public key for KMIP key URL: {err}") })?; - let owner = kss.keyset.name().clone().flatten_into(); - // TODO: Where does this TTL come from? + let owner: Name<_> = kss.keyset.name().clone().flatten_into(); let record = Record::new( owner, Class::IN, - Ttl::from_days(1), + ksc.default_ttl, key.dnskey(flags).convert(), ); dnskeys.push(record); @@ -2206,31 +2267,25 @@ fn create_cds_rrset( "file" => { let path = pub_url.path(); let filename = env.in_cwd(&path); - let mut file = File::open(&filename).map_err::(|e| { - format!("unable to open public key file {}: {e}", filename.display()).into() - })?; - let zonefile = domain::zonefile::inplace::Zonefile::load(&mut file) + let public_data = + std::fs::read_to_string(&filename).map_err::(|e| { + format!("unable read from file {}: {e}", filename.display()).into() + })?; + let mut public_key = parse_from_bind::>(&public_data) .map_err::(|e| { - format!("unable to read zone from file {}: {e}", filename.display()) - .into() + format!( + "unable to parse public key file {}: {e}", + filename.display() + ) + .into() })?; - for entry in zonefile { - let entry = entry.map_err::(|e| { - format!("bad entry in key file {k}: {e}\n").into() - })?; - - // We only care about records in a zonefile - let Entry::Record(record) = entry else { - continue; - }; - - create_cds_rrset_helper( - digest_alg, - &mut cds_list, - &mut cdnskey_list, - record.flatten_into(), - )?; - } + public_key.set_ttl(ksc.default_ttl); + create_cds_rrset_helper( + digest_alg, + &mut cds_list, + &mut cdnskey_list, + public_key, + )?; } #[cfg(feature = "kmip")] @@ -2242,9 +2297,8 @@ fn create_cds_rrset( domain::crypto::kmip::PublicKey::for_key_url(key_url, conn_pool) .map_err(|err| format!("Failed to look up KMIP public key: {err}"))?; let dnskey = public_key.dnskey(flags); - let dnskey = ZoneRecordData::Dnskey(dnskey.convert()); let owner = kss.keyset.name().clone().flatten_into(); - let record = Record::new(owner, Class::IN, Ttl::from_days(1), dnskey); + let record = Record::new(owner, Class::IN, ksc.default_ttl, dnskey); create_cds_rrset_helper(digest_alg, &mut cds_list, &mut cdnskey_list, record)?; } @@ -2391,13 +2445,10 @@ fn create_cds_rrset_helper( digest_alg: DigestAlgorithm, cds_list: &mut Vec, Cds>>>, cdnskey_list: &mut Vec, Cdnskey>>>, - record: Record, ZoneRecordData>>, + record: Record>, Dnskey>>, ) -> Result<(), Error> { - let owner = record.owner().clone(); - let ZoneRecordData::Dnskey(dnskey) = record.data() else { - return Ok(()); - }; - let dnskey: Dnskey> = dnskey.clone().convert(); + let owner: Name = record.owner().to_name(); + let dnskey = record.data(); let cdnskey = Cdnskey::new( dnskey.flags(), dnskey.protocol(), @@ -2429,11 +2480,13 @@ fn remove_cds_rrset(kss: &mut KeySetState) { /// The DS records are generated from all keys where at_parent() returns true. /// This RRset is not signed. fn update_ds_rrset( + ksc: &KeySetConfig, kss: &mut KeySetState, - digest_alg: DigestAlgorithm, env: &impl Env, verbose: bool, ) -> Result<(), Error> { + let digest_alg = ksc.ds_algorithm.to_digest_algorithm(); + #[allow(clippy::type_complexity)] let mut ds_list: Vec>, Ds>>> = Vec::new(); for (k, v) in kss.keyset.keys() { @@ -2450,52 +2503,44 @@ fn update_ds_rrset( "file" => { let path = pub_url.path(); let filename = env.in_cwd(&path); - let mut file = File::open(&filename).map_err::(|e| { - format!( - "update_ds_rrset: unable to open public key file {}: {e}", - filename.display() - ) - .into() - })?; - let zonefile = domain::zonefile::inplace::Zonefile::load(&mut file) + let public_data = + std::fs::read_to_string(&filename).map_err::(|e| { + format!("unable read from file {}: {e}", filename.display()).into() + })?; + let public_key = + parse_from_bind::>(&public_data).map_err::(|e| { + format!( + "unable to parse public key file {}: {e}", + filename.display() + ) + .into() + })?; + + let digest = public_key + .data() + .digest(&public_key.owner(), digest_alg) .map_err::(|e| { - format!("unable to read zone from file {}: {e}", filename.display()) - .into() - })?; - for entry in zonefile { - let entry = entry.map_err::(|e| { - format!("bad entry in key file {k}: {e}\n").into() + format!("error creating digest for DNSKEY record: {e}").into() })?; - // We only care about records in a zonefile - let Entry::Record(record) = entry else { - continue; - }; + let ds = Ds::new( + public_key.data().key_tag(), + public_key.data().algorithm(), + digest_alg, + digest.as_ref().to_vec(), + ) + .expect( + "Infallible because the digest won't be too long since it's a valid digest", + ); - // Of the records that we see, we only care about DNSKEY records - let ScannedRecordData::Dnskey(dnskey) = record.data() else { - continue; - }; + let ds_record = Record::new( + public_key.owner().clone().flatten_into(), + public_key.class(), + ksc.default_ttl, + ds, + ); - let digest = dnskey - .digest(&record.owner(), digest_alg) - .map_err::(|e| { - format!("error creating digest for DNSKEY record: {e}").into() - })?; - - let ds = Ds::new(dnskey.key_tag(), dnskey.algorithm(), digest_alg, digest.as_ref().to_vec()).expect( - "Infallible because the digest won't be too long since it's a valid digest", - ); - - let ds_record = Record::new( - record.owner().clone().flatten_into(), - record.class(), - record.ttl(), - ds, - ); - - ds_list.push(ds_record); - } + ds_list.push(ds_record); } #[cfg(feature = "kmip")] @@ -2567,7 +2612,7 @@ fn handle_actions( ) -> Result<(), Error> { for action in actions { match action { - Action::UpdateDnskeyRrset => update_dnskey_rrset(kss, ksc, env, verbose)?, + Action::UpdateDnskeyRrset => update_dnskey_rrset(ksc, kss, env, verbose)?, Action::CreateCdsRrset => create_cds_rrset( kss, ksc, @@ -2578,7 +2623,7 @@ fn handle_actions( Action::RemoveCdsRrset => remove_cds_rrset(kss), Action::UpdateDsRrset => { *run_update_ds_command = true; - update_ds_rrset(kss, ksc.ds_algorithm.to_digest_algorithm(), env, verbose)? + update_ds_rrset(ksc, kss, env, verbose)? } Action::UpdateRrsig => (), Action::ReportDnskeyPropagated => (), @@ -2941,9 +2986,7 @@ async fn auto_wait_actions( warn!("Check SOA propagation failed: {e}"); false }); - dbg!(format!("got {res} for {serial}")); if res { - dbg!("Setting rrsig to Report"); let mut report_state_locked = report_state.lock().expect("lock() should not fail"); report_state_locked.rrsig = @@ -2953,7 +2996,6 @@ async fn auto_wait_actions( continue; } else { let next = UnixTime::now() + ttl.into(); - dbg!("Setting rrsig to WaitSoa"); let mut report_state_locked = report_state.lock().expect("lock() should not fail"); report_state_locked.rrsig = Some(AutoReportRrsigResult::WaitSoa { @@ -3174,9 +3216,7 @@ async fn auto_report_actions( warn!("Check SOA propagation failed: {e}"); false }); - dbg!(format!("got {res} for {serial}")); if res { - dbg!("Setting rrsig to Report"); let mut report_state_locked = report_state.lock().expect("lock() should not fail"); report_state_locked.rrsig = @@ -3187,7 +3227,6 @@ async fn auto_report_actions( continue; } else { let next = UnixTime::now() + ttl.into(); - dbg!("Setting rrsig to WaitSoa"); let mut report_state_locked = report_state.lock().expect("lock() should not fail"); report_state_locked.rrsig = Some(AutoReportRrsigResult::WaitSoa { @@ -3433,8 +3472,7 @@ fn start_ksk_roll( verbose: bool, run_update_ds_command: &mut bool, ) -> Result, Error> { - //let roll_type = RollType::KskRoll; - let roll_type = RollType::KskDoubleDsRoll; + let roll_type = RollType::KskRoll; assert!(!kss.keyset.keys().is_empty()); @@ -5083,6 +5121,358 @@ fn new_csk_or_ksk_zsk( Ok((new_stored, new_urls)) } +/// Return the right RollType for a RollVariant. +fn roll_variant_to_roll(roll_variant: RollVariant) -> RollType { + // For key type, such as KSK and ZSK, that can have different rolls, we + // we should find out which variant is used. + match roll_variant { + RollVariant::Ksk => RollType::KskRoll, + RollVariant::Zsk => RollType::ZskRoll, + RollVariant::Csk => RollType::CskRoll, + RollVariant::Algorithm => RollType::AlgorithmRoll, + } +} + +/// Implementation of the Import subcommands. +fn import_command( + subcommand: ImportCommands, + ksc: &KeySetConfig, + kss: &mut KeySetState, + env: &impl Env, + state_changed: &mut bool, +) -> Result<(), Error> { + match subcommand { + ImportCommands::PublicKey { path } => { + let public_data = std::fs::read_to_string(&path).map_err::(|e| { + format!("unable read from file {}: {e}", path.display()).into() + })?; + + let public_key = parse_from_bind::>(&public_data).map_err::(|e| { + format!("unable to parse public key file {}: {e}", path.display()).into() + })?; + + let path = absolute(&path).map_err::(|e| { + format!("unable to make {} absolute: {}", path.display(), e).into() + })?; + let public_key_url = "file://".to_owned() + &path.display().to_string(); + kss.keyset + .add_public_key( + public_key_url.clone(), + public_key.data().algorithm(), + public_key.data().key_tag(), + UnixTime::now(), + true, + ) + .map_err::(|e| { + format!("unable to add public key {public_key_url}: {e}\n").into() + })?; + kss.keyset + .set_present(&public_key_url, true) + .expect("should not happen"); + + // What about visible. We should visible when DNSKEY RRset has + // propagated. But we are not doing a key roll now. Just set it + // unconditionally. + kss.keyset + .set_visible(&public_key_url, UnixTime::now()) + .expect("should not happen"); + } + ImportCommands::Ksk { subcommand } => { + import_key_command(subcommand, KeyVariant::Ksk, kss)?; + } + ImportCommands::Zsk { subcommand } => { + import_key_command(subcommand, KeyVariant::Zsk, kss)?; + } + ImportCommands::Csk { subcommand } => { + import_key_command(subcommand, KeyVariant::Csk, kss)?; + } + } + *state_changed = true; + + // Update the DNSKEY RRset if is is not empty. We don't want to create + // and incomplete DNSKEY RRset. + if !kss.dnskey_rrset.is_empty() { + update_dnskey_rrset(ksc, kss, env, true)?; + } + Ok(()) +} + +/// Implement import subcommand for a specific key type. +fn import_key_command( + subcommand: ImportKeyCommands, + key_variant: KeyVariant, + kss: &mut KeySetState, +) -> Result<(), Error> { + let (public_key_url, private_key_url, algorithm, key_tag, coupled) = match subcommand { + ImportKeyCommands::File { + path, + coupled, + private_key, + } => { + let private_path = match private_key { + Some(private_key) => private_key, + None => { + if path.extension() != Some(OsStr::new("key")) { + return Err(format!("public key {} should end in .pub, use --private-key to specify a private key separately", path.display()).into()); + } + path.with_extension("private") + } + }; + let private_data = std::fs::read_to_string(&private_path).map_err::(|e| { + format!("unable read from file {}: {e}", private_path.display()).into() + })?; + let secret_key = + SecretKeyBytes::parse_from_bind(&private_data).map_err::(|e| { + format!( + "unable to parse private key file {}: {e}", + private_path.display() + ) + .into() + })?; + let public_data = std::fs::read_to_string(&path).map_err::(|e| { + format!("unable read from file {}: {e}", path.display()).into() + })?; + let public_key = parse_from_bind::>(&public_data).map_err::(|e| { + format!("unable to parse public key file {}: {e}", path.display()).into() + })?; + + // Check the consistency of the public and private key pair. + let _key_pair = KeyPair::from_bytes(&secret_key, public_key.data()) + .map_err::(|e| { + format!( + "private key {} and public key {} do not match: {e}", + private_path.display(), + path.display() + ) + .into() + })?; + + if public_key.owner() != kss.keyset.name() { + return Err(format!( + "public key {} has wrong owner name {}, expected {}", + path.display(), + public_key.owner(), + kss.keyset.name() + ) + .into()); + } + + let path = absolute(&path).map_err::(|e| { + format!("unable to make {} absolute: {}", path.display(), e).into() + })?; + let private_path = absolute(&private_path).map_err::(|e| { + format!("unable to make {} absolute: {}", private_path.display(), e).into() + })?; + let public_key_url = "file://".to_owned() + &path.display().to_string(); + let private_key_url = "file://".to_owned() + &private_path.display().to_string(); + + ( + public_key_url, + private_key_url, + public_key.data().algorithm(), + public_key.data().key_tag(), + coupled, + ) + } + #[cfg(feature = "kmip")] + ImportKeyCommands::Kmip { + server, + public_id, + private_id, + algorithm, + flags, + coupled, + } => { + let pool = kss.kmip.get_pool(&server)?; + let keypair = + kmip::sign::KeyPair::from_metadata(algorithm, flags, &private_id, &public_id, pool) + .map_err(|e| { + format!("error constructing key pair on KMIP server '{server}': {e}") + })?; + let public_key_url = keypair.public_key_url(); + let private_key_url = keypair.private_key_url(); + ( + public_key_url.to_string(), + private_key_url.to_string(), + keypair.algorithm(), + keypair.dnskey().key_tag(), + coupled, + ) + } + }; + let mut set_at_parent = false; + let mut set_rrsig_visible = false; + match key_variant { + KeyVariant::Ksk => { + kss.keyset + .add_key_ksk( + public_key_url.clone(), + Some(private_key_url.clone()), + algorithm, + key_tag, + UnixTime::now(), + true, + ) + .map_err::(|e| { + format!("unable to add KSK {public_key_url}/{private_key_url}: {e}\n").into() + })?; + set_at_parent = true; + } + KeyVariant::Zsk => { + kss.keyset + .add_key_zsk( + public_key_url.clone(), + Some(private_key_url.clone()), + algorithm, + key_tag, + UnixTime::now(), + true, + ) + .map_err::(|e| { + format!("unable to add ZSK {public_key_url}: {e}\n").into() + })?; + set_rrsig_visible = true; + } + KeyVariant::Csk => { + kss.keyset + .add_key_csk( + public_key_url.clone(), + Some(private_key_url.clone()), + algorithm, + key_tag, + UnixTime::now(), + true, + ) + .map_err::(|e| { + format!("unable to add CSK {public_key_url}: {e}\n").into() + })?; + set_at_parent = true; + set_rrsig_visible = true; + } + } + + kss.keyset + .set_present(&public_key_url, true) + .expect("should not happen"); + + // What about visible? We should visible when the DNSKEY + // RRset has propagated. But we are not doing a key roll + // now. Just set it unconditionally. + kss.keyset + .set_visible(&public_key_url, UnixTime::now()) + .expect("should not happen"); + + kss.keyset + .set_signer(&public_key_url, true) + .expect("should not happen"); + + kss.keyset + .set_decoupled(&public_key_url, !coupled) + .expect("should not happen"); + + if set_at_parent { + kss.keyset + .set_at_parent(&public_key_url, true) + .expect("should not happen"); + + // What about ds_visible? We should ds_visible when the DS + // RRset has propagated. But we are not doing a key roll + // now. Just set it unconditionally. + kss.keyset + .set_ds_visible(&public_key_url, UnixTime::now()) + .expect("should not happen"); + } + if set_rrsig_visible { + // We should set rrsig_visible when the zone's RRSIG records + // have propagated. But we are not doing a key roll + // now. Just set it unconditionally. + kss.keyset + .set_rrsig_visible(&public_key_url, UnixTime::now()) + .expect("should not happen"); + } + Ok(()) +} + +/// Implement the remove-key subcommand. +fn remove_key_command( + key: String, + force: bool, + continue_flag: bool, + kss: &mut KeySetState, +) -> Result<(), Error> { + // The strategy depends on whether the key is decoupled or not. + // If the key is decoupled, then just remove the key from the keyset and + // leave underlying keys where they are. + // If the key is not decoupled, then we also need to remove the underlying + // keys. In that case, first check if the key is stale or if force is set. + // Then remove the private key (if any). If that fails abort unless + // continue is set. Then remove the public key. If that fails and the + // private key is remove then just log an error. Finally remove the key + // from the keyset. + // If force is true, then mark the key stale before removing. + let Some(k) = kss.keyset.keys().get(&key) else { + return Err(format!("key {key} not found").into()); + }; + let k = k.clone(); + if k.decoupled() { + if force { + kss.keyset.set_stale(&key).expect("should not fail"); + } + kss.keyset + .delete_key(&key) + .map_err(|e| format!("unable to remove key {key}: {e}").into()) + } else { + let stale = match k.keytype() { + KeyType::Ksk(keystate) | KeyType::Zsk(keystate) | KeyType::Include(keystate) => { + keystate.stale() + } + KeyType::Csk(ksk_keystate, zsk_keystate) => { + ksk_keystate.stale() && zsk_keystate.stale() + } + }; + if !stale && !force { + return Err(format!( + "unable to remove key {key}. Key is not stale. Use --force to override" + ) + .into()); + } + + // If there is a private key then try to remove that one first. We + // don't want lingering private key when something else fails. + if let Some(privref) = k.privref() { + let private_key_url = Url::parse(privref) + .map_err(|e| format!("unable to parse {privref} as Url: {e}"))?; + let res = remove_key(kss, private_key_url); + if !continue_flag { + res?; + } else if let Err(e) = res { + error!("unable to remove key {privref}: {e}"); + } + } + + // Move on to the public key. + let public_key_url = + Url::parse(&key).map_err(|e| format!("unable to parse {key} as Url: {e}"))?; + let res = remove_key(kss, public_key_url); + if k.privref().is_some() || continue_flag { + // Ignore errors removing a public key if we previously removed + // (or tried to remove) a private key. Or if we are told to + // continue. + if let Err(e) = res { + error!("unable to remove key {key}: {e}"); + } + } else { + res?; + } + if force { + kss.keyset.set_stale(&key).expect("should not fail"); + } + kss.keyset + .delete_key(&key) + .map_err(|e| format!("unable to remove key {key}: {e}").into()) + } +} + /* Test for RRSIG check - records before the zone diff --git a/src/commands/keyset/kmip.rs b/src/commands/keyset/kmip.rs index 78da433..417c0c3 100644 --- a/src/commands/keyset/kmip.rs +++ b/src/commands/keyset/kmip.rs @@ -1625,7 +1625,7 @@ impl KmipState { Some(pool) => Ok(pool.clone()), None => { let Some(srv_conn_settings) = self.servers.get(id) else { - return Err(format!("No KMIP server config exists for server '{id}").into()); + return Err(format!("No KMIP server config exists for server '{id}'").into()); }; let conn_settings = srv_conn_settings.load(id).map_err(|err| { format!("Unable to prepare KMIP connection settings for server '{id}': {err}") From 913ff60d23a950c07b62cd418d2e8d8208c8a133 Mon Sep 17 00:00:00 2001 From: Philip Homburg Date: Fri, 5 Sep 2025 12:18:50 +0200 Subject: [PATCH 32/32] Update Cargo.lock. --- Cargo.lock | 61 +++++++++++++++++++++++++++--------------------------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c729642..33ec6b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,9 +201,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.46" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" dependencies = [ "clap_builder", "clap_derive", @@ -211,9 +211,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.46" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" dependencies = [ "anstream", "anstyle", @@ -224,9 +224,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.45" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ "heck", "proc-macro2", @@ -375,7 +375,7 @@ dependencies = [ [[package]] name = "domain" version = "0.11.1-dev" -source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#6221dcc0e9611169f7438e57a9b617594d02612c" +source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#922a74acb96728100ce4ff0b7494b7f0210f181c" dependencies = [ "arc-swap", "bcder", @@ -411,7 +411,7 @@ dependencies = [ [[package]] name = "domain-macros" version = "0.11.1-dev" -source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#6221dcc0e9611169f7438e57a9b617594d02612c" +source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#922a74acb96728100ce4ff0b7494b7f0210f181c" dependencies = [ "proc-macro2", "quote", @@ -852,9 +852,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" dependencies = [ "once_cell", "wasm-bindgen", @@ -940,9 +940,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "loom" @@ -1721,9 +1721,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.42" +version = "0.3.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca967379f9d8eb8058d86ed467d81d03e81acd45757e4ca341c24affbe8e8e3" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" dependencies = [ "deranged", "num-conv", @@ -1735,15 +1735,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9108bb380861b07264b950ded55a44a14a4adc68b9f5efd85aafc3aa4d40a68" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7182799245a7264ce590b349d90338f1c1affad93d2639aed5f8f69c090b334c" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -1960,21 +1960,22 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" dependencies = [ "bumpalo", "log", @@ -1986,9 +1987,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1996,9 +1997,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" dependencies = [ "proc-macro2", "quote", @@ -2009,18 +2010,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12" dependencies = [ "js-sys", "wasm-bindgen",