diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml index 342bcef..80adea4 100644 --- a/.forgejo/workflows/check.yml +++ b/.forgejo/workflows/check.yml @@ -1,43 +1,39 @@ on: [push] jobs: + check-renovaterc: + runs-on: nixos + steps: + - 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 .# - check: + test: needs: build # we use the built binaries in the checks runs-on: nixos - strategy: - matrix: - check: - - treefmt - - clippy - - nextest - - module-ipv4-test - - module-ipv6-test - - module-nginx-test - - module-ipv4-only-test - - module-ipv6-only-test steps: - - uses: https://git.salame.cl/actions/checkout@v4 - - name: Check + - uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - name: Run tests run: | - set -x nix --version - # shellcheck disable=SC2016 - nix build --print-build-logs '.#checks.x86_64-linux.${{ matrix.check }}' + 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@v4 + - uses: https://git.salame.cl/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - run: nix --version - name: Generate size report - uses: https://git.salame.cl/jalil/nix-flake-outputs-size@main + 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' }} diff --git a/.renovaterc.json b/.renovaterc.json index 83d8eaf..2a15a88 100644 --- a/.renovaterc.json +++ b/.renovaterc.json @@ -9,7 +9,7 @@ "commitBodyTable": true, "dependencyDashboard": true, "extends": [ - "config:recommended" + "config:best-practices" ], "prCreation": "immediate", "cargo": { @@ -31,14 +31,14 @@ "lockFileMaintenance": { "enabled": true, "recreateWhen": "always", - "rebaseStalePrs": true, + "rebaseWhen": "behind-base-branch", "branchTopic": "lock-file-maintenance", "commitMessageAction": "Lock file maintenance", "schedule": [ - "* 23 * * *" + "* 22 * * *" ] }, "automergeSchedule": [ - "* 0-1 * * *" + "* 23 * * *" ] } diff --git a/Cargo.lock b/Cargo.lock index 32cf25c..cad88f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,21 +173,21 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.8.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" [[package]] name = "bytes" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.12" +version = "1.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755717a7de9ec452bf7f3f1a3099085deabd7f2962b861dae91ecd7a365903d2" +checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" dependencies = [ "shlex", ] @@ -200,9 +200,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.5.28" +version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e77c3243bd94243c03672cb5154667347c457ca271254724f9f393aee1c05ff" +checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83" dependencies = [ "clap_builder", "clap_derive", @@ -220,9 +220,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.27" +version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7" +checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8" dependencies = [ "anstream", "anstyle", @@ -232,9 +232,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.28" +version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" dependencies = [ "heck", "proc-macro2", @@ -256,9 +256,9 @@ checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" [[package]] name = "console" -version = "0.15.10" +version = "0.15.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" dependencies = [ "encode_unicode", "libc", @@ -279,7 +279,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -304,7 +304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" dependencies = [ "nonempty", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -365,9 +365,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.2.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", @@ -386,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", @@ -399,9 +399,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2d708df4e7140240a16cd6ab0ab65c972d7433ab77819ea693fde9c43811e2a" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" @@ -409,6 +409,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" + [[package]] name = "hyper" version = "1.6.0" @@ -446,14 +452,15 @@ dependencies = [ [[package]] name = "insta" -version = "1.42.1" +version = "1.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c1b125e30d93896b365e156c33dadfffab45ee8400afcbba4752f59de08a86" +checksum = "50259abbaa67d11d2bcafc7ba1d094ed7a0c70e3ce893f0d0997f73558cb3084" dependencies = [ "console", "linked-hash-map", "once_cell", "pin-project", + "serde", "similar", ] @@ -471,9 +478,9 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itoa" -version = "1.0.14" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "lazy_static" @@ -483,9 +490,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.169" +version = "0.2.171" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" [[package]] name = "linked-hash-map" @@ -495,15 +502,15 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" -version = "0.4.15" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" [[package]] name = "log" -version = "0.4.25" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" [[package]] name = "matchers" @@ -542,8 +549,8 @@ dependencies = [ "supports-unicode", "terminal_size", "textwrap", - "thiserror", - "unicode-width", + "thiserror 1.0.69", + "unicode-width 0.1.14", ] [[package]] @@ -565,9 +572,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "miniz_oxide" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", ] @@ -610,9 +617,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.20.2" +version = "1.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" [[package]] name = "overload" @@ -622,9 +629,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" @@ -634,18 +641,18 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfe2e71e1471fe07709406bf725f710b02927c9c54b2b5b2ec0e8087d97c327d" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e859e6e5bd50440ab63c47e3ebabc90f26251f7c73c3d3e837b74a1cc3fa67" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", @@ -666,18 +673,18 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "proc-macro2" -version = "1.0.93" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.38" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] @@ -728,15 +735,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", ] @@ -749,43 +755,43 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" -version = "0.38.44" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "e56a18552996ac8d29ecc3b190b4fdbb2d91ca4ec396de7bbffaf43f3d637e96" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] name = "rustversion" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" [[package]] name = "ryu" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "serde" -version = "1.0.217" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.217" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -794,9 +800,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.138" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", "memchr", @@ -806,9 +812,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,9 +864,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "smallvec" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" [[package]] name = "socket2" @@ -872,12 +878,6 @@ dependencies = [ "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,9 +907,9 @@ checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "syn" -version = "2.0.98" +version = "2.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" dependencies = [ "proc-macro2", "quote", @@ -924,9 +924,9 @@ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" [[package]] name = "terminal_size" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" +checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" dependencies = [ "rustix", "windows-sys 0.59.0", @@ -934,12 +934,12 @@ dependencies = [ [[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 = [ "unicode-linebreak", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] @@ -948,7 +948,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] @@ -962,6 +971,17 @@ dependencies = [ "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", + "syn", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -974,9 +994,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.43.0" +version = "1.44.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" +checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" dependencies = [ "backtrace", "bytes", @@ -1107,9 +1127,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.16" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-linebreak" @@ -1123,6 +1143,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" @@ -1157,11 +1183,13 @@ dependencies = [ "clap", "clap-verbosity-flag", "http", + "humantime", "insta", "miette", "ring", "serde", "serde_json", + "thiserror 2.0.12", "tokio", "tower-http", "tracing", diff --git a/Cargo.toml b/Cargo.toml index dd4bde5..ee5cfbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,3 @@ -cargo-features = ["codegen-backend"] - [package] description = "An HTTP server using HTTP basic auth to make secure calls to nsupdate" name = "webnsupdate" @@ -25,17 +23,19 @@ clap-verbosity-flag = { version = "3", default-features = false, features = [ "tracing", ] } http = "1" +humantime = "2.2.0" miette = { version = "7", features = ["fancy"] } ring = { version = "0.17", features = ["std"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +thiserror = "2" tokio = { version = "1", features = ["macros", "rt", "process", "io-util"] } tower-http = { version = "0.6", features = ["validate-request"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } [dev-dependencies] -insta = "1" +insta = { version = "=1.42.2", features = ["json"] } [profile.release] opt-level = "s" @@ -46,4 +46,3 @@ codegen-units = 1 [profile.dev] debug = 0 -codegen-backend = "cranelift" diff --git a/flake-modules/default.nix b/flake-modules/default.nix index 27ecc50..6736a16 100644 --- a/flake-modules/default.nix +++ b/flake-modules/default.nix @@ -3,10 +3,18 @@ imports = [ inputs.treefmt-nix.flakeModule ./package.nix - ./module.nix ./tests.nix ]; + flake.nixosModules = + let + webnsupdate = ../module.nix; + in + { + default = webnsupdate; + inherit webnsupdate; + }; + perSystem = { pkgs, ... }: { diff --git a/flake-modules/module.nix b/flake-modules/module.nix deleted file mode 100644 index e70ae35..0000000 --- a/flake-modules/module.nix +++ /dev/null @@ -1,208 +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; - }; - allowedIPVersion = mkOption { - description = ''The allowed IP versions to accept updates from.''; - type = types.enum [ - "both" - "ipv4-only" - "ipv6-only" - ]; - default = "both"; - example = "ipv4-only"; - }; - 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 - "--ip-type" - cfg.allowedIPVersion - "--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 unsupported 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/tests.nix b/flake-modules/tests.nix index 45fc5ff..8257326 100644 --- a/flake-modules/tests.nix +++ b/flake-modules/tests.nix @@ -9,7 +9,7 @@ lastIPPath = "/var/lib/webnsupdate/last-ip.json"; zoneFile = pkgs.writeText "${testDomain}.zoneinfo" '' - $TTL 60 ; 1 minute + $TTL 600 ; 10 minutes $ORIGIN ${testDomain}. @ IN SOA ns1.${testDomain}. admin.${testDomain}. ( 1 ; serial @@ -73,20 +73,19 @@ webnsupdate = { enable = true; - bindIp = lib.mkDefault "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}. - ''; + 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}." + ]; + }; }; }; }; @@ -97,7 +96,7 @@ webnsupdate-ipv4-machine ]; - config.services.webnsupdate.bindIp = "::1"; + config.services.webnsupdate.settings.address = "[::1]:5353"; }; webnsupdate-nginx-machine = @@ -109,26 +108,26 @@ config.services = { # Use default IP Source - webnsupdate.extraArgs = lib.mkForce [ "-vvv" ]; # debug messages + webnsupdate.settings.ip_source = "RightmostXForwardedFor"; nginx = { enable = true; recommendedProxySettings = true; virtualHosts.webnsupdate.locations."/".proxyPass = - "http://${config.services.webnsupdate.bindIp}:${builtins.toString config.services.webnsupdate.bindPort}"; + "http://${config.services.webnsupdate.settings.address}"; }; }; }; webnsupdate-ipv4-only-machine = { imports = [ webnsupdate-nginx-machine ]; - config.services.webnsupdate.allowedIPVersion = "ipv4-only"; + config.services.webnsupdate.settings.ip_type = "Ipv4Only"; }; webnsupdate-ipv6-only-machine = { imports = [ webnsupdate-nginx-machine ]; - config.services.webnsupdate.allowedIPVersion = "ipv6-only"; + config.services.webnsupdate.settings.ip_type = "Ipv6Only"; }; # "A" for IPv4, "AAAA" for IPv6, "ANY" for any @@ -158,9 +157,9 @@ 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) -> str: - match_ip = "" if ip is None else f"\\s\\+60\\s\\+IN\\s\\+{record}\\s\\+{ip}$" - return f"dig @localhost {record} {domain} +noall +answer | grep '^{domain}.{match_ip}'" + 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 @@ -168,10 +167,16 @@ 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): - machine.succeed(dig_cmd(domain, record, ip)) + 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): - machine.fail(dig_cmd(domain, record, ip)) + 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)) diff --git a/flake.lock b/flake.lock index 31c94b6..4c2f403 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1738652123, - "narHash": "sha256-zdZek5FXK/k95J0vnLF0AMnYuZl4AjARq83blKuJBYY=", + "lastModified": 1742394900, + "narHash": "sha256-vVOAp9ahvnU+fQoKd4SEXB2JG2wbENkpqcwlkIXgUC0=", "owner": "ipetkov", "repo": "crane", - "rev": "c7e015a5fcefb070778c7d91734768680188a9cd", + "rev": "70947c1908108c0c551ddfd73d4f750ff2ea67cd", "type": "github" }, "original": { @@ -22,11 +22,11 @@ ] }, "locked": { - "lastModified": 1738453229, - "narHash": "sha256-7H9XgNiGLKN1G1CgRh0vUL4AheZSYzPm+zmZ7vxbJdo=", + "lastModified": 1741352980, + "narHash": "sha256-+u2UunDA4Cl5Fci3m7S643HzKmIDAe+fiXrLqYsR2fs=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "32ea77a06711b758da0ad9bd6a844c5740a87abd", + "rev": "f4330d22f1c5d2ba72d3d22df5597d123fdb60a9", "type": "github" }, "original": { @@ -37,11 +37,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1738546358, - "narHash": "sha256-nLivjIygCiqLp5QcL7l56Tca/elVqM9FG1hGd9ZSsrg=", + "lastModified": 1742288794, + "narHash": "sha256-Txwa5uO+qpQXrNG4eumPSD+hHzzYi/CdaM80M9XRLCo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c6e957d81b96751a3d5967a0fd73694f303cc914", + "rev": "b6eaf97c6960d97350c584de1b6dcff03c9daf42", "type": "github" }, "original": { @@ -82,11 +82,11 @@ ] }, "locked": { - "lastModified": 1738680491, - "narHash": "sha256-8X7tR3kFGkE7WEF5EXVkt4apgaN85oHZdoTGutCFs6I=", + "lastModified": 1742370146, + "narHash": "sha256-XRE8hL4vKIQyVMDXykFh4ceo3KSpuJF3ts8GKwh5bIU=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "64dbb922d51a42c0ced6a7668ca008dded61c483", + "rev": "adc195eef5da3606891cedf80c0d9ce2d3190808", "type": "github" }, "original": { 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/config.rs b/src/config.rs new file mode 100644 index 0000000..6e4af7f --- /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::SecureClientIpSource; +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: SecureClientIpSource, + + /// 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() -> SecureClientIpSource { + SecureClientIpSource::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 b7f27f5..210b481 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,16 +10,18 @@ use axum::{ routing::get, Router, }; -use axum_client_ip::{SecureClientIp, SecureClientIpSource}; +use axum_client_ip::SecureClientIp; 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 tracing::{debug, error, info}; use tracing_subscriber::EnvFilter; mod auth; +mod config; mod nsupdate; mod password; mod records; @@ -32,120 +34,52 @@ struct Opts { #[command(flatten)] verbosity: Verbosity, - /// Ip address of the server - #[arg(long, default_value = "127.0.0.1")] - address: IpAddr, - - /// Port of the server - #[arg(long, default_value_t = 5353)] - port: u16, - - /// File containing password to match against - /// - /// Should be of the format `username:password` and contain a single password - #[arg(long)] - password_file: Option, - - /// Salt to get more unique hashed passwords and prevent table based attacks - #[arg(long, default_value = DEFAULT_SALT)] - salt: String, - - /// Time To Live (in seconds) to set on the DNS records - #[arg(long, default_value_t = DEFAULT_TTL.as_secs())] - ttl: u64, - /// Data directory - #[arg(long, 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: - #[clap(long, default_value = "RightmostXForwardedFor")] - ip_source: SecureClientIpSource, + #[clap(flatten)] + config_or_command: ConfigOrCommand, +} - /// Set which IPs to allow updating - #[clap(long, default_value_t = IpType::Both)] - ip_type: IpType, +#[derive(clap::Args, Debug)] +#[group(multiple = false)] +struct ConfigOrCommand { + /// Path to the configuration file + #[arg(long, short)] + config: Option, #[clap(subcommand)] subcommand: Option, } -#[derive(Debug, Default, Clone, Copy)] -enum IpType { - #[default] - Both, - IPv4Only, - IPv6Only, -} - -impl IpType { - 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), - _ => bail!("expected one of 'ipv4-only', 'ipv6-only' or 'both', got '{s}'"), - } +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 } } } @@ -168,7 +102,7 @@ struct AppState<'a> { last_ips: std::sync::Arc>, /// The IP type for which to allow updates - ip_type: IpType, + ip_type: config::IpType, } #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] @@ -211,33 +145,38 @@ impl SavedIPs { } impl AppState<'static> { - fn from_args(args: &Opts) -> miette::Result { + fn from_args(args: &Opts, config: &config::Config) -> miette::Result { let Opts { verbosity: _, - address: _, - port: _, - password_file: _, data_dir, - key_file, insecure, - subcommand: _, - records, - salt: _, - ttl, - ip_source: _, - ip_type, + config_or_command: _, } = args; - // Set state - let ttl = Duration::from_secs(*ttl); + 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, - // Load DNS records - records: records::load_no_verify(records)?, + ttl: **ttl, + records, // Load keyfile key_file: key_file .as_deref() @@ -363,34 +302,37 @@ fn main() -> Result<()> { debug!("{args:?}"); - // process subcommand - if let Some(cmd) = args.subcommand.take() { - return cmd.process(&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)?; + let state = AppState::from_args(&args, &config)?; let Opts { verbosity: _, - address: ip, - port, - password_file, data_dir: _, - key_file: _, insecure, - subcommand: _, - records: _, - salt, - ttl: _, - ip_source, - ip_type, + config_or_command: _, } = args; info!("checking environment"); // Load password hash - let password_hash = password_file + 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()?; @@ -421,23 +363,26 @@ fn main() -> Result<()> { // Update DNS record with previous IPs (if available) let ips = state.last_ips.lock().await.clone(); - let actions = ips + let mut actions = ips .ips() - .filter(|ip| ip_type.valid_for_type(*ip)) - .flat_map(|ip| nsupdate::Action::from_records(ip, state.ttl, state.records)); + .filter(|ip| config.records.ip_type.valid_for_type(*ip)) + .flat_map(|ip| nsupdate::Action::from_records(ip, state.ttl, state.records)) + .peekable(); - match nsupdate::nsupdate(state.key_file, actions).await { - Ok(status) => { - if !status.success() { - error!("nsupdate failed: code {status}"); - bail!("nsupdate returned with code {status}"); + if actions.peek().is_some() { + match nsupdate::nsupdate(state.key_file, actions).await { + Ok(status) => { + if !status.success() { + error!("nsupdate failed: code {status}"); + bail!("nsupdate returned with code {status}"); + } + } + Err(err) => { + error!("Failed to update records with previous IP: {err}"); + return Err(err) + .into_diagnostic() + .wrap_err("failed to update records with previous IP"); } - } - Err(err) => { - error!("Failed to update records with previous IP: {err}"); - return Err(err) - .into_diagnostic() - .wrap_err("failed to update records with previous IP"); } } @@ -445,19 +390,24 @@ fn main() -> Result<()> { let app = Router::new().route("/update", get(update_records)); // if a password is provided, validate it let app = if let Some(pass) = password_hash { - app.layer(auth::layer(Box::leak(pass), String::leak(salt))) + app.layer(auth::layer( + Box::leak(pass), + Box::leak(config.password.salt), + )) } else { app } - .layer(ip_source.into_extension()) + .layer(config.records.ip_source.into_extension()) .with_state(state); + let config::Server { address } = config.server; + // Start services - 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::(), @@ -596,6 +546,15 @@ async fn trigger_update( 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 = { diff --git a/src/nsupdate.rs b/src/nsupdate.rs index 74397fa..5d266b9 100644 --- a/src/nsupdate.rs +++ b/src/nsupdate.rs @@ -25,7 +25,7 @@ impl<'a> Action<'a> { to: IpAddr, ttl: Duration, records: &'a [&'a str], - ) -> impl IntoIterator + 'a { + ) -> impl IntoIterator + std::iter::ExactSizeIterator + 'a { records .iter() .map(move |&domain| Action::Reassign { domain, to, ttl }) @@ -37,14 +37,14 @@ impl std::fmt::Display for Action<'_> { match self { Action::Reassign { domain, to, ttl } => { let ttl = ttl.as_secs(); - let typ = match to { + let kind = match to { IpAddr::V4(_) => "A", IpAddr::V6(_) => "AAAA", }; - // Delete previous record of type `typ` - writeln!(f, "update delete {domain} {ttl} IN {typ}")?; + // 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 {typ} {to}") + writeln!(f, "update add {domain} {ttl} IN {kind} {to}") } } } @@ -91,7 +91,7 @@ fn update_ns_records<'a>( ) -> std::io::Result<()> { writeln!(buf, "server 127.0.0.1")?; for action in actions { - writeln!(buf, "{action}")?; + write!(buf, "{action}")?; } writeln!(buf, "send")?; writeln!(buf, "quit") diff --git a/src/password.rs b/src/password.rs index 8d965ba..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,11 +20,18 @@ 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) } } @@ -45,13 +52,16 @@ pub fn hash_identity(username: &str, password: &str, salt: &str) -> Digest { } 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 860f719..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-ascii-ß.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 eb8102b..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:24] - 1 │ example.com. - 2 │ name.this-is-not-ascii-ß.example.org. + ╭──── + 1 │ name.this-is-not-ascii-ß.example.org. · ┬ · ╰── octet - 3 │ example.net. ╰──── help: we only accept ascii characters diff --git a/src/snapshots/webnsupdate__records__test__invalid_octet.snap b/src/snapshots/webnsupdate__records__test__invalid_octet.snap index 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 '.')