diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2563af1..bbb2609 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,83 +1,426 @@ -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.82.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.82.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" + # 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@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 }} 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 diff --git a/Cargo.lock b/Cargo.lock index fddfec7..40adbcf 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", @@ -136,10 +136,26 @@ dependencies = [ ] [[package]] -name = "bitflags" -version = "2.9.1" +name = "base64" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" [[package]] name = "bumpalo" @@ -155,18 +171,19 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.30" +version = "1.2.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "5252b3d2648e5eedbc1a6f501e3c795e07025c1e93bbf8bbdd6eef7f447a6d54" dependencies = [ + "find-msvc-tools", "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" @@ -184,9 +201,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.42" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed87a9d530bb41a67537289bafcac159cb3ee28460e0a4571123d2a778a6a882" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" dependencies = [ "clap_builder", "clap_derive", @@ -194,26 +211,27 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.42" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64f4f3f3c77c94aff3c7e9aac9a2ca1974a5adf392a8bb751e827d6d127ab966" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] name = "clap_derive" -version = "4.5.41" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -299,9 +317,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" dependencies = [ "powerfmt", ] @@ -320,26 +338,30 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] name = "dnst" -version = "0.1.1-dev" +version = "0.1.0-rc2" dependencies = [ "bytes", "chrono", "clap", "const_format", "domain", + "futures", + "indenter", "jiff", "lazy_static", "lexopt", "octseq", + "openssl", "pretty_assertions", + "rand 0.9.2", "rayon", "regex", - "ring", + "ring 0.17.14", "serde", "serde_json", "smallvec", @@ -354,23 +376,25 @@ dependencies = [ [[package]] name = "domain" version = "0.11.1-dev" -source = "git+https://github.com/NLnetLabs/domain.git?branch=crypto-and-keyset-fixes#b500eebaba7f8a119f13de0bcc37044fe2f3b9a3" +source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#922a74acb96728100ce4ff0b7494b7f0210f181c" 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", @@ -381,16 +405,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#b500eebaba7f8a119f13de0bcc37044fe2f3b9a3" +source = "git+https://github.com/NLnetLabs/domain.git?branch=patches-for-nameshed-prototype#922a74acb96728100ce4ff0b7494b7f0210f181c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -399,6 +425,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" @@ -411,9 +459,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", @@ -436,6 +484,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + [[package]] name = "foreign-types" version = "0.3.2" @@ -453,19 +507,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" @@ -474,9 +570,15 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "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" @@ -489,9 +591,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", @@ -499,9 +605,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", @@ -531,7 +637,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.3+wasi-0.2.4", ] [[package]] @@ -555,6 +661,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" @@ -667,9 +779,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", @@ -687,10 +799,16 @@ dependencies = [ ] [[package]] -name = "io-uring" -version = "0.7.9" +name = "indenter" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ "bitflags", "cfg-if", @@ -730,19 +848,57 @@ checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[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", ] +[[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" @@ -757,9 +913,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" @@ -785,9 +941,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" @@ -804,11 +960,22 @@ dependencies = [ [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", +] + +[[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.106", ] [[package]] @@ -861,12 +1028,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" dependencies = [ - "overload", - "winapi", + "windows-sys 0.52.0", ] [[package]] @@ -939,7 +1105,16 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", +] + +[[package]] +name = "openssl-src" +version = "300.5.2+3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d270b79e2926f5150189d475bc7e9d2c69f9c4697b185fa917d5a32b792d21b4" +dependencies = [ + "cc", ] [[package]] @@ -950,16 +1125,11 @@ checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "parking" version = "2.2.1" @@ -991,9 +1161,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" @@ -1030,9 +1200,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" dependencies = [ "zerovec", ] @@ -1064,9 +1234,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", ] @@ -1086,6 +1256,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" @@ -1093,8 +1274,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]] @@ -1104,7 +1295,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]] @@ -1117,10 +1318,19 @@ dependencies = [ ] [[package]] -name = "rayon" -version = "1.10.0" +name = "rand_core" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -1128,9 +1338,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", @@ -1147,47 +1357,47 @@ dependencies = [ [[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", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.1.10" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" -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", ] [[package]] name = "regex-syntax" -version = "0.6.29" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] -name = "regex-syntax" -version = "0.8.5" +name = "ring" +version = "0.16.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted 0.7.1", + "web-sys", + "winapi", +] [[package]] name = "ring" @@ -1199,7 +1409,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -1232,10 +1442,32 @@ dependencies = [ ] [[package]] -name = "rustversion" -version = "1.0.21" +name = "rustls" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -1243,6 +1475,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" @@ -1255,6 +1496,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" @@ -1279,6 +1530,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" @@ -1287,14 +1547,14 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] name = "serde_json" -version = "1.0.141" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "itoa", "memchr", @@ -1325,9 +1585,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" @@ -1345,6 +1605,12 @@ dependencies = [ "windows-sys 0.59.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" @@ -1359,9 +1625,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.104" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -1376,7 +1653,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1387,15 +1664,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]] @@ -1421,7 +1708,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1435,12 +1722,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde", @@ -1450,15 +1736,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -1476,9 +1762,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.47.0" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43864ed400b6043a4757a25c7a64a8efde741aed79a056a2fb348a406701bb35" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", @@ -1500,7 +1786,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1534,7 +1820,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1560,14 +1846,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -1576,6 +1862,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" @@ -1588,6 +1885,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" @@ -1596,13 +1899,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]] @@ -1619,9 +1923,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.3", "js-sys", @@ -1648,44 +1952,45 @@ 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]] 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", "proc-macro2", "quote", - "syn", + "syn 2.0.106", "wasm-bindgen-shared", ] [[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", @@ -1693,26 +1998,46 @@ 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", - "syn", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[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.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12" +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" @@ -1789,7 +2114,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1800,7 +2125,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -2003,13 +2328,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] +checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814" [[package]] name = "writeable" @@ -2043,7 +2365,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", "synstructure", ] @@ -2064,7 +2386,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -2084,7 +2406,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", "synstructure", ] @@ -2107,9 +2429,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", @@ -2124,5 +2446,5 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] diff --git a/Cargo.toml b/Cargo.toml index e65fe1e..29d195a 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" @@ -17,17 +17,22 @@ 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"] +# For building in a cargo cross container that lacks openssl-dev so cannot +# successfully compile the Rust OpenSSL crate. +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", branch = "crypto-and-keyset-fixes", features = [ +clap = { version = "4.3.4", features = ["cargo", "derive", "wrap_help"] } +domain = { git = "https://github.com/NLnetLabs/domain.git", branch = "patches-for-nameshed-prototype", features = [ "bytes", "net", "resolv", @@ -36,13 +41,16 @@ domain = { git = "https://github.com/NLnetLabs/domain.git", branch = "crypto-and "unstable-client-transport", "unstable-sign", "unstable-validator", - "unstable-zonetree", + "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" +openssl = { version = "*", features = ["vendored"], optional = true } # LDNS-xxx mode specific dependencies. # TODO: put these behind a feature gate? @@ -58,13 +66,14 @@ smallvec = "1.13.2" tracing = "0.1.41" tracing-subscriber = "0.3.19" url = "2.5.4" +futures = "0.3.31" [dev-dependencies] 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/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-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/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/doc/manual/source/man/dnst.rst b/doc/manual/source/man/dnst.rst index 09b4bb7..a5104b6 100644 --- a/doc/manual/source/man/dnst.rst +++ b/doc/manual/source/man/dnst.rst @@ -17,8 +17,25 @@ managing DNS servers and DNS zones. Please consult the manual pages for these individual commands for more information. -dnst Commands -------------- +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 + ``--help``). + +.. option:: -V, --version + + Print the version. + +Commands +-------- .. glossary:: 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: diff --git a/pkg/rules/packages-to-test.yml b/pkg/rules/packages-to-test.yml index 1441b68..cbc78fa 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' @@ -57,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' 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/key2ds.rs b/src/commands/key2ds.rs index 676cb8d..c1e0cfc 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; @@ -6,8 +7,8 @@ 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::Record; +use domain::base::zonefile_fmt::ZonefileFmt; +use domain::base::{Record, RecordData, ToName}; use domain::dnssec::validator::base::DnskeyExt; use domain::rdata::Ds; use domain::zonefile::inplace::{Entry, ScannedRecordData}; @@ -15,12 +16,11 @@ use lexopt::Arg; use crate::env::Env; use crate::error::Error; -use crate::Args; +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")] @@ -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 { @@ -103,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()) @@ -123,6 +129,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 +173,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()); @@ -185,7 +196,11 @@ impl Key2ds { let rr = Record::new(owner, class, ttl, ds); if self.write_to_stdout { - writeln!(env.stdout(), "{}", rr.display_zonefile(DisplayKind::Simple)); + 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(); @@ -215,8 +230,13 @@ 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)) - .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}"); } @@ -226,19 +246,38 @@ 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()), } } +fn write_rr_as_ldns( + 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; @@ -276,6 +315,7 @@ mod test { force_overwrite: false, algorithm: None, keyfile: PathBuf::from("keyfile1.key"), + invoked_as_ldns: false, }; // Check the defaults @@ -365,6 +405,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 +467,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 } @@ -441,7 +487,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] @@ -455,10 +501,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] @@ -472,7 +518,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, ""); } @@ -500,4 +546,60 @@ mod test { assert_eq!(res.stdout, "Kexample.test.+015+60136\n"); 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(); + + let res = FakeCmd::new(["ldns-key2ds", "-nf", "key3.key"]) + .cwd(&dir) + .run(); + + assert_eq!(res.exit_code, 0); + assert_eq!( + res.stdout, + ".\t3600\tIN\tDS\t15147 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." + )); + } } diff --git a/src/commands/keygen.rs b/src/commands/keygen.rs index 415274b..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}; @@ -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, @@ -357,7 +359,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) ); @@ -367,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) ) }); @@ -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!( @@ -612,7 +630,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 +662,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(); diff --git a/src/commands/keyset.rs b/src/commands/keyset.rs deleted file mode 100644 index c51a7e1..0000000 --- a/src/commands/keyset.rs +++ /dev/null @@ -1,2037 +0,0 @@ -use crate::env::Env; -use crate::error::Error; -use crate::util; -use bytes::Bytes; -use clap::Subcommand; -use domain::base::iana::Class; -use domain::base::iana::{DigestAlgorithm, SecurityAlgorithm}; -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}; -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, Ds, ZoneRecordData}; -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; -use url::Url; - -const MAX_KEY_TAG_TRIES: u8 = 10; - -#[derive(Clone, Debug, clap::Args)] -pub struct Keyset { - /// Keyset config - #[arg(short = 'c')] - keyset_conf: PathBuf, - - /// Subcommand - #[command(subcommand)] - cmd: Commands, -} - -type OptDuration = Option; - -#[derive(Clone, Debug, Subcommand)] -enum Commands { - Create { - /// Domain name - #[arg(short = 'n')] - domain_name: Name>, - - /// State file - #[arg(short = 's')] - keyset_state: PathBuf, - }, - - Init, - StartKskRoll, - StartZskRoll, - StartCskRoll, - StartAlgorithmRoll, - KskPropagation1Complete { - ttl: u32, - }, - KskPropagation2Complete { - ttl: u32, - }, - ZskPropagation1Complete { - ttl: u32, - }, - ZskPropagation2Complete { - ttl: u32, - }, - CskPropagation1Complete { - ttl: u32, - }, - CskPropagation2Complete { - ttl: u32, - }, - AlgorithmPropagation1Complete { - ttl: u32, - }, - AlgorithmPropagation2Complete { - ttl: u32, - }, - KskCacheExpired1, - KskCacheExpired2, - ZskCacheExpired1, - ZskCacheExpired2, - CskCacheExpired1, - CskCacheExpired2, - AlgorithmCacheExpired1, - AlgorithmCacheExpired2, - KskRollDone, - ZskRollDone, - CskRollDone, - AlgorithmRollDone, - Status, - Actions, - Keys, - - Get { - #[command(subcommand)] - subcommand: GetCommands, - }, - - Set { - #[command(subcommand)] - subcommand: SetCommands, - }, - - Show, - Cron, -} - -#[derive(Clone, Debug, Subcommand)] -enum GetCommands { - UseCsk, - Autoremove, - KskAlgorithm, - ZskAlgorithm, - CskAlgorithm, - DsAlgorithm, - DnskeyLifetime, - CdsLifetime, - Dnskey, - Cds, - Ds, -} - -#[derive(Clone, Debug, Subcommand)] -enum SetCommands { - UseCsk { - #[arg(action = clap::ArgAction::Set)] - boolean: bool, - }, - Autoremove { - #[arg(action = clap::ArgAction::Set)] - boolean: bool, - }, - KskAlgorithm { - #[arg(short = 'b')] - bits: Option, - - algorithm: String, - }, - ZskAlgorithm { - #[arg(short = 'b')] - bits: Option, - - algorithm: String, - }, - CskAlgorithm { - #[arg(short = 'b')] - bits: Option, - - algorithm: String, - }, - DsAlgorithm { - #[arg(value_parser = DsAlgorithm::new)] - algorithm: DsAlgorithm, - }, - DnskeyInceptionOffset { - #[arg(value_parser = parse_duration)] - duration: Duration, - }, - DnskeyLifetime { - #[arg(value_parser = parse_duration)] - duration: Duration, - }, - DnskeyRemainTime { - #[arg(value_parser = parse_duration)] - duration: Duration, - }, - CdsInceptionOffset { - #[arg(value_parser = parse_duration)] - duration: Duration, - }, - CdsLifetime { - #[arg(value_parser = parse_duration)] - duration: Duration, - }, - CdsRemainTime { - #[arg(value_parser = parse_duration)] - duration: Duration, - }, - KskValidity { - #[arg(value_parser = parse_opt_duration)] - opt_duration: OptDuration, - }, - ZskValidity { - #[arg(value_parser = parse_opt_duration)] - opt_duration: OptDuration, - }, - CskValidity { - #[arg(value_parser = parse_opt_duration)] - opt_duration: OptDuration, - }, -} - -impl Keyset { - 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> { - if let Commands::Create { - domain_name, - keyset_state, - } = self.cmd - { - let state_file = absolute(&keyset_state).map_err::(|e| { - format!("unable to make {} absolute: {}", keyset_state.display(), e).into() - })?; - let keys_dir = make_parent_dir(state_file.clone()); - - let ks = KeySet::new(domain_name); - let kss = KeySetState { - keyset: ks, - dnskey_rrset: Vec::new(), - ds_rrset: Vec::new(), - cds_rrset: Vec::new(), - ns_rrset: Vec::new(), - cron_next: None, - }; - const ONE_DAY: u64 = 86400; - const FOUR_WEEKS: u64 = 2419200; - let ksc = KeySetConfig { - 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), - ksk_validity: None, - zsk_validity: None, - csk_validity: None, - 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), - cds_inception_offset: Duration::from_secs(ONE_DAY), - cds_signature_lifetime: Duration::from_secs(FOUR_WEEKS), - cds_remain_time: Duration::from_secs(FOUR_WEEKS / 2), - ds_algorithm: DsAlgorithm::Sha256, - autoremove: false, - }; - let json = serde_json::to_string_pretty(&kss).expect("should not fail"); - let mut file = File::create(&state_file).map_err::(|e| { - format!("unable to create file {}: {e}", state_file.display()).into() - })?; - write!(file, "{json}").map_err::(|e| { - format!("unable to write to file {}: {e}", state_file.display()).into() - })?; - - let json = serde_json::to_string_pretty(&ksc).expect("should not fail"); - let mut file = File::create(&self.keyset_conf).map_err::(|e| { - format!("unable to create file {}: {e}", self.keyset_conf.display()).into() - })?; - write!(file, "{json}").map_err::(|e| { - format!( - "unable to write to file {}: {e}", - self.keyset_conf.display() - ) - .into() - })?; - return Ok(()); - } - - let file = File::open(self.keyset_conf.clone()).map_err::(|e| { - format!( - "unable to open config file {}: {e}", - self.keyset_conf.display() - ) - .into() - })?; - let mut ksc: KeySetConfig = serde_json::from_reader(file).map_err::(|e| { - format!("error loading {:?}: {e}\n", self.keyset_conf).into() - })?; - let file = File::open(ksc.state_file.clone()).map_err::(|e| { - format!( - "unable to open state file {}: {e}", - ksc.state_file.display() - ) - .into() - })?; - let mut kss: KeySetState = serde_json::from_reader(file) - .map_err::(|e| format!("error loading {:?}: {e}\n", ksc.state_file).into())?; - - let mut config_changed = false; - let mut state_changed = false; - - match self.cmd { - Commands::Create { .. } => unreachable!(), - Commands::Init => { - // Check for re-init. - if !kss.keyset.keys().is_empty() { - // Avoid re-init. - 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, - )?; - 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"); - - 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, - )?; - 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, - )?; - 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 = [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)?; - - 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, - )?; - 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. - 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}"); - } - - return Err(e); - } - }; - handle_actions(&actions, &ksc, &mut kss, env)?; - - 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, - )?; - 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. - 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}"); - } - return Err(e); - } - }; - handle_actions(&actions, &ksc, &mut kss, env)?; - - 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, - )?; - 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, - )?; - 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, - )?; - 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 { - 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}"); - } - } - return Err(e); - } - }; - - handle_actions(&actions, &ksc, &mut kss, env)?; - - 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, - )?; - 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, - )?; - 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, - )?; - 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 { - 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}"); - } - } - return Err(e); - } - }; - - handle_actions(&actions, &ksc, &mut kss, env)?; - - 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)?; - - // 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)?; - - // Report actions - print_actions(&actions); - state_changed = true; - } - Commands::KskRollDone - | 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), - _ => 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 files: 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 !files.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() - })?; - if let Some(privkey) = privkey { - print!(" {privkey}"); - remove_file(privkey).map_err::(|e| { - format!("unable to remove file {privkey}: {e}\n").into() - })?; - } - } - println!(); - } - } - state_changed = true; - } - Commands::Status => { - for (roll, state) in kss.keyset.rollstates().iter() { - println!("{roll:?}: {state:?}"); - } - if sig_renew(&kss.dnskey_rrset, &ksc.dnskey_remain_time) { - println!("DNSKEY RRSIG(s) need to be renewed"); - } - if sig_renew(&kss.cds_rrset, &ksc.cds_remain_time) { - println!("CDS/CDNSKEY RRSIG(s) need to be renewed"); - } - - // Check for expired keys. - for (pubref, k) in kss.keyset.keys() { - let (expired, label) = key_expired(k, &ksc); - if expired { - println!("{label} {pubref} has expired"); - } - } - } - Commands::Actions => { - for roll in kss.keyset.rollstates().keys() { - let actions = kss.keyset.actions(*roll); - println!("{roll:?} actions:"); - for a in actions { - println!("\t{a:?}"); - } - } - } - Commands::Keys => { - println!("Keys:"); - let mut keys: Vec<_> = kss.keyset.keys().iter().collect(); - keys.sort_by(|(pubref1, key1), (pubref2, key2)| { - (key1.timestamps().creation(), pubref1) - .cmp(&(key2.timestamps().creation(), pubref2)) - }); - for (pubref, key) in keys { - println!("\t{} {}", pubref, key.privref().unwrap_or_default(),); - let (keytype, state, opt_state) = match key.keytype() { - KeyType::Ksk(keystate) => ("KSK", keystate, None), - KeyType::Zsk(keystate) => ("ZSK", keystate, None), - KeyType::Include(keystate) => ("Include", keystate, None), - KeyType::Csk(keystate_ksk, keystate_zsk) => { - ("CSK", keystate_ksk, Some(keystate_zsk)) - } - }; - println!( - "\t\tType: {keytype}, algorithm: {}, key tag: {}", - key.algorithm(), - key.key_tag() - ); - if let Some(zskstate) = opt_state { - println!("\t\tKSK role state: {state}"); - println!("\t\tZSK role state: {zskstate}"); - } else { - println!("\t\tState: {state}"); - } - let ts = key.timestamps(); - println!( - "\t\tCreated: {}", - ts.creation() - .map_or("".to_string(), |x| x.to_string()), - ); - println!( - "\t\tPublished: {}", - ts.published() - .map_or("".to_string(), |x| x.to_string()) - ); - println!( - "\t\tVisible: {}", - ts.visible() - .map_or("".to_string(), |x| x.to_string()), - ); - println!( - "\t\tDS visible: {}", - ts.ds_visible() - .map_or("".to_string(), |x| x.to_string()) - ); - println!( - "\t\tRRSIG visible: {}", - ts.rrsig_visible() - .map_or("".to_string(), |x| x.to_string()), - ); - println!( - "\t\tWithdrawn: {}", - ts.withdrawn() - .map_or("".to_string(), |x| x.to_string()) - ); - } - } - Commands::Get { subcommand } => get_command(subcommand, &ksc, &kss), - Commands::Set { subcommand } => set_command(subcommand, &mut ksc, &mut config_changed)?, - 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!("ksk-validity: {:?}", ksc.ksk_validity); - println!("zsk-validity: {:?}", ksc.zsk_validity); - println!("csk-validity: {:?}", ksc.csk_validity); - println!("dnskey-inception-offset: {:?}", ksc.dnskey_inception_offset); - println!( - "dnskey-signature-lifetime: {:?}", - ksc.dnskey_signature_lifetime - ); - println!("dnskey-remain-time: {:?}", ksc.dnskey_remain_time); - println!("cds-inception-offset: {:?}", ksc.cds_inception_offset); - println!("cds-signature-lifetime: {:?}", ksc.cds_signature_lifetime); - println!("cds-remain-time: {:?}", ksc.cds_remain_time); - println!("ds-algorithm: {:?}", ksc.ds_algorithm); - println!("autoremove: {:?}", ksc.autoremove); - } - 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)?; - 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)?; - state_changed = true; - } - } - } - - 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) - } - } else { - cron_next_cds - }; - if cron_next != kss.cron_next { - kss.cron_next = cron_next; - state_changed = true; - } - if config_changed { - let json = serde_json::to_string_pretty(&ksc).expect("should not fail"); - let mut file = File::create(&self.keyset_conf).map_err::(|e| { - format!("unable to create file {}: {e}", self.keyset_conf.display()).into() - })?; - write!(file, "{json}").map_err::(|e| { - format!( - "unable to write to file {}: {e}", - self.keyset_conf.display() - ) - .into() - })?; - } - if state_changed { - let json = serde_json::to_string_pretty(&kss).expect("should not fail"); - let mut file = File::create(&ksc.state_file).map_err::(|e| { - format!("unable to create file {}: {e}", ksc.state_file.display()).into() - })?; - write!(file, "{json}").map_err::(|e| { - format!("unable to write to file {}: {e}", ksc.state_file.display()).into() - })?; - } - Ok(()) - } -} - -fn get_command(cmd: GetCommands, ksc: &KeySetConfig, kss: &KeySetState) { - match cmd { - GetCommands::UseCsk => { - println!("{}", ksc.use_csk); - } - 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::DsAlgorithm => { - println!("{}", ksc.ds_algorithm); - } - GetCommands::DnskeyLifetime => { - let span = Span::try_from(ksc.dnskey_signature_lifetime).expect("should not fail"); - let signeddur = span - .to_duration(SpanRelativeTo::days_are_24_hours()) - .expect("should not fail"); - println!("{signeddur:#}"); - } - GetCommands::CdsLifetime => { - let span = Span::try_from(ksc.cds_signature_lifetime).expect("should not fail"); - let signeddur = span - .to_duration(SpanRelativeTo::days_are_24_hours()) - .expect("should not fail"); - println!("{signeddur:#}"); - } - GetCommands::Dnskey => { - for r in &kss.dnskey_rrset { - println!("{r}"); - } - } - GetCommands::Cds => { - for r in &kss.cds_rrset { - println!("{r}"); - } - } - GetCommands::Ds => { - for r in &kss.ds_rrset { - println!("{r}"); - } - } - } -} - -fn set_command( - cmd: SetCommands, - ksc: &mut KeySetConfig, - config_changed: &mut bool, -) -> Result<(), Error> { - match cmd { - SetCommands::UseCsk { boolean } => { - ksc.use_csk = boolean; - } - SetCommands::Autoremove { boolean } => { - ksc.autoremove = boolean; - } - SetCommands::KskAlgorithm { algorithm, bits } => { - ksc.ksk_generate_params = KeyParameters::new(&algorithm, bits)?; - } - SetCommands::ZskAlgorithm { algorithm, bits } => { - ksc.zsk_generate_params = KeyParameters::new(&algorithm, bits)?; - } - SetCommands::CskAlgorithm { algorithm, bits } => { - ksc.csk_generate_params = KeyParameters::new(&algorithm, bits)?; - } - SetCommands::DsAlgorithm { algorithm } => { - ksc.ds_algorithm = algorithm; - } - SetCommands::DnskeyInceptionOffset { duration } => { - ksc.dnskey_inception_offset = duration; - } - SetCommands::DnskeyLifetime { duration } => { - ksc.dnskey_signature_lifetime = duration; - } - SetCommands::DnskeyRemainTime { duration } => { - ksc.dnskey_remain_time = duration; - } - SetCommands::CdsInceptionOffset { duration } => { - ksc.cds_inception_offset = duration; - } - SetCommands::CdsLifetime { duration } => { - ksc.cds_signature_lifetime = duration; - } - SetCommands::CdsRemainTime { duration } => { - ksc.cds_remain_time = duration; - } - SetCommands::KskValidity { opt_duration } => { - ksc.ksk_validity = opt_duration; - } - SetCommands::ZskValidity { opt_duration } => { - ksc.zsk_validity = opt_duration; - } - SetCommands::CskValidity { opt_duration } => { - ksc.csk_validity = opt_duration; - } - } - *config_changed = true; - Ok(()) -} - -/// Config for the keyset command. -#[derive(Deserialize, Serialize)] -struct KeySetConfig { - state_file: PathBuf, - keys_dir: PathBuf, - - use_csk: bool, - - /// Algorithm and other parameters for key generation. - ksk_generate_params: KeyParameters, - zsk_generate_params: KeyParameters, - csk_generate_params: KeyParameters, - - ksk_validity: Option, - zsk_validity: Option, - csk_validity: Option, - // ksk validity - // auto-ksk - - // DNSKEY inception offset - dnskey_inception_offset: Duration, - - // DNSKEY sig lifetime - dnskey_signature_lifetime: Duration, - - // DNSKEY resign - dnskey_remain_time: Duration, - - // CDS/CDNSKEY inception offset - cds_inception_offset: Duration, - - // CDS/CDNSKEY sig lifetime - cds_signature_lifetime: Duration, - - // CDS/CDNSKEY resign - cds_remain_time: Duration, - - // DS hash algorithm - ds_algorithm: DsAlgorithm, - - /// Automatically remove keys that are no long in use. - autoremove: bool, -} - -/// Persistent state for the keyset command. -#[derive(Deserialize, Serialize)] -pub struct KeySetState { - /// Domain KeySet state. - pub keyset: KeySet, - - pub dnskey_rrset: Vec, - pub ds_rrset: Vec, - pub cds_rrset: Vec, - pub ns_rrset: Vec, - - cron_next: Option, -} - -#[derive(Deserialize, Serialize)] -enum KeyParameters { - RsaSha256(usize), - RsaSha512(usize), - EcdsaP256Sha256, - EcdsaP384Sha384, - Ed25519, - Ed448, -} - -impl KeyParameters { - fn new(algorithm: &str, bits: Option) -> Result { - if algorithm == "RSASHA256" { - let bits = bits.ok_or::("bits option expected\n".into())?; - Ok(KeyParameters::RsaSha256(bits)) - } else if algorithm == "RSASHA512" { - let bits = bits.ok_or::("bits option expected\n".into())?; - Ok(KeyParameters::RsaSha512(bits)) - } else if algorithm == "ECDSAP256SHA256" { - Ok(KeyParameters::EcdsaP256Sha256) - } else if algorithm == "ECDSAP384SHA384" { - Ok(KeyParameters::EcdsaP384Sha384) - } else if algorithm == "ED25519" { - Ok(KeyParameters::Ed25519) - } else if algorithm == "ED448" { - Ok(KeyParameters::Ed448) - } else { - Err(format!("unknown algorithm {algorithm}\n").into()) - } - } - - fn to_generate_params(&self) -> GenerateParams { - match self { - KeyParameters::RsaSha256(size) => GenerateParams::RsaSha256 { - bits: (*size).try_into().expect("should not fail"), - }, - KeyParameters::RsaSha512(size) => GenerateParams::RsaSha512 { - bits: (*size).try_into().expect("should not fail"), - }, - KeyParameters::EcdsaP256Sha256 => GenerateParams::EcdsaP256Sha256, - KeyParameters::EcdsaP384Sha384 => GenerateParams::EcdsaP384Sha384, - KeyParameters::Ed25519 => GenerateParams::Ed25519, - KeyParameters::Ed448 => GenerateParams::Ed448, - } - } -} - -impl Display for KeyParameters { - fn fmt(&self, fmt: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { - match self { - KeyParameters::RsaSha256(bits) => write!(fmt, "RSASHA256 {bits} bits"), - KeyParameters::RsaSha512(bits) => write!(fmt, "RSASHA512 {bits} bits"), - KeyParameters::EcdsaP256Sha256 => write!(fmt, "ECDSAP256SHA256"), - KeyParameters::EcdsaP384Sha384 => write!(fmt, "ECDSAP384SHA384"), - KeyParameters::Ed25519 => write!(fmt, "ED25519"), - KeyParameters::Ed448 => write!(fmt, "ED448"), - } - } -} - -// Do we want Deserialize and Serialize for DigestAlgorithm? -#[derive(Clone, Debug, Deserialize, Serialize)] -enum DsAlgorithm { - Sha256, - Sha384, -} - -impl DsAlgorithm { - fn new(digest: &str) -> Result { - if digest == "SHA-256" { - Ok(DsAlgorithm::Sha256) - } else if digest == "SHA-384" { - Ok(DsAlgorithm::Sha384) - } else { - Err(format!("unknown digest {digest}\n").into()) - } - } - - fn to_digest_algorithm(&self) -> DigestAlgorithm { - match self { - DsAlgorithm::Sha256 => DigestAlgorithm::SHA256, - DsAlgorithm::Sha384 => DigestAlgorithm::SHA384, - } - } -} - -impl Display for DsAlgorithm { - fn fmt(&self, fmt: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { - match self { - DsAlgorithm::Sha256 => write!(fmt, "SHA-256"), - DsAlgorithm::Sha384 => write!(fmt, "SHA-384"), - } - } -} - -fn new_keys( - name: &Name>, - algorithm: GenerateParams, - make_ksk: bool, - keys: &HashMap, - keys_dir: &Path, - env: &impl Env, -) -> 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; - 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 key_tag = public_key.key_tag(); - if !keys.iter().any(|(_, k)| k.key_tag() == key_tag) { - break (secret_key, public_key, key_tag); - } - if retries <= 1 { - return Err("unable to generate key with unique key tag".into()); - } - retries -= 1; - }; - - let algorithm = public_key.algorithm(); - - let public_key = Record::new(name.clone(), Class::IN, Ttl::ZERO, public_key); - - let base = format!( - "K{}+{:03}+{:05}", - name.fmt_with_dot(), - algorithm.to_int(), - key_tag - ); - - let mut secret_key_path = keys_dir.to_path_buf(); - secret_key_path.push(Path::new(&format!("{base}.private"))); - let mut public_key_path = keys_dir.to_path_buf(); - public_key_path.push(Path::new(&format!("{base}.key"))); - - let mut secret_key_file = util::create_new_file(&env, &secret_key_path)?; - let mut public_key_file = util::create_new_file(&env, &public_key_path)?; - // Prepare the contents to write. - let secret_key = secret_key.display_as_bind().to_string(); - let public_key = display_as_bind(&public_key).to_string(); - - // Write the key files. - secret_key_file - .write_all(secret_key.as_bytes()) - .map_err(|err| format!("error while writing private key file '{base}.private': {err}"))?; - public_key_file - .write_all(public_key.as_bytes()) - .map_err(|err| format!("error while writing public key file '{base}.key': {err}"))?; - - let secret_key_path = secret_key_path.to_str().ok_or::( - format!("path {} needs to be valid UTF-8", secret_key_path.display()).into(), - )?; - let secret_key_url = "file://".to_owned() + secret_key_path; - let public_key_path = public_key_path.to_str().ok_or::( - format!("path {} needs to be valid UTF-8", public_key_path.display()).into(), - )?; - let public_key_url = "file://".to_owned() + public_key_path; - - let secret_key_url = Url::parse(&secret_key_url) - .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)) -} - -fn update_dnskey_rrset( - kss: &mut KeySetState, - ksc: &KeySetConfig, - env: &impl Env, -) -> Result<(), Error> { - let mut dnskeys = Vec::new(); - for (k, v) in kss.keyset.keys() { - let present = match v.keytype() { - KeyType::Ksk(key_state) => key_state.present(), - KeyType::Zsk(key_state) => key_state.present(), - KeyType::Csk(key_state, _) => key_state.present(), - KeyType::Include(key_state) => key_state.present(), - }; - - 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())?; - - // 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); - } - } - } - let now = Timestamp::now().into_int(); - let inception = (now - ksc.dnskey_inception_offset.as_secs() as u32).into(); - let expiration = (now + ksc.dnskey_signature_lifetime.as_secs() as u32).into(); - - let mut sigs = Vec::new(); - for (k, v) in kss.keyset.keys() { - let dnskey_signer = match v.keytype() { - KeyType::Ksk(key_state) => key_state.signer(), - KeyType::Zsk(_) => false, - KeyType::Csk(key_state, _) => key_state.signer(), - KeyType::Include(_) => false, - }; - - let rrset = Rrset::new(&dnskeys) - .map_err::(|e| format!("unable to create Rrset: {e}\n").into())?; - - 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 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() - })?; - sigs.push(sig); - } - } - - kss.dnskey_rrset.truncate(0); - for r in dnskeys { - kss.dnskey_rrset - .push(r.display_zonefile(DisplayKind::Simple).to_string()); - } - for r in sigs { - kss.dnskey_rrset - .push(r.display_zonefile(DisplayKind::Simple).to_string()); - } - println!("Got DNSKEY RRset: {:?}", kss.dnskey_rrset); - Ok(()) -} - -fn create_cds_rrset( - kss: &mut KeySetState, - ksc: &KeySetConfig, - digest_alg: DigestAlgorithm, - env: &impl Env, -) -> Result<(), Error> { - let mut cds_list = Vec::new(); - let mut cdnskey_list = Vec::new(); - for (k, v) in kss.keyset.keys() { - let at_parent = match v.keytype() { - KeyType::Ksk(key_state) => key_state.at_parent(), - KeyType::Zsk(key_state) => key_state.at_parent(), - KeyType::Csk(key_state, _) => key_state.at_parent(), - KeyType::Include(key_state) => key_state.at_parent(), - }; - - 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() - })?; - - 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( - record - .owner() - .try_to_name::() - .expect("should not fail"), - record.class(), - record.ttl(), - cds, - ); - - cds_list.push(cds_record); - } - } - - // Need to sign - } - - let now = Timestamp::now().into_int(); - let inception = (now - ksc.cds_inception_offset.as_secs() as u32).into(); - let expiration = (now + ksc.cds_signature_lifetime.as_secs() as u32).into(); - - let mut cds_sigs = Vec::new(); - let mut cdnskey_sigs = Vec::new(); - for (k, v) in kss.keyset.keys() { - let dnskey_signer = match v.keytype() { - KeyType::Ksk(key_state) => key_state.signer(), - KeyType::Zsk(_) => false, - KeyType::Csk(key_state, _) => key_state.signer(), - KeyType::Include(_) => false, - }; - - let cds_rrset = Rrset::new(&cds_list) - .map_err::(|e| format!("unable to create Rrset: {e}\n").into())?; - let cdnskey_rrset = Rrset::new(&cdnskey_list) - .map_err::(|e| format!("unable to create Rrset: {e}\n").into())?; - - 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 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) - .map_err::(|e| { - format!("error signing CDS RRset with private key {privref}: {e}").into() - })?; - cds_sigs.push(sig); - let sig = - sign_rrset::<_, _, Bytes, _>(&signing_key, &cdnskey_rrset, inception, expiration) - .map_err::(|e| { - format!("error signing CDNSKEY RRset with private key {privref}: {e}").into() - })?; - cdnskey_sigs.push(sig); - } - } - - kss.cds_rrset.truncate(0); - for r in cdnskey_list { - kss.cds_rrset - .push(r.display_zonefile(DisplayKind::Simple).to_string()); - } - for r in cdnskey_sigs { - kss.cds_rrset - .push(r.display_zonefile(DisplayKind::Simple).to_string()); - } - for r in cds_list { - kss.cds_rrset - .push(r.display_zonefile(DisplayKind::Simple).to_string()); - } - for r in cds_sigs { - kss.cds_rrset - .push(r.display_zonefile(DisplayKind::Simple).to_string()); - } - - println!("Got CDS/CDNSKEY RRset: {:?}", kss.cds_rrset); - Ok(()) -} - -fn remove_cds_rrset(kss: &mut KeySetState) { - kss.cds_rrset.truncate(0); -} - -fn update_ds_rrset( - kss: &mut KeySetState, - digest_alg: DigestAlgorithm, - env: &impl Env, -) -> Result<(), Error> { - let mut ds_list = Vec::new(); - for (k, v) in kss.keyset.keys() { - let at_parent = match v.keytype() { - KeyType::Ksk(key_state) => key_state.at_parent(), - KeyType::Zsk(key_state) => key_state.at_parent(), - KeyType::Csk(key_state, _) => key_state.at_parent(), - KeyType::Include(key_state) => key_state.at_parent(), - }; - - 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() - })?; - - 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", - ); - - let ds_record = - Record::new(record.owner().clone(), record.class(), record.ttl(), ds); - - ds_list.push(ds_record); - } - } - } - - kss.ds_rrset.truncate(0); - for r in ds_list { - kss.ds_rrset - .push(r.display_zonefile(DisplayKind::Simple).to_string()); - } - - println!("Got DS RRset: {:?}", kss.ds_rrset); - Ok(()) -} - -fn handle_actions( - actions: &[Action], - ksc: &KeySetConfig, - kss: &mut KeySetState, - env: &impl Env, -) -> 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::RemoveCdsRrset => remove_cds_rrset(kss), - Action::UpdateDsRrset => { - update_ds_rrset(kss, ksc.ds_algorithm.to_digest_algorithm(), env)? - } - Action::UpdateRrsig => (), - Action::ReportDnskeyPropagated => (), - Action::ReportDsPropagated => (), - Action::ReportRrsigPropagated => (), - Action::WaitDnskeyPropagated => (), - Action::WaitDsPropagated => (), - Action::WaitRrsigPropagated => (), - } - } - Ok(()) -} - -fn print_actions(actions: &[Action]) { - if actions.is_empty() { - println!("No actions"); - } else { - print!("Actions:"); - for a in actions { - print!(" {a:?}"); - } - println!(); - } -} - -fn parse_duration(value: &str) -> Result { - let span: Span = value - .parse() - .map_err::(|e| format!("unable to parse {value} as lifetime: {e}\n").into())?; - let signeddur = span - .to_duration(SpanRelativeTo::days_are_24_hours()) - .map_err::(|e| format!("unable to convert duration: {e}\n").into())?; - Duration::try_from(signeddur).map_err(|e| format!("unable to convert duration: {e}\n").into()) -} - -fn parse_opt_duration(value: &str) -> Result, Error> { - if value == "off" { - return Ok(None); - } - let duration = parse_duration(value)?; - Ok(Some(duration)) -} - -fn sig_renew(rrset: &[String], remain_time: &Duration) -> bool { - let mut zonefile = Zonefile::new(); - for r in rrset { - zonefile.extend_from_slice(r.as_ref()); - zonefile.extend_from_slice(b"\n"); - } - let now = Timestamp::now(); - let renew = now.into_int() as u64 + remain_time.as_secs(); - for e in zonefile { - let e = e.expect("should not fail"); - match e { - Entry::Record(r) => { - if let ZoneRecordData::Rrsig(rrsig) = r.data() { - if renew > rrsig.expiration().into_int() as u64 { - return true; - } - } - } - Entry::Include { .. } => continue, // Just ignore include. - } - } - false -} - -fn key_expired(key: &Key, ksc: &KeySetConfig) -> (bool, &'static str) { - let Some(timestamp) = key.timestamps().published() else { - return (false, ""); - }; - - // Take published time as basis for computing expiration. - let (keystate, label, validity) = match key.keytype() { - KeyType::Ksk(keystate) => (keystate, "KSK", ksc.ksk_validity), - KeyType::Zsk(keystate) => (keystate, "ZSK", ksc.zsk_validity), - 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() { - // Old key. - return (false, ""); - } - let Some(validity) = validity else { - // No limit on key validity. - return (false, ""); - }; - (timestamp.elapsed() > validity, label) -} - -fn make_parent_dir(filename: PathBuf) -> PathBuf { - filename.parent().unwrap_or(Path::new("/")).to_path_buf() -} - -fn compute_cron_next(rrset: &[String], remain_time: &Duration) -> Option { - let mut zonefile = Zonefile::new(); - for r in rrset { - zonefile.extend_from_slice(r.as_ref()); - zonefile.extend_from_slice(b"\n"); - } - - let now = SystemTime::now(); - let min_expiration = zonefile - .map(|r| r.expect("should not fail")) - .filter_map(|r| match r { - Entry::Record(r) => Some(r), - Entry::Include { .. } => None, - }) - .filter_map(|r| { - if let ZoneRecordData::Rrsig(rrsig) = r.data() { - Some(rrsig.expiration()) - } else { - None - } - }) - .map(|t| t.to_system_time(now)) - .min(); - - min_expiration.map(|t| (t - *remain_time).try_into().unwrap()) -} diff --git a/src/commands/keyset/cmd.rs b/src/commands/keyset/cmd.rs new file mode 100644 index 0000000..0ac810e --- /dev/null +++ b/src/commands/keyset/cmd.rs @@ -0,0 +1,5490 @@ +//! 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, 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, 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::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); + +/// 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>; +/// 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 + #[arg(short = 'c')] + keyset_conf: PathBuf, + + /// Subcommand + #[command(subcommand)] + 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')] + domain_name: Name>, + + /// State file + #[arg(short = 's')] + keyset_state: PathBuf, + }, + + /// Init creates keys for an empty state file. + Init, + + /// Command for KSK rolls. + Ksk { + /// The specific key roll subcommand. + #[command(subcommand)] + subcommand: RollCommands, + }, + /// Command for ZSK rolls. + Zsk { + /// The specific key roll subcommand. + #[command(subcommand)] + subcommand: RollCommands, + }, + /// Command for CSK rolls. + Csk { + /// The specific key roll subcommand. + #[command(subcommand)] + subcommand: RollCommands, + }, + /// Command for algorithm rolls. + Algorithm { + /// The specific key roll subcommand. + #[command(subcommand)] + subcommand: RollCommands, + }, + + /// Command for importing existing keys. + Import { + /// The specific import subcommand. + #[command(subcommand)] + subcommand: ImportCommands, + }, + + /// 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, + }, + + /// 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, + }, +} + +#[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, + /// 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, + }, + /// 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, + }, + + /// 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, + }, + /// 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, + }, +} + +#[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> { + let runtime = + tokio::runtime::Runtime::new().expect("tokio::runtime::Runtime::new should not fail"); + runtime.block_on(self.run(&env)) + } + + /// Run the command as an async function + pub async fn run(self, env: &impl Env) -> Result<(), Error> { + if let Commands::Create { + domain_name, + keyset_state, + } = self.cmd + { + let state_file = absolute(&keyset_state).map_err::(|e| { + format!("unable to make {} absolute: {}", keyset_state.display(), e).into() + })?; + let keys_dir = make_parent_dir(state_file.clone()); + + let ks = KeySet::new(domain_name); + let kss = KeySetState { + keyset: ks, + dnskey_rrset: Vec::new(), + ds_rrset: Vec::new(), + cds_rrset: Vec::new(), + ns_rrset: Vec::new(), + cron_next: None, + internal: HashMap::new(), + + #[cfg(feature = "kmip")] + kmip: Default::default(), + }; + const ONE_DAY: u64 = 86400; + const FOUR_WEEKS: u64 = 2419200; + let ksc = KeySetConfig { + state_file: state_file.clone(), + keys_dir, + use_csk: false, + 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), + cds_inception_offset: Duration::from_secs(ONE_DAY), + 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(), + }; + let json = serde_json::to_string_pretty(&kss).expect("should not fail"); + let mut file = File::create(&state_file).map_err::(|e| { + format!("unable to create file {}: {e}", state_file.display()).into() + })?; + write!(file, "{json}").map_err::(|e| { + format!("unable to write to file {}: {e}", state_file.display()).into() + })?; + + let json = serde_json::to_string_pretty(&ksc).expect("should not fail"); + let mut file = File::create(&self.keyset_conf).map_err::(|e| { + format!("unable to create file {}: {e}", self.keyset_conf.display()).into() + })?; + write!(file, "{json}").map_err::(|e| { + format!( + "unable to write to file {}: {e}", + self.keyset_conf.display() + ) + .into() + })?; + return Ok(()); + } + + let file = File::open(self.keyset_conf.clone()).map_err::(|e| { + format!( + "unable to open config file {}: {e}", + self.keyset_conf.display() + ) + .into() + })?; + let mut ksc: KeySetConfig = serde_json::from_reader(file).map_err::(|e| { + format!("error loading {:?}: {e}\n", self.keyset_conf).into() + })?; + let file = File::open(ksc.state_file.clone()).map_err::(|e| { + format!( + "unable to open state file {}: {e}", + ksc.state_file.display() + ) + .into() + })?; + let mut kss: KeySetState = serde_json::from_reader(file) + .map_err::(|e| format!("error loading {:?}: {e}\n", ksc.state_file).into())?; + + let mut config_changed = false; + let mut state_changed = false; + let mut run_update_ds_command = false; + + match self.cmd { + Commands::Create { .. } => unreachable!(), + Commands::Init => { + // Check for re-init. + if !kss.keyset.keys().is_empty() { + // Avoid re-init. + return Err("already initialized\n".into()); + } + + let (new_stored, _) = new_csk_or_ksk_zsk(&ksc, &mut kss, env)?; + + 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"); + + 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::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, + )?, + + 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::Status => { + for (roll, state) in kss.keyset.rollstates().iter() { + println!("{roll:?}: {state:?}"); + } + if sig_renew(&kss.dnskey_rrset, &ksc.dnskey_remain_time) { + println!("DNSKEY RRSIG(s) need to be renewed"); + } + if sig_renew(&kss.cds_rrset, &ksc.cds_remain_time) { + println!("CDS/CDNSKEY RRSIG(s) need to be renewed"); + } + + // Check for expired keys. + for (pubref, k) in kss.keyset.keys() { + let (expired, label) = key_expired(k, &ksc); + if expired { + 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); + println!("{roll:?} actions:"); + print_actions(&actions); + } + } + Commands::Keys => { + println!("Keys:"); + let mut keys: Vec<_> = kss.keyset.keys().iter().collect(); + keys.sort_by(|(pubref1, key1), (pubref2, key2)| { + (key1.timestamps().creation(), pubref1) + .cmp(&(key2.timestamps().creation(), pubref2)) + }); + 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), + KeyType::Include(keystate) => ("Include", keystate, None), + KeyType::Csk(keystate_ksk, keystate_zsk) => { + ("CSK", keystate_ksk, Some(keystate_zsk)) + } + }; + println!( + "\t\tType: {keytype}, algorithm: {}, key tag: {}", + key.algorithm(), + key.key_tag() + ); + if let Some(zskstate) = opt_state { + println!("\t\tKSK role state: {state}"); + println!("\t\tZSK role state: {zskstate}"); + } else { + println!("\t\tState: {state}"); + } + let ts = key.timestamps(); + println!( + "\t\tCreated: {}", + ts.creation() + .map_or("".to_string(), |x| x.to_string()), + ); + println!( + "\t\tPublished: {}", + ts.published() + .map_or("".to_string(), |x| x.to_string()) + ); + println!( + "\t\tVisible: {}", + ts.visible() + .map_or("".to_string(), |x| x.to_string()), + ); + println!( + "\t\tDS visible: {}", + ts.ds_visible() + .map_or("".to_string(), |x| x.to_string()) + ); + println!( + "\t\tRRSIG visible: {}", + ts.rrsig_visible() + .map_or("".to_string(), |x| x.to_string()), + ); + println!( + "\t\tWithdrawn: {}", + ts.withdrawn() + .map_or("".to_string(), |x| x.to_string()) + ); + } + } + Commands::Get { subcommand } => get_command(subcommand, &ksc, &kss), + Commands::Set { subcommand } => set_command(subcommand, &mut ksc, &mut config_changed)?, + Commands::Show => { + println!("state-file: {:?}", ksc.state_file); + println!("use-csk: {}", ksc.use_csk); + 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: {:?}", + ksc.dnskey_signature_lifetime + ); + println!("dnskey-remain-time: {:?}", ksc.dnskey_remain_time); + println!("cds-inception-offset: {:?}", ksc.cds_inception_offset); + 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(&ksc, &mut kss, 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, + 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")] + Commands::Kmip { subcommand } => { + state_changed = kmip_command(env, subcommand, &mut kss)?; + } + } + + 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_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; + } + if config_changed { + let json = serde_json::to_string_pretty(&ksc).expect("should not fail"); + let mut file = File::create(&self.keyset_conf).map_err::(|e| { + format!("unable to create file {}: {e}", self.keyset_conf.display()).into() + })?; + write!(file, "{json}").map_err::(|e| { + format!( + "unable to write to file {}: {e}", + self.keyset_conf.display() + ) + .into() + })?; + } + if state_changed { + let json = serde_json::to_string_pretty(&kss).expect("should not fail"); + let mut file = File::create(&ksc.state_file).map_err::(|e| { + format!("unable to create file {}: {e}", ksc.state_file.display()).into() + })?; + write!(file, "{json}").map_err::(|e| { + 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() { + "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(()) +} + +/// 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 { + GetCommands::UseCsk => { + println!("{}", ksc.use_csk); + } + GetCommands::Autoremove => { + println!("{}", ksc.autoremove); + } + GetCommands::Algorithm => { + println!("{}", ksc.algorithm); + } + GetCommands::DsAlgorithm => { + println!("{}", ksc.ds_algorithm); + } + GetCommands::DnskeyLifetime => { + let span = Span::try_from(ksc.dnskey_signature_lifetime).expect("should not fail"); + let signeddur = span + .to_duration(SpanRelativeTo::days_are_24_hours()) + .expect("should not fail"); + println!("{signeddur:#}"); + } + GetCommands::CdsLifetime => { + let span = Span::try_from(ksc.cds_signature_lifetime).expect("should not fail"); + let signeddur = span + .to_duration(SpanRelativeTo::days_are_24_hours()) + .expect("should not fail"); + println!("{signeddur:#}"); + } + GetCommands::Dnskey => { + for r in &kss.dnskey_rrset { + println!("{r}"); + } + } + GetCommands::Cds => { + for r in &kss.cds_rrset { + println!("{r}"); + } + } + GetCommands::Ds => { + for r in &kss.ds_rrset { + println!("{r}"); + } + } + } +} + +/// Implement the set subcommand. +fn set_command( + cmd: SetCommands, + ksc: &mut KeySetConfig, + config_changed: &mut bool, +) -> Result<(), Error> { + match cmd { + SetCommands::UseCsk { boolean } => { + ksc.use_csk = boolean; + } + SetCommands::Autoremove { boolean } => { + ksc.autoremove = boolean; + } + SetCommands::Algorithm { algorithm, bits } => { + ksc.algorithm = KeyParameters::new(&algorithm, bits)?; + } + SetCommands::AutoKsk { + start, + report, + expire, + done, + } => { + ksc.auto_ksk = AutoConfig { + start, + report, + expire, + done, + }; + *config_changed = true; + } + 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; + } + SetCommands::DnskeyInceptionOffset { duration } => { + ksc.dnskey_inception_offset = duration; + } + SetCommands::DnskeyLifetime { duration } => { + ksc.dnskey_signature_lifetime = duration; + } + SetCommands::DnskeyRemainTime { duration } => { + ksc.dnskey_remain_time = duration; + } + SetCommands::CdsInceptionOffset { duration } => { + ksc.cds_inception_offset = duration; + } + SetCommands::CdsLifetime { duration } => { + ksc.cds_signature_lifetime = duration; + } + SetCommands::CdsRemainTime { duration } => { + ksc.cds_remain_time = duration; + } + SetCommands::KskValidity { opt_duration } => { + ksc.ksk_validity = opt_duration; + } + SetCommands::ZskValidity { opt_duration } => { + ksc.zsk_validity = opt_duration; + } + SetCommands::CskValidity { opt_duration } => { + ksc.csk_validity = opt_duration; + } + SetCommands::UpdateDsCommand { args } => { + ksc.update_ds_command = args; + } + } + *config_changed = true; + Ok(()) +} + +/// 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. + algorithm: KeyParameters, + + /// Validity of KSKs. + ksk_validity: Option, + /// Validity of ZSKs. + zsk_validity: Option, + /// Validity of CSKs. + csk_validity: Option, + + /// 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 signature lifetime + dnskey_signature_lifetime: Duration, + + /// The required remaining signature lifetime. + dnskey_remain_time: Duration, + + /// CDS/CDNSKEY signature inception offset + cds_inception_offset: Duration, + + /// CDS/CDNSKEY signature lifetime + cds_signature_lifetime: Duration, + + /// The required remaining signature lifetime. + cds_remain_time: Duration, + + /// 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, + + /// 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. +#[derive(Deserialize, Serialize)] +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())?; + Ok(KeyParameters::RsaSha256(bits)) + } else if algorithm == "RSASHA512" { + let bits = bits.ok_or::("bits option expected\n".into())?; + Ok(KeyParameters::RsaSha512(bits)) + } else if algorithm == "ECDSAP256SHA256" { + Ok(KeyParameters::EcdsaP256Sha256) + } else if algorithm == "ECDSAP384SHA384" { + Ok(KeyParameters::EcdsaP384Sha384) + } else if algorithm == "ED25519" { + Ok(KeyParameters::Ed25519) + } else if algorithm == "ED448" { + Ok(KeyParameters::Ed448) + } else { + Err(format!("unknown algorithm {algorithm}\n").into()) + } + } + + /// Return the GenerateParams equivalent of a KeyParameters object. + fn to_generate_params(&self) -> GenerateParams { + match self { + KeyParameters::RsaSha256(size) => GenerateParams::RsaSha256 { + bits: (*size).try_into().expect("should not fail"), + }, + KeyParameters::RsaSha512(size) => GenerateParams::RsaSha512 { + bits: (*size).try_into().expect("should not fail"), + }, + KeyParameters::EcdsaP256Sha256 => GenerateParams::EcdsaP256Sha256, + KeyParameters::EcdsaP384Sha384 => GenerateParams::EcdsaP384Sha384, + KeyParameters::Ed25519 => GenerateParams::Ed25519, + KeyParameters::Ed448 => GenerateParams::Ed448, + } + } +} + +impl Display for KeyParameters { + fn fmt(&self, fmt: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + KeyParameters::RsaSha256(bits) => write!(fmt, "RSASHA256 {bits} bits"), + KeyParameters::RsaSha512(bits) => write!(fmt, "RSASHA512 {bits} bits"), + KeyParameters::EcdsaP256Sha256 => write!(fmt, "ECDSAP256SHA256"), + KeyParameters::EcdsaP384Sha384 => write!(fmt, "ECDSAP384SHA384"), + KeyParameters::Ed25519 => write!(fmt, "ED25519"), + KeyParameters::Ed448 => write!(fmt, "ED448"), + } + } +} + +/// 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) + } else if digest == "SHA-384" { + Ok(DsAlgorithm::Sha384) + } else { + Err(format!("unknown digest {digest}\n").into()) + } + } + + /// Return the equivalent DigestAlgorithm for a DsAlgorithm object. + fn to_digest_algorithm(&self) -> DigestAlgorithm { + match self { + DsAlgorithm::Sha256 => DigestAlgorithm::SHA256, + DsAlgorithm::Sha384 => DigestAlgorithm::SHA384, + } + } +} + +impl Display for DsAlgorithm { + fn fmt(&self, fmt: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + DsAlgorithm::Sha256 => write!(fmt, "SHA-256"), + DsAlgorithm::Sha384 => write!(fmt, "SHA-384"), + } + } +} + +/// 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, + make_ksk: bool, + keys: &HashMap, + keys_dir: &Path, + env: &impl Env, + #[cfg(feature = "kmip")] kmip: &mut KmipState, +) -> Result<(Url, Url, SecurityAlgorithm, u16), Error> { + // Generate the key. + // 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) = + 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) { + break (secret_key, public_key, key_tag); + } + if retries <= 1 { + return Err("unable to generate key with unique key tag".into()); + } + retries -= 1; + }; + + let algorithm = public_key.algorithm(); + + let public_key = Record::new(name.clone(), Class::IN, Ttl::ZERO, public_key); + + let base = format!( + "K{}+{:03}+{:05}", + name.fmt_with_dot(), + algorithm.to_int(), + key_tag + ); + + let mut secret_key_path = keys_dir.to_path_buf(); + secret_key_path.push(Path::new(&format!("{base}.private"))); + let mut public_key_path = keys_dir.to_path_buf(); + public_key_path.push(Path::new(&format!("{base}.key"))); + + let mut secret_key_file = util::create_new_file(&env, &secret_key_path)?; + let mut public_key_file = util::create_new_file(&env, &public_key_path)?; + // Prepare the contents to write. + let secret_key = secret_key.display_as_bind().to_string(); + let public_key = display_as_bind(&public_key).to_string(); + + // Write the key files. + secret_key_file + .write_all(secret_key.as_bytes()) + .map_err(|err| format!("error while writing private key file '{base}.private': {err}"))?; + public_key_file + .write_all(public_key.as_bytes()) + .map_err(|err| format!("error while writing public key file '{base}.key': {err}"))?; + + let secret_key_path = secret_key_path.to_str().ok_or::( + format!("path {} needs to be valid UTF-8", secret_key_path.display()).into(), + )?; + let secret_key_url = "file://".to_owned() + secret_key_path; + let public_key_path = public_key_path.to_str().ok_or::( + format!("path {} needs to be valid UTF-8", public_key_path.display()).into(), + )?; + let public_key_url = "file://".to_owned() + public_key_path; + + let secret_key_url = Url::parse(&secret_key_url) + .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)) +} + +/// 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( + ksc: &KeySetConfig, + kss: &mut KeySetState, + env: &impl Env, + verbose: bool, +) -> Result<(), Error> { + let mut dnskeys = Vec::new(); + for (k, v) in kss.keyset.keys() { + let present = match v.keytype() { + KeyType::Ksk(key_state) => key_state.present(), + KeyType::Zsk(key_state) => key_state.present(), + KeyType::Csk(key_state, _) => key_state.present(), + KeyType::Include(key_state) => key_state.present(), + }; + + let pub_url = Url::parse(k).expect("valid URL expected"); + + if present { + match pub_url.scheme() { + "file" => { + 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 read from file {}: {e}", filename.display()).into() + })?; + let mut public_key = parse_from_bind::>(&public_data) + .map_err::(|e| { + format!( + "unable to parse public key file {}: {e}", + filename.display() + ) + .into() + })?; + + public_key.set_ttl(ksc.default_ttl); + dnskeys.push(public_key); + } + + #[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: Name<_> = kss.keyset.name().clone().flatten_into(); + let record = Record::new( + owner, + Class::IN, + ksc.default_ttl, + key.dnskey(flags).convert(), + ); + dnskeys.push(record); + } + + _ => { + panic!("unsupported scheme in {pub_url}"); + } + } + } + } + let now = Timestamp::now().into_int(); + let inception = (now - ksc.dnskey_inception_offset.as_secs() as u32).into(); + let expiration = (now + ksc.dnskey_signature_lifetime.as_secs() as u32).into(); + + let mut sigs = Vec::new(); + for (k, v) in kss.keyset.keys() { + let dnskey_signer = match v.keytype() { + KeyType::Ksk(key_state) => key_state.signer(), + KeyType::Zsk(_) => false, + KeyType::Csk(key_state, _) => key_state.signer(), + KeyType::Include(_) => false, + }; + + let rrset = Rrset::new(&dnskeys) + .map_err::(|e| format!("unable to create Rrset: {e}\n").into())?; + + if dnskey_signer { + let privref = v.privref().ok_or("missing private key")?; + let priv_url = Url::parse(privref).expect("valid URL expected"); + let pub_url = Url::parse(k).expect("valid URL expected"); + 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() + })?; + 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); + } + } + + kss.dnskey_rrset.truncate(0); + for r in dnskeys { + kss.dnskey_rrset + .push(r.display_zonefile(DisplayKind::Simple).to_string()); + } + for r in sigs { + kss.dnskey_rrset + .push(r.display_zonefile(DisplayKind::Simple).to_string()); + } + 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(); + for (k, v) in kss.keyset.keys() { + let at_parent = match v.keytype() { + KeyType::Ksk(key_state) => key_state.at_parent(), + KeyType::Zsk(key_state) => key_state.at_parent(), + KeyType::Csk(key_state, _) => key_state.at_parent(), + KeyType::Include(key_state) => key_state.at_parent(), + }; + + if at_parent { + let pub_url = Url::parse(k).expect("valid URL expected"); + match pub_url.scheme() { + "file" => { + 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 read from file {}: {e}", filename.display()).into() + })?; + let mut public_key = parse_from_bind::>(&public_data) + .map_err::(|e| { + format!( + "unable to parse public key file {}: {e}", + filename.display() + ) + .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")] + "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 = kss.keyset.name().clone().flatten_into(); + let record = Record::new(owner, Class::IN, ksc.default_ttl, dnskey); + create_cds_rrset_helper(digest_alg, &mut cds_list, &mut cdnskey_list, record)?; + } + + _ => panic!("unsupported scheme in {pub_url}"), + } + } + + // Need to sign + } + + let now = Timestamp::now().into_int(); + let inception = (now - ksc.cds_inception_offset.as_secs() as u32).into(); + let expiration = (now + ksc.cds_signature_lifetime.as_secs() as u32).into(); + + let mut cds_sigs = Vec::new(); + let mut cdnskey_sigs = Vec::new(); + for (k, v) in kss.keyset.keys() { + let dnskey_signer = match v.keytype() { + KeyType::Ksk(key_state) => key_state.signer(), + KeyType::Zsk(_) => false, + KeyType::Csk(key_state, _) => key_state.signer(), + KeyType::Include(_) => false, + }; + + let cds_rrset = Rrset::new(&cds_list) + .map_err::(|e| format!("unable to create Rrset: {e}\n").into())?; + let cdnskey_rrset = Rrset::new(&cdnskey_list) + .map_err::(|e| format!("unable to create Rrset: {e}\n").into())?; + + if dnskey_signer { + let privref = v.privref().ok_or("missing private key")?; + let priv_url = Url::parse(privref).expect("valid URL expected"); + let pub_url = Url::parse(k).expect("valid URL expected"); + 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() + })?; + 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() + })?; + cds_sigs.push(sig); + let sig = + sign_rrset::<_, _, Bytes, _>(&signing_key, &cdnskey_rrset, inception, expiration) + .map_err::(|e| { + format!("error signing CDNSKEY RRset with private key {privref}: {e}").into() + })?; + cdnskey_sigs.push(sig); + } + } + + kss.cds_rrset.truncate(0); + for r in cdnskey_list { + kss.cds_rrset + .push(r.display_zonefile(DisplayKind::Simple).to_string()); + } + for r in cdnskey_sigs { + kss.cds_rrset + .push(r.display_zonefile(DisplayKind::Simple).to_string()); + } + for r in cds_list { + kss.cds_rrset + .push(r.display_zonefile(DisplayKind::Simple).to_string()); + } + for r in cds_sigs { + kss.cds_rrset + .push(r.display_zonefile(DisplayKind::Simple).to_string()); + } + + 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>>>, + cdnskey_list: &mut Vec, Cdnskey>>>, + record: Record>, Dnskey>>, +) -> Result<(), Error> { + let owner: Name = record.owner().to_name(); + let dnskey = record.data(); + 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(()) +} + +/// 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( + ksc: &KeySetConfig, + kss: &mut KeySetState, + 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() { + let at_parent = match v.keytype() { + KeyType::Ksk(key_state) => key_state.at_parent(), + KeyType::Zsk(key_state) => key_state.at_parent(), + KeyType::Csk(key_state, _) => key_state.at_parent(), + KeyType::Include(key_state) => key_state.at_parent(), + }; + + if at_parent { + let pub_url = Url::parse(k).expect("valid URL expected"); + match pub_url.scheme() { + "file" => { + 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 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!("error creating digest for DNSKEY record: {e}").into() + })?; + + 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", + ); + + let ds_record = Record::new( + public_key.owner().clone().flatten_into(), + public_key.class(), + ksc.default_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}"), + } + } + } + + kss.ds_rrset.truncate(0); + for r in ds_list { + kss.ds_rrset + .push(r.display_zonefile(DisplayKind::Simple).to_string()); + } + + 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(ksc, kss, 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 => { + *run_update_ds_command = true; + update_ds_rrset(ksc, kss, env, verbose)? + } + Action::UpdateRrsig => (), + Action::ReportDnskeyPropagated => (), + Action::ReportDsPropagated => (), + Action::ReportRrsigPropagated => (), + Action::WaitDnskeyPropagated => (), + Action::WaitDsPropagated => (), + Action::WaitRrsigPropagated => (), + } + } + 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 { + println!("Actions:"); + let mut report_count = 0; + for a in actions { + 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."); + } + } +} + +/// 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() + .map_err::(|e| format!("unable to parse {value} as lifetime: {e}\n").into())?; + let signeddur = span + .to_duration(SpanRelativeTo::days_are_24_hours()) + .map_err::(|e| format!("unable to convert duration: {e}\n").into())?; + 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); + } + let duration = parse_duration(value)?; + 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 { + zonefile.extend_from_slice(r.as_ref()); + zonefile.extend_from_slice(b"\n"); + } + let now = Timestamp::now(); + let renew = now.into_int() as u64 + remain_time.as_secs(); + for e in zonefile { + let e = e.expect("should not fail"); + match e { + Entry::Record(r) => { + if let ZoneRecordData::Rrsig(rrsig) = r.data() { + if renew > rrsig.expiration().into_int() as u64 { + return true; + } + } + } + Entry::Include { .. } => continue, // Just ignore include. + } + } + 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, ""); + }; + + // Take published time as basis for computing expiration. + let (keystate, label, validity) = match key.keytype() { + KeyType::Ksk(keystate) => (keystate, "KSK", ksc.ksk_validity), + KeyType::Zsk(keystate) => (keystate, "ZSK", ksc.zsk_validity), + KeyType::Csk(keystate, _) => (keystate, "CSK", ksc.csk_validity), + KeyType::Include(_) => return (false, ""), // Does not expire. + }; + if keystate.stale() { + // Old key. + return (false, ""); + } + let Some(validity) = validity else { + // No limit on key validity. + return (false, ""); + }; + (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 { + zonefile.extend_from_slice(r.as_ref()); + zonefile.extend_from_slice(b"\n"); + } + + let now = SystemTime::now(); + let min_expiration = zonefile + .map(|r| r.expect("should not fail")) + .filter_map(|r| match r { + Entry::Record(r) => Some(r), + Entry::Include { .. } => None, + }) + .filter_map(|r| { + if let ZoneRecordData::Rrsig(rrsig) = r.data() { + Some(rrsig.expiration()) + } else { + None + } + }) + .map(|t| t.to_system_time(now)) + .min(); + + // Map to the Unix epoch in case of failure. + min_expiration.map(|t| { + (t - *remain_time) + .try_into() + .unwrap_or_else(|_| UNIX_EPOCH.try_into().expect("should not fail")) + }) +} + +/// The result of an automatic action check that does not need to report a +/// TTL. +#[derive(Debug)] +enum AutoActionsResult { + /// The action has completed. + Ok, + /// Try again after the UnixTime parameter. + Wait(UnixTime), +} + +/// The result of an automatic action check the does need to report a TTL. +#[derive(Clone, Debug, Deserialize, Serialize)] +enum AutoReportActionsResult { + /// The action has completed, report at least the Ttl in the parameter. + Report(Ttl), + /// Try again after the UnixTime parameter. + Wait(UnixTime), +} + +/// The result of checking for RRSIG propagation. +#[derive(Clone, Debug, Deserialize, Serialize)] +enum AutoReportRrsigResult { + /// The action has completed, report at least the Ttl in the parameter. + Report(Ttl), + /// A DNS request failed (for example due to a network problem). Try again + /// after the UnixTime parameter. + Wait(UnixTime), + /// The zone has updated signatures, wait for this version of the zone to + /// appear on all name servers. + WaitSoa { + /// Try again after this time. + next: UnixTime, + /// Wait for this serial or newer. + serial: Serial, + /// The ttl to use to compute a new 'next' wait time if the check fails. + ttl: Ttl, + /// The ttl to put in the Report variable when the check succeeds. + report_ttl: Ttl, + }, + /// Wait for a specific record to get updated signatures. + WaitRecord { + /// Try again after this time. + next: UnixTime, + /// Name to check. + name: Name>, + /// 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 + }); + if res { + 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(); + 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 + }); + if res { + 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(); + 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; + + 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)) +} + +/// 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 +- 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 +*/ diff --git a/src/commands/keyset/kmip.rs b/src/commands/keyset/kmip.rs new file mode 100644 index 0000000..417c0c3 --- /dev/null +++ b/src/commands/keyset/kmip.rs @@ -0,0 +1,1850 @@ +//! 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 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 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. + /// + /// 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, + + /// 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: + /// { + /// "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, + pending, + 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, + pending, + 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, + pending: bool, + 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 !pending && 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::*; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9e56da5..2abc33a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -48,6 +48,10 @@ pub enum Command { #[command(name = "keygen", verbatim_doc_comment)] Keygen(self::keygen::Keygen), + /// Maintain a set of DNSSEC keys. EXPERIMENTAL. + #[command(name = "keyset")] + Keyset(self::keyset::Keyset), + /// Generate a DS RR from the DNSKEYS in keyfile /// /// The following file will be created for each key: @@ -80,12 +84,12 @@ pub enum Command { #[command(name = "update")] Update(self::update::Update), - /// Maintain a set of DNSSEC keys - #[command(name = "keyset")] - Keyset(self::keyset::Keyset), - - /// Show the manual pages - Help(self::help::Help), + /// 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 /// @@ -100,13 +104,14 @@ impl Command { match self { Self::Key2ds(key2ds) => key2ds.execute(env), Self::Keygen(keygen) => keygen.execute(env), + Self::Keyset(keyset) => keyset.execute(env), Self::Nsec3Hash(nsec3hash) => nsec3hash.execute(env), Self::Notify(notify) => notify.execute(env), Self::Signer(signer) => signer.execute(env), Self::SignZone(signzone) => signzone.execute(env), Self::Update(update) => update.execute(env), - Self::Keyset(keyset) => keyset.execute(env), - Self::Help(help) => help.execute(), + // 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/signzone.rs b/src/commands/signzone.rs index d236e8c..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 )] @@ -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. @@ -252,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" @@ -263,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")), @@ -275,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")), @@ -286,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"], @@ -505,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, @@ -977,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(); @@ -1160,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() { @@ -1255,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)? @@ -1283,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")?; } @@ -1334,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))) } @@ -1562,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. @@ -2033,6 +2079,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)] @@ -2427,8 +2519,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, @@ -2967,14 +3059,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([ @@ -3003,14 +3095,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 = 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/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 { 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 de0d8ef..cfe694e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,13 +7,13 @@ 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; use error::Error; use log::LogFormatter; - -use domain::base::zonefile_fmt::DisplayKind; +use tracing::level_filters::LevelFilter; pub use self::args::Args; @@ -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()), }?; @@ -100,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() - } - } + }) }) }