diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 28bde6a..0000000 --- a/.editorconfig +++ /dev/null @@ -1,14 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true - -[*.{nix,toml,json}] -indent_style = space -indent_size = 2 - -[*.rs] -indent_style = space -indent_size = 4 diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml index 5aa79c1..f4e299e 100644 --- a/.forgejo/workflows/check.yml +++ b/.forgejo/workflows/check.yml @@ -3,29 +3,24 @@ jobs: build: runs-on: nixos steps: - - uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: https://git.salame.cl/actions/checkout@v4 - run: nix --version - - name: Build Package - run: | - nix build --print-build-logs .# - check-integration-tests: + - run: nix build --print-build-logs .# + check: needs: build # we use the built binaries in the checks runs-on: nixos steps: - - uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: https://git.salame.cl/actions/checkout@v4 - run: nix --version - - name: Run tests - run: | - nix-fast-build --max-jobs 2 --no-nom --skip-cached --no-link \ - --flake ".#checks.$(nix eval --raw --impure --expr builtins.currentSystem)" + - run: nix flake check --keep-going --verbose --print-build-logs report-size: runs-on: nixos needs: build steps: - - uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: https://git.salame.cl/actions/checkout@v4 - run: nix --version - name: Generate size report - uses: "https://git.salame.cl/jalil/nix-flake-outputs-size@838f2050208b41c339803a1111608d7182bbda3e" # main + uses: https://git.salame.cl/jalil/nix-flake-outputs-size@main with: comment-on-pr: ${{ github.ref_name != 'main' }} generate-artifact: ${{ github.ref_name == 'main' }} diff --git a/.forgejo/workflows/renovate.yml b/.forgejo/workflows/renovate.yml deleted file mode 100644 index 8d0fc54..0000000 --- a/.forgejo/workflows/renovate.yml +++ /dev/null @@ -1,14 +0,0 @@ -on: - push: - paths: - # only run if the renovate config changed - - renovate.json -jobs: - check-renovaterc: - runs-on: nixos - steps: - - uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - run: nix --version - - name: Validate renovaterc - run: | - nix shell nixpkgs#renovate --command renovate-config-validator diff --git a/.forgejo/workflows/update.yml b/.forgejo/workflows/update.yml new file mode 100644 index 0000000..8992715 --- /dev/null +++ b/.forgejo/workflows/update.yml @@ -0,0 +1,71 @@ +on: + workflow_dispatch: + schedule: + # 03:42 on Saturdays + - cron: '42 3 * * 6' +env: + PR_TITLE: Weekly `cargo update` of dependencies + PR_MESSAGE: | + Automation to keep dependencies in `Cargo.lock` current. + + The following is the output from `cargo update`: + COMMIT_MESSAGE: "chore(deps): cargo update" +jobs: + update-cargo: + runs-on: nixos + env: + BRANCH_NAME: cargo-update + steps: + - uses: https://git.salame.cl/actions/checkout@v4 + - run: nix --version + - run: nix run .#cargo-update + - name: craft PR body and commit message + run: | + set -xeuo pipefail + + echo "${COMMIT_MESSAGE}" > commit.txt + printf '\n\n' >> commit.txt + cat cargo_update.log >> commit.txt + + echo "${PR_MESSAGE}" > body.md + echo '```txt' >> body.md + cat cargo_update.log >> body.md + echo '```' >> body.md + - name: commit + run: | + set -xeuo pipefail + + git config user.name forgejo-actions + git config user.email forgejo-actions@salame.cl + git switch --force-create "$BRANCH_NAME" + git add ./Cargo.lock + DIFF="$(git diff --staged)" + if [[ "$DIFF" == "" ]]; then + echo "Cargo.lock was not changed, bailing out and not making a PR" + exit 1 + fi + git commit --no-verify --file=commit.txt + - name: push + run: | + set -xeuo pipefail + git push --no-verify --force --set-upstream origin "$BRANCH_NAME" + - name: open new pull request + env: + # We have to use a Personal Access Token (PAT) here. + # PRs opened from a workflow using the standard `GITHUB_TOKEN` in GitHub Actions + # do not automatically trigger more workflows: + # https://docs.github.com/en/actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + curl -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H 'Content-Type: application/json' \ + -d "$( + echo '{}' | + jq --arg body "$(cat body.md)" \ + --arg title "$COMMIT_MESSAGE" \ + --arg head "$BRANCH_NAME" \ + '{"body": $body, "title": $title, "head": $head, "base": "main"}' + )" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls" diff --git a/CHANGELOG.md b/CHANGELOG.md index 45c9f8c..64cc1c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,49 +2,6 @@ All notable changes to this project will be documented in this file. -## [0.3.6] - 2025-01-26 - -### ๐Ÿš€ Features - -- *(webnsupdate)* Allow running in IPv4/6 only mode -- *(module)* Add option for setting --ip-type -- *(flake)* Add tests for new allowedIPVersion option - -## [0.3.5] - 2025-01-23 - -### ๐Ÿš€ Features - -- *(renovate)* Enable lockFileMaintenance -- *(webnsupdate)* Add handling for multiple IPs -- Tune compilation for size -- *(tests)* Add nginx integration test - -### ๐Ÿ› Bug Fixes - -- *(flake)* Switch to github ref -- *(renovate)* Switch automergeStrategy to auto -- *(ci)* Remove update workflow -- *(typos)* Typos caught more typos :3 -- *(renovate)* Branch creation before automerge -- *(renovaterc)* Invalid cron syntax -- *(deps)* Update rust crate clap to v4.5.24 -- *(deps)* Update rust crate tokio to v1.43.0 -- *(deps)* Update rust crate clap to v4.5.25 -- *(deps)* Update rust crate clap to v4.5.26 -- *(flake)* Switch overlay to callPackage -- *(deps)* Update rust crate clap to v4.5.27 -- *(deps)* Update rust crate axum to v0.8.2 -- *(module)* Test both IPv4 and IPv6 - -### ๐Ÿšœ Refactor - -- Setup renovate to manage dependencies - -### โš™๏ธ Miscellaneous Tasks - -- Update to axum 0.8 -- Parallelize checks - ## [0.3.4] - 2024-12-26 ### ๐Ÿ› Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index 1b2d811..c399cae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "adler2" -version = "2.0.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" [[package]] name = "aho-corasick" @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" dependencies = [ "anstyle", "anstyle-parse", @@ -43,48 +43,58 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" dependencies = [ "anstyle", - "once_cell_polyfill", "windows-sys 0.59.0", ] [[package]] -name = "axum" -version = "0.8.4" +name = "async-trait" +version = "0.1.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", "axum-core", "bytes", - "form_urlencoded", "futures-util", "http", "http-body", @@ -112,23 +122,24 @@ dependencies = [ [[package]] name = "axum-client-ip" -version = "1.1.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f08a543641554404b42acd0d2494df12ca2be034d7b8ee4dbbf7446f940a2ef" +checksum = "9eefda7e2b27e1bda4d6fa8a06b50803b8793769045918bc37ad062d48a6efac" dependencies = [ "axum", - "client-ip", + "forwarded-header-value", "serde", ] [[package]] name = "axum-core" -version = "0.5.2" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" dependencies = [ + "async-trait", "bytes", - "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -143,9 +154,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.75" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" dependencies = [ "addr2line", "cfg-if", @@ -153,7 +164,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -173,36 +184,36 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "bytes" -version = "1.10.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" -version = "1.2.27" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" +checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e" dependencies = [ "shlex", ] [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.5.40" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" dependencies = [ "clap_builder", "clap_derive", @@ -210,9 +221,9 @@ dependencies = [ [[package]] name = "clap-verbosity-flag" -version = "3.0.3" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeab6a5cdfc795a05538422012f20a5496f050223c91be4e5420bfd13c641fb1" +checksum = "2678fade3b77aa3a8ff3aae87e9c008d3fb00473a41c71fbf74e91c8c7b37e84" dependencies = [ "clap", "tracing-core", @@ -220,9 +231,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.40" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" dependencies = [ "anstream", "anstyle", @@ -232,9 +243,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.40" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" dependencies = [ "heck", "proc-macro2", @@ -244,30 +255,21 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.5" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" - -[[package]] -name = "client-ip" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31211fc26899744f5b22521fdc971e5f3875991d8880537537470685a0e9552d" -dependencies = [ - "http", -] +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" [[package]] name = "console" -version = "0.15.11" +version = "0.15.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" dependencies = [ "encode_unicode", "libc", @@ -283,12 +285,12 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "errno" -version = "0.3.13" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -306,6 +308,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -341,9 +353,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", @@ -364,9 +376,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.3.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" dependencies = [ "bytes", "fnv", @@ -385,12 +397,12 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", - "futures-core", + "futures-util", "http", "http-body", "pin-project-lite", @@ -398,9 +410,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.10.1" +version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" [[package]] name = "httpdate" @@ -408,17 +420,11 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humantime" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" - [[package]] name = "hyper" -version = "1.6.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "256fb8d4bd6413123cc9d91832d78325c48ff41677595be797d90f42969beae0" dependencies = [ "bytes", "futures-channel", @@ -435,12 +441,12 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.14" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" dependencies = [ "bytes", - "futures-core", + "futures-util", "http", "http-body", "hyper", @@ -451,27 +457,16 @@ dependencies = [ [[package]] name = "insta" -version = "1.43.1" +version = "1.41.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "154934ea70c58054b556dd430b99a98c2a7ff5309ac9891597e339b5c28f4371" +checksum = "7e9ffc4d4892617c50a928c52b2961cb5174b6fc6ebf252b2fac9d21955c48b8" dependencies = [ "console", - "once_cell", - "serde", + "lazy_static", + "linked-hash-map", "similar", ] -[[package]] -name = "io-uring" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" -dependencies = [ - "bitflags", - "cfg-if", - "libc", -] - [[package]] name = "is_ci" version = "1.2.0" @@ -486,9 +481,9 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "lazy_static" @@ -498,21 +493,27 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.174" +version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "log" -version = "0.4.27" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "matchers" @@ -525,21 +526,21 @@ dependencies = [ [[package]] name = "matchit" -version = "0.8.4" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "miette" -version = "7.6.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +checksum = "317f146e2eb7021892722af37cf1b971f0a70c8406f487e24952667616192c64" dependencies = [ "backtrace", "backtrace-ext", @@ -551,14 +552,15 @@ dependencies = [ "supports-unicode", "terminal_size", "textwrap", - "unicode-width 0.1.14", + "thiserror", + "unicode-width", ] [[package]] name = "miette-derive" -version = "7.6.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +checksum = "23c9b935fbe1d6cbd1dac857b54a688145e2d93f48db36010514d0f612d0ad67" dependencies = [ "proc-macro2", "quote", @@ -573,24 +575,30 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" dependencies = [ "adler2", ] [[package]] name = "mio" -version = "1.0.4" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", "wasi", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -603,24 +611,18 @@ dependencies = [ [[package]] name = "object" -version = "0.36.7" +version = "0.36.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "overload" @@ -630,9 +632,9 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "owo-colors" -version = "4.2.2" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" +checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56" [[package]] name = "percent-encoding" @@ -642,9 +644,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" [[package]] name = "pin-utils" @@ -654,18 +656,18 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -716,29 +718,30 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "ring" -version = "0.17.14" +version = "0.17.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", "cfg-if", "getrandom", "libc", + "spin", "untrusted", "windows-sys 0.52.0", ] [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" -version = "1.0.7" +version = "0.38.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" dependencies = [ "bitflags", "errno", @@ -749,30 +752,30 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" dependencies = [ "proc-macro2", "quote", @@ -781,9 +784,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" dependencies = [ "itoa", "memchr", @@ -793,9 +796,9 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.17" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" dependencies = [ "itoa", "serde", @@ -830,41 +833,41 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" dependencies = [ "libc", ] [[package]] name = "similar" -version = "2.7.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - -[[package]] -name = "slab" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" -version = "0.5.10" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "strsim" version = "0.11.1" @@ -894,9 +897,9 @@ checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "syn" -version = "2.0.104" +version = "2.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" dependencies = [ "proc-macro2", "quote", @@ -911,9 +914,9 @@ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" [[package]] name = "terminal_size" -version = "0.4.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" dependencies = [ "rustix", "windows-sys 0.59.0", @@ -921,28 +924,28 @@ dependencies = [ [[package]] name = "textwrap" -version = "0.16.2" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" dependencies = [ "unicode-linebreak", - "unicode-width 0.2.1", + "unicode-width", ] [[package]] name = "thiserror" -version = "2.0.12" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", @@ -951,27 +954,26 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" dependencies = [ "cfg-if", + "once_cell", ] [[package]] name = "tokio" -version = "1.46.0" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1140bb80481756a8cbe10541f37433b459c5aa1e727b4c020fbfebdc25bf3ec4" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", "bytes", - "io-uring", "libc", "mio", "pin-project-lite", "signal-hook-registry", - "slab", "socket2", "tokio-macros", "windows-sys 0.52.0", @@ -979,9 +981,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", @@ -1006,9 +1008,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" dependencies = [ "bitflags", "bytes", @@ -1045,9 +1047,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", @@ -1056,9 +1058,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", "valuable", @@ -1095,9 +1097,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" [[package]] name = "unicode-linebreak" @@ -1111,12 +1113,6 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" -[[package]] -name = "unicode-width" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" - [[package]] name = "untrusted" version = "0.9.0" @@ -1131,19 +1127,19 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "valuable" -version = "0.1.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" [[package]] name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "webnsupdate" -version = "0.3.6" +version = "0.3.4" dependencies = [ "axum", "axum-client-ip", @@ -1151,13 +1147,9 @@ dependencies = [ "clap", "clap-verbosity-flag", "http", - "humantime", "insta", "miette", "ring", - "serde", - "serde_json", - "thiserror", "tokio", "tower-http", "tracing", @@ -1192,7 +1184,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -1201,16 +1193,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.2", + "windows-targets", ] [[package]] @@ -1219,30 +1202,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" -dependencies = [ - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -1251,92 +1218,44 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" diff --git a/Cargo.toml b/Cargo.toml index a6446fe..34a2ca2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,11 @@ +cargo-features = ["codegen-backend"] + [package] description = "An HTTP server using HTTP basic auth to make secure calls to nsupdate" name = "webnsupdate" -version = "0.3.6" -edition = "2024" -license = "MIT" +version = "0.3.4" +edition = "2021" +license-file = "LICENSE" readme = "README.md" keywords = ["dns", "dyndns", "dynamic-ip"] categories = ["networking", "dns", "dyndns"] @@ -15,34 +17,30 @@ multiple_crate_versions = "allow" pedantic = { level = "warn", priority = -1 } [dependencies] -axum = "0.8" -axum-client-ip = "1.0" +axum = "0.7" +axum-client-ip = "0.6" base64 = "0.22" clap = { version = "4", features = ["derive", "env"] } clap-verbosity-flag = { version = "3", default-features = false, features = [ "tracing", ] } http = "1" -humantime = "2.2.0" miette = { version = "7", features = ["fancy"] } ring = { version = "0.17", features = ["std"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -thiserror = "2" tokio = { version = "1", features = ["macros", "rt", "process", "io-util"] } -tower-http = { version = "0.6", features = ["validate-request"] } +tower-http = { version = "0.6.2", features = ["validate-request"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } [dev-dependencies] -insta = { version = "=1.43.1", features = ["json"] } +insta = "1" [profile.release] opt-level = "s" panic = "abort" lto = true strip = true -codegen-units = 1 [profile.dev] debug = 0 +codegen-backend = "cranelift" diff --git a/default.nix b/default.nix deleted file mode 100644 index 28e3808..0000000 --- a/default.nix +++ /dev/null @@ -1,37 +0,0 @@ -{ - pkgs ? - (builtins.getFlake (builtins.toString ./.)).inputs.nixpkgs.legacyPackages.${builtins.currentSystem}, - lib ? pkgs.lib, - crane ? (builtins.getFlake (builtins.toString ./.)).inputs.crane, - pkgSrc ? ./., - mold ? pkgs.mold, -}: -let - craneLib = crane.mkLib pkgs; - src = craneLib.cleanCargoSource pkgSrc; - - commonArgs = { - inherit src; - strictDeps = true; - - doCheck = false; # tests will be run in the `checks` derivation - NEXTEST_HIDE_PROGRESS_BAR = 1; - NEXTEST_FAILURE_OUTPUT = "immediate-final"; - - nativeBuildInputs = [ mold ]; - - meta = { - license = lib.licenses.mit; - homepage = "https://github.com/jalil-salame/webnsupdate"; - mainProgram = "webnsupdate"; - }; - }; - - cargoArtifacts = craneLib.buildDepsOnly commonArgs; -in -craneLib.buildPackage ( - lib.mergeAttrsList [ - commonArgs - { inherit cargoArtifacts; } - ] -) diff --git a/flake-modules/default.nix b/flake-modules/default.nix index 7cf532e..27ecc50 100644 --- a/flake-modules/default.nix +++ b/flake-modules/default.nix @@ -1,20 +1,12 @@ -{ lib, inputs, ... }: -let - webnsupdate = ../module.nix; - cargoToml = lib.importTOML ../Cargo.toml; -in +{ inputs, ... }: { imports = [ inputs.treefmt-nix.flakeModule ./package.nix + ./module.nix ./tests.nix ]; - flake.nixosModules = { - default = webnsupdate; - inherit webnsupdate; - }; - perSystem = { pkgs, ... }: { @@ -23,10 +15,7 @@ in projectRootFile = "flake.nix"; programs = { nixfmt.enable = true; - rustfmt = { - enable = true; - inherit (cargoToml.package) edition; # respect the package's edition - }; + rustfmt.enable = true; statix.enable = true; typos.enable = true; }; diff --git a/flake-modules/module.nix b/flake-modules/module.nix new file mode 100644 index 0000000..6ffbba6 --- /dev/null +++ b/flake-modules/module.nix @@ -0,0 +1,196 @@ +let + module = + { + lib, + pkgs, + config, + ... + }: + let + cfg = config.services.webnsupdate; + inherit (lib) + mkOption + mkEnableOption + mkPackageOption + types + ; + in + { + options.services.webnsupdate = mkOption { + description = "An HTTP server for nsupdate."; + default = { }; + type = types.submodule { + options = { + enable = mkEnableOption "webnsupdate"; + extraArgs = mkOption { + description = '' + Extra arguments to be passed to the webnsupdate server command. + ''; + type = types.listOf types.str; + default = [ ]; + example = [ "--ip-source" ]; + }; + package = mkPackageOption pkgs "webnsupdate" { }; + bindIp = mkOption { + description = '' + IP address to bind to. + + Setting it to anything other than localhost is very insecure as + `webnsupdate` only supports plain HTTP and should always be behind a + reverse proxy. + ''; + type = types.str; + default = "localhost"; + example = "0.0.0.0"; + }; + bindPort = mkOption { + description = "Port to bind to."; + type = types.port; + default = 5353; + }; + passwordFile = mkOption { + description = '' + The file where the password is stored. + + This file can be created by running `webnsupdate mkpasswd $USERNAME $PASSWORD`. + ''; + type = types.path; + example = "/secrets/webnsupdate.pass"; + }; + keyFile = mkOption { + description = '' + The TSIG key that `nsupdate` should use. + + This file will be passed to `nsupdate` through the `-k` option, so look + at `man 8 nsupdate` for information on the key's format. + ''; + type = types.path; + example = "/secrets/webnsupdate.key"; + }; + ttl = mkOption { + description = "The TTL that should be set on the zone records created by `nsupdate`."; + type = types.ints.positive; + default = 60; + example = 3600; + }; + records = mkOption { + description = '' + The fqdn of records that should be updated. + + Empty lines will be ignored, but whitespace will not be. + ''; + type = types.nullOr types.lines; + default = null; + example = '' + example.com. + + example.org. + ci.example.org. + ''; + }; + recordsFile = mkOption { + description = '' + The fqdn of records that should be updated. + + Empty lines will be ignored, but whitespace will not be. + ''; + type = types.nullOr types.path; + default = null; + example = "/secrets/webnsupdate.records"; + }; + user = mkOption { + description = "The user to run as."; + type = types.str; + default = "named"; + }; + group = mkOption { + description = "The group to run as."; + type = types.str; + default = "named"; + }; + }; + }; + }; + + config = + let + recordsFile = + if cfg.recordsFile != null then cfg.recordsFile else pkgs.writeText "webnsrecords" cfg.records; + args = lib.strings.escapeShellArgs ( + [ + "--records" + recordsFile + "--key-file" + cfg.keyFile + "--password-file" + cfg.passwordFile + "--address" + cfg.bindIp + "--port" + (builtins.toString cfg.bindPort) + "--ttl" + (builtins.toString cfg.ttl) + "--data-dir=%S/webnsupdate" + ] + ++ cfg.extraArgs + ); + cmd = "${lib.getExe cfg.package} ${args}"; + in + lib.mkIf cfg.enable { + # warnings = + # lib.optional (!config.services.bind.enable) "`webnsupdate` is expected to be used alongside `bind`. This is an unsopported configuration."; + assertions = [ + { + assertion = + (cfg.records != null || cfg.recordsFile != null) + && !(cfg.records != null && cfg.recordsFile != null); + message = "Exactly one of `services.webnsupdate.records` and `services.webnsupdate.recordsFile` must be set."; + } + ]; + + systemd.services.webnsupdate = { + description = "Web interface for nsupdate."; + wantedBy = [ "multi-user.target" ]; + after = [ + "network.target" + "bind.service" + ]; + preStart = "${cmd} verify"; + path = [ pkgs.dig ]; + startLimitIntervalSec = 60; + serviceConfig = { + ExecStart = [ cmd ]; + Type = "exec"; + Restart = "on-failure"; + RestartSec = "10s"; + # User and group + User = cfg.user; + Group = cfg.group; + # Runtime directory and mode + RuntimeDirectory = "webnsupdate"; + RuntimeDirectoryMode = "0750"; + # Cache directory and mode + CacheDirectory = "webnsupdate"; + CacheDirectoryMode = "0750"; + # Logs directory and mode + LogsDirectory = "webnsupdate"; + LogsDirectoryMode = "0750"; + # State directory and mode + StateDirectory = "webnsupdate"; + StateDirectoryMode = "0750"; + # New file permissions + UMask = "0027"; + # Security + NoNewPrivileges = true; + ProtectHome = true; + }; + }; + }; + }; +in +{ + flake.nixosModules = { + default = module; + webnsupdate = module; + }; +} diff --git a/flake-modules/package.nix b/flake-modules/package.nix index 1d488d0..7e8bd52 100644 --- a/flake-modules/package.nix +++ b/flake-modules/package.nix @@ -1,24 +1,18 @@ -{ inputs, ... }: -let - inherit (inputs) crane; -in +{ withSystem, inputs, ... }: { - flake.overlays.default = final: prev: { - webnsupdate = prev.callPackage ../default.nix { - inherit crane; - pkgSrc = inputs.self; - }; - }; + flake.overlays.default = + final: prev: + withSystem prev.stdenv.hostPlatform.system ( + { self', ... }: + { + inherit (self'.packages) webnsupdate; + } + ); perSystem = - { - system, - pkgs, - lib, - ... - }: + { pkgs, lib, ... }: let - craneLib = (crane.mkLib pkgs).overrideToolchain (pkgs: pkgs.rust-bin.stable.latest.default); + craneLib = inputs.crane.mkLib pkgs; src = craneLib.cleanCargoSource inputs.self; commonArgs = { @@ -39,28 +33,29 @@ in }; cargoArtifacts = craneLib.buildDepsOnly commonArgs; - withArtifacts = lib.mergeAttrsList [ - commonArgs - { inherit cargoArtifacts; } - ]; - webnsupdate = pkgs.callPackage ../default.nix { - inherit crane; - pkgSrc = src; - }; + webnsupdate = craneLib.buildPackage ( + lib.mergeAttrsList [ + commonArgs + { inherit cargoArtifacts; } + ] + ); in { - # Consume the rust-rust-overlay - _module.args.pkgs = import inputs.nixpkgs { - inherit system; - overlays = [ inputs.rust-overlay.overlays.default ]; - }; - checks = { - nextest = craneLib.cargoNextest withArtifacts; clippy = craneLib.cargoClippy ( lib.mergeAttrsList [ - withArtifacts - { cargoClippyExtraArgs = "--all-targets -- --deny warnings"; } + commonArgs + { + inherit cargoArtifacts; + cargoClippyExtraArgs = "--all-targets -- --deny warnings"; + } + ] + ); + + nextest = craneLib.cargoNextest ( + lib.mergeAttrsList [ + commonArgs + { inherit cargoArtifacts; } ] ); }; @@ -69,6 +64,16 @@ in inherit webnsupdate; inherit (pkgs) git-cliff; default = webnsupdate; + cargo-update = pkgs.writeShellApplication { + name = "cargo-update-lockfile"; + runtimeInputs = with pkgs; [ + cargo + gnused + ]; + text = '' + CARGO_TERM_COLOR=never cargo update 2>&1 | sed '/crates.io index/d' | tee -a cargo_update.log + ''; + }; }; }; } diff --git a/flake-modules/tests.nix b/flake-modules/tests.nix index 8257326..7ec61ab 100644 --- a/flake-modules/tests.nix +++ b/flake-modules/tests.nix @@ -6,337 +6,138 @@ checks = let testDomain = "webnstest.example"; - lastIPPath = "/var/lib/webnsupdate/last-ip.json"; - + dynamicZonesDir = "/var/lib/named/zones"; zoneFile = pkgs.writeText "${testDomain}.zoneinfo" '' - $TTL 600 ; 10 minutes - $ORIGIN ${testDomain}. - @ IN SOA ns1.${testDomain}. admin.${testDomain}. ( - 1 ; serial - 6h ; refresh - 1h ; retry - 1w ; expire - 1d) ; negative caching TTL + $ORIGIN . + $TTL 60 ; 1 minute + ${testDomain} IN SOA ns1.${testDomain}. admin.${testDomain}. ( + 1 ; serial + 21600 ; refresh (6 hours) + 3600 ; retry (1 hour) + 604800 ; expire (1 week) + 86400) ; negative caching TTL (1 day) - IN NS ns1.${testDomain}. - @ IN A 127.0.0.1 - ns1 IN A 127.0.0.1 - nsupdate IN A 127.0.0.1 - @ IN AAAA ::1 - ns1 IN AAAA ::1 - nsupdate IN AAAA ::1 + IN NS ns1.${testDomain}. + $ORIGIN ${testDomain}. + ${testDomain}. IN A 127.0.0.1 + ${testDomain}. IN AAAA ::1 + ns1 IN A 127.0.0.1 + ns1 IN AAAA ::1 + nsupdate IN A 127.0.0.1 + nsupdate IN AAAA ::1 ''; - bindDynamicZone = - { config, ... }: - let - bindCfg = config.services.bind; - bindData = bindCfg.directory; - dynamicZonesDir = "${bindData}/zones"; - in - { - services.bind.zones.${testDomain} = { - master = true; - file = "${dynamicZonesDir}/${testDomain}"; - extraConfig = '' - allow-update { key rndc-key; }; - ''; + webnsupdate-machine = { + imports = [ self.nixosModules.webnsupdate ]; + + config = { + environment.systemPackages = [ + pkgs.dig + pkgs.curl + ]; + + services = { + webnsupdate = { + enable = true; + bindIp = "127.0.0.1"; + keyFile = "/etc/bind/rndc.key"; + # test:test (user:password) + passwordFile = pkgs.writeText "webnsupdate.pass" "FQoNmuU1BKfg8qsU96F6bK5ykp2b0SLe3ZpB3nbtfZA"; + package = self'.packages.webnsupdate; + extraArgs = [ + "-vvv" # debug messages + "--ip-source=ConnectInfo" + ]; + records = '' + test1.${testDomain}. + test2.${testDomain}. + test3.${testDomain}. + ''; + }; + + bind = { + enable = true; + zones.${testDomain} = { + master = true; + file = "${dynamicZonesDir}/${testDomain}"; + extraConfig = '' + allow-update { key rndc-key; }; + ''; + }; + }; }; systemd.services.bind.preStart = '' # shellcheck disable=SC2211,SC1127 rm -f ${dynamicZonesDir}/* # reset dynamic zones - # create a dynamic zones dir - mkdir -m 0755 -p ${dynamicZonesDir} + ${pkgs.coreutils}/bin/mkdir -m 0755 -p ${dynamicZonesDir} + chown "named" ${dynamicZonesDir} + chown "named" /var/lib/named + # copy dynamic zone's file to the dynamic zones dir cp ${zoneFile} ${dynamicZonesDir}/${testDomain} ''; }; - - webnsupdate-ipv4-machine = - { lib, ... }: - { - imports = [ - bindDynamicZone - self.nixosModules.webnsupdate - ]; - - config = { - environment.systemPackages = [ - pkgs.dig - pkgs.curl - ]; - - services = { - bind.enable = true; - - webnsupdate = { - enable = true; - package = self'.packages.webnsupdate; - extraArgs = [ "-vvv" ]; # debug messages - settings = { - address = lib.mkDefault "127.0.0.1:5353"; - key_file = "/etc/bind/rndc.key"; - password_file = pkgs.writeText "webnsupdate.pass" "FQoNmuU1BKfg8qsU96F6bK5ykp2b0SLe3ZpB3nbtfZA"; # test:test - ip_source = lib.mkDefault "ConnectInfo"; - records = [ - "test1.${testDomain}." - "test2.${testDomain}." - "test3.${testDomain}." - ]; - }; - }; - }; - }; - }; - - webnsupdate-ipv6-machine = { - imports = [ - webnsupdate-ipv4-machine - ]; - - config.services.webnsupdate.settings.address = "[::1]:5353"; }; - - webnsupdate-nginx-machine = - { lib, config, ... }: - { - imports = [ - webnsupdate-ipv4-machine - ]; - - config.services = { - # Use default IP Source - webnsupdate.settings.ip_source = "RightmostXForwardedFor"; - - nginx = { - enable = true; - recommendedProxySettings = true; - - virtualHosts.webnsupdate.locations."/".proxyPass = - "http://${config.services.webnsupdate.settings.address}"; - }; - }; - }; - - webnsupdate-ipv4-only-machine = { - imports = [ webnsupdate-nginx-machine ]; - config.services.webnsupdate.settings.ip_type = "Ipv4Only"; - }; - - webnsupdate-ipv6-only-machine = { - imports = [ webnsupdate-nginx-machine ]; - config.services.webnsupdate.settings.ip_type = "Ipv6Only"; - }; - - # "A" for IPv4, "AAAA" for IPv6, "ANY" for any - testTemplate = - { - ipv4 ? false, - ipv6 ? false, - nginx ? false, - exclusive ? false, - }: - if exclusive && (ipv4 == ipv6) then - builtins.throw "exclusive means one of ipv4 or ipv6 must be set, but not both" - else - '' - IPV4: bool = ${if ipv4 then "True" else "False"} - IPV6: bool = ${if ipv6 then "True" else "False"} - NGINX: bool = ${if nginx then "True" else "False"} - EXCLUSIVE: bool = ${if exclusive then "True" else "False"} - print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") - - CURL: str = "curl --fail --no-progress-meter --show-error" - - machine.start(allow_reboot=True) - machine.wait_for_unit("bind.service") - machine.wait_for_unit("webnsupdate.service") - - STATIC_DOMAINS: list[str] = ["${testDomain}", "ns1.${testDomain}", "nsupdate.${testDomain}"] - DYNAMIC_DOMAINS: list[str] = ["test1.${testDomain}", "test2.${testDomain}", "test3.${testDomain}"] - - def dig_cmd(domain: str, record: str, ip: str | None) -> tuple[str, str]: - match_ip = "" if ip is None else f"\\s\\+600\\s\\+IN\\s\\+{record}\\s\\+{ip}$" - return f"dig @localhost {record} {domain} +noall +answer", f"grep '^{domain}.{match_ip}'" - - def curl_cmd(domain: str, identity: str, path: str, query: dict[str, str]) -> str: - from urllib.parse import urlencode - q= f"?{urlencode(query)}" if query else "" - return f"{CURL} -u {identity} -X GET 'http://{domain}{"" if NGINX else ":5353"}/{path}{q}'" - - def domain_available(domain: str, record: str, ip: str | None=None): - dig, grep = dig_cmd(domain, record, ip) - rc, output = machine.execute(dig) - print(f"{dig}[{rc}]: {output}") - machine.succeed(f"{dig} | {grep}") - - def domain_missing(domain: str, record: str, ip: str | None=None): - dig, grep = dig_cmd(domain, record, ip) - rc, output = machine.execute(dig) - print(f"{dig}[{rc}]: {output}") - machine.fail(f"{dig} | {grep}") - - def update_records(domain: str="localhost", /, *, path: str="update", **kwargs): - machine.succeed(curl_cmd(domain, "test:test", path, kwargs)) - machine.succeed("cat ${lastIPPath}") - - def update_records_fail(domain: str="localhost", /, *, identity: str="test:test", path: str="update", **kwargs): - machine.fail(curl_cmd(domain, identity, path, kwargs)) - machine.fail("cat ${lastIPPath}") - - def invalid_update(domain: str="localhost"): - update_records_fail(domain, identity="bad_user:test") - update_records_fail(domain, identity="test:bad_pass") - - # Tests - - with subtest("static DNS records are available"): - print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") - for domain in STATIC_DOMAINS: - domain_available(domain, "A", "127.0.0.1") # IPv4 - domain_available(domain, "AAAA", "::1") # IPv6 - - with subtest("dynamic DNS records are missing"): - print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") - for domain in DYNAMIC_DOMAINS: - domain_missing(domain, "A") # IPv4 - domain_missing(domain, "AAAA") # IPv6 - - with subtest("invalid auth fails to update records"): - print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") - invalid_update() - for domain in DYNAMIC_DOMAINS: - domain_missing(domain, "A") # IPv4 - domain_missing(domain, "AAAA") # IPv6 - - if EXCLUSIVE: - with subtest("exclusive IP version fails to update with invalid version"): - print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") - if IPV6: - update_records_fail("127.0.0.1") - if IPV4: - update_records_fail("[::1]") - - with subtest("valid auth updates records"): - print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") - if IPV4: - update_records("127.0.0.1") - if IPV6: - update_records("[::1]") - - for domain in DYNAMIC_DOMAINS: - if IPV4: - domain_available(domain, "A", "127.0.0.1") - elif IPV6 and EXCLUSIVE: - domain_missing(domain, "A") - - if IPV6: - domain_available(domain, "AAAA", "::1") - elif IPV4 and EXCLUSIVE: - domain_missing(domain, "AAAA") - - with subtest("valid auth fritzbox compatible updates records"): - print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") - if IPV4 and IPV6: - update_records("127.0.0.1", domain="test", ipv4="1.2.3.4", ipv6="::1234") - elif IPV4: - update_records("127.0.0.1", ipv4="1.2.3.4", ipv6="") - elif IPV6: - update_records("[::1]", ipv4="", ipv6="::1234") - - for domain in DYNAMIC_DOMAINS: - if IPV4: - domain_available(domain, "A", "1.2.3.4") - elif IPV6 and EXCLUSIVE: - domain_missing(domain, "A") - - if IPV6: - domain_available(domain, "AAAA", "::1234") - elif IPV4 and EXCLUSIVE: - domain_missing(domain, "AAAA") - - with subtest("valid auth replaces records"): - print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") - if IPV4: - update_records("127.0.0.1") - if IPV6: - update_records("[::1]") - - for domain in DYNAMIC_DOMAINS: - if IPV4: - domain_available(domain, "A", "127.0.0.1") - elif IPV6 and EXCLUSIVE: - domain_missing(domain, "A") - - if IPV6: - domain_available(domain, "AAAA", "::1") - elif IPV4 and EXCLUSIVE: - domain_missing(domain, "AAAA") - - machine.reboot() - machine.succeed("cat ${lastIPPath}") - machine.wait_for_unit("webnsupdate.service") - machine.succeed("cat ${lastIPPath}") - - with subtest("static DNS records are available after reboot"): - print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") - for domain in STATIC_DOMAINS: - domain_available(domain, "A", "127.0.0.1") # IPv4 - domain_available(domain, "AAAA", "::1") # IPv6 - - with subtest("dynamic DNS records are available after reboot"): - print(f"{IPV4=} {IPV6=} {EXCLUSIVE=}") - for domain in DYNAMIC_DOMAINS: - if IPV4: - domain_available(domain, "A", "127.0.0.1") - elif IPV6 and EXCLUSIVE: - domain_missing(domain, "A") - - if IPV6: - domain_available(domain, "AAAA", "::1") - elif IPV4 and EXCLUSIVE: - domain_missing(domain, "AAAA") - ''; in { - module-ipv4-test = pkgs.testers.nixosTest { - name = "webnsupdate-ipv4-module"; - nodes.machine = webnsupdate-ipv4-machine; - testScript = testTemplate { ipv4 = true; }; - }; - module-ipv6-test = pkgs.testers.nixosTest { - name = "webnsupdate-ipv6-module"; - nodes.machine = webnsupdate-ipv6-machine; - testScript = testTemplate { ipv6 = true; }; - }; - module-nginx-test = pkgs.testers.nixosTest { - name = "webnsupdate-nginx-module"; - nodes.machine = webnsupdate-nginx-machine; - testScript = testTemplate { - ipv4 = true; - ipv6 = true; - nginx = true; - }; - }; - module-ipv4-only-test = pkgs.testers.nixosTest { - name = "webnsupdate-ipv4-only-module"; - nodes.machine = webnsupdate-ipv4-only-machine; - testScript = testTemplate { - ipv4 = true; - nginx = true; - exclusive = true; - }; - }; - module-ipv6-only-test = pkgs.testers.nixosTest { - name = "webnsupdate-ipv6-only-module"; - nodes.machine = webnsupdate-ipv6-only-machine; - testScript = testTemplate { - ipv6 = true; - nginx = true; - exclusive = true; - }; + module-test = pkgs.testers.runNixOSTest { + name = "webnsupdate-module"; + nodes.machine = webnsupdate-machine; + testScript = '' + machine.start(allow_reboot=True) + machine.wait_for_unit("webnsupdate.service") + + # ensure base DNS records area available + with subtest("query base DNS records"): + machine.succeed("dig @127.0.0.1 ${testDomain} | grep ^${testDomain}") + machine.succeed("dig @127.0.0.1 ns1.${testDomain} | grep ^ns1.${testDomain}") + machine.succeed("dig @127.0.0.1 nsupdate.${testDomain} | grep ^nsupdate.${testDomain}") + + # ensure webnsupdate managed records are missing + with subtest("query webnsupdate DNS records (fail)"): + machine.fail("dig @127.0.0.1 test1.${testDomain} | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} | grep ^test3.${testDomain}") + + with subtest("update webnsupdate DNS records (invalid auth)"): + machine.fail("curl --fail --silent -u test1:test1 -X GET http://localhost:5353/update") + machine.fail("cat /var/lib/webnsupdate/last-ip") # no last-ip set yet + + # ensure webnsupdate managed records are missing + with subtest("query webnsupdate DNS records (fail)"): + machine.fail("dig @127.0.0.1 test1.${testDomain} | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} | grep ^test3.${testDomain}") + + with subtest("update webnsupdate DNS records (valid auth)"): + machine.succeed("curl --fail --silent -u test:test -X GET http://localhost:5353/update") + machine.succeed("cat /var/lib/webnsupdate/last-ip") + + # ensure webnsupdate managed records are available + with subtest("query webnsupdate DNS records (succeed)"): + machine.succeed("dig @127.0.0.1 test1.${testDomain} | grep ^test1.${testDomain}") + machine.succeed("dig @127.0.0.1 test2.${testDomain} | grep ^test2.${testDomain}") + machine.succeed("dig @127.0.0.1 test3.${testDomain} | grep ^test3.${testDomain}") + + machine.reboot() + machine.succeed("cat /var/lib/webnsupdate/last-ip") + machine.wait_for_unit("webnsupdate.service") + machine.succeed("cat /var/lib/webnsupdate/last-ip") + + # ensure base DNS records area available after a reboot + with subtest("query base DNS records"): + machine.succeed("dig @127.0.0.1 ${testDomain} | grep ^${testDomain}") + machine.succeed("dig @127.0.0.1 ns1.${testDomain} | grep ^ns1.${testDomain}") + machine.succeed("dig @127.0.0.1 nsupdate.${testDomain} | grep ^nsupdate.${testDomain}") + + # ensure webnsupdate managed records are available after a reboot + with subtest("query webnsupdate DNS records (succeed)"): + machine.succeed("dig @127.0.0.1 test1.${testDomain} | grep ^test1.${testDomain}") + machine.succeed("dig @127.0.0.1 test2.${testDomain} | grep ^test2.${testDomain}") + machine.succeed("dig @127.0.0.1 test3.${testDomain} | grep ^test3.${testDomain}") + ''; }; }; }; diff --git a/flake.lock b/flake.lock index 070c78c..b4bb41d 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1751562746, - "narHash": "sha256-smpugNIkmDeicNz301Ll1bD7nFOty97T79m4GUMUczA=", + "lastModified": 1734808813, + "narHash": "sha256-3aH/0Y6ajIlfy7j52FGZ+s4icVX0oHhqBzRdlOeztqg=", "owner": "ipetkov", "repo": "crane", - "rev": "aed2020fd3dc26e1e857d4107a5a67a33ab6c1fd", + "rev": "72e2d02dbac80c8c86bf6bf3e785536acf8ee926", "type": "github" }, "original": { @@ -22,11 +22,11 @@ ] }, "locked": { - "lastModified": 1751413152, - "narHash": "sha256-Tyw1RjYEsp5scoigs1384gIg6e0GoBVjms4aXFfRssQ=", + "lastModified": 1733312601, + "narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "77826244401ea9de6e3bac47c2db46005e1f30b5", + "rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9", "type": "github" }, "original": { @@ -37,15 +37,17 @@ }, "nixpkgs": { "locked": { - "lastModified": 1751332518, - "narHash": "sha256-BTVn9nIjhCeWpaxOs3Wbhwum4xID4p36dbmEfLDyh9Y=", - "rev": "3016b4b15d13f3089db8a41ef937b13a9e33a8df", - "type": "tarball", - "url": "https://releases.nixos.org/nixos/unstable/nixos-25.11pre823481.3016b4b15d13/nixexprs.tar.xz?rev=3016b4b15d13f3089db8a41ef937b13a9e33a8df" + "lastModified": 1734424634, + "narHash": "sha256-cHar1vqHOOyC7f1+tVycPoWTfKIaqkoe1Q6TnKzuti4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d3c42f187194c26d9f0309a8ecc469d6c878ce33", + "type": "github" }, "original": { - "type": "tarball", - "url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz" + "id": "nixpkgs", + "ref": "nixos-unstable", + "type": "indirect" } }, "root": { @@ -53,31 +55,10 @@ "crane": "crane", "flake-parts": "flake-parts", "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay", "systems": "systems", "treefmt-nix": "treefmt-nix" } }, - "rust-overlay": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1751510438, - "narHash": "sha256-m8PjOoyyCR4nhqtHEBP1tB/jF+gJYYguSZmUmVTEAQE=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "7f415261f298656f8164bd636c0dc05af4e95b6b", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" - } - }, "systems": { "locked": { "lastModified": 1681028828, @@ -100,11 +81,11 @@ ] }, "locked": { - "lastModified": 1750931469, - "narHash": "sha256-0IEdQB1nS+uViQw4k3VGUXntjkDp7aAlqcxdewb/hAc=", + "lastModified": 1734704479, + "narHash": "sha256-MMi74+WckoyEWBRcg/oaGRvXC9BVVxDZNRMpL+72wBI=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "ac8e6f32e11e9c7f153823abc3ab007f2a65d3e1", + "rev": "65712f5af67234dad91a5a4baee986a8b62dbf8f", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index b4e835d..3e7f04f 100644 --- a/flake.nix +++ b/flake.nix @@ -1,22 +1,17 @@ { description = "An http server that calls nsupdate internally"; inputs = { - nixpkgs.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"; + crane.url = "github:ipetkov/crane"; flake-parts = { url = "github:hercules-ci/flake-parts"; inputs.nixpkgs-lib.follows = "nixpkgs"; }; + nixpkgs.url = "nixpkgs/nixos-unstable"; + systems.url = "github:nix-systems/default"; treefmt-nix = { url = "github:numtide/treefmt-nix"; inputs.nixpkgs.follows = "nixpkgs"; }; - - crane.url = "github:ipetkov/crane"; - rust-overlay = { - url = "github:oxalica/rust-overlay"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - systems.url = "github:nix-systems/default"; }; outputs = diff --git a/justfile b/justfile deleted file mode 100644 index 6cacc42..0000000 --- a/justfile +++ /dev/null @@ -1,2 +0,0 @@ -changelog version: - git cliff --unreleased --prepend=CHANGELOG.md --tag='{{ version }}' diff --git a/module.nix b/module.nix deleted file mode 100644 index 8bdfced..0000000 --- a/module.nix +++ /dev/null @@ -1,162 +0,0 @@ -{ lib, pkgs, ... }@args: -let - cfg = args.config.services.webnsupdate; - inherit (lib) - mkOption - mkEnableOption - mkPackageOption - types - ; - format = pkgs.formats.json { }; -in -{ - options.services.webnsupdate = mkOption { - description = "An HTTP server for nsupdate."; - default = { }; - type = types.submodule { - options = { - enable = mkEnableOption "webnsupdate"; - extraArgs = mkOption { - description = '' - Extra arguments to be passed to the webnsupdate server command. - ''; - type = types.listOf types.str; - default = [ ]; - example = [ "--ip-source" ]; - }; - package = mkPackageOption pkgs "webnsupdate" { }; - settings = mkOption { - description = "The webnsupdate JSON configuration"; - default = { }; - type = types.submodule { - freeformType = format.type; - options = { - address = mkOption { - description = '' - IP address and port to bind to. - - Setting it to anything other than localhost is very - insecure as `webnsupdate` only supports plain HTTP and - should always be behind a reverse proxy. - ''; - type = types.str; - default = "127.0.0.1:5353"; - example = "[::1]:5353"; - }; - ip_type = mkOption { - description = ''The allowed IP versions to accept updates from.''; - type = types.enum [ - "Both" - "Ipv4Only" - "Ipv6Only" - ]; - default = "Both"; - example = "Ipv4Only"; - }; - password_file = mkOption { - description = '' - The file where the password is stored. - - This file can be created by running `webnsupdate mkpasswd $USERNAME $PASSWORD`. - ''; - type = types.path; - example = "/secrets/webnsupdate.pass"; - }; - key_file = mkOption { - description = '' - The TSIG key that `nsupdate` should use. - - This file will be passed to `nsupdate` through the `-k` option, so look - at `man 8 nsupdate` for information on the key's format. - ''; - type = types.path; - example = "/secrets/webnsupdate.key"; - }; - ttl = mkOption { - description = "The TTL that should be set on the zone records created by `nsupdate`."; - default = "10m"; - example = "60s"; - type = types.str; - }; - records = mkOption { - description = '' - The fqdn of records that should be updated. - - Empty lines will be ignored, but whitespace will not be. - ''; - type = types.listOf types.str; - default = [ ]; - example = [ - "example.com." - "example.org." - "ci.example.org." - ]; - }; - }; - }; - }; - user = mkOption { - description = "The user to run as."; - type = types.str; - default = "named"; - }; - group = mkOption { - description = "The group to run as."; - type = types.str; - default = "named"; - }; - }; - }; - }; - - config = - let - configFile = format.generate "webnsupdate.json" cfg.settings; - args = lib.strings.escapeShellArgs ([ "--config=${configFile}" ] ++ cfg.extraArgs); - cmd = "${lib.getExe cfg.package} ${args}"; - in - lib.mkIf cfg.enable { - # FIXME: re-enable once I stop using the patched version of bind - # warnings = - # lib.optional (!config.services.bind.enable) "`webnsupdate` is expected to be used alongside `bind`. This is an unsupported configuration."; - - systemd.services.webnsupdate = { - description = "Web interface for nsupdate."; - wantedBy = [ "multi-user.target" ]; - after = [ - "network.target" - "bind.service" - ]; - preStart = "${lib.getExe cfg.package} verify ${configFile}"; - path = [ pkgs.dig ]; - startLimitIntervalSec = 60; - environment.DATA_DIR = "%S/webnsupdate"; - serviceConfig = { - ExecStart = [ cmd ]; - Type = "exec"; - Restart = "on-failure"; - RestartSec = "10s"; - # User and group - User = cfg.user; - Group = cfg.group; - # Runtime directory and mode - RuntimeDirectory = "webnsupdate"; - RuntimeDirectoryMode = "0750"; - # Cache directory and mode - CacheDirectory = "webnsupdate"; - CacheDirectoryMode = "0750"; - # Logs directory and mode - LogsDirectory = "webnsupdate"; - LogsDirectoryMode = "0750"; - # State directory and mode - StateDirectory = "webnsupdate"; - StateDirectoryMode = "0750"; - # New file permissions - UMask = "0027"; - # Security - NoNewPrivileges = true; - ProtectHome = true; - }; - }; - }; -} diff --git a/renovate.json b/renovate.json deleted file mode 100644 index ae48190..0000000 --- a/renovate.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "assignees": [ - "jalil" - ], - "automerge": true, - "automergeStrategy": "auto", - "automergeType": "pr", - "commitBodyTable": true, - "dependencyDashboard": true, - "extends": [ - "config:best-practices" - ], - "prCreation": "immediate", - "cargo": { - "enabled": true - }, - "nix": { - "enabled": true - }, - "lockFileMaintenance": { - "enabled": true, - "recreateWhen": "always", - "rebaseWhen": "behind-base-branch", - "branchTopic": "lock-file-maintenance", - "commitMessageAction": "Lock file maintenance", - "schedule": [ - "* 22 * * *" - ] - }, - "automergeSchedule": [ - "* 23 * * *" - ] -} diff --git a/src/auth.rs b/src/auth.rs index 1731ddc..c0156d0 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,5 +1,5 @@ -use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; use tower_http::validate_request::ValidateRequestHeaderLayer; use tracing::{trace, warn}; diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 05861c2..0000000 --- a/src/config.rs +++ /dev/null @@ -1,250 +0,0 @@ -use std::{ - fs::File, - net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, - path::PathBuf, -}; - -use axum_client_ip::ClientIpSource; -use miette::{Context, IntoDiagnostic}; - -#[derive(Debug, Default, Clone, Copy, serde::Deserialize, serde::Serialize)] -pub enum IpType { - #[default] - Both, - Ipv4Only, - Ipv6Only, -} - -impl IpType { - pub fn valid_for_type(self, ip: IpAddr) -> bool { - match self { - IpType::Both => true, - IpType::Ipv4Only => ip.is_ipv4(), - IpType::Ipv6Only => ip.is_ipv6(), - } - } -} - -impl std::fmt::Display for IpType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - IpType::Both => f.write_str("both"), - IpType::Ipv4Only => f.write_str("ipv4-only"), - IpType::Ipv6Only => f.write_str("ipv6-only"), - } - } -} - -impl std::str::FromStr for IpType { - type Err = miette::Error; - - fn from_str(s: &str) -> std::result::Result { - match s { - "both" => Ok(Self::Both), - "ipv4-only" => Ok(Self::Ipv4Only), - "ipv6-only" => Ok(Self::Ipv6Only), - _ => miette::bail!("expected one of 'ipv4-only', 'ipv6-only' or 'both', got '{s}'"), - } - } -} - -/// Webserver settings -#[derive(Debug, serde::Deserialize, serde::Serialize)] -pub struct Server { - /// Ip address and port of the server - #[serde(default = "default_address")] - pub address: SocketAddr, -} - -/// Password settings -#[derive(Debug, serde::Deserialize, serde::Serialize)] -pub struct Password { - /// File containing password to match against - /// - /// Should be of the format `username:password` and contain a single password - #[serde(default, skip_serializing_if = "Option::is_none")] - pub password_file: Option, - - /// Salt to get more unique hashed passwords and prevent table based attacks - #[serde(default = "default_salt")] - pub salt: Box, -} - -/// Records settings -#[derive(Debug, serde::Deserialize, serde::Serialize)] -pub struct Records { - /// Time To Live (in seconds) to set on the DNS records - #[serde( - default = "default_ttl", - serialize_with = "humantime_ser", - deserialize_with = "humantime_de" - )] - pub ttl: humantime::Duration, - - /// List of domain names for which to update the IP when an update is requested - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[allow(clippy::struct_field_names)] - pub records: Vec>, - - /// If provided, when an IPv6 prefix is provided with an update, this will be used to derive - /// the full IPv6 address of the client - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option, - - /// If a client id is provided the ipv6 update will be ignored (only the prefix will be used). - /// This domain will point to the ipv6 address instead of the address derived from the client - /// id (usually this is the router). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub router_domain: Option>, - - /// Set client IP source - /// - /// see: - #[serde(default = "default_ip_source")] - pub ip_source: ClientIpSource, - - /// Set which IPs to allow updating (ipv4, ipv6 or both) - #[serde(default = "default_ip_type")] - pub ip_type: IpType, - - /// Keyfile `nsupdate` should use - /// - /// If specified, then `webnsupdate` must have read access to the file - #[serde(default, skip_serializing_if = "Option::is_none")] - pub key_file: Option, -} - -#[derive(Debug, serde::Deserialize, serde::Serialize)] -pub struct Config { - /// Server Configuration - #[serde(flatten)] - pub server: Server, - - /// Password Configuration - #[serde(flatten)] - pub password: Password, - - /// Records Configuration - #[serde(flatten)] - pub records: Records, - - /// The config schema (used for lsp completions) - #[serde(default, rename = "$schema", skip_serializing)] - pub _schema: serde::de::IgnoredAny, -} - -impl Config { - /// Load the configuration without verifying it - pub fn load(path: &std::path::Path) -> miette::Result { - serde_json::from_reader::( - File::open(path) - .into_diagnostic() - .wrap_err_with(|| format!("failed open {}", path.display()))?, - ) - .into_diagnostic() - .wrap_err_with(|| format!("failed to load configuration from {}", path.display())) - } - - /// Ensure only a verified configuration is returned - pub fn verified(self) -> miette::Result { - self.verify()?; - Ok(self) - } - - /// Verify the configuration - pub fn verify(&self) -> Result<(), Invalid> { - let mut invalid_records: Vec = self - .records - .records - .iter() - .filter_map(|record| crate::records::validate_record_str(record).err()) - .collect(); - - invalid_records.extend( - self.records - .router_domain - .as_ref() - .and_then(|domain| crate::records::validate_record_str(domain).err()), - ); - - let err = Invalid { invalid_records }; - - if err.invalid_records.is_empty() { - Ok(()) - } else { - Err(err) - } - } -} - -#[derive(Debug, miette::Diagnostic, thiserror::Error)] -#[error("the configuration was invalid")] -pub struct Invalid { - #[related] - pub invalid_records: Vec, -} - -// --- Default Values (sadly serde doesn't have a way to specify a constant as a default value) --- - -fn default_ttl() -> humantime::Duration { - super::DEFAULT_TTL.into() -} - -fn default_salt() -> Box { - super::DEFAULT_SALT.into() -} - -fn default_address() -> SocketAddr { - SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 5353) -} - -fn default_ip_source() -> ClientIpSource { - ClientIpSource::RightmostXForwardedFor -} - -fn default_ip_type() -> IpType { - IpType::Both -} - -fn humantime_de<'de, D>(de: D) -> Result -where - D: serde::Deserializer<'de>, -{ - struct Visitor; - impl serde::de::Visitor<'_> for Visitor { - type Value = humantime::Duration; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(formatter, "a duration (e.g. 5s)") - } - - fn visit_str(self, v: &str) -> Result - where - E: serde::de::Error, - { - v.parse().map_err(E::custom) - } - } - de.deserialize_str(Visitor) -} - -fn humantime_ser(duration: &humantime::Duration, ser: S) -> Result -where - S: serde::Serializer, -{ - ser.serialize_str(&duration.to_string()) -} - -#[test] -fn default_values_config_snapshot() { - let config: Config = serde_json::from_str("{}").unwrap(); - insta::assert_json_snapshot!(config, @r#" - { - "address": "127.0.0.1:5353", - "salt": "UpdateMyDNS", - "ttl": "1m", - "ip_source": "RightmostXForwardedFor", - "ip_type": "Both" - } - "#); -} diff --git a/src/main.rs b/src/main.rs index 193df24..d2b9136 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,27 +1,21 @@ use std::{ io::ErrorKind, - net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + net::{IpAddr, SocketAddr}, path::{Path, PathBuf}, time::Duration, }; -use axum::{ - Router, - extract::{Query, State}, - routing::get, -}; -use axum_client_ip::ClientIp; -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use axum::{extract::State, routing::get, Router}; +use axum_client_ip::{SecureClientIp, SecureClientIpSource}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use clap::{Parser, Subcommand}; use clap_verbosity_flag::Verbosity; -use config::Config; use http::StatusCode; -use miette::{Context, IntoDiagnostic, Result, bail, ensure}; +use miette::{bail, ensure, Context, IntoDiagnostic, Result}; use tracing::{debug, error, info}; use tracing_subscriber::EnvFilter; mod auth; -mod config; mod nsupdate; mod password; mod records; @@ -34,52 +28,75 @@ struct Opts { #[command(flatten)] verbosity: Verbosity, + /// Ip address of the server + #[arg(long, default_value = "127.0.0.1")] + address: IpAddr, + + /// Port of the server + #[arg(long, default_value_t = 5353)] + port: u16, + + /// File containing password to match against + /// + /// Should be of the format `username:password` and contain a single password + #[arg(long)] + password_file: Option, + + /// Salt to get more unique hashed passwords and prevent table based attacks + #[arg(long, default_value = DEFAULT_SALT)] + salt: String, + + /// Time To Live (in seconds) to set on the DNS records + #[arg(long, default_value_t = DEFAULT_TTL.as_secs())] + ttl: u64, + /// Data directory - #[arg(long, env, default_value = ".")] + #[arg(long, default_value = ".")] data_dir: PathBuf, + /// File containing the records that should be updated when an update request is made + /// + /// There should be one record per line: + /// + /// ```text + /// example.com. + /// mail.example.com. + /// ``` + #[arg(long)] + records: PathBuf, + + /// Keyfile `nsupdate` should use + /// + /// If specified, then `webnsupdate` must have read access to the file + #[arg(long)] + key_file: Option, + /// Allow not setting a password #[arg(long)] insecure: bool, - #[clap(flatten)] - config_or_command: ConfigOrCommand, -} - -#[derive(clap::Args, Debug)] -#[group(multiple = false)] -struct ConfigOrCommand { - /// Path to the configuration file - #[arg(long, short)] - config: Option, + /// Set client IP source + /// + /// see: + #[clap(long, default_value = "RightmostXForwardedFor")] + ip_source: SecureClientIpSource, #[clap(subcommand)] subcommand: Option, } -impl ConfigOrCommand { - pub fn take(&mut self) -> (Option, Option) { - (self.config.take(), self.subcommand.take()) - } -} - #[derive(Debug, Subcommand)] enum Cmd { Mkpasswd(password::Mkpasswd), - /// Verify the configuration file - Verify { - /// Path to the configuration file - config: PathBuf, - }, + /// Verify the records file + Verify, } impl Cmd { pub fn process(self, args: &Opts) -> Result<()> { match self { Cmd::Mkpasswd(mkpasswd) => mkpasswd.process(args), - Cmd::Verify { config } => config::Config::load(&config) // load config - .and_then(Config::verified) // verify config - .map(drop), // ignore config data + Cmd::Verify => records::load(&args.records).map(drop), } } } @@ -97,90 +114,39 @@ struct AppState<'a> { /// The file where the last IP is stored ip_file: &'a Path, - - /// Last recorded IPs - last_ips: std::sync::Arc>, - - /// The IP type for which to allow updates - ip_type: config::IpType, -} - -#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] -struct SavedIPs { - #[serde(skip_serializing_if = "Option::is_none")] - ipv4: Option, - #[serde(skip_serializing_if = "Option::is_none")] - ipv6: Option, -} - -impl SavedIPs { - fn update(&mut self, ip: IpAddr) { - match ip { - IpAddr::V4(ipv4_addr) => self.ipv4 = Some(ipv4_addr), - IpAddr::V6(ipv6_addr) => self.ipv6 = Some(ipv6_addr), - } - } - - fn ips(&self) -> impl Iterator + use<> { - self.ipv4 - .map(IpAddr::V4) - .into_iter() - .chain(self.ipv6.map(IpAddr::V6)) - } - - fn from_str(data: &str) -> Result { - match data.parse::() { - // Old format - Ok(IpAddr::V4(ipv4)) => Ok(Self { - ipv4: Some(ipv4), - ipv6: None, - }), - Ok(IpAddr::V6(ipv6)) => Ok(Self { - ipv4: None, - ipv6: Some(ipv6), - }), - Err(_) => serde_json::from_str(data).into_diagnostic(), - } - } } impl AppState<'static> { - fn from_args(args: &Opts, config: &config::Config) -> Result { + fn from_args(args: &Opts) -> miette::Result { let Opts { verbosity: _, + address: _, + port: _, + password_file: _, data_dir, + key_file, insecure, - config_or_command: _, + subcommand: _, + records, + salt: _, + ttl, + ip_source: _, } = args; - let config::Records { - ttl, - records, - client_id: _, - router_domain: _, - ip_source: _, - ip_type, - key_file, - } = &config.records; + // Set state + let ttl = Duration::from_secs(*ttl); // Use last registered IP address if available - let ip_file = Box::leak(data_dir.join("last-ip.json").into_boxed_path()); - - // Leak DNS records - let records: &[&str] = &*Vec::leak( - records - .iter() - .map(|record| &*Box::leak(record.clone())) - .collect(), - ); + let ip_file = data_dir.join("last-ip"); let state = AppState { - ttl: **ttl, - records, + ttl, + // Load DNS records + records: records::load_no_verify(records)?, // Load keyfile key_file: key_file .as_deref() - .map(|path| -> Result<_> { + .map(|path| -> miette::Result<_> { std::fs::File::open(path) .into_diagnostic() .wrap_err_with(|| { @@ -189,11 +155,7 @@ impl AppState<'static> { Ok(&*Box::leak(path.into())) }) .transpose()?, - ip_file, - ip_type: *ip_type, - last_ips: std::sync::Arc::new(tokio::sync::Mutex::new( - load_ip(ip_file)?.unwrap_or_default(), - )), + ip_file: Box::leak(ip_file.into_boxed_path()), }; ensure!( @@ -205,7 +167,7 @@ impl AppState<'static> { } } -fn load_ip(path: &Path) -> Result> { +fn load_ip(path: &Path) -> Result> { debug!("loading last IP from {}", path.display()); let data = match std::fs::read_to_string(path) { Ok(ip) => ip, @@ -215,56 +177,15 @@ fn load_ip(path: &Path) -> Result> { _ => Err(err).into_diagnostic().wrap_err_with(|| { format!("failed to load last ip address from {}", path.display()) }), - }; + } } }; - SavedIPs::from_str(&data) - .wrap_err_with(|| format!("failed to load last ip address from {}", path.display())) - .map(Some) -} - -#[derive(Clone, Copy, Debug)] -struct Ipv6Prefix { - prefix: Ipv6Addr, - length: u32, -} - -impl std::fmt::Display for Ipv6Prefix { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Self { prefix, length } = self; - write!(f, "{prefix}/{length}") - } -} - -impl std::str::FromStr for Ipv6Prefix { - type Err = miette::Error; - - fn from_str(s: &str) -> std::result::Result { - let (addr, len) = s.split_once('/').wrap_err("missing `/` in ipv6 prefix")?; - Ok(Self { - prefix: addr - .parse() - .into_diagnostic() - .wrap_err("invalid ipv6 address for ipv6 prefix")?, - length: len - .parse() - .into_diagnostic() - .wrap_err("invalid length for ipv6 prefix")?, - }) - } -} - -fn load_password(path: &Path) -> Result> { - let pass = std::fs::read_to_string(path).into_diagnostic()?; - - let pass: Box<[u8]> = URL_SAFE_NO_PAD - .decode(pass.trim().as_bytes()) - .into_diagnostic() - .wrap_err_with(|| format!("failed to decode password from {}", path.display()))? - .into(); - - Ok(pass) + Ok(Some( + data.parse() + .into_diagnostic() + .wrap_err("failed to parse last ip address")?, + )) } #[tracing::instrument(err)] @@ -291,39 +212,45 @@ fn main() -> Result<()> { debug!("{args:?}"); - let config = match args.config_or_command.take() { - // process subcommand - (None, Some(cmd)) => return cmd.process(&args), - (Some(path), None) => { - let config = config::Config::load(&path)?; - if let Err(err) = config.verify() { - error!("failed to verify configuration: {err}"); - } - config - } - (None, None) | (Some(_), Some(_)) => unreachable!( - "bad state, one of config or subcommand should be available (clap should enforce this)" - ), - }; + // process subcommand + if let Some(cmd) = args.subcommand.take() { + return cmd.process(&args); + } // Initialize state - let state = AppState::from_args(&args, &config)?; + let state = AppState::from_args(&args)?; let Opts { verbosity: _, + address: ip, + port, + password_file, data_dir: _, + key_file: _, insecure, - config_or_command: _, + subcommand: _, + records: _, + salt, + ttl: _, + ip_source, } = args; info!("checking environment"); // Load password hash - let password_hash = config - .password - .password_file - .as_deref() - .map(load_password) + let password_hash = password_file + .map(|path| -> miette::Result<_> { + let path = path.as_path(); + let pass = std::fs::read_to_string(path).into_diagnostic()?; + + let pass: Box<[u8]> = URL_SAFE_NO_PAD + .decode(pass.trim().as_bytes()) + .into_diagnostic() + .wrap_err_with(|| format!("failed to decode password from {}", path.display()))? + .into(); + + Ok(pass) + }) .transpose() .wrap_err("failed to load password hash")?; @@ -338,381 +265,86 @@ fn main() -> Result<()> { .into_diagnostic() .wrap_err("failed to start the tokio runtime")?; - rt.block_on(async_main(state, config, password_hash)) - .wrap_err("failed to run main loop") -} - -#[tracing::instrument(err, skip(state, pass))] -async fn async_main( - state: AppState<'static>, - config: Config, - pass: Option>, -) -> Result<()> { - // Update DNS record with previous IPs (if available) - let ips = state.last_ips.lock().await.clone(); - - let mut actions = ips - .ips() - .filter(|ip| config.records.ip_type.valid_for_type(*ip)) - .flat_map(|ip| nsupdate::Action::from_records(ip, state.ttl, state.records)) - .peekable(); - - if actions.peek().is_some() { - match nsupdate::nsupdate(state.key_file, actions).await { - Ok(status) => { - if !status.success() { - error!("nsupdate failed: code {status}"); - bail!("nsupdate returned with code {status}"); + rt.block_on(async { + // Load previous IP and update DNS record to point to it (if available) + match load_ip(state.ip_file) { + Ok(Some(ip)) => { + match nsupdate::nsupdate(ip, state.ttl, state.key_file, state.records).await { + Ok(status) => { + if !status.success() { + error!("nsupdate failed: code {status}"); + bail!("nsupdate returned with code {status}"); + } + } + Err(err) => { + error!("Failed to update records with previous IP: {err}"); + return Err(err) + .into_diagnostic() + .wrap_err("failed to update records with previous IP"); + } } } - Err(err) => { - error!("Failed to update records with previous IP: {err}"); - return Err(err) - .into_diagnostic() - .wrap_err("failed to update records with previous IP"); - } + Ok(None) => info!("No previous IP address set"), + + Err(err) => error!("Ignoring previous IP due to: {err}"), + }; + + // Create services + let app = Router::new().route("/update", get(update_records)); + // if a password is provided, validate it + let app = if let Some(pass) = password_hash { + app.layer(auth::layer(Box::leak(pass), String::leak(salt))) + } else { + app } - } + .layer(ip_source.into_extension()) + .with_state(state); - // Create services - let app = Router::new().route("/update", get(update_records)); - // if a password is provided, validate it - let app = if let Some(pass) = pass { - app.layer(auth::layer( - Box::leak(pass), - Box::leak(config.password.salt), - )) - } else { - app - } - .layer(config.records.ip_source.into_extension()) - .with_state(state); - - let config::Server { address } = config.server; - - // Start services - info!("starting listener on {address}"); - let listener = tokio::net::TcpListener::bind(address) + // Start services + info!("starting listener on {ip}:{port}"); + let listener = tokio::net::TcpListener::bind(SocketAddr::new(ip, port)) + .await + .into_diagnostic()?; + info!("listening on {ip}:{port}"); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) .await - .into_diagnostic()?; - info!("listening on {address}"); - axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ) - .await - .into_diagnostic() -} - -/// Serde deserialization decorator to map empty Strings to None, -/// -/// Adapted from: -fn empty_string_as_none<'de, D, T>(de: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, - T: std::str::FromStr, - T::Err: std::fmt::Display, -{ - use serde::Deserialize; - - let opt = Option::>::deserialize(de)?; - match opt.as_deref() { - None | Some("") => Ok(None), - Some(s) => s.parse::().map_err(serde::de::Error::custom).map(Some), - } -} - -#[derive(Debug, serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct FritzBoxUpdateParams { - /// The domain that should be updated - #[allow(unused)] - #[serde(default, deserialize_with = "empty_string_as_none")] - domain: Option, - /// IPv4 address for the domain - #[serde(default, deserialize_with = "empty_string_as_none")] - ipv4: Option, - /// IPv6 address for the domain - #[serde(default, deserialize_with = "empty_string_as_none")] - ipv6: Option, - /// IPv6 prefix for the home network - #[allow(unused)] - #[serde(default, deserialize_with = "empty_string_as_none")] - ipv6prefix: Option, - /// Whether the networks uses both IPv4 and IPv6 - #[allow(unused)] - #[serde(default, deserialize_with = "empty_string_as_none")] - dualstack: Option, -} - -impl FritzBoxUpdateParams { - fn has_data(&self) -> bool { - let Self { - domain, - ipv4, - ipv6, - ipv6prefix, - dualstack, - } = self; - domain.is_some() - | ipv4.is_some() - | ipv6.is_some() - | ipv6prefix.is_some() - | dualstack.is_some() - } + .into_diagnostic() + }) + .wrap_err("failed to run main loop") } #[tracing::instrument(skip(state), level = "trace", ret(level = "info"))] async fn update_records( State(state): State>, - ClientIp(ip): ClientIp, - Query(update_params): Query, + SecureClientIp(ip): SecureClientIp, ) -> axum::response::Result<&'static str> { info!("accepted update from {ip}"); - - if !update_params.has_data() { - if !state.ip_type.valid_for_type(ip) { - tracing::warn!( - "rejecting update from {ip} as we are running a {} filter", - state.ip_type - ); - return Err(( - StatusCode::CONFLICT, - format!("running in {} mode", state.ip_type), - ) - .into()); - } - - return trigger_update(ip, &state).await; - } - - // FIXME: mark suspicious updates (where IP doesn't match the update_ip) and reject them based - // on policy - - let FritzBoxUpdateParams { - domain: _, - ipv4, - ipv6, - ipv6prefix: _, - dualstack: _, - } = update_params; - - if ipv4.is_none() && ipv6.is_none() { - return Err(( - StatusCode::BAD_REQUEST, - "failed to provide an IP for the update", - ) - .into()); - } - - if let Some(ip) = ipv4 { - let ip = IpAddr::V4(ip); - if state.ip_type.valid_for_type(ip) { - _ = trigger_update(ip, &state).await?; - } else { - tracing::warn!("requested update of IPv4 but we are {}", state.ip_type); - } - } - - if let Some(ip) = ipv6 { - let ip = IpAddr::V6(ip); - if state.ip_type.valid_for_type(ip) { - _ = trigger_update(ip, &state).await?; - } else { - tracing::warn!("requested update of IPv6 but we are {}", state.ip_type); - } - } - - Ok("Successfully updated IP of records!\n") -} - -#[tracing::instrument(skip(state), level = "trace", ret(level = "info"))] -async fn trigger_update( - ip: IpAddr, - state: &AppState<'static>, -) -> axum::response::Result<&'static str> { - let actions = nsupdate::Action::from_records(ip, state.ttl, state.records); - - if actions.len() == 0 { - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - "Nothing to do (e.g. we are ipv4-only but an ipv6 update was requested)", - ) - .into()); - } - - match nsupdate::nsupdate(state.key_file, actions).await { + match nsupdate::nsupdate(ip, state.ttl, state.key_file, state.records).await { Ok(status) if status.success() => { - let ips = { - // Update state - let mut ips = state.last_ips.lock().await; - ips.update(ip); - ips.clone() - }; - - let ip_file = state.ip_file; tokio::task::spawn_blocking(move || { - info!("updating last ips to {ips:?}"); - let data = serde_json::to_vec(&ips).expect("invalid serialization impl"); - if let Err(err) = std::fs::write(ip_file, data) { + info!("updating last ip to {ip}"); + if let Err(err) = std::fs::write(state.ip_file, format!("{ip}")) { error!("Failed to update last IP: {err}"); } - info!("updated last ips to {ips:?}"); + info!("updated last ip to {ip}"); }); - - Ok("Successfully updated IP of records!\n") + Ok("successful update") } Ok(status) => { error!("nsupdate failed with code {status}"); Err(( StatusCode::INTERNAL_SERVER_ERROR, - "nsupdate failed, check server logs\n", + "nsupdate failed, check server logs", ) .into()) } Err(error) => Err(( StatusCode::INTERNAL_SERVER_ERROR, - format!("failed to update records: {error}\n"), + format!("failed to update records: {error}"), ) .into()), } } - -#[cfg(test)] -mod parse_query_params { - use axum::extract::Query; - - use super::FritzBoxUpdateParams; - - #[test] - fn no_params() { - let uri = http::Uri::builder() - .path_and_query("/update") - .build() - .unwrap(); - let query: Query = Query::try_from_uri(&uri).unwrap(); - insta::assert_debug_snapshot!(query, @r#" - Query( - FritzBoxUpdateParams { - domain: None, - ipv4: None, - ipv6: None, - ipv6prefix: None, - dualstack: None, - }, - ) - "#); - } - - #[test] - fn ipv4() { - let uri = http::Uri::builder() - .path_and_query("/update?ipv4=1.2.3.4") - .build() - .unwrap(); - let query: Query = Query::try_from_uri(&uri).unwrap(); - insta::assert_debug_snapshot!(query, @r#" - Query( - FritzBoxUpdateParams { - domain: None, - ipv4: Some( - 1.2.3.4, - ), - ipv6: None, - ipv6prefix: None, - dualstack: None, - }, - ) - "#); - } - - #[test] - fn ipv6() { - let uri = http::Uri::builder() - .path_and_query("/update?ipv6=%3A%3A1234") - .build() - .unwrap(); - let query: Query = Query::try_from_uri(&uri).unwrap(); - insta::assert_debug_snapshot!(query, @r#" - Query( - FritzBoxUpdateParams { - domain: None, - ipv4: None, - ipv6: Some( - ::1234, - ), - ipv6prefix: None, - dualstack: None, - }, - ) - "#); - } - - #[test] - fn ipv4_and_ipv6() { - let uri = http::Uri::builder() - .path_and_query("/update?ipv4=1.2.3.4&ipv6=%3A%3A1234") - .build() - .unwrap(); - let query: Query = Query::try_from_uri(&uri).unwrap(); - insta::assert_debug_snapshot!(query, @r#" - Query( - FritzBoxUpdateParams { - domain: None, - ipv4: Some( - 1.2.3.4, - ), - ipv6: Some( - ::1234, - ), - ipv6prefix: None, - dualstack: None, - }, - ) - "#); - } - - #[test] - fn ipv4_and_empty_ipv6() { - let uri = http::Uri::builder() - .path_and_query("/update?ipv4=1.2.3.4&ipv6=") - .build() - .unwrap(); - let query: Query = Query::try_from_uri(&uri).unwrap(); - insta::assert_debug_snapshot!(query, @r#" - Query( - FritzBoxUpdateParams { - domain: None, - ipv4: Some( - 1.2.3.4, - ), - ipv6: None, - ipv6prefix: None, - dualstack: None, - }, - ) - "#); - } - - #[test] - fn empty_ipv4_and_ipv6() { - let uri = http::Uri::builder() - .path_and_query("/update?ipv4=&ipv6=%3A%3A1234") - .build() - .unwrap(); - let query: Query = Query::try_from_uri(&uri).unwrap(); - insta::assert_debug_snapshot!(query, @r#" - Query( - FritzBoxUpdateParams { - domain: None, - ipv4: None, - ipv6: Some( - ::1234, - ), - ipv6prefix: None, - dualstack: None, - }, - ) - "#); - } -} diff --git a/src/nsupdate.rs b/src/nsupdate.rs index eeacc93..62395b7 100644 --- a/src/nsupdate.rs +++ b/src/nsupdate.rs @@ -9,51 +9,12 @@ use std::{ use tokio::io::AsyncWriteExt; use tracing::{debug, warn}; -pub enum Action<'a> { - // Reassign a domain to a different IP - Reassign { - domain: &'a str, - to: IpAddr, - ttl: Duration, - }, -} - -impl<'a> Action<'a> { - /// Create a set of [`Action`]s reassigning the domains in `records` to the specified - /// [`IpAddr`] - pub fn from_records( - to: IpAddr, - ttl: Duration, - records: &'a [&'a str], - ) -> impl IntoIterator + std::iter::ExactSizeIterator + 'a { - records - .iter() - .map(move |&domain| Action::Reassign { domain, to, ttl }) - } -} - -impl std::fmt::Display for Action<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Action::Reassign { domain, to, ttl } => { - let ttl = ttl.as_secs(); - let kind = match to { - IpAddr::V4(_) => "A", - IpAddr::V6(_) => "AAAA", - }; - // Delete previous record of type `kind` - writeln!(f, "update delete {domain} {ttl} IN {kind}")?; - // Add record with new IP - writeln!(f, "update add {domain} {ttl} IN {kind} {to}") - } - } - } -} - -#[tracing::instrument(level = "trace", skip(actions), ret(level = "warn"))] +#[tracing::instrument(level = "trace", ret(level = "warn"))] pub async fn nsupdate( + ip: IpAddr, + ttl: Duration, key_file: Option<&Path>, - actions: impl IntoIterator>, + records: &[&str], ) -> std::io::Result { let mut cmd = tokio::process::Command::new("nsupdate"); if let Some(key_file) = key_file { @@ -66,13 +27,10 @@ pub async fn nsupdate( .inspect_err(|err| warn!("failed to spawn child: {err}"))?; let mut stdin = child.stdin.take().expect("stdin not present"); debug!("sending update request"); - let mut buf = Vec::new(); - update_ns_records(&mut buf, actions).unwrap(); stdin - .write_all(&buf) + .write_all(update_ns_records(ip, ttl, records).as_bytes()) .await .inspect_err(|err| warn!("failed to write to the stdin of nsupdate: {err}"))?; - debug!("closing stdin"); stdin .shutdown() @@ -85,16 +43,21 @@ pub async fn nsupdate( .inspect_err(|err| warn!("failed to wait for child: {err}")) } -fn update_ns_records<'a>( - mut buf: impl std::io::Write, - actions: impl IntoIterator>, -) -> std::io::Result<()> { - writeln!(buf, "server 127.0.0.1")?; - for action in actions { - write!(buf, "{action}")?; +fn update_ns_records(ip: IpAddr, ttl: Duration, records: &[&str]) -> String { + use std::fmt::Write; + let ttl_s: u64 = ttl.as_secs(); + + let rec_type = match ip { + IpAddr::V4(_) => "A", + IpAddr::V6(_) => "AAAA", + }; + let mut cmds = String::from("server 127.0.0.1\n"); + for &record in records { + writeln!(cmds, "update delete {record} {ttl_s} IN {rec_type}").unwrap(); + writeln!(cmds, "update add {record} {ttl_s} IN {rec_type} {ip}").unwrap(); } - writeln!(buf, "send")?; - writeln!(buf, "quit") + writeln!(cmds, "send\nquit").unwrap(); + cmds } #[cfg(test)] @@ -103,21 +66,17 @@ mod test { use insta::assert_snapshot; - use super::{Action, update_ns_records}; + use super::update_ns_records; use crate::DEFAULT_TTL; #[test] #[allow(non_snake_case)] fn expected_update_string_A() { - let mut buf = Vec::new(); - let actions = Action::from_records( - IpAddr::V4(Ipv4Addr::LOCALHOST), - DEFAULT_TTL, - &["example.com.", "example.org.", "example.net."], - ); - update_ns_records(&mut buf, actions).unwrap(); - - assert_snapshot!(String::from_utf8(buf).unwrap(), @r###" + assert_snapshot!(update_ns_records( + IpAddr::V4(Ipv4Addr::LOCALHOST), + DEFAULT_TTL, + &["example.com.", "example.org.", "example.net."], + ), @r###" server 127.0.0.1 update delete example.com. 60 IN A update add example.com. 60 IN A 127.0.0.1 @@ -133,15 +92,11 @@ mod test { #[test] #[allow(non_snake_case)] fn expected_update_string_AAAA() { - let mut buf = Vec::new(); - let actions = Action::from_records( - IpAddr::V6(Ipv6Addr::LOCALHOST), - DEFAULT_TTL, - &["example.com.", "example.org.", "example.net."], - ); - update_ns_records(&mut buf, actions).unwrap(); - - assert_snapshot!(String::from_utf8(buf).unwrap(), @r###" + assert_snapshot!(update_ns_records( + IpAddr::V6(Ipv6Addr::LOCALHOST), + DEFAULT_TTL, + &["example.com.", "example.org.", "example.net."], + ), @r###" server 127.0.0.1 update delete example.com. 60 IN AAAA update add example.com. 60 IN AAAA ::1 diff --git a/src/password.rs b/src/password.rs index d99a93b..8d965ba 100644 --- a/src/password.rs +++ b/src/password.rs @@ -4,7 +4,7 @@ //! records use std::io::Write; use std::os::unix::fs::OpenOptionsExt; -use std::path::PathBuf; +use std::path::Path; use base64::prelude::*; use miette::{Context, IntoDiagnostic, Result}; @@ -20,18 +20,11 @@ pub struct Mkpasswd { /// The password password: String, - - /// An application specific value - #[arg(long, default_value = crate::DEFAULT_SALT)] - salt: String, - - /// The file to write the password to - password_file: Option, } impl Mkpasswd { - pub fn process(self, _args: &crate::Opts) -> Result<()> { - mkpasswd(self) + pub fn process(self, args: &crate::Opts) -> Result<()> { + mkpasswd(self, args.password_file.as_deref(), &args.salt) } } @@ -52,16 +45,13 @@ pub fn hash_identity(username: &str, password: &str, salt: &str) -> Digest { } pub fn mkpasswd( - Mkpasswd { - username, - password, - salt, - password_file, - }: Mkpasswd, + Mkpasswd { username, password }: Mkpasswd, + password_file: Option<&Path>, + salt: &str, ) -> miette::Result<()> { - let hash = hash_identity(&username, &password, &salt); + let hash = hash_identity(&username, &password, salt); let encoded = BASE64_URL_SAFE_NO_PAD.encode(hash.as_ref()); - let Some(path) = password_file.as_deref() else { + let Some(path) = password_file else { println!("{encoded}"); return Ok(()); }; diff --git a/src/records.rs b/src/records.rs index 4e2b832..5ca6528 100644 --- a/src/records.rs +++ b/src/records.rs @@ -1,9 +1,52 @@ //! Deal with the DNS records -use miette::{LabeledSpan, Result, ensure, miette}; +use std::path::Path; -pub fn validate_record_str(record: &str) -> Result<()> { - validate_line(0, record).map_err(|err| err.with_source_code(String::from(record))) +use miette::{ensure, miette, Context, IntoDiagnostic, LabeledSpan, NamedSource, Result}; + +/// Loads and verifies the records from a file +pub fn load(path: &Path) -> Result<()> { + let records = std::fs::read_to_string(path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read records from {}", path.display()))?; + + verify(&records, path)?; + + Ok(()) +} + +/// Load records without verifying them +pub fn load_no_verify(path: &Path) -> Result<&'static [&'static str]> { + let records = std::fs::read_to_string(path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read records from {}", path.display()))?; + + if let Err(err) = verify(&records, path) { + tracing::error!("Failed to verify records: {err}"); + } + + // leak memory: we only do this here and it prevents a bunch of allocations + let records: &str = records.leak(); + let records: Box<[&str]> = records.lines().collect(); + + Ok(Box::leak(records)) +} + +/// Verifies that a list of records is valid +pub fn verify(data: &str, path: &Path) -> Result<()> { + let mut offset = 0usize; + for line in data.lines() { + validate_line(offset, line).map_err(|err| { + err.with_source_code(NamedSource::new( + path.display().to_string(), + data.to_string(), + )) + })?; + + offset += line.len() + 1; + } + + Ok(()) } fn validate_line(offset: usize, line: &str) -> Result<()> { @@ -113,7 +156,7 @@ fn validate_octet(offset: usize, octet: u8) -> Result<()> { #[cfg(test)] mod test { - use crate::records::validate_record_str; + use crate::records::verify; macro_rules! assert_miette_snapshot { ($diag:expr) => {{ @@ -137,51 +180,104 @@ mod test { #[test] fn valid_records() -> miette::Result<()> { - for record in [ - "example.com.", - "example.org.", - "example.net.", - "subdomain.example.com.", - ] { - validate_record_str(record)?; - } - Ok(()) + verify( + "\ + example.com.\n\ + example.org.\n\ + example.net.\n\ + subdomain.example.com.\n\ + ", + std::path::Path::new("test_records_valid"), + ) } #[test] fn hostname_too_long() { - let err = validate_record_str("example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.").unwrap_err(); + let err = verify( + "\ + example.com.\n\ + example.org.\n\ + example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.\n\ + subdomain.example.com.\n\ + ", + std::path::Path::new("test_records_invalid"), + ) + .unwrap_err(); assert_miette_snapshot!(err); } #[test] fn not_fqd() { - let err = validate_record_str("example.net").unwrap_err(); + let err = verify( + "\ + example.com.\n\ + example.org.\n\ + example.net\n\ + subdomain.example.com.\n\ + ", + std::path::Path::new("test_records_invalid"), + ) + .unwrap_err(); assert_miette_snapshot!(err); } #[test] fn empty_label() { - let err = validate_record_str("name..example.org.").unwrap_err(); + let err = verify( + "\ + example.com.\n\ + name..example.org.\n\ + example.net.\n\ + subdomain.example.com.\n\ + ", + std::path::Path::new("test_records_invalid"), + ) + .unwrap_err(); assert_miette_snapshot!(err); } #[test] fn label_too_long() { - let err = validate_record_str("name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.").unwrap_err(); + let err = verify( + "\ + example.com.\n\ + name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.\n\ + example.net.\n\ + subdomain.example.com.\n\ + ", + std::path::Path::new("test_records_invalid"), + ) + .unwrap_err(); assert_miette_snapshot!(err); } #[test] fn invalid_ascii() { - let err = validate_record_str("name.this-is-not-ascii-รŸ.example.org.").unwrap_err(); + let err = verify( + "\ + example.com.\n\ + name.this-is-not-aรŸcii.example.org.\n\ + example.net.\n\ + subdomain.example.com.\n\ + ", + std::path::Path::new("test_records_invalid"), + ) + .unwrap_err(); assert_miette_snapshot!(err); } #[test] fn invalid_octet() { - let err = - validate_record_str("name.this-character:-is-not-allowed.example.org.").unwrap_err(); + let err = verify( + "\ + example.com.\n\ + name.this-character:-is-not-allowed.example.org.\n\ + example.net.\n\ + subdomain.example.com.\n\ + ", + std::path::Path::new("test_records_invalid"), + ) + .unwrap_err(); assert_miette_snapshot!(err); } } diff --git a/src/snapshots/webnsupdate__records__test__empty_label.snap b/src/snapshots/webnsupdate__records__test__empty_label.snap index d6fb7fa..e4d227b 100644 --- a/src/snapshots/webnsupdate__records__test__empty_label.snap +++ b/src/snapshots/webnsupdate__records__test__empty_label.snap @@ -6,9 +6,11 @@ expression: out ]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\ ร— empty label - โ•ญโ”€โ”€โ”€โ”€ - 1 โ”‚ name..example.org. + โ•ญโ”€[test_records_invalid:2:6] + 1 โ”‚ example.com. + 2 โ”‚ name..example.org. ยท โ–ฒ ยท โ•ฐโ”€โ”€ label + 3 โ”‚ example.net. โ•ฐโ”€โ”€โ”€โ”€ help: each label should have at least one character diff --git a/src/snapshots/webnsupdate__records__test__hostname_too_long.snap b/src/snapshots/webnsupdate__records__test__hostname_too_long.snap index 5c48b16..051d8ce 100644 --- a/src/snapshots/webnsupdate__records__test__hostname_too_long.snap +++ b/src/snapshots/webnsupdate__records__test__hostname_too_long.snap @@ -6,9 +6,11 @@ expression: out ]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\ ร— hostname too long (260 octets) - โ•ญโ”€โ”€โ”€โ”€ - 1 โ”‚ example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net. + โ•ญโ”€[test_records_invalid:3:1] + 2 โ”‚ example.org. + 3 โ”‚ example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net. ยท โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ยท โ•ฐโ”€โ”€ this line + 4 โ”‚ subdomain.example.com. โ•ฐโ”€โ”€โ”€โ”€ help: fully qualified domain names can be at most 255 characters long diff --git a/src/snapshots/webnsupdate__records__test__invalid_ascii.snap b/src/snapshots/webnsupdate__records__test__invalid_ascii.snap index 6ef64e3..a777b6d 100644 --- a/src/snapshots/webnsupdate__records__test__invalid_ascii.snap +++ b/src/snapshots/webnsupdate__records__test__invalid_ascii.snap @@ -6,9 +6,11 @@ expression: out ]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\ ร— invalid octet: '\xc3' - โ•ญโ”€โ”€โ”€โ”€ - 1 โ”‚ name.this-is-not-ascii-รŸ.example.org. - ยท โ”ฌ - ยท โ•ฐโ”€โ”€ octet + โ•ญโ”€[test_records_invalid:2:19] + 1 โ”‚ example.com. + 2 โ”‚ name.this-is-not-aรŸcii.example.org. + ยท โ”ฌ + ยท โ•ฐโ”€โ”€ octet + 3 โ”‚ example.net. โ•ฐโ”€โ”€โ”€โ”€ help: we only accept ascii characters diff --git a/src/snapshots/webnsupdate__records__test__invalid_octet.snap b/src/snapshots/webnsupdate__records__test__invalid_octet.snap index ed8a44c..2da284e 100644 --- a/src/snapshots/webnsupdate__records__test__invalid_octet.snap +++ b/src/snapshots/webnsupdate__records__test__invalid_octet.snap @@ -6,9 +6,11 @@ expression: out ]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\ ร— invalid octet: ':' - โ•ญโ”€โ”€โ”€โ”€ - 1 โ”‚ name.this-character:-is-not-allowed.example.org. + โ•ญโ”€[test_records_invalid:2:20] + 1 โ”‚ example.com. + 2 โ”‚ name.this-character:-is-not-allowed.example.org. ยท โ”ฌ ยท โ•ฐโ”€โ”€ octet + 3 โ”‚ example.net. โ•ฐโ”€โ”€โ”€โ”€ help: hostnames are only allowed to contain characters in [a-zA-Z0-9_-] diff --git a/src/snapshots/webnsupdate__records__test__label_too_long.snap b/src/snapshots/webnsupdate__records__test__label_too_long.snap index f1561ae..b529a1f 100644 --- a/src/snapshots/webnsupdate__records__test__label_too_long.snap +++ b/src/snapshots/webnsupdate__records__test__label_too_long.snap @@ -6,9 +6,11 @@ expression: out ]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\ ร— label too long (78 octets) - โ•ญโ”€โ”€โ”€โ”€ - 1 โ”‚ name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org. + โ•ญโ”€[test_records_invalid:2:6] + 1 โ”‚ example.com. + 2 โ”‚ name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org. ยท โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ยท โ•ฐโ”€โ”€ label + 3 โ”‚ example.net. โ•ฐโ”€โ”€โ”€โ”€ help: labels should be at most 63 octets diff --git a/src/snapshots/webnsupdate__records__test__not_fqd.snap b/src/snapshots/webnsupdate__records__test__not_fqd.snap index ccf6746..bc8270d 100644 --- a/src/snapshots/webnsupdate__records__test__not_fqd.snap +++ b/src/snapshots/webnsupdate__records__test__not_fqd.snap @@ -6,9 +6,11 @@ expression: out ]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\ ร— not a fully qualified domain name - โ•ญโ”€โ”€โ”€โ”€ - 1 โ”‚ example.net + โ•ญโ”€[test_records_invalid:3:11] + 2 โ”‚ example.org. + 3 โ”‚ example.net ยท โ”ฌ ยท โ•ฐโ”€โ”€ last character + 4 โ”‚ subdomain.example.com. โ•ฐโ”€โ”€โ”€โ”€ help: hostname should be a fully qualified domain name (end with a '.')