diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f06ccfa..9cef13c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,12 +9,14 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macOS-latest] - rust: [1.78.0, stable, beta, nightly] + rust: [1.79.0, stable, beta, nightly] env: + RUSTFLAGS: "-D warnings" + # We use 'vcpkg' to install OpenSSL on Windows. VCPKG_ROOT: "${{ github.workspace }}\\vcpkg" VCPKGRS_TRIPLET: x64-windows-release # Ensure that OpenSSL is dynamically linked. - VCPKGRS_DYNAMIC: 1 + VCPKGRS_DYNAMIC: 1 steps: - name: Checkout repository uses: actions/checkout@v1 @@ -50,7 +52,9 @@ jobs: - name: Install Rust uses: hecrj/setup-rust-action@v2 with: - rust-version: "1.78.0" + rust-version: "1.79.0" + - name: Install OpenSSL + run: sudo apt-get install -y libssl-dev - name: Install nightly Rust run: rustup install nightly - name: Check with minimal-versions diff --git a/Cargo.lock b/Cargo.lock index 0dfa027..5a468f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 4 +version = 3 [[package]] name = "addr2line" @@ -18,10 +18,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" [[package]] -name = "allocator-api2" -version = "0.2.20" +name = "aho-corasick" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45862d1c77f2228b9e10bc609d5bc203d86ebc9b87ad8d5d5167a6c9abf739d9" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] [[package]] name = "anstream" @@ -72,6 +96,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "async-lock" version = "3.4.0" @@ -135,15 +165,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" +checksum = "f34d93e62b03caf570cccc334cbc6c2fceca82f39211051345108adcba3eebdc" dependencies = [ "shlex", ] @@ -155,10 +185,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "clap" -version = "4.5.21" +name = "chrono" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "clap" +version = "4.5.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69371e34337c4c984bbe322360c2547210bf632eb2814bbe78a6e87a2935bd2b" dependencies = [ "clap_builder", "clap_derive", @@ -166,9 +210,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.21" +version = "4.5.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec" +checksum = "6e24c1b4099818523236a8ca881d2b45db98dadfb4625cf6608c12069fcbbde1" dependencies = [ "anstream", "anstyle", @@ -209,6 +253,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crossbeam-channel" version = "0.5.13" @@ -247,23 +297,30 @@ name = "dnst" version = "0.1.0" dependencies = [ "bytes", + "chrono", "clap", "domain", + "lazy_static", "lexopt", "octseq", + "regex", "ring", "tempfile", "test_bin", + "tokio", ] [[package]] name = "domain" version = "0.10.3" -source = "git+https://github.com/NLnetLabs/domain?branch=sortedrecords-zonemd-remove-replace-plus-pr444#39e1c9d10bd9205b59abaa012795c7062f9b9d3f" +source = "git+https://github.com/NLnetLabs/domain?branch=sortedrecords-zonemd-remove-replace-plus-pr444#7aac5ed30141a9fb53c63a27a148104d2821d5ae" dependencies = [ + "arc-swap", "bytes", + "chrono", "futures-util", "hashbrown", + "libc", "moka", "octseq", "openssl", @@ -273,19 +330,23 @@ dependencies = [ "rustversion", "secrecy", "serde", + "siphasher", + "smallvec", "time", "tokio", + "tokio-stream", "tracing", + "tracing-subscriber", ] [[package]] name = "errno" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -301,9 +362,9 @@ dependencies = [ [[package]] name = "event-listener-strategy" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" dependencies = [ "event-listener", "pin-project-lite", @@ -400,10 +461,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "hermit-abi" -version = "0.3.9" +name = "iana-time-zone" +version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] [[package]] name = "is_terminal_polyfill" @@ -413,13 +491,20 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "js-sys" -version = "0.3.72" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +checksum = "a865e038f7f6ed956f788f0d7d60c541fff74c7bd74272c5d4cf15c63743e705" dependencies = [ + "once_cell", "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "lexopt" version = "0.3.0" @@ -428,9 +513,9 @@ checksum = "baff4b617f7df3d896f97fe922b64817f6cd9a756bb81d40f8883f2f66dcb401" [[package]] name = "libc" -version = "0.2.164" +version = "0.2.167" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" +checksum = "09d6582e104315a817dff97f75133544b2e094ee22447d2acf4a74e189ba06fc" [[package]] name = "linux-raw-sys" @@ -454,6 +539,15 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "memchr" version = "2.7.4" @@ -471,11 +565,10 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ - "hermit-abi", "libc", "wasi", "windows-sys 0.52.0", @@ -505,12 +598,31 @@ dependencies = [ "uuid", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.36.5" @@ -528,6 +640,7 @@ checksum = "126c3ca37c9c44cec575247f43a3e4374d8927684f129d2beeb0d2cef262fe12" dependencies = [ "bytes", "serde", + "smallvec", ] [[package]] @@ -574,6 +687,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking" version = "2.2.1" @@ -717,6 +836,50 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "ring" version = "0.17.8" @@ -807,12 +970,27 @@ dependencies = [ "syn", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.9" @@ -830,9 +1008,9 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", "windows-sys 0.52.0", @@ -852,9 +1030,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.89" +version = "2.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" dependencies = [ "proc-macro2", "quote", @@ -907,10 +1085,20 @@ dependencies = [ ] [[package]] -name = "time" -version = "0.3.36" +name = "thread_local" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "time" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", "num-conv", @@ -927,9 +1115,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "tokio" -version = "1.41.1" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", "bytes", @@ -952,6 +1140,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-stream" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tracing" version = "0.1.41" @@ -981,6 +1180,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1016,6 +1245,12 @@ dependencies = [ "getrandom", ] +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1030,9 +1265,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +checksum = "d15e63b4482863c109d70a7b8706c1e364eb6ea449b201a76c5b89cedcec2d5c" dependencies = [ "cfg-if", "once_cell", @@ -1041,9 +1276,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +checksum = "8d36ef12e3aaca16ddd3f67922bc63e48e953f126de60bd33ccc0101ef9998cd" dependencies = [ "bumpalo", "log", @@ -1056,9 +1291,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +checksum = "705440e08b42d3e4b36de7d66c944be628d579796b8090bfa3471478a2260051" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1066,9 +1301,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +checksum = "98c9ae5a76e46f4deecd0f0255cc223cfa18dc9b261213b8aa0c7b36f61b3f1d" dependencies = [ "proc-macro2", "quote", @@ -1079,15 +1314,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +checksum = "6ee99da9c5ba11bd675621338ef6fa52296b76b83305e9b6e5c77d4c286d6d49" [[package]] name = "web-sys" -version = "0.3.72" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +checksum = "a98bc3c33f0fe7e59ad7cd041b89034fa82a7c2d4365ca538dda6cdaf513863c" dependencies = [ "js-sys", "wasm-bindgen", @@ -1115,6 +1350,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 43926d5..e159380 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,19 +3,33 @@ name = "dnst" version = "0.1.0" edition = "2021" default-run = "dnst" +rust-version = "1.79" [[bin]] name = "ldns" path = "src/bin/ldns.rs" +[features] +default = ["openssl", "ring"] + +# Cryptographic backends +openssl = ["domain/openssl"] +ring = ["domain/ring"] + [dependencies] bytes = { version = "1.1", default-features = false } +chrono = "0.4.38" clap = { version = "4.3.4", features = ["cargo", "derive"] } domain = { git = "https://github.com/NLnetLabs/domain", branch = "sortedrecords-zonemd-remove-replace-plus-pr444", features = [ #domain = { path = "../domain2", features = [ "bytes", + "net", "openssl", + "resolv", "ring", + "tokio-stream", + "tsig", + "unstable-client-transport", "unstable-sign", "unstable-validate", "unstable-validator", @@ -25,7 +39,17 @@ domain = { git = "https://github.com/NLnetLabs/domain", branch = "sortedrecords- lexopt = "0.3.0" octseq = "0.5.2" ring = "0.17.8" +tokio = "1.40.0" + +# This is a workaround. lazy_static 1.0.0 fails to compile, but sharded-slab +# still uses it. And sharded-slab is used by tracing-subscriber, which is +# used by domain, which is used by us. +_unused_lazy_static = { package = "lazy_static", version = "1.0.2" } [dev-dependencies] test_bin = "0.4.0" tempfile = "3.14.0" +regex = "1.11.1" +domain = { version = "*", git = "https://github.com/NLnetLabs/domain.git", branch = "sortedrecords-zonemd-remove-replace-plus-pr444", features = [ + "unstable-stelline", +] } diff --git a/dsset-example.com. b/dsset-example.com. new file mode 100644 index 0000000..66b227b --- /dev/null +++ b/dsset-example.com. @@ -0,0 +1 @@ +example.com. IN DS 23561 13 2 3A574940D1CAD2532DEF4DEBB31D22D971CA762C6AA345D40A914751 29C42570 diff --git a/ldns-signzone b/ldns-signzone new file mode 120000 index 0000000..a780d58 --- /dev/null +++ b/ldns-signzone @@ -0,0 +1 @@ +target/release/dnst \ No newline at end of file diff --git a/src/args.rs b/src/args.rs index 7c868b5..1b1cbaf 100644 --- a/src/args.rs +++ b/src/args.rs @@ -3,7 +3,9 @@ use crate::env::Env; use super::commands::Command; use super::error::Error; -#[derive(Clone, Debug, clap::Parser)] +use clap::Parser; + +#[derive(Clone, Debug, Parser)] #[command(version, disable_help_subcommand = true)] pub struct Args { #[command(subcommand)] diff --git a/src/commands/key2ds.rs b/src/commands/key2ds.rs index 03383f4..d8cb55f 100644 --- a/src/commands/key2ds.rs +++ b/src/commands/key2ds.rs @@ -252,9 +252,9 @@ mod test { use super::Key2ds; #[track_caller] - fn parse(args: FakeCmd) -> Key2ds { - let res = args.parse(); - let Command::Key2ds(x) = res.unwrap().command else { + fn parse(cmd: FakeCmd) -> Key2ds { + let res = cmd.parse().unwrap(); + let Command::Key2ds(x) = res.command else { panic!("Not a Key2ds!"); }; x diff --git a/src/commands/keygen.rs b/src/commands/keygen.rs new file mode 100644 index 0000000..ddf5b76 --- /dev/null +++ b/src/commands/keygen.rs @@ -0,0 +1,644 @@ +use std::ffi::OsString; +use std::io::Write; +use std::path::Path; + +use clap::builder::ValueParser; +use clap::ValueEnum; +use domain::base::iana::{DigestAlg, SecAlg}; +use domain::base::name::Name; +use domain::base::zonefile_fmt::ZonefileFmt; +use domain::sign::{common, GenerateParams}; +use domain::validate::Key; +use lexopt::Arg; + +use crate::env::Env; +use crate::error::{Context, Error}; +use crate::parse::parse_name; +use crate::{util, Args}; + +use super::{parse_os, parse_os_with, Command, LdnsCommand}; + +#[derive(Clone, Debug, PartialEq, Eq, clap::Args)] +pub struct Keygen { + /// The signature algorithm to generate for + /// + /// Possible values: + /// - RSASHA256[:]: An RSA SHA-256 key (algorithm 8) of the given size (default 2048) + /// - ECDSAP256SHA256: An ECDSA P-256 SHA-256 key (algorithm 13) + /// - ECDSAP384SHA384: An ECDSA P-384 SHA-384 key (algorithm 14) + /// - ED25519: An Ed25519 key (algorithm 15) + /// - ED448: An Ed448 key (algorithm 16) + #[allow(rustdoc::invalid_html_tags)] + #[arg( + short = 'a', + long = "algorithm", + value_name = "algorithm", + value_parser = ValueParser::new(Keygen::parse_algorithm), + verbatim_doc_comment, + )] + algorithm: GenerateParams, + + /// Generate a key signing key instead of a zone signing key + #[arg(short = 'k')] + make_ksk: bool, + + /// Whether to create symlinks. + // + // We want to allow '-s' / '--symlink' to mean 'Symlink::Yes' for convenience. + // Clap supports this through 'default_missing_value', but it also requires + // 'num_args' and 'require_equals' to be explicitly set. + // + // In the end, this can be used as: + // - '-s=no' / '--symlink=no': Symlink::No (also the default) + // - '-s' / '--symlink': Symlink::Yes (convenient form) + // - '-s=yes' / '--symlink=yes': Symlink::Yes + // - '-s=force' / '--symlink=force': Symlink::Force + #[arg( + short = 's', + long = "symlink", + value_enum, + value_name = "how", + num_args = 0..=1, + require_equals = true, + default_missing_value = "yes", + default_value = "no", + )] + symlink: SymlinkArg, + + /// The domain name to generate a key for + #[arg(value_name = "domain", value_parser = ValueParser::new(parse_name))] + name: Name>, +} + +/// Symlinking behaviour. +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +pub enum SymlinkArg { + /// Don't create symlinks. + No, + + /// Create symlinks, but don't overwrite existing ones. + Yes, + + /// Create symlinks, overwriting existing ones. + Force, +} + +impl SymlinkArg { + /// Whether symlinks should be created. + pub fn create(&self) -> bool { + matches!(self, Self::Yes | Self::Force) + } + + /// Whether symlinks should be forced. + pub fn force(&self) -> bool { + matches!(self, Self::Force) + } +} + +const LDNS_HELP: &str = "\ +ldns-keygen -a [-b bits] [-r /dev/random] [-s] [-f] [-v] domain + generate a new key pair for domain + -a use the specified algorithm (-a list to show a list) + -k set the flags to 257; key signing key + -b specify the keylength (only used for RSA keys) + -r randomness device (unused) + -s create additional symlinks with constant names + -f force override of existing symlinks + -v show the version and exit + The following files will be created: + K++.key Public key in RR format + K++.private Private key in key format + K++.ds DS in RR format (only for DNSSEC KSK keys) + The base name (K++) will be printed to stdout +"; + +const LDNS_ALGS_HELP: &str = "\ +Supported algorithms: +- RSASHA256 (8) +- ECDSAP256SHA256 (13) +- ECDSAP384SHA384 (14) +- ED25519 (15) +- ED448 (16)\ +"; + +impl LdnsCommand for Keygen { + const NAME: &'static str = "keygen"; + const HELP: &'static str = LDNS_HELP; + const COMPATIBLE_VERSION: &'static str = "1.8.4"; + + fn parse_ldns>(args: I) -> Result { + let mut algorithm = None; + let mut make_ksk = false; + let mut bits = 2048; + let mut create_symlinks = false; + let mut force_symlinks = false; + let mut name = None; + + let mut parser = lexopt::Parser::from_args(args); + + while let Some(arg) = parser.next()? { + match arg { + Arg::Short('a') => { + if algorithm.is_some() { + return Err("cannot specify algorithm (-a) more than once".into()); + } + + let value = parser.value()?; + + if value == "list" { + return Ok(Args::from(Command::Report(LDNS_ALGS_HELP.into()))); + } + + algorithm = parse_os_with("algorithm (-a)", &value, |s| { + Ok(match s { + "RSASHA256" | "8" => Some(SecAlg::RSASHA256), + "ECDSAP256SHA256" | "13" => Some(SecAlg::ECDSAP256SHA256), + "ECDSAP384SHA384" | "14" => Some(SecAlg::ECDSAP384SHA384), + "ED25519" | "15" => Some(SecAlg::ED25519), + "ED448" | "16" => Some(SecAlg::ED448), + + _ => { + return Err("unknown algorithm mnemonic or number"); + } + }) + })?; + } + + Arg::Short('k') => { + // NOTE: '-k' can be repeated, to no effect. + make_ksk = true; + } + + Arg::Short('b') => { + // NOTE: '-b' can be repeated; the last instance wins. + bits = parse_os("bits (-b)", &parser.value()?)?; + } + + Arg::Short('r') => { + // We don't support '-r', people could rely on it for deterministic output. + return Err("a custom source of randomness (-r) is not supported".into()); + } + + Arg::Short('s') => { + // NOTE: '-s' can be repeated, to no effect. + create_symlinks = true; + } + + Arg::Short('f') => { + // NOTE: '-f' can be repeated, to no effect. + force_symlinks = true; + } + + Arg::Short('v') => { + return Ok(Self::report_version()); + } + + Arg::Value(value) => { + if name.is_some() { + return Err("cannot specify multiple domain names".into()); + } + + name = Some(parse_os("domain name", &value)?); + } + + Arg::Short(x) => return Err(format!("Invalid short option: -{x}").into()), + Arg::Long(x) => { + return Err(format!("Long options are not supported, but `--{x}` given").into()) + } + } + } + + let algorithm = match algorithm { + Some(SecAlg::RSASHA256) => GenerateParams::RsaSha256 { bits }, + Some(SecAlg::ECDSAP256SHA256) => GenerateParams::EcdsaP256Sha256, + Some(SecAlg::ECDSAP384SHA384) => GenerateParams::EcdsaP384Sha384, + Some(SecAlg::ED25519) => GenerateParams::Ed25519, + Some(SecAlg::ED448) => GenerateParams::Ed448, + Some(_) => unreachable!(), + None => { + return Err("Missing algorithm (-a) option".into()); + } + }; + + let symlink = match (create_symlinks, force_symlinks) { + (true, true) => SymlinkArg::Force, + (true, false) => SymlinkArg::Yes, + // If only '-f' is specified, no symlinking is done. + (false, _) => SymlinkArg::No, + }; + + let Some(name) = name else { + return Err("Missing domain name argument".into()); + }; + + Ok(Command::Keygen(Self { + algorithm, + make_ksk, + symlink, + name, + }) + .into()) + } +} + +impl From for Command { + fn from(value: Keygen) -> Self { + Self::Keygen(value) + } +} + +impl Keygen { + fn parse_algorithm(value: &str) -> Result { + match value { + "RSASHA256" => return Ok(GenerateParams::RsaSha256 { bits: 2048 }), + "ECDSAP256SHA256" => return Ok(GenerateParams::EcdsaP256Sha256), + "ECDSAP384SHA384" => return Ok(GenerateParams::EcdsaP384Sha384), + "ED25519" => return Ok(GenerateParams::Ed25519), + "ED448" => return Ok(GenerateParams::Ed448), + _ => {} + } + + // TODO: Remove attrs when more RSA algorithms are added. + #[allow(clippy::collapsible_match)] + if let Some((name, params)) = value.split_once(':') { + #[allow(clippy::single_match)] + match name { + "RSASHA256" => { + let bits: u32 = params.parse().map_err(|err| { + clap::Error::raw( + clap::error::ErrorKind::InvalidValue, + format!("invalid RSA key size '{params}': {err}"), + ) + })?; + return Ok(GenerateParams::RsaSha256 { bits }); + } + _ => {} + } + } + + Err(clap::Error::raw( + clap::error::ErrorKind::InvalidValue, + format!("unrecognized algorithm '{value}'"), + )) + } + + pub fn execute(self, env: impl Env) -> Result<(), Error> { + let mut stdout = env.stdout(); + + let params = self.algorithm; + + // The digest algorithm is selected based on the key algorithm. + let digest_alg = match params.algorithm() { + SecAlg::RSASHA256 => DigestAlg::SHA256, + SecAlg::ECDSAP256SHA256 => DigestAlg::SHA256, + SecAlg::ECDSAP384SHA384 => DigestAlg::SHA384, + SecAlg::ED25519 => DigestAlg::SHA256, + SecAlg::ED448 => DigestAlg::SHA256, + _ => unreachable!(), + }; + + // Generate the key. + // TODO: Attempt repeated generation to avoid key tag collisions. + let (secret_key, public_key) = common::generate(params) + .map_err(|err| format!("an implementation error occurred: {err}").into()) + .context("generating a cryptographic keypair")?; + // TODO: Add a high-level operation in 'domain' to select flags? + let flags = if self.make_ksk { 257 } else { 256 }; + let public_key = Key::new(self.name.clone(), flags, public_key); + let digest = self.make_ksk.then(|| { + public_key + .digest(digest_alg) + .expect("only supported digest algorithms are used") + }); + + let base = format!( + "K{}+{:03}+{:05}", + self.name.fmt_with_dot(), + public_key.algorithm().to_int(), + public_key.key_tag() + ); + + let secret_key_path = format!("{base}.private"); + let public_key_path = format!("{base}.key"); + let digest_file_path = self.make_ksk.then(|| format!("{base}.ds")); + + let mut secret_key_file = util::create_new_file(&env, &secret_key_path)?; + let mut public_key_file = util::create_new_file(&env, &public_key_path)?; + let mut digest_file = digest_file_path + .as_ref() + .map(|digest_file_path| util::create_new_file(&env, digest_file_path)) + .transpose()?; + + Self::symlink(&secret_key_path, ".private", self.symlink, &env)?; + Self::symlink(&public_key_path, ".key", self.symlink, &env)?; + if let Some(digest_file_path) = &digest_file_path { + Self::symlink(digest_file_path, ".ds", self.symlink, &env)?; + } + + // Prepare the contents to write. + let secret_key = secret_key.display_as_bind().to_string(); + let public_key = public_key.display_as_bind().to_string(); + let digest = digest.map(|digest| { + format!( + "{} IN DS {}\n", + self.name.fmt_with_dot(), + digest.display_zonefile(false, true) + ) + }); + + // Write the key files. + secret_key_file + .write_all(secret_key.as_bytes()) + .map_err(|err| { + format!("error while writing private key file '{base}.private': {err}") + })?; + public_key_file + .write_all(public_key.as_bytes()) + .map_err(|err| format!("error while writing public key file '{base}.key': {err}"))?; + if let Some(digest_file) = digest_file.as_mut() { + digest_file + .write_all(digest.unwrap().as_bytes()) + .map_err(|err| format!("error while writing digest file '{base}.ds': {err}"))?; + } + + // Let the user know what the base name of the files is. + writeln!(stdout, "{}", base); + + Ok(()) + } + + /// Create a symlink to the given location. + fn symlink( + target: impl AsRef, + link: impl AsRef, + how: SymlinkArg, + env: &impl Env, + ) -> Result<(), Error> { + #[cfg(unix)] + match how { + SymlinkArg::No => Ok(()), + SymlinkArg::Yes => util::symlink(env, target, link), + SymlinkArg::Force => util::symlink_force(env, target, link), + } + + #[cfg(not(unix))] + if how.create() { + let _ = (target, link, env); + Err("Symlinks can only be created on Unix platforms".into()) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +mod test { + use domain::sign::GenerateParams; + use regex::Regex; + + use crate::commands::Command; + use crate::env::fake::FakeCmd; + + use super::{Keygen, SymlinkArg}; + + #[track_caller] + fn parse(args: FakeCmd) -> Keygen { + let res = args.parse(); + let Command::Keygen(x) = res.unwrap().command else { + panic!("Not a Keygen!"); + }; + x + } + + #[test] + fn dnst_parse() { + let cmd = FakeCmd::new(["dnst", "keygen"]); + + // Algorithm and domain name are needed. + let _ = cmd.parse().unwrap_err(); + + // Multiple domain names cannot be provided. + let _ = cmd + .args(["foo.example.org", "bar.example.org"]) + .parse() + .unwrap_err(); + + let base = Keygen { + algorithm: GenerateParams::Ed25519, + make_ksk: false, + symlink: SymlinkArg::No, + name: "example.org".parse().unwrap(), + }; + + // The simplest invocation. + assert_eq!(parse(cmd.args(["-a", "ED25519", "example.org"])), base); + + // Test 'algorithm': + // - RSA-SHA256 uses 2048 bits by default. + assert_eq!( + parse(cmd.args(["-a", "RSASHA256", "example.org"])), + Keygen { + algorithm: GenerateParams::RsaSha256 { bits: 2048 }, + ..base.clone() + } + ); + // - RSA-SHA256 accepts other key sizes. + assert_eq!( + parse(cmd.args(["-a", "RSASHA256:1024", "example.org"])), + Keygen { + algorithm: GenerateParams::RsaSha256 { bits: 1024 }, + ..base.clone() + } + ); + + // Test 'make_ksk': + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-k", "example.org"])), + Keygen { + make_ksk: true, + ..base.clone() + } + ); + + // Test 'symlink': + // - Symlinks can be disabled. + for symlink in ["-s=no", "--symlink=no"] { + assert_eq!( + parse(cmd.args(["-a", "ED25519", symlink, "example.org"])), + Keygen { + symlink: SymlinkArg::No, + ..base.clone() + } + ); + } + // - Symlinks can be enabled. + for symlink in ["-s", "-s=yes", "--symlink", "--symlink=yes"] { + assert_eq!( + parse(cmd.args(["-a", "ED25519", symlink, "example.org"])), + Keygen { + symlink: SymlinkArg::Yes, + ..base.clone() + } + ); + } + // - Symlinks can be enabled with overwriting. + for symlink in ["-s=force", "--symlink=force"] { + assert_eq!( + parse(cmd.args(["-a", "ED25519", symlink, "example.org"])), + Keygen { + symlink: SymlinkArg::Force, + ..base.clone() + } + ); + } + + // Test 'name': + // - Domain names can have a trailing dot. + assert_eq!(parse(cmd.args(["-a", "ED25519", "example.org."])), base); + } + + #[test] + fn ldns_parse() { + let cmd = FakeCmd::new(["ldns-keygen"]); + + // Algorithm and domain name are needed. + let _ = cmd.parse().unwrap_err(); + + // Multiple domain names cannot be provided. + let _ = cmd + .args(["foo.example.org", "bar.example.org"]) + .parse() + .unwrap_err(); + + let base = Keygen { + algorithm: GenerateParams::Ed25519, + make_ksk: false, + symlink: SymlinkArg::No, + name: "example.org".parse().unwrap(), + }; + + // The simplest invocation. + assert_eq!(parse(cmd.args(["-a", "ED25519", "example.org"])), base); + + // Test 'algorithm': + // - RSA-SHA256 uses 2048 bits by default. + assert_eq!( + parse(cmd.args(["-a", "RSASHA256", "example.org"])), + Keygen { + algorithm: GenerateParams::RsaSha256 { bits: 2048 }, + ..base.clone() + } + ); + // - RSA-SHA256 accepts other key sizes. + assert_eq!( + parse(cmd.args(["-a", "RSASHA256", "-b", "1024", "example.org"])), + Keygen { + algorithm: GenerateParams::RsaSha256 { bits: 1024 }, + ..base.clone() + } + ); + + // Test 'make_ksk': + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-k", "example.org"])), + Keygen { + make_ksk: true, + ..base.clone() + } + ); + + // Test 'symlink': + // - Symlinks can be enabled. + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-s", "example.org"])), + Keygen { + symlink: SymlinkArg::Yes, + ..base.clone() + } + ); + // - Symlinks can be enabled with overwriting. + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-s", "-f", "example.org"])), + Keygen { + symlink: SymlinkArg::Force, + ..base.clone() + } + ); + // - '-f' without '-s' does not enable symlinks. + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-f", "example.org"])), + Keygen { + symlink: SymlinkArg::No, + ..base.clone() + } + ); + + // Test 'name': + // - Domain names can have a trailing dot. + assert_eq!(parse(cmd.args(["-a", "ED25519", "example.org."])), base); + } + + #[test] + fn simple() { + let dir = tempfile::TempDir::new().unwrap(); + let res = FakeCmd::new(["dnst", "keygen", "-a", "ED25519", "example.org"]) + .cwd(&dir) + .run(); + + let name_regex = Regex::new(r"^Kexample\.org\.\+015\+[0-9]{5}$").unwrap(); + let public_key_regex = + Regex::new(r"^example.org. IN DNSKEY 256 3 15 [A-Za-z0-9/+=]+").unwrap(); + let secret_key_regex = Regex::new( + r"^Private-key-format: v1\.2\nAlgorithm: 15 \(ED25519\)\nPrivateKey: [A-Za-z0-9/+=]+\n$", + ) + .unwrap(); + + assert_eq!(res.exit_code, 0, "{res:?}"); + assert_eq!(res.stderr, ""); + + let name = res.stdout.trim(); + assert!(name_regex.is_match(name)); + + let public_key = std::fs::read_to_string(dir.path().join(format!("{name}.key"))).unwrap(); + assert!(public_key_regex.is_match(&public_key)); + + // The digest file must not be created. + assert!(!dir.path().join("{name}.ds").try_exists().unwrap()); + + let secret_key = + std::fs::read_to_string(dir.path().join(format!("{name}.private"))).unwrap(); + assert!(secret_key_regex.is_match(&secret_key)); + } + + #[test] + fn simple_ksk() { + let dir = tempfile::TempDir::new().unwrap(); + let res = FakeCmd::new(["dnst", "keygen", "-k", "-a", "ED25519", "example.org"]) + .cwd(&dir) + .run(); + + let name_regex = Regex::new(r"^Kexample\.org\.\+015\+[0-9]{5}$").unwrap(); + let public_key_regex = + Regex::new(r"^example.org. IN DNSKEY 257 3 15 [A-Za-z0-9/+=]+").unwrap(); + let digest_key_regex = + Regex::new(r"^example.org. IN DS [0-9]+ 15 2 [0-9a-fA-F]+\n$").unwrap(); + + assert_eq!(res.exit_code, 0, "{res:?}"); + assert_eq!(res.stderr, ""); + + let name = res.stdout.trim(); + assert!(name_regex.is_match(name)); + + let public_key = std::fs::read_to_string(dir.path().join(format!("{name}.key"))).unwrap(); + assert!(public_key_regex.is_match(&public_key)); + + let digest_key = std::fs::read_to_string(dir.path().join(format!("{name}.ds"))).unwrap(); + assert!(digest_key_regex.is_match(&digest_key)); + + assert!(dir + .path() + .join(format!("{name}.private")) + .try_exists() + .unwrap()); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index cefb98e..033405a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,22 +1,52 @@ //! The command of _dnst_. pub mod help; pub mod key2ds; +pub mod keygen; +pub mod notify; pub mod nsec3hash; pub mod signzone; - -use std::ffi::{OsStr, OsString}; -use std::str::FromStr; +pub mod update; use clap::crate_version; +use std::ffi::{OsStr, OsString}; +use std::str::FromStr; use crate::env::Env; use crate::Args; use super::error::Error; +#[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, clap::Subcommand)] pub enum Command { - /// Prints the NSEC3 hash of a given domain name + /// Generate a new key pair for a given domain name + /// + /// The following files will be created: + /// + /// - K++.key: The public key file + /// + /// This is a DNSKEY resource record in zone file format. + /// + /// - K++.private: The private key file + /// + /// This is a text file in the conventional BIND format which + /// contains fields describing the private key data. + /// + /// - K++.ds: The public key digest file + /// + /// This is a DS resource record in zone file format. + /// It is only created for key signing keys. + /// + /// is the fully-qualified owner name for the key (with a trailing dot). + /// is the algorithm number of the key, zero-padded to 3 digits. + /// is the 16-bit tag of the key, zero-padded to 5 digits. + /// + /// Upon completion, 'K++' will be printed. + #[allow(rustdoc::invalid_html_tags)] + #[command(name = "keygen", verbatim_doc_comment)] + Keygen(self::keygen::Keygen), + + /// Print the NSEC3 hash of a given domain name #[command(name = "nsec3-hash")] Nsec3Hash(self::nsec3hash::Nsec3Hash), @@ -24,6 +54,14 @@ pub enum Command { #[command(name = "signzone")] SignZone(self::signzone::SignZone), + /// Send a NOTIFY packet to DNS servers + /// + /// This tells them that an updated zone is available at the primaries. It can perform TSIG + /// signatures and it can add a SOA serial number of the updated zone. If a server already has + /// that serial number it will disregard the message. + #[command(name = "notify")] + Notify(self::notify::Notify), + /// Generate a DS RR from the DNSKEYS in keyfile /// /// The following file will be created for each key: @@ -32,6 +70,10 @@ pub enum Command { #[command(name = "key2ds")] Key2ds(key2ds::Key2ds), + /// Send an UPDATE packet + #[command(name = "update")] + Update(self::update::Update), + /// Show the manual pages Help(self::help::Help), @@ -47,8 +89,11 @@ impl Command { pub fn execute(self, env: impl Env) -> Result<(), Error> { match self { Self::Key2ds(key2ds) => key2ds.execute(env), + Self::Keygen(keygen) => keygen.execute(env), Self::Nsec3Hash(nsec3hash) => nsec3hash.execute(env), + Self::Notify(notify) => notify.execute(env), Self::SignZone(signzone) => signzone.execute(env), + Self::Update(update) => update.execute(env), Self::Help(help) => help.execute(), Self::Report(s) => { writeln!(env.stdout(), "{s}"); @@ -74,7 +119,11 @@ pub trait LdnsCommand { fn parse_ldns>(args: I) -> Result; fn parse_ldns_args>(args: I) -> Result { - Self::parse_ldns(args).map_err(|e| format!("Error: {e}\n\n{}", Self::HELP).into()) + Self::parse_ldns(args).map_err(|e| format!("{e}\n\n{}", Self::HELP).into()) + } + + fn report_help() -> Args { + Args::from(Command::Report(Self::HELP.into())) } fn report_version() -> Args { diff --git a/src/commands/notify.rs b/src/commands/notify.rs new file mode 100644 index 0000000..d9ac3e9 --- /dev/null +++ b/src/commands/notify.rs @@ -0,0 +1,586 @@ +use std::net::SocketAddr; +use std::str::FromStr; + +use chrono::Local; +use domain::base::iana::{Class, Opcode}; +use domain::base::{Message, MessageBuilder, Name, Question, Record, Rtype, Serial, Ttl}; +use domain::net::client::request::{RequestMessage, SendRequest}; +use domain::net::client::{dgram, tsig}; +use domain::rdata::Soa; +use domain::tsig::Key; +use domain::utils::base16; +use lexopt::Arg; + +use crate::env::Env; +use crate::error::Error; +use crate::parse::TSigInfo; +use crate::Args; + +use super::{parse_os, Command, LdnsCommand}; + +#[derive(Clone, Debug, clap::Args, PartialEq, Eq)] +pub struct Notify { + /// The zone + #[arg(short = 'z', long = "zone", required = true)] + zone: Name>, + + // The -I option is supported by ldns but is not available in domain yet. + // It requires creating a connection from a UdpSocket (or similar). + // /// Source address to query from + // #[arg(short = 'I', required = false)] + // source_address: (), + // + /// SOA version number to include + #[arg(short = 's', long = "soa")] + soa_version: Option, + + /// A base64 tsig key and optional algorithm to include + #[arg(short = 'y', long = "tsig", value_name = "name:key[:algo]")] + tsig: Option, + + /// Port to use to send the packet + #[arg(short = 'p', long = "port", default_value = "53")] + port: u16, + + /// Print debug information + #[arg(short = 'd', long = "debug")] + debug: bool, + + /// Max number of retries + #[arg(short = 'r', long = "retries", default_value = "15")] + retries: u8, + + /// DNS servers to send packet to + #[arg(required = true)] + servers: Vec, +} + +const LDNS_HELP: &str = "\ +usage: ldns-notify [other options] -z zone +Ldns notify utility + + Supported options: + -z zone The zone + -I
source address to query from (currently unsupported) + -s version SOA version number to include + -y specify named base64 tsig key, and optional an + algorithm (defaults to hmac-md5.sig-alg.reg.int) + -p port port to use to send to + -v Print version information + -d Print verbose debug information + -r num max number of retries (15) + -h Print this help information + +Report bugs to +"; + +impl LdnsCommand for Notify { + const NAME: &'static str = "notify"; + const HELP: &'static str = LDNS_HELP; + const COMPATIBLE_VERSION: &'static str = "1.8.4"; + + fn parse_ldns>(args: I) -> Result { + let mut zone = None; + let mut soa_version = None; + let mut tsig = None; + let mut port = 53; + let mut debug = false; + let mut retries = 15; + let mut servers = Vec::new(); + + let mut parser = lexopt::Parser::from_args(args); + + while let Some(arg) = parser.next()? { + match arg { + Arg::Short('z') => { + let val = parser.value()?; + zone = Some(parse_os("zone (-z)", &val)?); + } + Arg::Short('I') => return Err("The -I option is currently unsupported".into()), + Arg::Short('s') => { + let val = parser.value()?; + soa_version = Some(parse_os("soa version (-s)", &val)?); + } + Arg::Short('y') => { + let val = parser.value()?; + tsig = Some(parse_os("tsig key (-y)", &val)?); + } + Arg::Short('p') => { + let val = parser.value()?; + port = parse_os("port (-p)", &val)?; + } + Arg::Short('d') => debug = true, + Arg::Short('r') => { + let val = parser.value()?; + retries = parse_os("retries (-r)", &val)?; + } + Arg::Short('h') => return Ok(Self::report_help()), + Arg::Short('v') => return Ok(Self::report_version()), + Arg::Short(x) => return Err(format!("Invalid short option: -{x}").into()), + Arg::Long(x) => { + return Err(format!("Long options are not supported, but `--{x}` given").into()) + } + Arg::Value(x) => { + servers.push(parse_os("server", &x)?); + } + } + } + + let Some(zone) = zone else { + return Err("Missing zone name argument".into()); + }; + + if servers.is_empty() { + return Err("Missing servers".into()); + } + + Ok(Args::from(Command::Notify(Notify { + zone, + soa_version, + tsig, + port, + debug, + retries, + servers, + }))) + } +} + +impl Notify { + pub fn execute(&self, env: impl Env) -> Result<(), Error> { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(self.run(env)) + } + + async fn run(&self, env: impl Env) -> Result<(), Error> { + let mut msg = MessageBuilder::new_vec(); + + let header = msg.header_mut(); + header.set_opcode(Opcode::NOTIFY); + header.set_aa(true); + header.set_random_id(); + + let mut msg = msg.question(); + let question = Question::new(&self.zone, Rtype::SOA, Class::IN); + msg.push(question) + .map_err(|e| format!("could not create question section: {e}"))?; + + let mut msg = msg.answer(); + if let Some(soa_version) = self.soa_version { + let soa = Record::new( + &self.zone, + Class::IN, + Ttl::from_secs(3600), + Soa::new( + Name::root_vec(), + Name::root_vec(), + Serial(soa_version), + Ttl::ZERO, + Ttl::ZERO, + Ttl::ZERO, + Ttl::ZERO, + ), + ); + msg.push(soa) + .map_err(|e| format!("could not add SOA record: {e}"))?; + } + + let msg = msg.additional(); + + let tsig = self + .tsig + .as_ref() + .map(|tsig| { + Key::new(tsig.algorithm, &tsig.key, tsig.name.clone(), None, None) + .map_err(|e| format!("TSIG key is invalid: {e}")) + }) + .transpose()?; + + let msg = msg.into_message(); + + writeln!(env.stdout(), "# Sending packet:"); + writeln!(env.stdout(), "{}", msg.display_dig_style()); + + if self.debug { + writeln!(env.stdout(), "Hexdump of notify packet:"); + writeln!(env.stdout(), "{}", base16::encode_display(&msg)); + } + + let resolver = env.stub_resolver().await; + + for server in &self.servers { + writeln!(env.stdout(), "# sending to {}", server); + + // The specified server might be an IP address. In ldns, this case is + // handled by `getaddrinfo`, but we have to do it ourselves. + // We parse it as an IP address and then send it to the one socket we + // can. + if let Ok(addr) = server.parse() { + let socket = SocketAddr::new(addr, self.port); + self.notify_host(&env, socket, msg.clone(), server, &tsig) + .await; + continue; + } + + let Ok(name) = Name::>::from_str(server) else { + writeln!( + env.stderr(), + "warning: invalid domain name \"{server}\", skipping." + ); + continue; + }; + + let Ok(hosts) = resolver.lookup_host(&name).await else { + writeln!( + env.stderr(), + "warning: could not resolve host \"{name}\", skipping." + ); + continue; + }; + + if hosts.is_empty() { + writeln!( + env.stderr(), + "skipping bad address: {name}: Name or service not known" + ); + continue; + } + + for socket in hosts.port_iter(self.port) { + self.notify_host(&env, socket, msg.clone(), server, &tsig) + .await; + } + } + + Ok(()) + } + + /// Send a notify packet to a single server and print the result + async fn notify_host( + &self, + env: &impl Env, + socket: SocketAddr, + msg: Message>, + server: &str, + tsig_key: &Option, + ) { + let mut config = dgram::Config::new(); + config.set_max_retries(self.retries); + + let dgram_connection = dgram::Connection::with_config(env.dgram(socket), config); + + let connection: Box> = if let Some(k) = tsig_key { + Box::new(tsig::Connection::new(k.clone(), dgram_connection)) + } else { + Box::new(dgram_connection) + }; + + let req = RequestMessage::new(msg).unwrap(); + let mut req = connection.send_request(req); + + let time1 = Local::now(); + let res = req.get_response().await; + let time2 = Local::now(); + + match res { + Ok(msg) => { + let mut out = env.stdout(); + writeln!(out, "# reply from {server} at {socket}:"); + writeln!(out, "{}", msg.display_dig_style()); + writeln!( + out, + ";; Query time: {} msec", + (time2 - time1).num_milliseconds() + ); + writeln!(out, ";; Server: {}#{}", socket.ip(), socket.port()); + writeln!(out, ";; WHEN: {}", time1.format("%a %b %d %H:%M:%S %Z %Y")); + writeln!(out, ";; MSG SIZE rcvd: {}", msg.as_slice().len()); + } + Err(e) => { + writeln!( + env.stdout(), + "warning: reply was not received or erroneous from: {socket}: {e}" + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, Ipv6Addr}; + use std::str::FromStr; + + use domain::base::Name; + use domain::tsig::Algorithm; + use domain::utils::base64; + + use crate::commands::notify::TSigInfo; + use crate::commands::Command; + use crate::env::fake::FakeCmd; + + use super::Notify; + + #[track_caller] + fn parse(cmd: FakeCmd) -> Notify { + let res = cmd.parse().unwrap(); + let Command::Notify(x) = res.command else { + panic!("not a notify!"); + }; + x + } + + #[test] + fn dnst_parse() { + let cmd = FakeCmd::new(["dnst", "notify"]); + + cmd.parse().unwrap_err(); + cmd.args(["--zone", "example.test"]).parse().unwrap_err(); + cmd.args(["--zone=example.test"]).parse().unwrap_err(); + cmd.args(["-z", "example.test"]).parse().unwrap_err(); + cmd.args(["-zexample.test"]).parse().unwrap_err(); + + let base = Notify { + zone: Name::from_str("example.test").unwrap(), + soa_version: None, + tsig: None, + port: 53, + debug: false, + retries: 15, + servers: vec!["some.example.test".into()], + }; + + // Create a command with some arguments that we reuse for some tests + let cmd2 = cmd.args(["-z", "example.test", "some.example.test"]); + + let res = parse(cmd2.clone()); + assert_eq!(res, base); + + for arg in ["-p", "--port"] { + let res = parse(cmd2.args([arg, "10"])); + assert_eq!( + res, + Notify { + port: 10, + ..base.clone() + } + ); + } + + let res = parse(cmd2.args(["-s", "10"])); + assert_eq!( + res, + Notify { + soa_version: Some(10), + ..base.clone() + } + ); + + for arg in ["-y", "--tsig"] { + let res = parse(cmd2.args([arg, "somekey:1234"])); + assert_eq!( + res, + Notify { + tsig: Some(TSigInfo { + name: "somekey".parse().unwrap(), + key: base64::decode("1234").unwrap(), + algorithm: Algorithm::Sha256, + }), + ..base.clone() + } + ); + } + } + + #[test] + fn ldns_parse() { + let cmd = FakeCmd::new(["ldns-notify"]); + + cmd.parse().unwrap_err(); + + // Shouldn't work at all + cmd.args(["--zone", "example.test"]).parse().unwrap_err(); + cmd.args(["--zone=example.test"]).parse().unwrap_err(); + + // Missing servers + cmd.args(["-z", "example.test"]).parse().unwrap_err(); + cmd.args(["-zexample.test"]).parse().unwrap_err(); + + // Create a command with some arguments that we reuse for some tests + let cmd2 = cmd.args(["-z", "example.test", "some.example.test"]); + + // Invalid numbers + cmd2.args(["-p", "blabla"]).parse().unwrap_err(); + cmd2.args(["-r", "blabla"]).parse().unwrap_err(); + + let base = Notify { + zone: Name::from_str("example.test").unwrap(), + soa_version: None, + tsig: None, + port: 53, + debug: false, + retries: 15, + servers: vec!["some.example.test".into()], + }; + + let res = parse(cmd2.clone()); + assert_eq!(res, base); + + let res = parse(cmd2.args(["-p", "10"])); + assert_eq!( + res, + Notify { + port: 10, + ..base.clone() + } + ); + + let res = parse(cmd2.args(["-s", "10"])); + assert_eq!( + res, + Notify { + soa_version: Some(10), + ..base.clone() + } + ); + + let res = parse(cmd2.args(["-y", "somekey:1234"])); + assert_eq!( + res, + Notify { + tsig: Some(TSigInfo { + name: "somekey".parse().unwrap(), + key: base64::decode("1234").unwrap(), + algorithm: Algorithm::Sha256, + }), + ..base.clone() + } + ); + } + + #[test] + fn version() { + let res = FakeCmd::new(["ldns-notify", "-v"]).run(); + assert_eq!(res.exit_code, 0); + assert!(res.stdout.contains("ldns-notify provided by dnst v")); + assert!(res.stdout.contains("(compatible with ldns v1.8.4)")); + } + + fn entries_for_name(name: &str, v4: &[Ipv4Addr], v6: &[Ipv6Addr]) -> String { + let v4 = v4 + .iter() + .map(|a| format!("{name} IN 10 A {a}")) + .collect::>() + .join("\n"); + + let v6 = v6 + .iter() + .map(|a| format!("{name} IN 10 AAAA {a}")) + .collect::>() + .join("\n"); + + format!( + " + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR RD RA NOERROR + SECTION QUESTION + {name} IN A + SECTION ANSWER + {v4} + ENTRY_END + + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR RD RA NOERROR + SECTION QUESTION + {name} IN AAAA + SECTION ANSWER + {v6} + ENTRY_END + " + ) + } + + #[test] + fn with_zone_and_ip() { + let rpl = " + CONFIG_END + + SCENARIO_BEGIN + + RANGE_BEGIN 0 100 + + ENTRY_BEGIN + ADJUST copy_id + REPLY QR + SECTION QUESTION + nlnetlabs.test SOA + SECTION ANSWER + success.test 10 A 2.2.2.2 + ENTRY_END + + RANGE_END + + SCENARIO_END + "; + + let cmd = FakeCmd::new(["dnst", "notify", "-z", "nlnetlabs.test", "1.1.1.1"]) + .stelline(rpl.as_bytes(), "notify.rpl"); + + let res = cmd.run(); + assert_eq!(res.exit_code, 0); + assert!(res.stdout.contains("success.test")); + assert_eq!(res.stderr, ""); + } + + #[test] + fn with_zone_and_domain_name() { + let foo = entries_for_name("foo.test", &[Ipv4Addr::new(1, 2, 3, 4)], &[]); + let bar = entries_for_name("bar.test", &[], &[]); + + let rpl = format!( + " + CONFIG_END + + SCENARIO_BEGIN + + RANGE_BEGIN 0 100 + + {foo} + + {bar} + + ENTRY_BEGIN + MATCH question + ADJUST copy_id + REPLY QR + SECTION QUESTION + nlnetlabs.test SOA + SECTION ANSWER + success.test IN 10 A 2.2.2.2 + ENTRY_END + + RANGE_END + + SCENARIO_END + " + ); + + let cmd = FakeCmd::new(["dnst", "notify", "-z", "nlnetlabs.test", "foo.test"]) + .stelline(rpl.as_bytes(), "notify.rpl"); + + let res = cmd.run(); + assert!(res.stdout.contains("success.test")); + assert_eq!(res.stderr, ""); + + let cmd = FakeCmd::new(["dnst", "notify", "-z", "nlnetlabs.test", "bar.test"]) + .stelline(rpl.as_bytes(), "notify.rpl"); + + let res = cmd.run(); + assert_eq!(res.exit_code, 0); + assert!(res.stderr.contains("Name or service not known")); + } +} diff --git a/src/commands/nsec3hash.rs b/src/commands/nsec3hash.rs index bc3edbd..906472a 100644 --- a/src/commands/nsec3hash.rs +++ b/src/commands/nsec3hash.rs @@ -3,13 +3,14 @@ use std::str::FromStr; use clap::builder::ValueParser; use domain::base::iana::nsec3::Nsec3HashAlg; -use domain::base::name::{self, Name}; +use domain::base::name::Name; use domain::rdata::nsec3::Nsec3Salt; use domain::validate::nsec3_hash; use lexopt::Arg; use crate::env::Env; use crate::error::Error; +use crate::parse::parse_name; use crate::Args; use super::{parse_os, parse_os_with, Command, LdnsCommand}; @@ -47,7 +48,7 @@ pub struct Nsec3Hash { salt: Nsec3Salt>, /// The domain name to hash - #[arg(value_name = "DOMAIN NAME", value_parser = ValueParser::new(Nsec3Hash::parse_name))] + #[arg(value_name = "DOMAIN NAME", value_parser = ValueParser::new(parse_name))] name: Name>, } @@ -116,10 +117,6 @@ impl LdnsCommand for Nsec3Hash { } impl Nsec3Hash { - pub fn parse_name(arg: &str) -> Result>, name::FromStrError> { - Name::from_str(&arg.to_lowercase()) - } - // Note: This function is only necessary until // https://github.com/NLnetLabs/domain/pull/431 is merged. pub fn parse_salt(arg: &str) -> Result>, Error> { @@ -193,6 +190,7 @@ mod tests { cmd: FakeCmd::new(["unused"]), stdout: FakeStream::default(), stderr: FakeStream::default(), + stelline: None, }; // We don't test all permutations as that would take too long (~20 seconds) diff --git a/src/commands/update.rs b/src/commands/update.rs new file mode 100644 index 0000000..c7fab0a --- /dev/null +++ b/src/commands/update.rs @@ -0,0 +1,626 @@ +use std::ffi::OsString; +use std::net::{IpAddr, SocketAddr}; + +use domain::base::iana::{Class, Opcode, Rcode}; +use domain::base::{ + Message, MessageBuilder, Name, Question, Record, Rtype, ToName, Ttl, UnknownRecordData, +}; +use domain::net::client::request::{RequestMessage, SendRequest}; +use domain::net::client::{dgram, tsig}; +use domain::rdata::{Aaaa, AllRecordData, Ns, Soa, A}; +use domain::resolv::stub::conf::{ResolvConf, ServerConf, Transport}; +use domain::tsig::Key; +use domain::utils::base64; + +use crate::env::Env; +use crate::error::Error; +use crate::parse::TSigInfo; +use crate::Args; + +use super::{parse_os, parse_os_with, Command, LdnsCommand}; + +// Clap gives `Option` special handling by making the argument optional. +// This is not what we want because we require an explicit "none" value. So, +// we create an alias, so that clap doesn't recognize that we are using an +// option and pray that Ed Page doesn't make clap smart enough to figure +// this out. +type OptionIpAddr = Option; + +#[derive(Clone, Debug, clap::Args, PartialEq, Eq)] +pub struct Update { + /// Domain name to update + #[arg(value_name = "DOMAIN NAME")] + domain: Name>, + + /// IP address to associate with the given domain name. + /// Use `none` to delete the records for the domain name. + #[arg(value_name = "IP", value_parser = optional_ip)] + ip: OptionIpAddr, + + /// Zone to update + #[arg(long = "zone")] + zone: Option>>, + + /// TSIG credentials for the UPDATE packet + #[arg(short = 'y', long = "tsig", value_name = "name:key[:algo]")] + tsig: Option, +} + +fn optional_ip(s: &str) -> Result, Error> { + if s == "none" { + Ok(None) + } else { + let ip = s.parse().map_err(|_| format!("Invalid IP address: {s}"))?; + Ok(Some(ip)) + } +} + +const LDNS_HELP: &str = "\ +ldns-update domain [zone] ip tsig_name tsig_alg tsig_hmac + send a dynamic update packet to + + Use 'none' instead of ip to remove any previous address + If 'zone' is not specified, try to figure it out from the zone's SOA + Example: ldns-update my.example.org 1.2.3.4 + +This command exists for compatibility purposes. +For a more modern version of this command try `dnst update`\ +"; + +impl LdnsCommand for Update { + const NAME: &'static str = "update"; + const HELP: &'static str = LDNS_HELP; + const COMPATIBLE_VERSION: &'static str = "1.8.4"; + + fn parse_ldns>(args: I) -> Result { + let args: Vec<_> = args.into_iter().collect(); + + // We have this signature + // [ZONE] [TSIG_NAME TSIG_ALG TSIG_HMAC] + // So we parse it by splitting the cases by number of arguments: + // 1: DOMAIN IP + // 2: DOMAIN ZONE IP + // 4: DOMAIN IP TSIG_NAME TSIG_ALG TSIG_HMAC + // 5: DOMAIN ZONE IP TSIG_NAME TSIG_ALG TSIG_HMAC + let (domain, zone, ip, tsig) = match &args[..] { + [domain, ip] => (domain, None, ip, None), + [domain, zone, ip] => (domain, Some(zone), ip, None), + [domain, ip, tsig_name, tsig_key, tsig_hmac] => { + (domain, None, ip, Some((tsig_name, tsig_key, tsig_hmac))) + } + [domain, zone, ip, tsig_name, tsig_alg, tsig_hmac] => ( + domain, + Some(zone), + ip, + Some((tsig_name, tsig_alg, tsig_hmac)), + ), + _ => { + return if args.len() < 2 { + Err("Not enough arguments. ldns-update requires at least 2 arguments".into()) + } else if args.len() > 6 { + Err("Too many arguments. ldns-update requires at most 6 arguments".into()) + } else { + Err("Cannot take 4 arguments. ldns-update needs 2, 3, 5 or 6 arguments".into()) + } + } + }; + + let domain = parse_os("domain name", domain)?; + + let ip = if *ip != "none" { + Some(parse_os("IP address", ip)?) + } else { + None + }; + + let zone = match zone { + Some(z) => Some(parse_os("zone", z)?), + None => None, + }; + + Ok(Args::from(Command::Update(Self { + domain, + ip, + zone, + tsig: match tsig { + Some((name, algorithm, key)) => Some(TSigInfo { + name: parse_os("TSIG name", name)?, + key: parse_os_with("TSIG key", key, base64::decode)?, + algorithm: parse_os("TSIG algorithm", algorithm)?, + }), + None => None, + }, + }))) + } +} + +impl Update { + pub fn execute(self, env: impl Env) -> Result<(), Error> { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(self.run(&env)) + } + + /// Run the command as an async function + pub async fn run(self, env: &impl Env) -> Result<(), Error> { + writeln!( + env.stdout(), + ";; trying UPDATE with FQDN \"{}\" and IP \"{}\"", + self.domain, + self.ip.map_or("".into(), |ip| ip.to_string()) + ); + + let soa_zone; + let soa_mname; + if let Some(zone) = &self.zone { + soa_mname = self.find_mname(env, zone).await?; + soa_zone = zone.clone(); + } else { + let name = self.domain.clone(); + (soa_zone, soa_mname) = self.find_mname_and_zone(env, &name).await?; + }; + + let nsnames = self.determine_nsnames(env, &soa_zone, &soa_mname).await?; + let msg = self.create_update_message(&soa_zone); + + self.send_update(env, msg, nsnames).await + } + + /// Find the MNAME by sending a SOA query for the zone + async fn find_mname( + &self, + env: &impl Env, + zone: &Name>, + ) -> Result>, Error> { + let resolver = env.stub_resolver().await; + + let response = resolver + .query(Question::new(&zone, Rtype::SOA, Class::IN)) + .await?; + + let mut answer = response.answer()?.limit_to::>(); + if let Some(soa) = answer.next() { + Ok(soa?.data().mname().to_name()) + } else { + Err("no SOA record found".into()) + } + } + + /// Find the MNAME and zone + /// + /// This is achieved in 3 steps: + /// 1. Get the MNAME with a SOA query for the domain name + /// 2. Get the A record for the MNAME + /// 3. Send a SOA query to that IP address and use the owner as zone + /// and the MNAME from that response. + async fn find_mname_and_zone( + &self, + env: &impl Env, + name: &Name>, + ) -> Result<(Name>, Name>), Error> { + let resolver = env.stub_resolver().await; + + // Step 1 - first find a nameserver that should know *something* + let response = resolver + .query(Question::new(&name, Rtype::SOA, Class::IN)) + .await?; + + // We look in both the answer and authority sections. + // The answer section is used if the domain name is the zone apex, + // otherwise the SOA is in the authority section. + let mut sections = response + .answer()? + .limit_to_in::>() + .chain(response.authority()?.limit_to_in::>()); + + let Some(soa) = sections.next() else { + return Err("no SOA found".into()); + }; + + let soa_mname: Name> = soa?.data().mname().to_name(); + + // Step 2 - find SOA MNAME IP address, add to resolver + let response = resolver.lookup_host(&soa_mname).await?; + + let Some(ipaddr) = response.iter().next() else { + return Err("no A record found".into()); + }; + + // Step 3 - Redo SOA query, sending to SOA MNAME directly. + let mut conf = ResolvConf::new(); + conf.servers = vec![ServerConf::new( + SocketAddr::new(ipaddr, 53), + Transport::UdpTcp, + )]; + // TODO: Add the standard servers? Is that necessary or just a quirk + // of ldns. + let resolver = env.stub_resolver_from_conf(conf).await; + + let response = resolver + .query(Question::new(&name, Rtype::SOA, Class::IN)) + .await?; + + // We look in both the answer and authority sections. + // The answer section is used if the domain name is the zone apex, + // otherwise the SOA is in the authority section. + let mut sections = response + .answer()? + .limit_to_in::>() + .chain(response.authority()?.limit_to_in::>()); + + let Some(soa) = sections.next() else { + return Err("no SOA found".into()); + }; + + let soa = soa?; + + let zone = soa.owner().to_name(); + let mname = soa.data().mname().to_name(); + Ok((zone, mname)) + } + + /// Send an NS query to find all nameservers for the given zone + /// + /// The name server with the given MNAME is put at the start of the list. + async fn determine_nsnames( + &self, + env: &impl Env, + zone: &Name>, + mname: &Name>, + ) -> Result>>, Error> { + let response = env + .stub_resolver() + .await + .query(Question::new(&zone, Rtype::NS, Class::IN)) + .await?; + + let mut nsnames = response + .answer()? + .limit_to_in::>() + .map(|ns| Ok(ns?.data().nsdname().to_name::>())) + .collect::, Error>>()?; + + // The MNAME should be tried first according to RFC2136 4.3 + // so we put that NSNAME first in the list. + if let Some(mname_idx) = nsnames.iter().position(|name| name == mname) { + nsnames.swap(0, mname_idx); + } + + Ok(nsnames) + } + + /// Create the packet of the update message to send to the name servers + fn create_update_message(&self, zone: &Name>) -> Vec { + let mut message = MessageBuilder::new_vec(); + + let header = message.header_mut(); + header.set_opcode(Opcode::UPDATE); + header.set_qr(false); + + let mut zone_section = message.question(); + zone_section + .push(Question::new(zone, Rtype::SOA, Class::IN)) + .unwrap(); + + let mut update_section = zone_section.authority(); + + // If we have an IP address, remove that ip address + // else remove A and/or AAAA as defined in RFC2136 2.5.2. + if let Some(ip) = self.ip { + let rdata: AllRecordData<&[u8], Name<&[u8]>> = match ip { + IpAddr::V4(ip) => AllRecordData::A(A::new(ip)), + IpAddr::V6(ip) => AllRecordData::Aaaa(Aaaa::new(ip)), + }; + update_section + .push(Record::new( + &self.domain, + Class::IN, + Ttl::from_secs(300), + rdata, + )) + .unwrap(); + } else { + update_section + .push(Record::new( + &self.domain, + Class::ANY, + Ttl::from_secs(0), + UnknownRecordData::from_octets(Rtype::A, &[]).unwrap(), + )) + .unwrap(); + + update_section + .push(Record::new( + &self.domain, + Class::ANY, + Ttl::from_secs(0), + UnknownRecordData::from_octets(Rtype::AAAA, &[]).unwrap(), + )) + .unwrap(); + } + + update_section.finish() + } + + /// Send the update packet to the names in nsnames in order until one responds + async fn send_update( + &self, + env: impl Env, + msg: Vec, + nsnames: Vec>>, + ) -> Result<(), Error> { + let msg = Message::from_octets(msg).unwrap(); + let resolver = env.stub_resolver().await; + + let tsig_key = self + .tsig + .as_ref() + .map(|tsig| { + Key::new(tsig.algorithm, &tsig.key, tsig.name.clone(), None, None) + .map_err(|e| format!("TSIG key is invalid: {e}")) + }) + .transpose()?; + + for name in nsnames { + let found_ips = resolver.lookup_host(&name).await?; + for socket in found_ips.port_iter(53) { + let dgram_connection = dgram::Connection::new(env.dgram(socket)); + + let connection: Box> = if let Some(k) = &tsig_key { + Box::new(tsig::Connection::new(k.clone(), dgram_connection)) + } else { + Box::new(dgram_connection) + }; + + let response = connection + .send_request(RequestMessage::new(msg.clone()).unwrap()) + .get_response() + .await; + + let resp = match response { + Ok(resp) => resp, + Err(err) => { + writeln!(env.stderr(), "{name} @ {socket}: {err}"); + continue; + } + }; + + let rcode = resp.header().rcode(); + if rcode != Rcode::NOERROR { + writeln!(env.stdout(), ";; UPDATE response was {rcode}"); + } + return Ok(()); + } + } + + // Our list of nsnames has been exhausted, we can only report that + // we couldn't find anything. + writeln!(env.stdout(), ";; No responses"); + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use domain::{tsig::Algorithm, utils::base64}; + + use crate::{commands::Command, env::fake::FakeCmd}; + + use super::{TSigInfo, Update}; + + #[track_caller] + fn parse(cmd: FakeCmd) -> Update { + let res = cmd.parse().unwrap(); + let Command::Update(x) = res.command else { + panic!("Not an Update!"); + }; + x + } + + #[test] + fn dnst_parse() { + let cmd = FakeCmd::new(["dnst", "update"]); + + cmd.parse().unwrap_err(); + cmd.args(["example.test"]).parse().unwrap_err(); + cmd.args(["--zone", "example.test"]).parse().unwrap_err(); + cmd.args(["--zone", "example.test", "ns.example.test"]) + .parse() + .unwrap_err(); + cmd.args(["foo.test", "bar.test", "none"]) + .parse() + .unwrap_err(); + + let base = Update { + domain: "foo.test".parse().unwrap(), + ip: None, + zone: None, + tsig: None, + }; + + let res = parse(cmd.args(["foo.test", "none"])); + assert_eq!(res, base); + + let res = parse(cmd.args(["foo.test", "1.1.1.1"])); + assert_eq!( + res, + Update { + ip: Some("1.1.1.1".parse().unwrap()), + ..base.clone() + } + ); + + let res = parse(cmd.args(["foo.test", "1.1.1.1", "--zone", "bar.test"])); + assert_eq!( + res, + Update { + ip: Some("1.1.1.1".parse().unwrap()), + zone: Some("bar.test".parse().unwrap()), + ..base.clone() + } + ); + + let res = parse(cmd.args(["foo.test", "none", "--tsig", "somekey:1234"])); + assert_eq!( + res, + Update { + tsig: Some(TSigInfo { + name: "somekey".parse().unwrap(), + key: base64::decode("1234").unwrap(), + algorithm: Algorithm::Sha256, + }), + ..base.clone() + } + ); + } + + #[test] + fn ldns_parse() { + let cmd = FakeCmd::new(["ldns-update"]); + + let base = Update { + domain: "foo.test".parse().unwrap(), + ip: None, + zone: None, + tsig: None, + }; + + cmd.args(["foo.test"]).parse().unwrap_err(); + + let res = parse(cmd.args(["foo.test", "none"])); + assert_eq!(res, base.clone()); + + let res = parse(cmd.args(["foo.test", "1.1.1.1"])); + assert_eq!( + res, + Update { + ip: Some("1.1.1.1".parse().unwrap()), + ..base.clone() + } + ); + + let res = parse(cmd.args(["foo.test", "base.test", "1.1.1.1"])); + assert_eq!( + res, + Update { + ip: Some("1.1.1.1".parse().unwrap()), + zone: Some("base.test".parse().unwrap()), + ..base.clone() + } + ); + } + + #[test] + fn run_with_stelline() { + let rpl = " + CONFIG_END + + SCENARIO_BEGIN + + RANGE_BEGIN 0 100 + + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR + SECTION QUESTION + foo.test IN SOA + SECTION ANSWER + foo.test 0 IN SOA ns.foo.test admin.foo.test 1 1 1 1 1 + SECTION AUTHORITY + foo.test 0 IN SOA ns.foo.test admin.foo.test 1 1 1 1 1 + ENTRY_END + + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR + SECTION QUESTION + zone.foo.test IN SOA + SECTION ANSWER + zone.foo.test 0 IN SOA ns.foo.test admin.foo.test 1 1 1 1 1 + ENTRY_END + + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR + SECTION QUESTION + zone.foo.test IN NS + SECTION ANSWER + zone.foo.test IN 0 NS ns.foo.test + ENTRY_END + + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR + SECTION QUESTION + foo.test IN NS + SECTION ANSWER + foo.test IN 0 NS ns.foo.test + ENTRY_END + + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR + SECTION QUESTION + ns.foo.test IN A + SECTION ANSWER + ns.foo.test IN 0 A 12.34.56.78 + ENTRY_END + + ENTRY_BEGIN + MATCH question + ADJUST copy_id copy_query + REPLY QR + SECTION QUESTION + ns.foo.test IN AAAA + SECTION ANSWER + ENTRY_END + + ENTRY_BEGIN + MATCH question opcode + ADJUST copy_id copy_query + OPCODE UPDATE + REPLY QR + SECTION QUESTION + zone.foo.test IN SOA + SECTION ANSWER + ENTRY_END + + RANGE_END + SCENARIO_END + "; + + let cmd = FakeCmd::new([ + "dnst", + "update", + "foo.test", + "none", + "--zone", + "zone.foo.test", + ]) + .stelline(rpl.as_bytes(), "update.rpl"); + + let res = cmd.run(); + assert_eq!(res.exit_code, 0); + assert_eq!( + res.stdout, + ";; trying UPDATE with FQDN \"foo.test\" and IP \"\"\n" + ); + assert_eq!(res.stderr, ""); + + let cmd = FakeCmd::new(["dnst", "update", "foo.test", "none"]) + .stelline(rpl.as_bytes(), "update.rpl"); + + let res = cmd.run(); + assert_eq!(res.exit_code, 0); + assert_eq!( + res.stdout, + ";; trying UPDATE with FQDN \"foo.test\" and IP \"\"\n" + ); + assert_eq!(res.stderr, ""); + } +} diff --git a/src/env/fake.rs b/src/env/fake.rs index e447393..0fe4230 100644 --- a/src/env/fake.rs +++ b/src/env/fake.rs @@ -1,11 +1,20 @@ use std::borrow::Cow; use std::ffi::OsString; -use std::fmt; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Mutex; +use std::{fmt, io}; -use crate::{error::Error, parse_args, run, Args}; +use domain::net::client::dgram; +use domain::net::client::protocol::{AsyncConnect, AsyncDgramRecv, AsyncDgramSend}; +use domain::resolv::stub::conf::ResolvConf; +use domain::resolv::StubResolver; +use domain::stelline::client::CurrStepValue; +use domain::stelline::dgram::Dgram; +use domain::stelline::parse_stelline::{self, Stelline}; + +use crate::error::Error; +use crate::{parse_args, run, Args}; use super::Env; use super::Stream; @@ -19,6 +28,7 @@ pub struct FakeCmd { /// The command to run, including `argv[0]` cmd: Vec, cwd: Option, + stelline: Option, } /// The result of running a [`FakeCmd`] @@ -41,8 +51,8 @@ pub struct FakeEnv { /// The mocked stderr pub stderr: FakeStream, - // pub stelline: Option, - // pub curr_step_value: Option>, + + pub stelline: Option<(Stelline, Arc)>, } impl Env for FakeEnv { @@ -64,6 +74,37 @@ impl Env for FakeEnv { None => path.as_ref().into(), } } + + fn dgram( + &self, + _addr: std::net::SocketAddr, + ) -> impl AsyncConnect + + Clone + + Send + + Sync + + 'static { + if let Some((stelline, step)) = &self.stelline { + Dgram::new(stelline.clone(), step.clone()) + } else { + panic!("Tried making a stelline connection without setting up stelline") + } + } + + async fn stub_resolver_from_conf(&self, mut config: ResolvConf) -> StubResolver { + let Some((stelline, step)) = &self.stelline else { + panic!("Tried making a stelline connection without setting up stelline") + }; + + config.servers = vec![]; + let resolver = StubResolver::from_conf(config); + resolver + .add_connection(Box::new(dgram::Connection::new(Dgram::new( + stelline.clone(), + step.clone(), + )))) + .await; + resolver + } } impl FakeCmd { @@ -74,6 +115,7 @@ impl FakeCmd { Self { cmd: cmd.into_iter().map(Into::into).collect(), cwd: None, + stelline: None, } } @@ -84,6 +126,13 @@ impl FakeCmd { } } + pub fn stelline(&self, file: impl fmt::Debug + io::Read, name: impl ToString) -> Self { + Self { + stelline: Some(parse_stelline::parse_file(file, name)), + ..self.clone() + } + } + /// Add arguments to a clone of the [`FakeCmd`] /// /// ```rust,ignore @@ -100,10 +149,15 @@ impl FakeCmd { /// Parse the arguments of this [`FakeCmd`] and return the result pub fn parse(&self) -> Result { + debug_assert!( + self.stelline.is_none(), + "We shouldn't need Stelline for argument parsing" + ); let env = FakeEnv { cmd: self.clone(), stdout: Default::default(), stderr: Default::default(), + stelline: None, }; parse_args(env) } @@ -114,6 +168,10 @@ impl FakeCmd { cmd: self.clone(), stdout: Default::default(), stderr: Default::default(), + stelline: self + .stelline + .clone() + .map(|s| (s, Arc::new(CurrStepValue::new()))), }; let exit_code = run(&env); diff --git a/src/env/mod.rs b/src/env/mod.rs index ebccd71..8fe7b85 100644 --- a/src/env/mod.rs +++ b/src/env/mod.rs @@ -1,15 +1,18 @@ use std::borrow::Cow; use std::ffi::OsString; use std::fmt; +use std::net::SocketAddr; use std::path::Path; +use domain::net::client::protocol::{AsyncConnect, AsyncDgramRecv, AsyncDgramSend}; +use domain::resolv::{stub::conf::ResolvConf, StubResolver}; + mod real; +pub use real::RealEnv; #[cfg(test)] pub mod fake; -pub use real::RealEnv; - pub trait Env { /// Get an iterator over the command line arguments passed to the program /// @@ -26,7 +29,25 @@ pub trait Env { /// Equivalent to [`std::io::stderr`] fn stderr(&self) -> Stream; + /// Make relative paths absolute. fn in_cwd<'a>(&self, path: &'a impl AsRef) -> Cow<'a, Path>; + + fn dgram( + &self, + socket: SocketAddr, + ) -> impl AsyncConnect + + Clone + + Send + + Sync + + 'static; + + #[allow(async_fn_in_trait)] + async fn stub_resolver(&self) -> StubResolver { + self.stub_resolver_from_conf(ResolvConf::default()).await + } + + #[allow(async_fn_in_trait)] + async fn stub_resolver_from_conf(&self, config: ResolvConf) -> StubResolver; } /// A type with an infallible `write_fmt` method for use with [`write!`] macros @@ -99,4 +120,19 @@ impl Env for &E { fn in_cwd<'a>(&self, path: &'a impl AsRef) -> Cow<'a, Path> { (**self).in_cwd(path) } + + fn dgram( + &self, + socket: SocketAddr, + ) -> impl AsyncConnect + + Clone + + Send + + Sync + + 'static { + (**self).dgram(socket) + } + + async fn stub_resolver_from_conf(&self, config: ResolvConf) -> StubResolver { + (**self).stub_resolver_from_conf(config).await + } } diff --git a/src/env/real.rs b/src/env/real.rs index ad6fe15..78299eb 100644 --- a/src/env/real.rs +++ b/src/env/real.rs @@ -3,6 +3,10 @@ use std::fmt; use std::io::{self, IsTerminal}; use std::path::Path; +use domain::net::client::protocol::{AsyncConnect, AsyncDgramRecv, AsyncDgramSend, UdpConnect}; +use domain::resolv::stub::conf::ResolvConf; +use domain::resolv::StubResolver; + use super::Env; use super::Stream; @@ -25,6 +29,21 @@ impl Env for RealEnv { fn in_cwd<'a>(&self, path: &'a impl AsRef) -> std::borrow::Cow<'a, std::path::Path> { path.as_ref().into() } + + fn dgram( + &self, + addr: std::net::SocketAddr, + ) -> impl AsyncConnect + + Clone + + Send + + Sync + + 'static { + UdpConnect::new(addr) + } + + async fn stub_resolver_from_conf(&self, config: ResolvConf) -> StubResolver { + StubResolver::from_conf(config) + } } struct FmtWriter(T); diff --git a/src/error.rs b/src/error.rs index 23f875c..9ffbf90 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,10 @@ -use std::fmt::{self, Display, Write}; +use core::fmt::{Display, Write}; + +use std::fmt; use std::{error, io}; +use domain::base::wire::ParseError; + use crate::env::{Env, Stream}; //------------ Error --------------------------------------------------------- @@ -134,6 +138,12 @@ impl From for Error { } } +impl From for Error { + fn from(error: ParseError) -> Self { + Self::new(&error.to_string()) + } +} + impl From for Error { fn from(value: lexopt::Error) -> Self { value.to_string().into() @@ -231,3 +241,11 @@ impl Context for Result { self.map_err(|err| err.context(&(context)())) } } + +/// Execute the given operation under the given context. +pub fn in_context( + context: impl FnOnce() -> String, + function: impl FnOnce() -> Result, +) -> Result { + (function)().with_context(context) +} diff --git a/src/lib.rs b/src/lib.rs index 97fac4b..add3319 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,8 +3,11 @@ use std::path::Path; use clap::Parser; use commands::key2ds::Key2ds; +use commands::keygen::Keygen; +use commands::notify::Notify; use commands::nsec3hash::Nsec3Hash; use commands::signzone::SignZone; +use commands::update::Update; use commands::LdnsCommand; use env::Env; use error::Error; @@ -15,6 +18,8 @@ pub mod args; pub mod commands; pub mod env; pub mod error; +pub mod parse; +pub mod util; pub fn try_ldns_compatibility>( args: I, @@ -32,8 +37,11 @@ pub fn try_ldns_compatibility>( let res = match binary_name { "key2ds" => Key2ds::parse_ldns_args(args_iter), + "notify" => Notify::parse_ldns_args(args_iter), + "keygen" => Keygen::parse_ldns_args(args_iter), "nsec3-hash" => Nsec3Hash::parse_ldns_args(args_iter), "signzone" => SignZone::parse_ldns_args(args_iter), + "update" => Update::parse_ldns_args(args_iter), _ => Err(format!("Unrecognized ldns command 'ldns-{binary_name}'").into()), }?; diff --git a/src/parse.rs b/src/parse.rs new file mode 100644 index 0000000..3a6639c --- /dev/null +++ b/src/parse.rs @@ -0,0 +1,69 @@ +use core::str::FromStr; + +use domain::base::Name; +use domain::tsig::{Algorithm, KeyName}; +use domain::utils::base64; + +use crate::error::Error; + +pub fn parse_name(arg: &str) -> Result>, Error> { + Name::from_str(&arg.to_lowercase()).map_err(|e| Error::from(e.to_string())) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TSigInfo { + pub name: KeyName, + pub key: Vec, + pub algorithm: Algorithm, +} + +impl FromStr for TSigInfo { + type Err = Error; + + fn from_str(s: &str) -> Result { + // TODO: better error messages + let Some((mut name, rest)) = s.split_once(':') else { + return Err("should contain at least one `:`".into()); + }; + + let mut key; + let mut algorithm; + if let Some((k, a)) = rest.split_once(':') { + key = k; + algorithm = a; + } else { + key = rest; + // This is different from the default algorithm that ldns-notify uses, which is MD5, + // but we don't support that. So we use the default that is also used by dig when MD5 + // is disabled. + algorithm = "hmac-sha256"; + }; + + // With dig TSIG keys are also specified with -y, + // but our format is: + // and dig's is: [hmac:]name:key + // + // When we detect an unknown TSIG algorithm in algo, + // but a known algorithm in name, we can assume dig + // order was used. + // + // We can correct this by checking whether the name contains a valid + // algorithm while the algorithm doesn't. + if Algorithm::from_str(algorithm).is_err() && Algorithm::from_str(name).is_ok() { + (name, key, algorithm) = (key, algorithm, name); + } + + let algorithm = Algorithm::from_str(algorithm) + .map_err(|_| format!("Unsupported TSIG algorithm: {algorithm}"))?; + + let key = base64::decode(key).map_err(|e| format!("TSIG key is invalid base64: {e}"))?; + + let name = KeyName::from_str(name).map_err(|e| format!("TSIG name is invalid: {e}"))?; + + Ok(TSigInfo { + name, + key, + algorithm, + }) + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..56ebc0f --- /dev/null +++ b/src/util.rs @@ -0,0 +1,75 @@ +//! A utility module for common operations. + +use std::fs::File; +use std::path::Path; + +use crate::env::Env; +use crate::error::Result; + +/// Create and open a file. +pub fn create_new_file(env: &impl Env, path: impl AsRef) -> Result { + let path = path.as_ref(); + let abs_path = env.in_cwd(&path); + File::create_new(abs_path) + .map_err(|err| format!("cannot create '{}': {err}", path.display()).into()) +} + +/// Rename a file. +pub fn rename_path(env: &impl Env, old: impl AsRef, new: impl AsRef) -> Result<()> { + let (old, new) = (old.as_ref(), new.as_ref()); + let abs_old = env.in_cwd(&old); + let abs_new = env.in_cwd(&new); + std::fs::rename(abs_old, abs_new).map_err(|err| { + format!( + "could not move '{}' to '{}': {err}", + old.display(), + new.display() + ) + .into() + }) +} + +/// Create a symlink. +#[cfg(unix)] +pub fn symlink(env: &impl Env, target: impl AsRef, link: impl AsRef) -> Result<()> { + let (target, link) = (target.as_ref(), link.as_ref()); + let target_path = env.in_cwd(&target); + let link_path = env.in_cwd(&link); + std::os::unix::fs::symlink(target_path, link_path).map_err(|err| { + format!( + "could not create symlink '{}' to '{}': {err}", + link.display(), + target.display(), + ) + .into() + }) +} + +/// Create a symlink, overwriting if it already exists. +#[cfg(unix)] +pub fn symlink_force( + env: &impl Env, + target: impl AsRef, + link: impl AsRef, +) -> Result<()> { + use crate::error::in_context; + + let (target, link) = (target.as_ref(), link.as_ref()); + let mut temp = link.to_path_buf(); + temp.as_mut_os_string().push(".new"); + + in_context( + || { + format!( + "creating symlink '{}' to '{}'", + link.display(), + target.display() + ) + }, + || { + symlink(env, target, &temp)?; + rename_path(env, &temp, link)?; + Ok(()) + }, + ) +}