diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..28bde6a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +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 ab33bfa..80adea4 100644 --- a/.forgejo/workflows/check.yml +++ b/.forgejo/workflows/check.yml @@ -1,14 +1,41 @@ on: [push] jobs: - check: + check-renovaterc: runs-on: nixos steps: - - uses: https://git.salame.cl/actions/checkout@v4 - - run: nix --version - - run: nix flake check --keep-going --verbose --print-build-logs + - uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - name: Validate renovaterc + run: | + nix --version + nix shell nixpkgs#renovate --command renovate-config-validator build: runs-on: nixos steps: - - uses: https://git.salame.cl/actions/checkout@v4 + - uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - name: Build Package + run: | + nix --version + nix build --print-build-logs .# + test: + needs: build # we use the built binaries in the checks + runs-on: nixos + steps: + - uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - name: Run tests + run: | + nix --version + nix-fast-build --max-jobs 2 --no-nom --skip-cached --no-link \ + --flake ".#checks.$(nix eval --raw --impure --expr builtins.currentSystem)" + report-size: + runs-on: nixos + needs: build + steps: + - uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - run: nix --version - - run: nix build --print-build-logs .# + - name: Generate size report + uses: "https://git.salame.cl/jalil/nix-flake-outputs-size@5c40a31e3e2ed0ea28f8ba68deca41d05fdf2e71" # main + with: + comment-on-pr: ${{ github.ref_name != 'main' }} + generate-artifact: ${{ github.ref_name == 'main' }} + do-comparison: true + job-name: report-size diff --git a/.forgejo/workflows/update.yml b/.forgejo/workflows/update.yml deleted file mode 100644 index 59f1214..0000000 --- a/.forgejo/workflows/update.yml +++ /dev/null @@ -1,62 +0,0 @@ -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: cargo update \n\n" -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 - 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.DEPS_UPDATER_GITHUB_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -xeuo pipefail - tea login add --token "$GITHUB_TOKEN" - tea pr create --title "${PR_TITLE}" --description "$(cat body.md)" --repo "$GITHUB_REPOSITORY" diff --git a/.renovaterc.json b/.renovaterc.json new file mode 100644 index 0000000..2a15a88 --- /dev/null +++ b/.renovaterc.json @@ -0,0 +1,44 @@ +{ + "$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": { + "commitMessageTopic": "Rust crate {{depName}}", + "fileMatch": [ + "(^|/)Cargo\\.toml$" + ], + "versioning": "cargo", + "enabled": true + }, + "nix": { + "fileMatch": [ + "(^|/)flake\\.nix$" + ], + "commitMessageTopic": "nixpkgs", + "commitMessageExtra": "to {{newValue}}", + "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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..45c9f8c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,139 @@ +# Changelog + +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 + +- *(main)* Add more logging and default to info + +## [0.3.3] - 2024-12-22 + +### ๐Ÿš€ Features + +- *(ci)* Generate package size report +- Add git-cliff to generate changelogs + +### ๐Ÿ› Bug Fixes + +- *(webnsupdate)* Reduce binary size +- *(ci)* Remove tea + +### โš™๏ธ Miscellaneous Tasks + +- *(flake.lock)* Update inputs +- Cargo update +- Generate base changelog + +## [0.3.2] - 2024-11-23 + +### ๐Ÿš€ Features + +- *(ci)* Check depends on build +- Upgrade clap_verbosity_flag +- Replace axum-auth with tower_http +- Release new version + +### ๐Ÿ› Bug Fixes + +- *(clippy)* Enable more lints and fix issues + +### ๐Ÿšœ Refactor + +- Reorganize main.rs + +### โš™๏ธ Miscellaneous Tasks + +- Cargo update +- Update flake inputs + +## [0.3.1] - 2024-10-28 + +### ๐Ÿ› Bug Fixes + +- Overlay was broken T-T + +### โš™๏ธ Miscellaneous Tasks + +- Next dev version + +## [0.3.0] - 2024-10-28 + +### ๐Ÿš€ Features + +- *(ci)* Auto-update rust deps +- Refactor and add ip saving +- Add -v verbosity flag +- Use treefmt-nix and split up flake.nix +- Add NixOS VM tests +- Switch to crane + +### ๐Ÿ› Bug Fixes + +- *(fmt)* Use nixfmt-rfc-style +- *(default.nix)* Small issues here and there +- *(ci)* Do not use a name when logging in + +### ๐Ÿšœ Refactor + +- *(flake)* Use flake-parts + +### โš™๏ธ Miscellaneous Tasks + +- Updarte deps +- *(flake.lock)* Update inputs +- Cargo update +- Cargo update +- Cargo update + +## [0.2.0] - 2024-06-02 + +### ๐Ÿ’ผ Other + +- Init at version 0.1.0 + + diff --git a/Cargo.lock b/Cargo.lock index 3f1ae0a..ce274f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.17" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" dependencies = [ "anstyle", "anstyle-parse", @@ -43,9 +43,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" @@ -67,34 +67,24 @@ dependencies = [ [[package]] name = "anstyle-wincon" -version = "3.0.6" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" dependencies = [ "anstyle", + "once_cell", "windows-sys 0.59.0", ] -[[package]] -name = "async-trait" -version = "0.1.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "axum" -version = "0.7.7" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae" +checksum = "de45108900e1f9b9242f7f2e254aa3e2c029c921c258fe9e6b4217eeebd54288" dependencies = [ - "async-trait", "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -112,7 +102,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper 1.0.1", + "sync_wrapper", "tokio", "tower", "tower-layer", @@ -120,23 +110,11 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-auth" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8169113a185f54f68614fcfc3581df585d30bf8542bcb99496990e1025e4120a" -dependencies = [ - "async-trait", - "axum-core", - "base64 0.21.7", - "http", -] - [[package]] name = "axum-client-ip" -version = "0.6.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eefda7e2b27e1bda4d6fa8a06b50803b8793769045918bc37ad062d48a6efac" +checksum = "b9329923fe6c30624095e63cb6c25796b32ffbf5d1da8c3a95d1054c301db92a" dependencies = [ "axum", "forwarded-header-value", @@ -145,20 +123,19 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" dependencies = [ - "async-trait", "bytes", - "futures-util", + "futures-core", "http", "http-body", "http-body-util", "mime", "pin-project-lite", "rustversion", - "sync_wrapper 1.0.1", + "sync_wrapper", "tower-layer", "tower-service", "tracing", @@ -176,7 +153,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -188,12 +165,6 @@ dependencies = [ "backtrace", ] -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -202,21 +173,21 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" [[package]] name = "bytes" -version = "1.8.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.1.31" +version = "1.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2e7962b54006dcfcc61cb72735f4d89bb97061dd6a7ed882ec6b8ee53714c6f" +checksum = "04da6a0d40b948dfc4fa8f5bbf402b0fc1a64a28dbf7d12ffd683550f2c1b63a" dependencies = [ "shlex", ] @@ -229,9 +200,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.5.20" +version = "4.5.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" +checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" dependencies = [ "clap_builder", "clap_derive", @@ -239,19 +210,19 @@ dependencies = [ [[package]] name = "clap-verbosity-flag" -version = "2.2.2" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e099138e1807662ff75e2cebe4ae2287add879245574489f9b1588eb5e5564ed" +checksum = "2678fade3b77aa3a8ff3aae87e9c008d3fb00473a41c71fbf74e91c8c7b37e84" dependencies = [ "clap", - "log", + "tracing-core", ] [[package]] name = "clap_builder" -version = "4.5.20" +version = "4.5.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" +checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" dependencies = [ "anstream", "anstyle", @@ -261,9 +232,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.18" +version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" dependencies = [ "heck", "proc-macro2", @@ -273,9 +244,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "colorchoice" @@ -285,30 +256,30 @@ checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" [[package]] name = "console" -version = "0.15.8" +version = "0.15.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" dependencies = [ "encode_unicode", - "lazy_static", "libc", - "windows-sys 0.52.0", + "once_cell", + "windows-sys 0.59.0", ] [[package]] name = "encode_unicode" -version = "0.3.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -333,7 +304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" dependencies = [ "nonempty", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -371,9 +342,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", @@ -392,17 +363,11 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - [[package]] name = "http" -version = "1.1.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", @@ -421,12 +386,12 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", - "futures-util", + "futures-core", "http", "http-body", "pin-project-lite", @@ -434,9 +399,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.9.5" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" @@ -445,10 +410,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "hyper" -version = "1.5.0" +name = "humantime" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a" +checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" dependencies = [ "bytes", "futures-channel", @@ -465,9 +436,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b" +checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" dependencies = [ "bytes", "futures-util", @@ -481,13 +452,13 @@ dependencies = [ [[package]] name = "insta" -version = "1.40.0" +version = "1.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6593a41c7a73841868772495db7dc1e8ecab43bb5c0b6da2059246c4b506ab60" +checksum = "ab2d11b2f17a45095b8c3603928ba29d7d918d7129d0d0641a36ba73cf07daa6" dependencies = [ "console", - "lazy_static", - "linked-hash-map", + "once_cell", + "serde", "similar", ] @@ -505,9 +476,9 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itoa" -version = "1.0.11" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "lazy_static" @@ -517,27 +488,21 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.161" +version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" - -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "linux-raw-sys" -version = "0.4.14" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "log" -version = "0.4.22" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "matchers" @@ -550,9 +515,9 @@ dependencies = [ [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" @@ -562,9 +527,9 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "miette" -version = "7.2.0" +version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4edc8853320c2a0dab800fbda86253c8938f6ea88510dc92c5f1ed20e794afc1" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" dependencies = [ "backtrace", "backtrace-ext", @@ -576,15 +541,14 @@ dependencies = [ "supports-unicode", "terminal_size", "textwrap", - "thiserror", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] name = "miette-derive" -version = "7.2.0" +version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf09caffaac8068c346b6df2a7fc27a177fd20b39421a39ce0a211bde679a6c" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", @@ -599,20 +563,19 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "miniz_oxide" -version = "0.8.0" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" dependencies = [ "adler2", ] [[package]] name = "mio" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ - "hermit-abi", "libc", "wasi", "windows-sys 0.52.0", @@ -636,18 +599,18 @@ dependencies = [ [[package]] name = "object" -version = "0.36.5" +version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.20.2" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "overload" @@ -657,9 +620,9 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "owo-colors" -version = "4.1.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56" +checksum = "1036865bb9422d3300cf723f657c2851d0e9ab12567854b1f4eba3d77decf564" [[package]] name = "percent-encoding" @@ -669,9 +632,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project-lite" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -681,18 +644,18 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "proc-macro2" -version = "1.0.89" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.37" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] @@ -705,7 +668,7 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.8", + "regex-automata 0.4.9", "regex-syntax 0.8.5", ] @@ -720,9 +683,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -743,15 +706,14 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "ring" -version = "0.17.8" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", "getrandom", "libc", - "spin", "untrusted", "windows-sys 0.52.0", ] @@ -764,43 +726,43 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" -version = "0.38.37" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "rustversion" -version = "1.0.18" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "serde" -version = "1.0.213" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.213" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -809,9 +771,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.132" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", "memchr", @@ -821,9 +783,9 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" dependencies = [ "itoa", "serde", @@ -858,47 +820,35 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" dependencies = [ "libc", ] [[package]] name = "similar" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" - -[[package]] -name = "smawk" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" [[package]] name = "socket2" -version = "0.5.7" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" 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" @@ -907,18 +857,18 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "supports-color" -version = "3.0.1" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8775305acf21c96926c900ad056abeef436701108518cf890020387236ac5a77" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" dependencies = [ "is_ci", ] [[package]] name = "supports-hyperlinks" -version = "3.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c0a1e5168041f5f3ff68ff7d95dcb9c8749df29f6e7e89ada40dd4c9de404ee" +checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" [[package]] name = "supports-unicode" @@ -928,9 +878,9 @@ checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "syn" -version = "2.0.85" +version = "2.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" dependencies = [ "proc-macro2", "quote", @@ -939,51 +889,64 @@ dependencies = [ [[package]] name = "sync_wrapper" -version = "0.1.2" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - -[[package]] -name = "sync_wrapper" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" [[package]] name = "terminal_size" -version = "0.3.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" +checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" dependencies = [ "rustix", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] name = "textwrap" -version = "0.16.1" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ - "smawk", "unicode-linebreak", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] name = "thiserror" -version = "1.0.65" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", ] [[package]] name = "thiserror-impl" -version = "1.0.65" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", @@ -1002,9 +965,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.41.0" +version = "1.44.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" dependencies = [ "backtrace", "bytes", @@ -1019,9 +982,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", @@ -1030,20 +993,35 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 0.1.2", + "sync_wrapper", "tokio", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" +dependencies = [ + "bitflags", + "bytes", + "http", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1058,9 +1036,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "log", "pin-project-lite", @@ -1070,9 +1048,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", @@ -1081,9 +1059,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", "valuable", @@ -1102,9 +1080,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ "matchers", "nu-ansi-term", @@ -1120,9 +1098,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.13" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-linebreak" @@ -1136,6 +1114,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "untrusted" version = "0.9.0" @@ -1150,9 +1134,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "valuable" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "wasi" @@ -1162,19 +1146,23 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "webnsupdate" -version = "0.3.1" +version = "0.3.6" dependencies = [ "axum", - "axum-auth", "axum-client-ip", - "base64 0.22.1", + "base64", "clap", "clap-verbosity-flag", "http", + "humantime", "insta", "miette", "ring", + "serde", + "serde_json", + "thiserror 2.0.12", "tokio", + "tower-http", "tracing", "tracing-subscriber", ] @@ -1201,22 +1189,13 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" 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]] @@ -1225,22 +1204,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-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", + "windows-targets", ] [[package]] @@ -1249,46 +1213,28 @@ 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_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "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", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1301,48 +1247,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[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_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[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_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 3f5944e..0356c95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,35 +1,49 @@ -cargo-features = ["codegen-backend"] - [package] description = "An HTTP server using HTTP basic auth to make secure calls to nsupdate" name = "webnsupdate" -version = "0.3.1" +version = "0.3.6" edition = "2021" +license = "MIT" +license-file = "LICENSE" +readme = "README.md" +keywords = ["dns", "dyndns", "dynamic-ip"] +categories = ["networking", "dns", "dyndns"] +repository = "https://github.com/jalil-salame/webnsupdate" + +[lints.clippy] +cargo = { level = "warn", priority = -2 } +multiple_crate_versions = "allow" +pedantic = { level = "warn", priority = -1 } [dependencies] -axum = "0.7" -axum-auth = { version = "0.7", default-features = false, features = [ - "auth-basic", -] } -axum-client-ip = "0.6" +axum = "0.8" +axum-client-ip = "1.0" base64 = "0.22" clap = { version = "4", features = ["derive", "env"] } -clap-verbosity-flag = "2" +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"] } -tokio = { version = "1", features = [ - "macros", - "rt", - "process", - "io-util", -] } +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"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } [dev-dependencies] -insta = "1" +insta = { version = "=1.43.0", features = ["json"] } + +[profile.release] +opt-level = "s" +panic = "abort" +lto = true +strip = true +codegen-units = 1 [profile.dev] debug = 0 -codegen-backend = "cranelift" diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..1502859 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,85 @@ +# git-cliff ~ default configuration file +# https://git-cliff.org/docs/configuration +# +# Lines starting with "#" are comments. +# Configuration options are organized into tables and keys. +# See documentation for more information on available options. + +[changelog] +# template for the changelog header +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# template for the changelog footer +footer = """ + +""" +# remove the leading and trailing s +trim = true +# postprocessors +postprocessors = [ + # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL +] +# render body even when there are no releases to process +# render_always = true +# output file path +# output = "test.md" + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # Replace issue numbers + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit with https://github.com/crate-ci/typos + # If the spelling is incorrect, it will be automatically fixed. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "๐Ÿš€ Features" }, + { message = "^fix", group = "๐Ÿ› Bug Fixes" }, + { message = "^doc", group = "๐Ÿ“š Documentation" }, + { message = "^perf", group = "โšก Performance" }, + { message = "^refactor", group = "๐Ÿšœ Refactor" }, + { message = "^style", group = "๐ŸŽจ Styling" }, + { message = "^test", group = "๐Ÿงช Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore: bump version", skip = true }, + { message = "^chore|^ci", group = "โš™๏ธ Miscellaneous Tasks" }, + { body = ".*security", group = "๐Ÿ›ก๏ธ Security" }, + { message = "^revert", group = "โ—€๏ธ Revert" }, + { message = ".*", group = "๐Ÿ’ผ Other" }, +] +# filter out the commits that are not matched by commit parsers +filter_commits = false +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..28e3808 --- /dev/null +++ b/default.nix @@ -0,0 +1,37 @@ +{ + 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 f4d7398..7cf532e 100644 --- a/flake-modules/default.nix +++ b/flake-modules/default.nix @@ -1,12 +1,20 @@ -{ inputs, ... }: +{ lib, inputs, ... }: +let + webnsupdate = ../module.nix; + cargoToml = lib.importTOML ../Cargo.toml; +in { imports = [ inputs.treefmt-nix.flakeModule ./package.nix - ./module.nix ./tests.nix ]; + flake.nixosModules = { + default = webnsupdate; + inherit webnsupdate; + }; + perSystem = { pkgs, ... }: { @@ -15,17 +23,21 @@ projectRootFile = "flake.nix"; programs = { nixfmt.enable = true; - rustfmt.enable = true; + rustfmt = { + enable = true; + inherit (cargoToml.package) edition; # respect the package's edition + }; statix.enable = true; typos.enable = true; }; }; devShells.default = pkgs.mkShellNoCC { - packages = [ - pkgs.cargo-insta - pkgs.cargo-udeps - pkgs.mold + packages = with pkgs; [ + cargo-insta + cargo-udeps + mold + git-cliff ]; }; }; diff --git a/flake-modules/module.nix b/flake-modules/module.nix deleted file mode 100644 index 6ffbba6..0000000 --- a/flake-modules/module.nix +++ /dev/null @@ -1,196 +0,0 @@ -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 86fc8b0..740d0bb 100644 --- a/flake-modules/package.nix +++ b/flake-modules/package.nix @@ -1,13 +1,11 @@ -{ withSystem, inputs, ... }: +{ inputs, ... }: { - flake.overlays.default = - final: prev: - withSystem prev.stdenv.hostPlatform.system ( - { self', ... }: - { - inherit (self'.packages) webnsupdate; - } - ); + flake.overlays.default = final: prev: { + webnsupdate = prev.callPackage ../default.nix { + inherit (inputs) crane; + pkgSrc = inputs.self; + }; + }; perSystem = { pkgs, lib, ... }: @@ -33,46 +31,30 @@ }; cargoArtifacts = craneLib.buildDepsOnly commonArgs; - webnsupdate = craneLib.buildPackage ( - lib.mergeAttrsList [ - commonArgs - { inherit cargoArtifacts; } - ] - ); + withArtifacts = lib.mergeAttrsList [ + commonArgs + { inherit cargoArtifacts; } + ]; + webnsupdate = pkgs.callPackage ../default.nix { + inherit (inputs) crane; + pkgSrc = inputs.self; + }; in { checks = { + nextest = craneLib.cargoNextest withArtifacts; clippy = craneLib.cargoClippy ( lib.mergeAttrsList [ - commonArgs - { - inherit cargoArtifacts; - cargoClippyExtraArgs = "--all-targets -- --deny warnings"; - } - ] - ); - - nextest = craneLib.cargoNextest ( - lib.mergeAttrsList [ - commonArgs - { inherit cargoArtifacts; } + withArtifacts + { cargoClippyExtraArgs = "--all-targets -- --deny warnings"; } ] ); }; packages = { 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 7ec61ab..8257326 100644 --- a/flake-modules/tests.nix +++ b/flake-modules/tests.nix @@ -6,138 +6,337 @@ checks = let testDomain = "webnstest.example"; - dynamicZonesDir = "/var/lib/named/zones"; - zoneFile = pkgs.writeText "${testDomain}.zoneinfo" '' - $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) + lastIPPath = "/var/lib/webnsupdate/last-ip.json"; - IN NS ns1.${testDomain}. + zoneFile = pkgs.writeText "${testDomain}.zoneinfo" '' + $TTL 600 ; 10 minutes $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 + @ IN SOA ns1.${testDomain}. admin.${testDomain}. ( + 1 ; serial + 6h ; refresh + 1h ; retry + 1w ; expire + 1d) ; negative caching TTL + + 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 ''; - 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; }; - ''; - }; - }; + 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; }; + ''; }; systemd.services.bind.preStart = '' # shellcheck disable=SC2211,SC1127 rm -f ${dynamicZonesDir}/* # reset dynamic zones - ${pkgs.coreutils}/bin/mkdir -m 0755 -p ${dynamicZonesDir} - chown "named" ${dynamicZonesDir} - chown "named" /var/lib/named - + # create a dynamic zones dir + mkdir -m 0755 -p ${dynamicZonesDir} # 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-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}") - ''; + 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; + }; }; }; }; diff --git a/flake.lock b/flake.lock index 2987e17..2b6d1fa 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1730060262, - "narHash": "sha256-RMgSVkZ9H03sxC+Vh4jxtLTCzSjPq18UWpiM0gq6shQ=", + "lastModified": 1745454774, + "narHash": "sha256-oLvmxOnsEKGtwczxp/CwhrfmQUG2ym24OMWowcoRhH8=", "owner": "ipetkov", "repo": "crane", - "rev": "498d9f122c413ee1154e8131ace5a35a80d8fa76", + "rev": "efd36682371678e2b6da3f108fdb5c613b3ec598", "type": "github" }, "original": { @@ -22,11 +22,11 @@ ] }, "locked": { - "lastModified": 1727826117, - "narHash": "sha256-K5ZLCyfO/Zj9mPFldf3iwS6oZStJcU4tSpiXTMYaaL0=", + "lastModified": 1743550720, + "narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "3d04084d54bedc3d6b8b736c70ef449225c361b1", + "rev": "c621e8422220273271f52058f618c94e405bb0f5", "type": "github" }, "original": { @@ -37,17 +37,18 @@ }, "nixpkgs": { "locked": { - "lastModified": 1728492678, - "narHash": "sha256-9UTxR8eukdg+XZeHgxW5hQA9fIKHsKCdOIUycTryeVw=", + "lastModified": 1745794561, + "narHash": "sha256-T36rUZHUART00h3dW4sV5tv4MrXKT7aWjNfHiZz7OHg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "5633bcff0c6162b9e4b5f1264264611e950c8ec7", + "rev": "5461b7fa65f3ca74cef60be837fd559a8918eaa0", "type": "github" }, "original": { - "id": "nixpkgs", + "owner": "NixOS", "ref": "nixos-unstable", - "type": "indirect" + "repo": "nixpkgs", + "type": "github" } }, "root": { @@ -81,11 +82,11 @@ ] }, "locked": { - "lastModified": 1729613947, - "narHash": "sha256-XGOvuIPW1XRfPgHtGYXd5MAmJzZtOuwlfKDgxX5KT3s=", + "lastModified": 1745848521, + "narHash": "sha256-gNrTO3pEjmu3WiuYrUHJrTGCFw9v+qZXCFmX/Vjf5WI=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "aac86347fb5063960eccb19493e0cadcdb4205ca", + "rev": "763f1ce0dd12fe44ce6a5c6ea3f159d438571874", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 3e7f04f..f93aaca 100644 --- a/flake.nix +++ b/flake.nix @@ -6,7 +6,7 @@ url = "github:hercules-ci/flake-parts"; inputs.nixpkgs-lib.follows = "nixpkgs"; }; - nixpkgs.url = "nixpkgs/nixos-unstable"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; systems.url = "github:nix-systems/default"; treefmt-nix = { url = "github:numtide/treefmt-nix"; diff --git a/justfile b/justfile new file mode 100644 index 0000000..6cacc42 --- /dev/null +++ b/justfile @@ -0,0 +1,2 @@ +changelog version: + git cliff --unreleased --prepend=CHANGELOG.md --tag='{{ version }}' diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..8bdfced --- /dev/null +++ b/module.nix @@ -0,0 +1,162 @@ +{ 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/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..c0156d0 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,104 @@ +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use tower_http::validate_request::ValidateRequestHeaderLayer; +use tracing::{trace, warn}; + +use crate::password; + +pub fn layer<'a, ResBody>( + user_pass_hash: &'a [u8], + salt: &'a str, +) -> ValidateRequestHeaderLayer> { + ValidateRequestHeaderLayer::custom(Basic::new(user_pass_hash, salt)) +} + +#[derive(Copy)] +pub struct Basic<'a, ResBody> { + pass: &'a [u8], + salt: &'a str, + _ty: std::marker::PhantomData ResBody>, +} + +impl std::fmt::Debug for Basic<'_, ResBody> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BasicAuth") + .field("pass", &self.pass) + .field("salt", &self.salt) + .field("_ty", &self._ty) + .finish() + } +} + +impl Clone for Basic<'_, ResBody> { + fn clone(&self) -> Self { + Self { + pass: self.pass, + salt: self.salt, + _ty: std::marker::PhantomData, + } + } +} + +impl<'a, ResBody> Basic<'a, ResBody> { + pub fn new(pass: &'a [u8], salt: &'a str) -> Self { + Self { + pass, + salt, + _ty: std::marker::PhantomData, + } + } + + fn check_headers(&self, headers: &http::HeaderMap) -> bool { + let Some(auth) = headers.get(http::header::AUTHORIZATION) else { + return false; + }; + + // Poor man's split once: https://doc.rust-lang.org/std/primitive.slice.html#method.split_once + let Some(index) = auth.as_bytes().iter().position(|&c| c == b' ') else { + return false; + }; + let user_pass = &auth.as_bytes()[index + 1..]; + + match base64::engine::general_purpose::URL_SAFE.decode(user_pass) { + Ok(user_pass) => { + let hashed = password::hash_basic_auth(&user_pass, self.salt); + if hashed.as_ref() == self.pass { + return true; + } + warn!("rejected update"); + trace!( + "mismatched hashes:\nprovided: {}\nstored: {}", + URL_SAFE_NO_PAD.encode(hashed.as_ref()), + URL_SAFE_NO_PAD.encode(self.pass), + ); + false + } + Err(err) => { + warn!("received invalid base64 when decoding Basic header: {err}"); + false + } + } + } +} + +impl tower_http::validate_request::ValidateRequest for Basic<'_, ResBody> +where + ResBody: Default, +{ + type ResponseBody = ResBody; + + fn validate( + &mut self, + request: &mut http::Request, + ) -> std::result::Result<(), http::Response> { + if self.check_headers(request.headers()) { + return Ok(()); + } + + let mut res = http::Response::new(ResBody::default()); + *res.status_mut() = http::status::StatusCode::UNAUTHORIZED; + res.headers_mut() + .insert(http::header::WWW_AUTHENTICATE, "Basic".parse().unwrap()); + Err(res) + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..d8523b2 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,253 @@ +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": { + "secs": 60, + "nanos": 0 + }, + "ip_source": "RightmostXForwardedFor", + "ip_type": "Both" + } + "#); +} diff --git a/src/main.rs b/src/main.rs index 7e2ffae..f7e6fcb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,24 +1,28 @@ use std::{ - ffi::OsStr, io::ErrorKind, - net::{IpAddr, SocketAddr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, path::{Path, PathBuf}, - process::{ExitStatus, Stdio}, time::Duration, }; -use axum::{extract::State, routing::get, Json, Router}; -use axum_auth::AuthBasic; -use axum_client_ip::{SecureClientIp, SecureClientIpSource}; +use axum::{ + extract::{Query, State}, + routing::get, + Router, +}; +use axum_client_ip::ClientIp; 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::{bail, ensure, Context, IntoDiagnostic, Result}; -use tokio::io::AsyncWriteExt; -use tracing::{debug, error, info, level_filters::LevelFilter, trace, warn}; +use tracing::{debug, error, info}; use tracing_subscriber::EnvFilter; +mod auth; +mod config; +mod nsupdate; mod password; mod records; @@ -28,77 +32,54 @@ const DEFAULT_SALT: &str = "UpdateMyDNS"; #[derive(Debug, Parser)] 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, + verbosity: Verbosity, /// Data directory - #[arg(long, default_value = ".")] + #[arg(long, env, 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, - /// Set client IP source - /// - /// see: https://docs.rs/axum-client-ip/latest/axum_client_ip/enum.SecureClientIpSource.html - #[clap(long, default_value = "RightmostXForwardedFor")] - ip_source: SecureClientIpSource, + #[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, #[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 records file - Verify, + /// Verify the configuration file + Verify { + /// Path to the configuration file + config: PathBuf, + }, } impl Cmd { pub fn process(self, args: &Opts) -> Result<()> { match self { Cmd::Mkpasswd(mkpasswd) => mkpasswd.process(args), - Cmd::Verify => records::load(&args.records).map(drop), + Cmd::Verify { config } => config::Config::load(&config) // load config + .and_then(Config::verified) // verify config + .map(drop), // ignore config data } } } @@ -108,23 +89,123 @@ struct AppState<'a> { /// TTL set on the Zonefile ttl: Duration, - /// Salt added to the password - salt: &'a str, - /// The IN A/AAAA records that should have their IPs updated records: &'a [&'a str], /// The TSIG key file key_file: Option<&'a Path>, - /// The password hash - password_hash: Option<&'a [u8]>, - /// 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, } -fn load_ip(path: &Path) -> Result> { +#[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 { + self.ipv4 + .map(IpAddr::V4) + .into_iter() + .chain(self.ipv6.map(IpAddr::V6)) + } + + fn from_str(data: &str) -> miette::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) -> miette::Result { + let Opts { + verbosity: _, + data_dir, + insecure, + config_or_command: _, + } = args; + + let config::Records { + ttl, + records, + client_id: _, + router_domain: _, + ip_source: _, + ip_type, + key_file, + } = &config.records; + + // 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 state = AppState { + ttl: **ttl, + records, + // Load keyfile + key_file: key_file + .as_deref() + .map(|path| -> miette::Result<_> { + std::fs::File::open(path) + .into_diagnostic() + .wrap_err_with(|| { + format!("{} is not readable by the current user", path.display()) + })?; + 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(), + )), + }; + + ensure!( + state.key_file.is_some() || *insecure, + "a key file must be used" + ); + + Ok(state) + } +} + +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, @@ -138,120 +219,117 @@ fn load_ip(path: &Path) -> Result> { } }; - Ok(Some( - data.parse() - .into_diagnostic() - .wrap_err("failed to parse last ip address")?, - )) + 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")?, + }) + } +} + +#[tracing::instrument(err)] fn main() -> Result<()> { // set panic hook to pretty print with miette's formatter miette::set_panic_hook(); // parse cli arguments let mut args = Opts::parse(); - debug!("{args:?}"); // configure logger let subscriber = tracing_subscriber::FmtSubscriber::builder() .without_time() .with_env_filter( EnvFilter::builder() - .with_default_directive( - if args.verbosity.is_present() { - match args.verbosity.log_level_filter() { - clap_verbosity_flag::LevelFilter::Off => LevelFilter::OFF, - clap_verbosity_flag::LevelFilter::Error => LevelFilter::ERROR, - clap_verbosity_flag::LevelFilter::Warn => LevelFilter::WARN, - clap_verbosity_flag::LevelFilter::Info => LevelFilter::INFO, - clap_verbosity_flag::LevelFilter::Debug => LevelFilter::DEBUG, - clap_verbosity_flag::LevelFilter::Trace => LevelFilter::TRACE, - } - } else { - LevelFilter::WARN - } - .into(), - ) + .with_default_directive(args.verbosity.tracing_level_filter().into()) .from_env_lossy(), ) .finish(); + tracing::subscriber::set_global_default(subscriber) .into_diagnostic() - .wrap_err("setting global tracing subscriber")?; + .wrap_err("failed to set global tracing subscriber")?; - // process subcommand - if let Some(cmd) = args.subcommand.take() { - return cmd.process(&args); - } + 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)" + ), + }; + + // Initialize state + let state = AppState::from_args(&args, &config)?; let Opts { verbosity: _, - address: ip, - port, - password_file, - data_dir, - key_file, + data_dir: _, insecure, - subcommand: _, - records, - salt, - ttl, - ip_source, + config_or_command: _, } = args; info!("checking environment"); - // Set state - let ttl = Duration::from_secs(ttl); + // Load password hash + let password_hash = config + .password + .password_file + .map(|path| -> miette::Result<_> { + let path = path.as_path(); + let pass = std::fs::read_to_string(path).into_diagnostic()?; - // Use last registered IP address if available - let ip_file = data_dir.join("last-ip"); + 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(); - let state = AppState { - ttl, - salt: salt.leak(), - // Load DNS records - records: records::load_no_verify(&records)?, - // Load keyfile - key_file: key_file - .map(|key_file| -> miette::Result<_> { - let path = key_file.as_path(); - std::fs::File::open(path) - .into_diagnostic() - .wrap_err_with(|| { - format!("{} is not readable by the current user", path.display()) - })?; - Ok(&*Box::leak(key_file.into_boxed_path())) - }) - .transpose()?, - // Load password hash - password_hash: password_file - .map(|path| -> miette::Result<_> { - let pass = std::fs::read_to_string(path.as_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(&*Box::leak(pass)) - }) - .transpose()?, - ip_file: Box::leak(ip_file.into_boxed_path()), - }; + Ok(pass) + }) + .transpose() + .wrap_err("failed to load password hash")?; ensure!( - state.password_hash.is_some() || insecure, + password_hash.is_some() || insecure, "a password must be used" ); - ensure!( - state.key_file.is_some() || insecure, - "a key file must be used" - ); - let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -259,9 +337,17 @@ fn main() -> Result<()> { .wrap_err("failed to start the tokio runtime")?; 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(ip, ttl, state.key_file, state.records).await { + // 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}"); @@ -274,25 +360,31 @@ fn main() -> Result<()> { .into_diagnostic() .wrap_err("failed to update records with previous IP"); } - }, - Ok(None) => { - info!("No previous IP address set"); } - Err(err) => { - error!("Failed to load last ip address: {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), + 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 - let app = Router::new() - .route("/update", get(update_records)) - .layer(ip_source.into_extension()) - .with_state(state); - info!("starting listener on {ip}:{port}"); - let listener = tokio::net::TcpListener::bind(SocketAddr::new(ip, port)) + info!("starting listener on {address}"); + let listener = tokio::net::TcpListener::bind(address) .await .into_diagnostic()?; - info!("listening on {ip}:{port}"); + info!("listening on {address}"); axum::serve( listener, app.into_make_service_with_connect_info::(), @@ -300,157 +392,318 @@ fn main() -> Result<()> { .await .into_diagnostic() }) + .wrap_err("failed to run main loop") } -#[tracing::instrument(skip(state, pass), level = "trace", ret(level = "info"))] +/// 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() + } +} + +#[tracing::instrument(skip(state), level = "trace", ret(level = "info"))] async fn update_records( State(state): State>, - AuthBasic((username, pass)): AuthBasic, - SecureClientIp(ip): SecureClientIp, + ClientIp(ip): ClientIp, + Query(update_params): Query, ) -> axum::response::Result<&'static str> { - debug!("received update request from {ip}"); - let Some(pass) = pass else { - return Err((StatusCode::UNAUTHORIZED, Json::from("no password provided")).into()); - }; + info!("accepted update from {ip}"); - if let Some(stored_pass) = state.password_hash { - let password = pass.trim().to_string(); - let pass_hash = password::hash_identity(&username, &password, state.salt); - if pass_hash.as_ref() != stored_pass { - warn!("rejected update"); - trace!( - "mismatched hashes:\n{}\n{}", - URL_SAFE_NO_PAD.encode(pass_hash.as_ref()), - URL_SAFE_NO_PAD.encode(stored_pass), + 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::UNAUTHORIZED, "invalid identity").into()); + 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); } } - info!("accepted update"); - match nsupdate(ip, state.ttl, state.key_file, state.records).await { + 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 { 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 ip to {ip}"); - if let Err(err) = std::fs::write(state.ip_file, format!("{ip}")) { + 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) { error!("Failed to update last IP: {err}"); } - info!("updated last ip to {ip}"); + info!("updated last ips to {ips:?}"); }); - Ok("successful update") + + Ok("Successfully updated IP of records!\n") } Ok(status) => { error!("nsupdate failed with code {status}"); Err(( StatusCode::INTERNAL_SERVER_ERROR, - "nsupdate failed, check server logs", + "nsupdate failed, check server logs\n", ) .into()) } Err(error) => Err(( StatusCode::INTERNAL_SERVER_ERROR, - format!("failed to update records: {error}"), + format!("failed to update records: {error}\n"), ) .into()), } } -#[tracing::instrument(level = "trace", ret(level = "warn"))] -async fn nsupdate( - ip: IpAddr, - ttl: Duration, - key_file: Option<&Path>, - records: &[&str], -) -> std::io::Result { - let mut cmd = tokio::process::Command::new("nsupdate"); - if let Some(key_file) = key_file { - cmd.args([OsStr::new("-k"), key_file.as_os_str()]); - } - debug!("spawning new process"); - let mut child = cmd - .stdin(Stdio::piped()) - .spawn() - .inspect_err(|err| warn!("failed to spawn child: {err}"))?; - let mut stdin = child.stdin.take().expect("stdin not present"); - debug!("sending update request"); - stdin - .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() - .await - .inspect_err(|err| warn!("failed to close stdin to nsupdate: {err}"))?; - debug!("waiting for nsupdate to exit"); - child - .wait() - .await - .inspect_err(|err| warn!("failed to wait for child: {err}")) -} - -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!(cmds, "send\nquit").unwrap(); - cmds -} - #[cfg(test)] -mod test { - use insta::assert_snapshot; +mod parse_query_params { + use axum::extract::Query; - use crate::{update_ns_records, DEFAULT_TTL}; - - use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use super::FritzBoxUpdateParams; #[test] - #[allow(non_snake_case)] - fn expected_update_string_A() { - 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 - update delete example.org. 60 IN A - update add example.org. 60 IN A 127.0.0.1 - update delete example.net. 60 IN A - update add example.net. 60 IN A 127.0.0.1 - send - quit - "###); + 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] - #[allow(non_snake_case)] - fn expected_update_string_AAAA() { - 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 - update delete example.org. 60 IN AAAA - update add example.org. 60 IN AAAA ::1 - update delete example.net. 60 IN AAAA - update add example.net. 60 IN AAAA ::1 - send - quit - "###); + 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 new file mode 100644 index 0000000..5d266b9 --- /dev/null +++ b/src/nsupdate.rs @@ -0,0 +1,156 @@ +use std::{ + ffi::OsStr, + net::IpAddr, + path::Path, + process::{ExitStatus, Stdio}, + time::Duration, +}; + +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"))] +pub async fn nsupdate( + key_file: Option<&Path>, + actions: impl IntoIterator>, +) -> std::io::Result { + let mut cmd = tokio::process::Command::new("nsupdate"); + if let Some(key_file) = key_file { + cmd.args([OsStr::new("-k"), key_file.as_os_str()]); + } + debug!("spawning new process"); + let mut child = cmd + .stdin(Stdio::piped()) + .spawn() + .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) + .await + .inspect_err(|err| warn!("failed to write to the stdin of nsupdate: {err}"))?; + + debug!("closing stdin"); + stdin + .shutdown() + .await + .inspect_err(|err| warn!("failed to close stdin to nsupdate: {err}"))?; + debug!("waiting for nsupdate to exit"); + child + .wait() + .await + .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}")?; + } + writeln!(buf, "send")?; + writeln!(buf, "quit") +} + +#[cfg(test)] +mod test { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use insta::assert_snapshot; + + use super::{update_ns_records, Action}; + 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###" + server 127.0.0.1 + update delete example.com. 60 IN A + update add example.com. 60 IN A 127.0.0.1 + update delete example.org. 60 IN A + update add example.org. 60 IN A 127.0.0.1 + update delete example.net. 60 IN A + update add example.net. 60 IN A 127.0.0.1 + send + quit + "###); + } + + #[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###" + server 127.0.0.1 + update delete example.com. 60 IN AAAA + update add example.com. 60 IN AAAA ::1 + update delete example.org. 60 IN AAAA + update add example.org. 60 IN AAAA ::1 + update delete example.net. 60 IN AAAA + update add example.net. 60 IN AAAA ::1 + send + quit + "###); + } +} diff --git a/src/password.rs b/src/password.rs index 84574a2..d99a93b 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::Path; +use std::path::PathBuf; use base64::prelude::*; use miette::{Context, IntoDiagnostic, Result}; @@ -20,28 +20,48 @@ 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, args.password_file.as_deref(), &args.salt) + pub fn process(self, _args: &crate::Opts) -> Result<()> { + mkpasswd(self) } } +pub fn hash_basic_auth(user_pass: &[u8], salt: &str) -> Digest { + let mut context = ring::digest::Context::new(&ring::digest::SHA256); + context.update(user_pass); + context.update(salt.as_bytes()); + context.finish() +} + pub fn hash_identity(username: &str, password: &str, salt: &str) -> Digest { - let mut data = Vec::with_capacity(username.len() + password.len() + salt.len() + 1); - write!(data, "{username}:{password}{salt}").unwrap(); - ring::digest::digest(&ring::digest::SHA256, &data) + let mut context = ring::digest::Context::new(&ring::digest::SHA256); + context.update(username.as_bytes()); + context.update(b":"); + context.update(password.as_bytes()); + context.update(salt.as_bytes()); + context.finish() } pub fn mkpasswd( - Mkpasswd { username, password }: Mkpasswd, - password_file: Option<&Path>, - salt: &str, + Mkpasswd { + username, + password, + salt, + password_file, + }: Mkpasswd, ) -> 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 else { + let Some(path) = password_file.as_deref() else { println!("{encoded}"); return Ok(()); }; diff --git a/src/records.rs b/src/records.rs index 5ca6528..9c5158c 100644 --- a/src/records.rs +++ b/src/records.rs @@ -1,52 +1,9 @@ //! Deal with the DNS records -use std::path::Path; +use miette::{ensure, miette, LabeledSpan, Result}; -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(()) +pub fn validate_record_str(record: &str) -> Result<()> { + validate_line(0, record).map_err(|err| err.with_source_code(String::from(record))) } fn validate_line(offset: usize, line: &str) -> Result<()> { @@ -156,7 +113,7 @@ fn validate_octet(offset: usize, octet: u8) -> Result<()> { #[cfg(test)] mod test { - use crate::records::verify; + use crate::records::validate_record_str; macro_rules! assert_miette_snapshot { ($diag:expr) => {{ @@ -180,104 +137,51 @@ mod test { #[test] fn valid_records() -> miette::Result<()> { - verify( - "\ - example.com.\n\ - example.org.\n\ - example.net.\n\ - subdomain.example.com.\n\ - ", - std::path::Path::new("test_records_valid"), - ) + for record in [ + "example.com.", + "example.org.", + "example.net.", + "subdomain.example.com.", + ] { + validate_record_str(record)?; + } + Ok(()) } #[test] fn hostname_too_long() { - 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(); + 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(); assert_miette_snapshot!(err); } #[test] fn not_fqd() { - 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(); + let err = validate_record_str("example.net").unwrap_err(); assert_miette_snapshot!(err); } #[test] fn empty_label() { - 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(); + let err = validate_record_str("name..example.org.").unwrap_err(); assert_miette_snapshot!(err); } #[test] fn label_too_long() { - 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(); + let err = validate_record_str("name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.").unwrap_err(); assert_miette_snapshot!(err); } #[test] fn invalid_ascii() { - 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(); + let err = validate_record_str("name.this-is-not-ascii-รŸ.example.org.").unwrap_err(); assert_miette_snapshot!(err); } #[test] fn invalid_octet() { - 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(); + let err = + validate_record_str("name.this-character:-is-not-allowed.example.org.").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 e4d227b..d6fb7fa 100644 --- a/src/snapshots/webnsupdate__records__test__empty_label.snap +++ b/src/snapshots/webnsupdate__records__test__empty_label.snap @@ -6,11 +6,9 @@ expression: out ]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\ ร— empty label - โ•ญโ”€[test_records_invalid:2:6] - 1 โ”‚ example.com. - 2 โ”‚ name..example.org. + โ•ญโ”€โ”€โ”€โ”€ + 1 โ”‚ 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 051d8ce..5c48b16 100644 --- a/src/snapshots/webnsupdate__records__test__hostname_too_long.snap +++ b/src/snapshots/webnsupdate__records__test__hostname_too_long.snap @@ -6,11 +6,9 @@ expression: out ]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\ ร— hostname too long (260 octets) - โ•ญโ”€[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. + โ•ญโ”€โ”€โ”€โ”€ + 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. ยท โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ยท โ•ฐโ”€โ”€ 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 a777b6d..6ef64e3 100644 --- a/src/snapshots/webnsupdate__records__test__invalid_ascii.snap +++ b/src/snapshots/webnsupdate__records__test__invalid_ascii.snap @@ -6,11 +6,9 @@ expression: out ]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\ ร— invalid octet: '\xc3' - โ•ญโ”€[test_records_invalid:2:19] - 1 โ”‚ example.com. - 2 โ”‚ name.this-is-not-aรŸcii.example.org. - ยท โ”ฌ - ยท โ•ฐโ”€โ”€ octet - 3 โ”‚ example.net. + โ•ญโ”€โ”€โ”€โ”€ + 1 โ”‚ name.this-is-not-ascii-รŸ.example.org. + ยท โ”ฌ + ยท โ•ฐโ”€โ”€ octet โ•ฐโ”€โ”€โ”€โ”€ 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 2da284e..ed8a44c 100644 --- a/src/snapshots/webnsupdate__records__test__invalid_octet.snap +++ b/src/snapshots/webnsupdate__records__test__invalid_octet.snap @@ -6,11 +6,9 @@ expression: out ]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\ ร— invalid octet: ':' - โ•ญโ”€[test_records_invalid:2:20] - 1 โ”‚ example.com. - 2 โ”‚ name.this-character:-is-not-allowed.example.org. + โ•ญโ”€โ”€โ”€โ”€ + 1 โ”‚ 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 b529a1f..f1561ae 100644 --- a/src/snapshots/webnsupdate__records__test__label_too_long.snap +++ b/src/snapshots/webnsupdate__records__test__label_too_long.snap @@ -6,11 +6,9 @@ expression: out ]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\ ร— label too long (78 octets) - โ•ญโ”€[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. + โ•ญโ”€โ”€โ”€โ”€ + 1 โ”‚ 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 bc8270d..ccf6746 100644 --- a/src/snapshots/webnsupdate__records__test__not_fqd.snap +++ b/src/snapshots/webnsupdate__records__test__not_fqd.snap @@ -6,11 +6,9 @@ expression: out ]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\ ร— not a fully qualified domain name - โ•ญโ”€[test_records_invalid:3:11] - 2 โ”‚ example.org. - 3 โ”‚ example.net + โ•ญโ”€โ”€โ”€โ”€ + 1 โ”‚ example.net ยท โ”ฌ ยท โ•ฐโ”€โ”€ last character - 4 โ”‚ subdomain.example.com. โ•ฐโ”€โ”€โ”€โ”€ help: hostname should be a fully qualified domain name (end with a '.')