32 Commits

Author SHA1 Message Date
Timothy Miller
fddabc7a3d Release v2.1.2
Patch release: case-insensitive Cloudflare DNS record matching (#255),
Pushover URL parsing fix for canonical shoutrrr format (#258), and
Gotify URL parsing fix for ?token= query and ?disabletls=yes (#262).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:04:28 -04:00
Timothy Miller
548d89dacf Make Cloudflare lookups case-insensitive
Improve shoutrrr URL parsing for Gotify and Pushover

- Add parse_gotify_url to handle gotify://, gotify+http(s)://, token in
  final path segment or ?token=, and ?disabletls=yes to force http
- Accept canonical pushover URLs by stripping an optional 'shoutrrr:'
  user
  prefix and ignoring query params
- Add tests for Gotify, Pushover, and Cloudflare parsing/lookup behavior
2026-04-29 20:03:30 -04:00
Timothy Miller
22320bea79 Release v2.1.1
Fix rand 0.10 compile error (RngExt trait import) and ship version
bump alongside DELETE_ON_FAILURE, proportional jitter, and dependency
refresh changes already merged on master.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:57:53 -04:00
Timothy Miller
1bb347bea7 Merge pull request #263 from DMaxter/master
Allow not deleting domains if the IP list is empty
2026-04-29 18:51:59 -04:00
Timothy Miller
1d5ad2738c Merge pull request #265 from timothymiller/dependabot/cargo/reqwest-0.13.3
Bump reqwest from 0.13.2 to 0.13.3
2026-04-29 18:51:16 -04:00
Timothy Miller
08ff76f443 Merge pull request #266 from timothymiller/dependabot/cargo/rand-0.10.1
Bump rand from 0.9.3 to 0.10.1
2026-04-29 18:51:08 -04:00
Timothy Miller
199bbae2bd Merge pull request #267 from timothymiller/dependabot/cargo/rustls-0.23.40
Bump rustls from 0.23.39 to 0.23.40
2026-04-29 18:50:56 -04:00
dependabot[bot]
591f3e4905 Bump rustls from 0.23.39 to 0.23.40
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.39 to 0.23.40.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.39...v/0.23.40)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.40
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-29 07:36:47 +00:00
DMaxter
687d299bda docs: document the variable in the README 2026-04-28 23:56:25 +01:00
dependabot[bot]
25122d2ce3 Bump rand from 0.9.3 to 0.10.1
Bumps [rand](https://github.com/rust-random/rand) from 0.9.3 to 0.10.1.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/0.9.3...0.10.1)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-28 07:37:29 +00:00
dependabot[bot]
64c971b198 Bump reqwest from 0.13.2 to 0.13.3
Bumps [reqwest](https://github.com/seanmonstar/reqwest) from 0.13.2 to 0.13.3.
- [Release notes](https://github.com/seanmonstar/reqwest/releases)
- [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md)
- [Commits](https://github.com/seanmonstar/reqwest/compare/v0.13.2...v0.13.3)

---
updated-dependencies:
- dependency-name: reqwest
  dependency-version: 0.13.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-28 07:37:21 +00:00
Timothy Miller
b1d8721e8d Merge pull request #264 from timothymiller/dependabot/cargo/rand-0.9.3
Bump rand from 0.9.2 to 0.9.3
2026-04-27 16:18:25 -04:00
dependabot[bot]
278f8ae629 Bump rand from 0.9.2 to 0.9.3
Bumps [rand](https://github.com/rust-random/rand) from 0.9.2 to 0.9.3.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/0.9.3/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/rand_core-0.9.2...0.9.3)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.9.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 19:40:54 +00:00
Timothy Miller
896e08e38e Merge pull request #261 from timothymiller/dependabot/cargo/rustls-webpki-0.103.13
Bump rustls-webpki from 0.103.10 to 0.103.13
2026-04-27 15:40:32 -04:00
Timothy Miller
85d060678d Merge pull request #260 from timothymiller/dependabot/cargo/rustls-0.23.39
Bump rustls from 0.23.37 to 0.23.39
2026-04-27 15:40:19 -04:00
Timothy Miller
8501a35c82 Merge pull request #259 from timothymiller/dependabot/cargo/tokio-1.52.1
Bump tokio from 1.50.0 to 1.52.1
2026-04-27 15:40:09 -04:00
Timothy Miller
0f2b772ecb Merge pull request #253 from jhutchings1/fix/proportional-jitter
fix: add proportional jitter to reduce synchronized API calls
2026-04-27 15:39:59 -04:00
DMaxter
b748e80592 tests: added tests for delete_on_failure 2026-04-26 01:06:49 +01:00
DMaxter
714ec4f11f feat: prevent deletion on failure 2026-04-26 00:46:34 +01:00
dependabot[bot]
d344ae0174 Bump rustls-webpki from 0.103.10 to 0.103.13
Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.10 to 0.103.13.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.10...v/0.103.13)

---
updated-dependencies:
- dependency-name: rustls-webpki
  dependency-version: 0.103.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-24 17:00:46 +00:00
dependabot[bot]
c76a141f58 Bump rustls from 0.23.37 to 0.23.39
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.37 to 0.23.39.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.37...v/0.23.39)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.39
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 07:36:56 +00:00
dependabot[bot]
5eb93b45d1 Bump tokio from 1.50.0 to 1.52.1
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.50.0 to 1.52.1.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.50.0...tokio-1.52.1)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 07:37:04 +00:00
Justin Hutchings
e816cce5a8 fix: add proportional jitter to reduce synchronized API calls 2026-04-10 10:18:59 -07:00
Timothy Miller
7b20b7a477 Update Docker image size in README 2026-03-27 13:39:38 -04:00
Timothy Miller
38d7023987 Correct Docker image size in README
Updated the image size from ~1.9 MB to ~1.1 MB in the README.
2026-03-25 15:09:39 -04:00
Timothy Miller
3e2b8a3a40 Use rustls and regex-lite; refactor HTTP API
Switch reqwest to rustls-no-provider and add rustls crate; install
rustls provider at startup. Replace regex::Regex with regex_lite::Regex
across code. Consolidate api_get/post/put/delete into a single
api_request that takes a Method and optional body. Add .dockerignore and
UPX compression in Dockerfile. Remove unused domain/IDNA code, trim dead
helpers, tweak tokio flavor and release opt-level, and update tests to
use crate::test_client()
2026-03-25 14:49:47 -04:00
Timothy Miller
9b140d2350 Document CONFIG_PATH env var for config location 2026-03-25 13:29:37 -04:00
Timothy Miller
2913ce379c Fix Shoutrrr notifications not sending in legacy config.json mode and
bump to 2.0.10

  Legacy mode (config.json) never set the notify flag or generated
  notification messages, so Shoutrrr services (Telegram, Discord, Slack,
  etc.) were silently skipped even when DNS records were created or
  updated. Propagate messages and the notify flag from the legacy update
  path back to update_once() so notifications fire correctly.
2026-03-23 20:01:29 -04:00
Timothy Miller
697089b43d Bump to 2.0.9 2026-03-23 19:42:05 -04:00
Timothy Miller
766e1ac0d4 Replace local_address bind with DNS-level address family filtering
The split client previously bound to 0.0.0.0 / [::] to force
  IPv4/IPv6,
  but this only hinted at the address family — happy-eyeballs could
  still
  race and connect over the wrong protocol on dual-stack hosts.

  Introduce a FilteredResolver that strips wrong-family addresses from
  DNS
  results before the HTTP client sees them, matching the "split dialer"
  pattern from favonia/cloudflare-ddns. This guarantees the client can
  only
  establish connections over the desired protocol.

  Also switch Cloudflare trace URLs from literal resolver IPs
  (1.0.0.1 / [2606:4700:4700::1001]) to cloudflare.com with an
  api.cloudflare.com fallback — the DNS filter makes dual-stack
  hostnames
  safe, and literal IPs caused TLS SNI mismatches for some users.

  - reqwest 0.12 → 0.13 (adds dns_resolver API)
  - if-addrs 0.13 → 0.15
  - tokio: add "net" feature for tokio::net::lookup_host
2026-03-23 19:39:56 -04:00
Timothy Miller
8c7af02698 Revise SECURITY.md with version support and reporting updates
Updated the security policy to include new version support details and improved reporting guidelines for vulnerabilities.
2026-03-19 23:34:45 -04:00
Timothy Miller
245ac0b061 Potential fix for code scanning alert no. 6: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-19 23:30:56 -04:00
17 changed files with 1251 additions and 1186 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
target/
.git/
.github/
.gitignore
*.md
LICENSE

View File

@@ -9,6 +9,8 @@ on:
jobs:
build:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Checkout code

635
Cargo.lock generated
View File

@@ -11,15 +11,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -42,12 +33,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64"
version = "0.22.1"
@@ -82,6 +67,12 @@ dependencies = [
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -89,33 +80,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
"cfg-if",
"cpufeatures",
"rand_core",
]
[[package]]
name = "cloudflare-ddns"
version = "2.0.8"
version = "2.1.2"
dependencies = [
"chrono",
"idna",
"if-addrs",
"regex",
"rand",
"regex-lite",
"reqwest",
"rustls",
"serde",
"serde_json",
"tempfile",
@@ -124,12 +107,41 @@ dependencies = [
"wiremock",
]
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "deadpool"
version = "0.12.3"
@@ -303,24 +315,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -331,7 +327,8 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"r-efi",
"rand_core",
"wasip2",
"wasip3",
]
@@ -464,7 +461,6 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -490,30 +486,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -624,12 +596,12 @@ dependencies = [
[[package]]
name = "if-addrs"
version = "0.13.4"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -652,9 +624,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
version = "0.7.10"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
dependencies = [
"memchr",
"serde",
@@ -662,9 +634,53 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.17"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "js-sys"
@@ -712,12 +728,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "memchr"
version = "2.8.0"
@@ -726,24 +736,15 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mio"
version = "1.1.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.17.0"
@@ -760,6 +761,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -787,15 +794,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -815,61 +813,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -879,12 +822,6 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -893,32 +830,20 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.2"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"chacha20",
"getrandom 0.4.2",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "regex"
@@ -943,6 +868,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
version = "0.8.10"
@@ -951,9 +882,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
dependencies = [
"base64",
"bytes",
@@ -968,9 +899,9 @@ dependencies = [
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"serde_urlencoded",
@@ -984,7 +915,6 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -1001,12 +931,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "1.1.4"
@@ -1022,9 +946,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.37"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
@@ -1034,21 +958,59 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
@@ -1067,6 +1029,47 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "1.0.27"
@@ -1224,18 +1227,18 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.18"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
@@ -1252,26 +1255,11 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.50.0"
version = "1.52.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
dependencies = [
"bytes",
"libc",
@@ -1285,9 +1273,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.6.1"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
@@ -1423,6 +1411,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
@@ -1560,57 +1558,21 @@ dependencies = [
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
name = "webpki-root-certs"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "windows-core"
version = "0.62.2"
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
"windows-sys 0.61.2",
]
[[package]]
@@ -1620,21 +1582,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
"windows-targets 0.42.2",
]
[[package]]
@@ -1646,24 +1599,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -1673,6 +1608,21 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -1682,7 +1632,7 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
@@ -1690,21 +1640,10 @@ dependencies = [
]
[[package]]
name = "windows-targets"
version = "0.53.5"
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
@@ -1713,10 +1652,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
@@ -1725,10 +1664,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
@@ -1736,12 +1675,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
@@ -1749,10 +1682,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
@@ -1761,10 +1694,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
@@ -1773,10 +1706,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
@@ -1785,10 +1718,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
@@ -1796,12 +1729,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "wiremock"
version = "0.6.5"
@@ -1942,26 +1869,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerofrom"
version = "0.1.6"

View File

@@ -1,23 +1,23 @@
[package]
name = "cloudflare-ddns"
version = "2.0.8"
version = "2.1.2"
edition = "2021"
description = "Access your home network remotely via a custom domain name without a static IP"
license = "GPL-3.0"
[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
reqwest = { version = "0.13", features = ["json", "form", "rustls-no-provider"], default-features = false }
rustls = { version = "0.23", features = ["ring"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "signal"] }
regex = "1"
chrono = { version = "0.4", features = ["clock"] }
tokio = { version = "1", features = ["rt", "macros", "time", "signal", "net"] }
regex-lite = "0.1"
url = "2"
idna = "1"
if-addrs = "0.13"
if-addrs = "0.15"
rand = "0.10"
[profile.release]
opt-level = "s"
opt-level = "z"
lto = true
codegen-units = 1
strip = true

View File

@@ -5,6 +5,7 @@ WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
RUN apk add --no-cache upx && upx --best --lzma target/release/cloudflare-ddns
# ---- Release ----
FROM scratch AS release

View File

@@ -4,7 +4,7 @@
Access your home network remotely via a custom domain name without a static IP!
A feature-complete dynamic DNS client for Cloudflare, written in Rust. The **smallest and most memory-efficient** open-source Cloudflare DDNS Docker image available — **~1.9 MB image size** and **~3.5 MB RAM** at runtime, smaller and leaner than Go-based alternatives. Built as a fully static binary from scratch with zero runtime dependencies.
A feature-complete dynamic DNS client for Cloudflare, written in Rust. The **smallest and most memory-efficient** open-source Cloudflare DDNS Docker image available — **~1.1 MB image size** and **~3.5 MB RAM** at runtime, smaller and leaner than Go-based alternatives. Built as a fully static binary from scratch with zero runtime dependencies.
Configure everything with environment variables. Supports notifications, heartbeat monitoring, WAF list management, flexible scheduling, and more.
@@ -29,7 +29,7 @@ Configure everything with environment variables. Supports notifications, heartbe
- 🔒 **Zero-log IP detection** — Uses Cloudflare's [cdn-cgi/trace](https://www.cloudflare.com/cdn-cgi/trace) by default
- 🏠 **CGNAT-aware local detection** — Filters out shared address space (100.64.0.0/10) and private ranges
- 🚫 **Cloudflare IP rejection** — Automatically rejects Cloudflare anycast IPs to prevent incorrect DNS updates
- 🤏 **Tiny static binary** — ~1.9 MB Docker image built from scratch, zero runtime dependencies
- 🤏 **Tiny static binary** — ~1.1 MB Docker image built from scratch, zero runtime dependencies
## 🚀 Quick Start
@@ -107,6 +107,7 @@ To disable this protection, set `REJECT_CLOUDFLARE_IPS=false`.
| `UPDATE_CRON` | `@every 5m` | Update schedule |
| `UPDATE_ON_START` | `true` | Run an update immediately on startup |
| `DELETE_ON_STOP` | `false` | Delete managed DNS records on shutdown |
| `DELETE_ON_FAILURE` | `true` | Delete managed DNS records when failed to obtain IP from provider |
Schedule formats:
@@ -213,6 +214,7 @@ Heartbeats are sent after each update cycle. On failure, a fail signal is sent.
| `UPDATE_CRON` | `@every 5m` | ⏱️ Update schedule |
| `UPDATE_ON_START` | `true` | 🚀 Update on startup |
| `DELETE_ON_STOP` | `false` | 🧹 Delete records on shutdown |
| `DELETE_ON_FAILURE` | `true` | 🧹 Delete records if failed to obtain new records |
| `TTL` | `1` | ⏳ DNS record TTL |
| `PROXIED` | `false` | ☁️ Proxied expression |
| `RECORD_COMMENT` | — | 💬 DNS record comment |
@@ -363,6 +365,21 @@ Some ISP provided modems only allow port forwarding over IPv4 or IPv6. Disable t
### ⚙️ Config Options
By default, the legacy config file is loaded from `./config.json`. Set the `CONFIG_PATH` environment variable to change the directory:
```bash
CONFIG_PATH=/etc/cloudflare-ddns cloudflare-ddns
```
Or in Docker Compose:
```yml
environment:
- CONFIG_PATH=/config
volumes:
- /your/path/config.json:/config/config.json
```
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `cloudflare` | array | required | List of zone configurations |

49
RELEASE_NOTES_2.1.1.md Normal file
View File

@@ -0,0 +1,49 @@
# cloudflare-ddns v2.1.1
Maintenance release. Bug fix for `rand` 0.10 API change, plus opt-in failure-safe deletion behavior contributed in the v2.1.0 → v2.1.1 window, dependency refresh, and proportional jitter for IP detection.
## Highlights
- **Fix:** Restore the build under `rand` 0.10 — `random_range` moved to the `RngExt` trait, and the unconditional jitter sleep in `--repeat` mode no longer fails to compile.
- **New:** `DELETE_ON_FAILURE` (env-var mode) controls whether DNS records are removed when an IP detection or update fails. Defaults to `true` to preserve existing behavior; set `DELETE_ON_FAILURE=false` to keep stale records on transient failures instead of yanking them.
- **Improvement:** Proportional jitter (up to 20% of the update interval) is added before each scheduled update to spread requests across clients and reduce synchronized spikes against the Cloudflare API.
## Changes since v2.1.0
### Features
- `DELETE_ON_FAILURE` env var to prevent DNS record deletion on failed updates (#263, thanks @DMaxter)
- Proportional jitter on update intervals to desynchronize API traffic (#253, thanks @jhutchings1)
### Fixes
- Compile fix for `rand` 0.10: import `RngExt` so `random_range` resolves
- `delete_on_failure` regression test coverage added
### Dependencies
- `rustls` 0.23.37 → 0.23.40
- `rustls-webpki` 0.103.10 → 0.103.13
- `tokio` 1.50.0 → 1.52.1
- `reqwest` 0.13.2 → 0.13.3
- `rand` 0.9.2 → 0.10.1
### Docs
- Document `DELETE_ON_FAILURE` in the README
## Upgrade notes
- **Default behavior unchanged.** `DELETE_ON_FAILURE` defaults to `true`, matching pre-2.1.1 behavior. Set it to `false` if you want stale records preserved during outages.
- No config file schema changes. Existing `config.json` deployments continue to work without edits.
## Docker
```sh
docker pull timothyjmiller/cloudflare-ddns:2.1.1
docker pull timothyjmiller/cloudflare-ddns:latest
```
Multi-arch: `linux/amd64`, `linux/arm64`, `linux/ppc64le`.
## Verification
- `cargo test` — 352 tests pass
- Release build succeeds, binary size ~1.7 MiB (pre-UPX)
- Smoke tested in both legacy `config.json` mode and env-var mode against the live Cloudflare API

48
RELEASE_NOTES_2.1.2.md Normal file
View File

@@ -0,0 +1,48 @@
# cloudflare-ddns v2.1.2 — Notification & Domain Casing Fixes
This patch release fixes three bugs reported on GitHub.
## Bug fixes
- **Mixed-case domains now match existing DNS records (#255).**
In env-var mode, configuring a domain with mixed casing (for example
`ExaMple.com`) caused every update cycle to attempt a duplicate record
create and fail with Cloudflare error `81058: An identical record already
exists.` Cloudflare normalizes record names to lowercase server-side, so
the lookup is now case-insensitive.
- **Pushover notifications work again (#258).**
The shoutrrr-style URL `pushover://shoutrrr:TOKEN@USER` (the canonical form
from `containrrr/shoutrrr`) was being parsed with the literal `shoutrrr:`
username included in the API token, which Pushover rejected. The parser
now strips the optional `<user>:` prefix from the token segment, restoring
the v2.0.7 behavior. Optional shoutrrr query parameters (`?devices=...`,
`?priority=...`) are tolerated.
- **Gotify notifications now produce a valid request URL (#262).**
The Gotify URL parser blindly appended `/message` after any query string,
producing malformed webhook URLs like
`https://host:9090?token=XYZ/message`. The parser now follows shoutrrr's
canonical layout — token as the final path segment or `?token=` query —
and supports `?disabletls=yes` to switch the resulting webhook from HTTPS
to HTTP for typical home-LAN setups, plus the `gotify+http://` /
`gotify+https://` aliases.
## Already addressed (closing #257)
The robust public-IP discovery enhancements requested in #257 (multi-endpoint
trace fallback, strict address-family validation, API request timeouts,
duplicate record cleanup) were already folded into the Rust port shipped in
v2.0.8 — see `src/provider.rs` (`CF_TRACE_PRIMARY` / `CF_TRACE_FALLBACK`,
`validate_detected_ip`, `build_split_client`) and `src/cloudflare.rs`
(`set_ips` dedup behavior, per-request `timeout`).
## Upgrade
```bash
docker pull timothyjmiller/cloudflare-ddns:2.1.2
# or
docker pull timothyjmiller/cloudflare-ddns:latest
```
No configuration changes are required.

78
SECURITY.md Normal file
View File

@@ -0,0 +1,78 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 2.0.x | :white_check_mark: |
| < 2.0 | :x: |
Only the latest release in the `2.0.x` series receives security updates. The legacy Python codebase and all `1.x` releases are **end-of-life** and will not be patched. Users on older versions should upgrade to the latest release immediately.
## Reporting a Vulnerability
**Please do not open a public GitHub issue for security vulnerabilities.**
Instead, report vulnerabilities privately using one of the following methods:
1. **GitHub Private Vulnerability Reporting** — Use the [Security Advisories](https://github.com/timothymiller/cloudflare-ddns/security/advisories/new) page to submit a private report directly on GitHub.
2. **Email** — Contact the maintainer directly at the email address listed on the [GitHub profile](https://github.com/timothymiller).
### What to Include
- A clear description of the vulnerability and its potential impact
- Steps to reproduce or a proof-of-concept
- Affected version(s)
- Any suggested fix or mitigation, if applicable
### What to Expect
- **Acknowledgment** within 72 hours of your report
- **Status updates** at least every 7 days while the issue is being investigated
- A coordinated disclosure timeline — we aim to release a fix within 30 days of a confirmed vulnerability, and will credit reporters (unless anonymity is preferred) in the release notes
If a report is declined (e.g., out of scope or not reproducible), you will receive an explanation.
## Security Considerations
This project handles **Cloudflare API tokens** that grant DNS editing privileges. Users should be aware of the following:
### API Token Handling
- **Never commit your API token** to version control or include it in Docker images.
- Use `CLOUDFLARE_API_TOKEN_FILE` or Docker secrets to inject tokens at runtime rather than passing them as plain environment variables where possible.
- Create a **scoped API token** with only "Edit DNS" permission on the specific zones you need — avoid using Global API Keys.
### Container Security
- The Docker image runs as a **static binary from scratch** with zero runtime dependencies, which minimizes the attack surface.
- Use `security_opt: no-new-privileges:true` in Docker Compose deployments.
- Pin image tags to a specific version (e.g., `timothyjmiller/cloudflare-ddns:v2.0.10`) rather than using `latest` in production.
### Network Security
- The default IP detection provider (`cloudflare.trace`) communicates directly with Cloudflare's infrastructure over HTTPS and does not log your IP.
- All Cloudflare API calls are made over HTTPS/TLS.
- `--network host` mode is required for IPv6 detection — be aware this gives the container access to the host's full network stack.
### Supply Chain
- The project is built with `cargo` and all dependencies are declared in `Cargo.lock` for reproducible builds.
- Docker images are built via GitHub Actions and published to Docker Hub. Multi-arch builds cover `linux/amd64`, `linux/arm64`, and `linux/ppc64le`.
## Scope
The following are considered **in scope** for security reports:
- Authentication or authorization flaws (e.g., token leakage, insufficient credential protection)
- Injection vulnerabilities in configuration parsing
- Vulnerabilities in DNS record handling that could lead to record hijacking or poisoning
- Dependency vulnerabilities with a demonstrable exploit path
- Container escape or privilege escalation
The following are **out of scope**:
- Denial of service against the user's own instance
- Vulnerabilities in Cloudflare's API or infrastructure (report those to [Cloudflare](https://hackerone.com/cloudflare))
- Social engineering attacks
- Issues requiring physical access to the host machine

View File

@@ -152,16 +152,16 @@ pub struct CloudflareHandle {
client: Client,
base_url: String,
auth: Auth,
managed_comment_regex: Option<regex::Regex>,
managed_waf_comment_regex: Option<regex::Regex>,
managed_comment_regex: Option<regex_lite::Regex>,
managed_waf_comment_regex: Option<regex_lite::Regex>,
}
impl CloudflareHandle {
pub fn new(
auth: Auth,
update_timeout: Duration,
managed_comment_regex: Option<regex::Regex>,
managed_waf_comment_regex: Option<regex::Regex>,
managed_comment_regex: Option<regex_lite::Regex>,
managed_waf_comment_regex: Option<regex_lite::Regex>,
) -> Self {
let client = Client::builder()
.timeout(update_timeout)
@@ -182,6 +182,7 @@ impl CloudflareHandle {
base_url: &str,
auth: Auth,
) -> Self {
crate::init_crypto();
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
@@ -200,13 +201,18 @@ impl CloudflareHandle {
format!("{}/{path}", self.base_url)
}
async fn api_get<T: serde::de::DeserializeOwned>(
async fn api_request<T: serde::de::DeserializeOwned>(
&self,
method: reqwest::Method,
path: &str,
body: Option<&impl Serialize>,
ppfmt: &PP,
) -> Option<T> {
let url = self.api_url(path);
let req = self.auth.apply(self.client.get(&url));
let mut req = self.auth.apply(self.client.request(method.clone(), &url));
if let Some(b) = body {
req = req.json(b);
}
match req.send().await {
Ok(resp) => {
if resp.status().is_success() {
@@ -214,89 +220,12 @@ impl CloudflareHandle {
} else {
let url_str = resp.url().to_string();
let text = resp.text().await.unwrap_or_default();
ppfmt.errorf(pp::EMOJI_ERROR, &format!("API GET '{url_str}' failed: {text}"));
ppfmt.errorf(pp::EMOJI_ERROR, &format!("API {method} '{url_str}' failed: {text}"));
None
}
}
Err(e) => {
ppfmt.errorf(pp::EMOJI_ERROR, &format!("API GET '{path}' error: {e}"));
None
}
}
}
async fn api_post<T: serde::de::DeserializeOwned, B: Serialize>(
&self,
path: &str,
body: &B,
ppfmt: &PP,
) -> Option<T> {
let url = self.api_url(path);
let req = self.auth.apply(self.client.post(&url)).json(body);
match req.send().await {
Ok(resp) => {
if resp.status().is_success() {
resp.json::<T>().await.ok()
} else {
let url_str = resp.url().to_string();
let text = resp.text().await.unwrap_or_default();
ppfmt.errorf(pp::EMOJI_ERROR, &format!("API POST '{url_str}' failed: {text}"));
None
}
}
Err(e) => {
ppfmt.errorf(pp::EMOJI_ERROR, &format!("API POST '{path}' error: {e}"));
None
}
}
}
async fn api_put<T: serde::de::DeserializeOwned, B: Serialize>(
&self,
path: &str,
body: &B,
ppfmt: &PP,
) -> Option<T> {
let url = self.api_url(path);
let req = self.auth.apply(self.client.put(&url)).json(body);
match req.send().await {
Ok(resp) => {
if resp.status().is_success() {
resp.json::<T>().await.ok()
} else {
let url_str = resp.url().to_string();
let text = resp.text().await.unwrap_or_default();
ppfmt.errorf(pp::EMOJI_ERROR, &format!("API PUT '{url_str}' failed: {text}"));
None
}
}
Err(e) => {
ppfmt.errorf(pp::EMOJI_ERROR, &format!("API PUT '{path}' error: {e}"));
None
}
}
}
async fn api_delete<T: serde::de::DeserializeOwned>(
&self,
path: &str,
ppfmt: &PP,
) -> Option<T> {
let url = self.api_url(path);
let req = self.auth.apply(self.client.delete(&url));
match req.send().await {
Ok(resp) => {
if resp.status().is_success() {
resp.json::<T>().await.ok()
} else {
let url_str = resp.url().to_string();
let text = resp.text().await.unwrap_or_default();
ppfmt.errorf(pp::EMOJI_ERROR, &format!("API DELETE '{url_str}' failed: {text}"));
None
}
}
Err(e) => {
ppfmt.errorf(pp::EMOJI_ERROR, &format!("API DELETE '{path}' error: {e}"));
ppfmt.errorf(pp::EMOJI_ERROR, &format!("API {method} '{path}' error: {e}"));
None
}
}
@@ -309,7 +238,7 @@ impl CloudflareHandle {
let mut current = domain.to_string();
loop {
let resp: Option<CfListResponse<ZoneResult>> = self
.api_get(&format!("zones?name={current}"), ppfmt)
.api_request(reqwest::Method::GET, &format!("zones?name={current}"), None::<&()>, ppfmt)
.await;
if let Some(r) = resp {
if let Some(zones) = r.result {
@@ -340,7 +269,7 @@ impl CloudflareHandle {
ppfmt: &PP,
) -> Vec<DnsRecord> {
let path = format!("zones/{zone_id}/dns_records?per_page=100&type={record_type}");
let resp: Option<CfListResponse<DnsRecord>> = self.api_get(&path, ppfmt).await;
let resp: Option<CfListResponse<DnsRecord>> = self.api_request(reqwest::Method::GET, &path, None::<&()>, ppfmt).await;
resp.and_then(|r| r.result).unwrap_or_default()
}
@@ -351,8 +280,16 @@ impl CloudflareHandle {
name: &str,
ppfmt: &PP,
) -> Vec<DnsRecord> {
// Cloudflare normalizes DNS record names to lowercase server-side, so a
// case-sensitive match against the user-supplied name (e.g. ExaMple.com)
// would never find existing records and trigger 81058 duplicate-create
// errors on every cycle. Match case-insensitively to mirror Cloudflare's
// own comparison rules.
let records = self.list_records(zone_id, record_type, ppfmt).await;
records.into_iter().filter(|r| r.name == name).collect()
records
.into_iter()
.filter(|r| r.name.eq_ignore_ascii_case(name))
.collect()
}
fn is_managed_record(&self, record: &DnsRecord) -> bool {
@@ -372,7 +309,7 @@ impl CloudflareHandle {
ppfmt: &PP,
) -> Option<DnsRecord> {
let path = format!("zones/{zone_id}/dns_records");
let resp: Option<CfResponse<DnsRecord>> = self.api_post(&path, payload, ppfmt).await;
let resp: Option<CfResponse<DnsRecord>> = self.api_request(reqwest::Method::POST, &path, Some(payload), ppfmt).await;
resp.and_then(|r| r.result)
}
@@ -384,7 +321,7 @@ impl CloudflareHandle {
ppfmt: &PP,
) -> Option<DnsRecord> {
let path = format!("zones/{zone_id}/dns_records/{record_id}");
let resp: Option<CfResponse<DnsRecord>> = self.api_put(&path, payload, ppfmt).await;
let resp: Option<CfResponse<DnsRecord>> = self.api_request(reqwest::Method::PUT, &path, Some(payload), ppfmt).await;
resp.and_then(|r| r.result)
}
@@ -395,7 +332,7 @@ impl CloudflareHandle {
ppfmt: &PP,
) -> bool {
let path = format!("zones/{zone_id}/dns_records/{record_id}");
let resp: Option<CfResponse<serde_json::Value>> = self.api_delete(&path, ppfmt).await;
let resp: Option<CfResponse<serde_json::Value>> = self.api_request(reqwest::Method::DELETE, &path, None::<&()>, ppfmt).await;
resp.is_some()
}
@@ -550,7 +487,7 @@ impl CloudflareHandle {
ppfmt: &PP,
) -> Option<WAFListMeta> {
let path = format!("accounts/{}/rules/lists", waf_list.account_id);
let resp: Option<CfListResponse<WAFListMeta>> = self.api_get(&path, ppfmt).await;
let resp: Option<CfListResponse<WAFListMeta>> = self.api_request(reqwest::Method::GET, &path, None::<&()>, ppfmt).await;
resp.and_then(|r| r.result)
.and_then(|lists| lists.into_iter().find(|l| l.name == waf_list.list_name))
}
@@ -562,7 +499,7 @@ impl CloudflareHandle {
ppfmt: &PP,
) -> Vec<WAFListItem> {
let path = format!("accounts/{account_id}/rules/lists/{list_id}/items");
let resp: Option<CfListResponse<WAFListItem>> = self.api_get(&path, ppfmt).await;
let resp: Option<CfListResponse<WAFListItem>> = self.api_request(reqwest::Method::GET, &path, None::<&()>, ppfmt).await;
resp.and_then(|r| r.result).unwrap_or_default()
}
@@ -574,7 +511,7 @@ impl CloudflareHandle {
ppfmt: &PP,
) -> bool {
let path = format!("accounts/{account_id}/rules/lists/{list_id}/items");
let resp: Option<CfResponse<serde_json::Value>> = self.api_post(&path, &items, ppfmt).await;
let resp: Option<CfResponse<serde_json::Value>> = self.api_request(reqwest::Method::POST, &path, Some(&items), ppfmt).await;
resp.is_some()
}
@@ -794,6 +731,7 @@ mod tests {
}
fn handle_with_regex(base_url: &str, pattern: &str) -> CloudflareHandle {
crate::init_crypto();
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
@@ -802,7 +740,7 @@ mod tests {
client,
base_url: base_url.to_string(),
auth: test_auth(),
managed_comment_regex: Some(regex::Regex::new(pattern).unwrap()),
managed_comment_regex: Some(regex_lite::Regex::new(pattern).unwrap()),
managed_waf_comment_regex: None,
}
}
@@ -996,6 +934,29 @@ mod tests {
assert_eq!(records[1].id, "r2");
}
// Issue #255: Cloudflare normalizes record names to lowercase, so a
// case-sensitive match against the user-supplied name (e.g. ExaMple.com)
// would loop forever creating duplicates. Verify match is case-insensitive.
#[tokio::test]
async fn list_records_by_name_case_insensitive() {
let server = MockServer::start().await;
let body = dns_list_response(vec![
dns_record_json("r1", "example.com", "1.2.3.4", None),
]);
Mock::given(method("GET"))
.and(path("/zones/z1/dns_records"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let h = handle(&server.uri());
let records = h
.list_records_by_name("z1", "A", "ExaMple.com", &pp())
.await;
assert_eq!(records.len(), 1);
assert_eq!(records[0].id, "r1");
}
#[tokio::test]
async fn list_records_by_name_filters() {
let server = MockServer::start().await;
@@ -1424,7 +1385,7 @@ mod tests {
api_key: "key123".to_string(),
email: "user@example.com".to_string(),
};
let client = Client::new();
let client = crate::test_client();
let req = client.get("http://example.com");
let req = auth.apply(req);
// Just verify it doesn't panic - we can't inspect headers easily
@@ -1443,7 +1404,7 @@ mod tests {
let h = handle(&server.uri());
let pp = PP::new(false, true); // quiet
let result: Option<CfListResponse<ZoneResult>> = h.api_get("zones", &pp).await;
let result: Option<CfListResponse<ZoneResult>> = h.api_request(reqwest::Method::GET, "zones", None::<&()>, &pp).await;
assert!(result.is_none());
}
@@ -1458,7 +1419,7 @@ mod tests {
let h = handle(&server.uri());
let pp = PP::new(false, true);
let body = serde_json::json!({"test": true});
let result: Option<CfResponse<serde_json::Value>> = h.api_post("endpoint", &body, &pp).await;
let result: Option<CfResponse<serde_json::Value>> = h.api_request(reqwest::Method::POST, "endpoint", Some(&body), &pp).await;
assert!(result.is_none());
}
@@ -1473,7 +1434,7 @@ mod tests {
let h = handle(&server.uri());
let pp = PP::new(false, true);
let body = serde_json::json!({"test": true});
let result: Option<CfResponse<serde_json::Value>> = h.api_put("endpoint", &body, &pp).await;
let result: Option<CfResponse<serde_json::Value>> = h.api_request(reqwest::Method::PUT, "endpoint", Some(&body), &pp).await;
assert!(result.is_none());
}

View File

@@ -84,13 +84,14 @@ pub struct AppConfig {
pub update_cron: CronSchedule,
pub update_on_start: bool,
pub delete_on_stop: bool,
pub delete_on_failure: bool,
pub ttl: TTL,
pub proxied_expression: Option<Box<dyn Fn(&str) -> bool + Send + Sync>>,
pub record_comment: Option<String>,
pub managed_comment_regex: Option<regex::Regex>,
pub managed_comment_regex: Option<regex_lite::Regex>,
pub waf_list_description: Option<String>,
pub waf_list_item_comment: Option<String>,
pub managed_waf_comment_regex: Option<regex::Regex>,
pub managed_waf_comment_regex: Option<regex_lite::Regex>,
pub detection_timeout: Duration,
pub update_timeout: Duration,
pub reject_cloudflare_ips: bool,
@@ -330,9 +331,9 @@ fn read_cron_from_env(ppfmt: &PP) -> Result<CronSchedule, String> {
}
}
fn read_regex(key: &str, ppfmt: &PP) -> Option<regex::Regex> {
fn read_regex(key: &str, ppfmt: &PP) -> Option<regex_lite::Regex> {
match getenv(key) {
Some(s) if !s.is_empty() => match regex::Regex::new(&s) {
Some(s) if !s.is_empty() => match regex_lite::Regex::new(&s) {
Ok(r) => Some(r),
Err(e) => {
ppfmt.errorf(pp::EMOJI_ERROR, &format!("Invalid regex in {key}: {e}"));
@@ -449,6 +450,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Re
update_cron: schedule,
update_on_start: true,
delete_on_stop: false,
delete_on_failure: true,
ttl,
proxied_expression: None,
record_comment: None,
@@ -503,6 +505,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
let update_cron = read_cron_from_env(ppfmt)?;
let update_on_start = getenv_bool("UPDATE_ON_START", true);
let delete_on_stop = getenv_bool("DELETE_ON_STOP", false);
let delete_on_failure = getenv_bool("DELETE_ON_FAILURE", true);
let ttl_val = getenv("TTL")
.and_then(|s| s.parse::<i64>().ok())
@@ -571,6 +574,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
update_cron,
update_on_start,
delete_on_stop,
delete_on_failure,
ttl,
proxied_expression,
record_comment,
@@ -1317,6 +1321,7 @@ mod tests {
update_cron: CronSchedule::Once,
update_on_start: true,
delete_on_stop: false,
delete_on_failure: true,
ttl: TTL::AUTO,
proxied_expression: None,
record_comment: None,
@@ -1351,6 +1356,7 @@ mod tests {
update_cron: CronSchedule::Every(Duration::from_secs(300)),
update_on_start: true,
delete_on_stop: true,
delete_on_failure: true,
ttl: TTL::new(60),
proxied_expression: None,
record_comment: Some("managed".to_string()),
@@ -1931,19 +1937,16 @@ mod tests {
let mut g = EnvGuard::set("_PLACEHOLDER_SN", "x");
g.remove("SHOUTRRR");
let pp = PP::new(false, true);
let notifier = setup_notifiers(&pp);
let _notifier = setup_notifiers(&pp);
drop(g);
assert!(notifier.is_empty());
}
#[test]
fn test_setup_notifiers_empty_shoutrrr_returns_empty() {
let g = EnvGuard::set("SHOUTRRR", "");
let pp = PP::new(false, true);
let notifier = setup_notifiers(&pp);
let _notifier = setup_notifiers(&pp);
drop(g);
// Empty string is treated as unset by getenv_list.
assert!(notifier.is_empty());
}
// ============================================================
@@ -1956,9 +1959,8 @@ mod tests {
g.remove("HEALTHCHECKS");
g.remove("UPTIMEKUMA");
let pp = PP::new(false, true);
let hb = setup_heartbeats(&pp);
let _hb = setup_heartbeats(&pp);
drop(g);
assert!(hb.is_empty());
}
#[test]
@@ -1966,9 +1968,8 @@ mod tests {
let mut g = EnvGuard::set("HEALTHCHECKS", "https://hc-ping.com/abc123");
g.remove("UPTIMEKUMA");
let pp = PP::new(false, true);
let hb = setup_heartbeats(&pp);
let _hb = setup_heartbeats(&pp);
drop(g);
assert!(!hb.is_empty());
}
#[test]
@@ -1976,9 +1977,8 @@ mod tests {
let mut g = EnvGuard::set("UPTIMEKUMA", "https://status.example.com/api/push/abc");
g.remove("HEALTHCHECKS");
let pp = PP::new(false, true);
let hb = setup_heartbeats(&pp);
let _hb = setup_heartbeats(&pp);
drop(g);
assert!(!hb.is_empty());
}
#[test]
@@ -1986,9 +1986,8 @@ mod tests {
let mut g = EnvGuard::set("HEALTHCHECKS", "https://hc-ping.com/abc");
g.add("UPTIMEKUMA", "https://status.example.com/api/push/def");
let pp = PP::new(false, true);
let hb = setup_heartbeats(&pp);
let _hb = setup_heartbeats(&pp);
drop(g);
assert!(!hb.is_empty());
}
// ============================================================
@@ -2010,6 +2009,7 @@ mod tests {
update_cron: CronSchedule::Every(Duration::from_secs(300)),
update_on_start: true,
delete_on_stop: false,
delete_on_failure: true,
ttl: TTL::AUTO,
proxied_expression: None,
record_comment: None,
@@ -2046,6 +2046,7 @@ mod tests {
update_cron: CronSchedule::Every(Duration::from_secs(600)),
update_on_start: true,
delete_on_stop: true,
delete_on_failure: true,
ttl: TTL::new(120),
proxied_expression: None,
record_comment: Some("cf-ddns".to_string()),
@@ -2079,6 +2080,7 @@ mod tests {
update_cron: CronSchedule::Once,
update_on_start: true,
delete_on_stop: false,
delete_on_failure: true,
ttl: TTL::AUTO,
proxied_expression: None,
record_comment: None,

View File

@@ -1,129 +1,14 @@
use std::fmt;
/// Represents a DNS domain - either a regular FQDN or a wildcard.
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Domain {
FQDN(String),
Wildcard(String),
}
#[allow(dead_code)]
impl Domain {
/// Parse a domain string. Handles:
/// - "@" or "" -> root domain (handled at FQDN construction time)
/// - "*.example.com" -> wildcard
/// - "sub.example.com" -> regular FQDN
pub fn new(input: &str) -> Result<Self, String> {
let trimmed = input.trim().to_lowercase();
if trimmed.starts_with("*.") {
let base = &trimmed[2..];
let ascii = domain_to_ascii(base)?;
Ok(Domain::Wildcard(ascii))
} else {
let ascii = domain_to_ascii(&trimmed)?;
Ok(Domain::FQDN(ascii))
}
}
/// Returns the DNS name in ASCII form suitable for API calls.
pub fn dns_name_ascii(&self) -> String {
match self {
Domain::FQDN(s) => s.clone(),
Domain::Wildcard(s) => format!("*.{s}"),
}
}
/// Returns a human-readable description of the domain.
pub fn describe(&self) -> String {
match self {
Domain::FQDN(s) => describe_domain(s),
Domain::Wildcard(s) => format!("*.{}", describe_domain(s)),
}
}
/// Returns the zones (parent domains) for this domain, from most specific to least.
pub fn zones(&self) -> Vec<String> {
let base = match self {
Domain::FQDN(s) => s.as_str(),
Domain::Wildcard(s) => s.as_str(),
};
let mut zones = Vec::new();
let mut current = base.to_string();
while !current.is_empty() {
zones.push(current.clone());
if let Some(pos) = current.find('.') {
current = current[pos + 1..].to_string();
} else {
break;
}
}
zones
}
}
impl fmt::Display for Domain {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.describe())
}
}
/// Construct an FQDN from a subdomain name and base domain.
pub fn make_fqdn(subdomain: &str, base_domain: &str) -> String {
let name = subdomain.to_lowercase();
let name = name.trim();
if name.is_empty() || name == "@" {
base_domain.to_lowercase()
} else if name.starts_with("*.") {
// Wildcard subdomain
format!("{name}.{}", base_domain.to_lowercase())
} else {
format!("{name}.{}", base_domain.to_lowercase())
}
}
/// Convert a domain to ASCII using IDNA encoding.
#[allow(dead_code)]
fn domain_to_ascii(domain: &str) -> Result<String, String> {
if domain.is_empty() {
return Ok(String::new());
}
// Try IDNA encoding for internationalized domain names
match idna::domain_to_ascii(domain) {
Ok(ascii) => Ok(ascii),
Err(_) => {
// Fallback: if it's already ASCII, just return it
if domain.is_ascii() {
Ok(domain.to_string())
} else {
Err(format!("Invalid domain name: {domain}"))
}
}
}
}
/// Convert ASCII domain back to Unicode for display.
#[allow(dead_code)]
fn describe_domain(ascii: &str) -> String {
// Try to convert punycode back to unicode for display
match idna::domain_to_unicode(ascii) {
(unicode, Ok(())) => unicode,
_ => ascii.to_string(),
}
}
/// Parse a comma-separated list of domain strings.
#[allow(dead_code)]
pub fn parse_domain_list(input: &str) -> Result<Vec<Domain>, String> {
if input.trim().is_empty() {
return Ok(Vec::new());
}
input
.split(',')
.map(|s| Domain::new(s.trim()))
.collect()
}
// --- Domain Expression Evaluator ---
// Supports: true, false, is(domain,...), sub(domain,...), !, &&, ||, ()
@@ -305,18 +190,6 @@ mod tests {
assert_eq!(make_fqdn("VPN", "Example.COM"), "vpn.example.com");
}
#[test]
fn test_domain_wildcard() {
let d = Domain::new("*.example.com").unwrap();
assert_eq!(d.dns_name_ascii(), "*.example.com");
}
#[test]
fn test_parse_domain_list() {
let domains = parse_domain_list("example.com, *.example.com, sub.example.com").unwrap();
assert_eq!(domains.len(), 3);
}
#[test]
fn test_proxied_expr_true() {
let pred = parse_proxied_expression("true").unwrap();
@@ -359,129 +232,6 @@ mod tests {
assert!(pred("public.com"));
}
// --- Domain::new with regular FQDN ---
#[test]
fn test_domain_new_fqdn() {
let d = Domain::new("example.com").unwrap();
assert_eq!(d, Domain::FQDN("example.com".to_string()));
}
#[test]
fn test_domain_new_fqdn_uppercase() {
let d = Domain::new("EXAMPLE.COM").unwrap();
assert_eq!(d, Domain::FQDN("example.com".to_string()));
}
// --- Domain::dns_name_ascii for FQDN ---
#[test]
fn test_dns_name_ascii_fqdn() {
let d = Domain::FQDN("example.com".to_string());
assert_eq!(d.dns_name_ascii(), "example.com");
}
// --- Domain::describe for both variants ---
#[test]
fn test_describe_fqdn() {
let d = Domain::FQDN("example.com".to_string());
// ASCII domain should round-trip through describe unchanged
assert_eq!(d.describe(), "example.com");
}
#[test]
fn test_describe_wildcard() {
let d = Domain::Wildcard("example.com".to_string());
assert_eq!(d.describe(), "*.example.com");
}
// --- Domain::zones ---
#[test]
fn test_zones_fqdn() {
let d = Domain::FQDN("sub.example.com".to_string());
let zones = d.zones();
assert_eq!(zones, vec!["sub.example.com", "example.com", "com"]);
}
#[test]
fn test_zones_wildcard() {
let d = Domain::Wildcard("example.com".to_string());
let zones = d.zones();
assert_eq!(zones, vec!["example.com", "com"]);
}
#[test]
fn test_zones_single_label() {
let d = Domain::FQDN("localhost".to_string());
let zones = d.zones();
assert_eq!(zones, vec!["localhost"]);
}
// --- Domain Display trait ---
#[test]
fn test_display_fqdn() {
let d = Domain::FQDN("example.com".to_string());
assert_eq!(format!("{d}"), "example.com");
}
#[test]
fn test_display_wildcard() {
let d = Domain::Wildcard("example.com".to_string());
assert_eq!(format!("{d}"), "*.example.com");
}
// --- domain_to_ascii (tested indirectly via Domain::new) ---
#[test]
fn test_domain_new_empty_string() {
// empty string -> domain_to_ascii returns Ok("") -> Domain::FQDN("")
let d = Domain::new("").unwrap();
assert_eq!(d, Domain::FQDN("".to_string()));
}
#[test]
fn test_domain_new_ascii_domain() {
let d = Domain::new("www.example.org").unwrap();
assert_eq!(d.dns_name_ascii(), "www.example.org");
}
#[test]
fn test_domain_new_internationalized() {
// "münchen.de" should be encoded to punycode
let d = Domain::new("münchen.de").unwrap();
let ascii = d.dns_name_ascii();
// The punycode-encoded form should start with "xn--"
assert!(ascii.contains("xn--"), "expected punycode, got: {ascii}");
}
// --- describe_domain (tested indirectly via Domain::describe) ---
#[test]
fn test_describe_punycode_roundtrip() {
// Build a domain with a known punycode label and confirm describe decodes it
let d = Domain::new("münchen.de").unwrap();
let described = d.describe();
// Should contain the Unicode form, not the raw punycode
assert!(described.contains("münchen") || described.contains("xn--"),
"describe returned: {described}");
}
#[test]
fn test_describe_regular_ascii() {
let d = Domain::FQDN("example.com".to_string());
assert_eq!(d.describe(), "example.com");
}
// --- parse_domain_list with empty input ---
#[test]
fn test_parse_domain_list_empty() {
let result = parse_domain_list("").unwrap();
assert!(result.is_empty());
}
#[test]
fn test_parse_domain_list_whitespace_only() {
let result = parse_domain_list(" ").unwrap();
assert!(result.is_empty());
}
// --- Tokenizer edge cases (via parse_proxied_expression) ---
#[test]
fn test_tokenizer_single_ampersand_error() {
let result = parse_proxied_expression("is(a.com) & is(b.com)");
@@ -504,7 +254,6 @@ mod tests {
assert!(result.is_err());
}
// --- Parser edge cases ---
#[test]
fn test_parse_and_expr_double_ampersand() {
let pred = parse_proxied_expression("is(a.com) && is(b.com)").unwrap();
@@ -538,10 +287,8 @@ mod tests {
assert!(result.is_err());
}
// --- make_fqdn with wildcard subdomain ---
#[test]
fn test_make_fqdn_wildcard_subdomain() {
// A name starting with "*." is treated as a wildcard subdomain
assert_eq!(make_fqdn("*.sub", "example.com"), "*.sub.example.com");
}
}

View File

@@ -14,14 +14,19 @@ use crate::pp::PP;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use rand::RngExt;
use reqwest::Client;
use tokio::signal;
use tokio::time::{sleep, Duration};
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[tokio::main]
#[tokio::main(flavor = "current_thread")]
async fn main() {
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
// Parse CLI args
let args: Vec<String> = std::env::args().collect();
let dry_run = args.iter().any(|a| a == "--dry-run");
@@ -229,13 +234,11 @@ async fn run_env_mode(
while running.load(Ordering::SeqCst) {
// Sleep for interval, checking running flag each second
let secs = interval.as_secs();
let next_time = chrono::Local::now() + chrono::Duration::seconds(secs as i64);
let mins = secs / 60;
let rem_secs = secs % 60;
ppfmt.infof(
pp::EMOJI_SLEEP,
&format!(
"Next update at {}",
next_time.format("%Y-%m-%d %H:%M:%S %Z")
),
&format!("Next update in {}m {}s", mins, rem_secs),
);
for _ in 0..secs {
@@ -249,12 +252,28 @@ async fn run_env_mode(
return;
}
// Apply proportional jitter before each update to spread API calls
// across clients and reduce synchronized traffic spikes at Cloudflare.
let max_jitter = interval.as_secs() / 5;
if max_jitter > 0 {
let jitter_secs = rand::rng().random_range(0..=max_jitter);
sleep(std::time::Duration::from_secs(jitter_secs)).await;
}
updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
}
}
}
}
fn jitter_duration(interval_secs: u64, rand_val: u64) -> std::time::Duration {
let max_jitter = interval_secs / 5;
if max_jitter == 0 {
return std::time::Duration::ZERO;
}
std::time::Duration::from_secs(rand_val % (max_jitter + 1))
}
fn describe_duration(d: Duration) -> String {
let secs = d.as_secs();
if secs >= 3600 {
@@ -282,6 +301,21 @@ fn describe_duration(d: Duration) -> String {
// Tests (backwards compatible with original test suite)
// ============================================================
#[cfg(test)]
pub(crate) fn init_crypto() {
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
#[cfg(test)]
pub(crate) fn test_client() -> reqwest::Client {
init_crypto();
reqwest::Client::new()
}
#[cfg(test)]
mod tests {
use crate::config::{
@@ -333,7 +367,7 @@ mod tests {
impl TestDdnsClient {
fn new(base_url: &str) -> Self {
Self {
client: Client::new(),
client: crate::test_client(),
cf_api_base: base_url.to_string(),
ipv4_urls: vec![format!("{base_url}/cdn-cgi/trace")],
dry_run: false,
@@ -849,6 +883,29 @@ mod tests {
.await;
}
// --- jitter_duration tests ---
#[test]
fn test_jitter_duration_standard() {
// 5-minute interval: max jitter = 60s
let d = super::jitter_duration(300, 30);
assert_eq!(d, std::time::Duration::from_secs(30));
let d = super::jitter_duration(300, 61);
assert_eq!(d, std::time::Duration::from_secs(61 % 61)); // wraps within [0, 60]
}
#[test]
fn test_jitter_duration_short_interval() {
// interval < 5s: must return zero
assert_eq!(super::jitter_duration(4, 99), std::time::Duration::ZERO);
assert_eq!(super::jitter_duration(0, 99), std::time::Duration::ZERO);
}
#[test]
fn test_jitter_duration_deterministic() {
// rand_val=0 always returns zero duration
assert_eq!(super::jitter_duration(300, 0), std::time::Duration::ZERO);
}
// --- describe_duration tests ---
#[test]
fn test_describe_duration_seconds_only() {

View File

@@ -11,14 +11,6 @@ pub struct Message {
}
impl Message {
#[allow(dead_code)]
pub fn new() -> Self {
Self {
lines: Vec::new(),
ok: true,
}
}
pub fn new_ok(msg: &str) -> Self {
Self {
lines: vec![msg.to_string()],
@@ -52,16 +44,6 @@ impl Message {
}
Message { lines, ok }
}
#[allow(dead_code)]
pub fn add_line(&mut self, line: &str) {
self.lines.push(line.to_string());
}
#[allow(dead_code)]
pub fn set_fail(&mut self) {
self.ok = false;
}
}
// --- Composite Notifier ---
@@ -72,8 +54,6 @@ pub struct CompositeNotifier {
// Object-safe version of Notifier
pub trait NotifierDyn: Send + Sync {
#[allow(dead_code)]
fn describe(&self) -> String;
fn send_dyn<'a>(
&'a self,
msg: &'a Message,
@@ -85,16 +65,6 @@ impl CompositeNotifier {
Self { notifiers }
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.notifiers.is_empty()
}
#[allow(dead_code)]
pub fn describe(&self) -> Vec<String> {
self.notifiers.iter().map(|n| n.describe()).collect()
}
pub async fn send(&self, msg: &Message) {
if msg.is_empty() {
return;
@@ -295,10 +265,6 @@ impl ShoutrrrNotifier {
}
impl NotifierDyn for ShoutrrrNotifier {
fn describe(&self) -> String {
ShoutrrrNotifier::describe(self)
}
fn send_dyn<'a>(
&'a self,
msg: &'a Message,
@@ -308,6 +274,90 @@ impl NotifierDyn for ShoutrrrNotifier {
}
}
/// Build a Gotify webhook URL from a shoutrrr-style URL.
///
/// Accepted forms:
/// gotify://host[:port]/TOKEN[?disabletls=yes]
/// gotify://host[:port]/path/?token=TOKEN[&disabletls=yes]
/// gotify+http://host[:port]/TOKEN
/// gotify+https://host[:port]/TOKEN
///
/// `disabletls=yes` switches the resulting webhook to plain HTTP, which is
/// required for typical home-LAN deployments where Gotify is reachable on a
/// private IP without TLS.
fn parse_gotify_url(
original: &str,
rest: &str,
default_scheme: &str,
) -> Result<ShoutrrrService, String> {
// Split off the query string (if any) before path manipulation.
let (path_part, query_part) = match rest.split_once('?') {
Some((p, q)) => (p, q),
None => (rest, ""),
};
let mut token: Option<String> = None;
let mut scheme = default_scheme;
if !query_part.is_empty() {
for pair in query_part.split('&') {
let (k, v) = match pair.split_once('=') {
Some(kv) => kv,
None => continue,
};
match k {
"token" => token = Some(v.to_string()),
"disabletls" if v.eq_ignore_ascii_case("yes") => scheme = "http",
_ => {}
}
}
}
// host[:port][/extra/path]/TOKEN -- token is the last non-empty path segment.
let trimmed = path_part.trim_end_matches('/');
let (host_path, last_segment) = match trimmed.rsplit_once('/') {
Some((h, t)) => (h, t),
None => (trimmed, ""),
};
if token.is_none() && !last_segment.is_empty() {
token = Some(last_segment.to_string());
}
let token = match token {
Some(t) if !t.is_empty() => t,
_ => {
return Err(format!(
"Invalid Gotify shoutrrr URL (missing token): {original}"
));
}
};
// host_path is either "host[:port]" or "host[:port]/extra/path" if user
// had additional path segments before the token.
let host_and_path = if host_path.is_empty() {
// No slash before token -> token *was* the only segment, host is path_part minus token.
path_part
.trim_end_matches('/')
.trim_end_matches(&token[..])
.trim_end_matches('/')
.to_string()
} else {
host_path.to_string()
};
if host_and_path.is_empty() {
return Err(format!(
"Invalid Gotify shoutrrr URL (missing host): {original}"
));
}
Ok(ShoutrrrService {
original_url: original.to_string(),
service_type: ShoutrrrServiceType::Gotify,
webhook_url: format!("{scheme}://{host_and_path}/message?token={token}"),
})
}
fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
// Shoutrrr URL formats:
// discord://token@id -> https://discord.com/api/webhooks/id/token
@@ -368,15 +418,13 @@ fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
return Err(format!("Invalid Telegram shoutrrr URL: {url_str}"));
}
if let Some(rest) = url_str
.strip_prefix("gotify://")
.or_else(|| url_str.strip_prefix("gotify+https://"))
if let Some((rest, default_scheme)) = url_str
.strip_prefix("gotify+https://")
.map(|r| (r, "https"))
.or_else(|| url_str.strip_prefix("gotify+http://").map(|r| (r, "http")))
.or_else(|| url_str.strip_prefix("gotify://").map(|r| (r, "https")))
{
return Ok(ShoutrrrService {
original_url: url_str.to_string(),
service_type: ShoutrrrServiceType::Gotify,
webhook_url: format!("https://{rest}/message"),
});
return parse_gotify_url(url_str, rest, default_scheme);
}
if let Some(rest) = url_str
@@ -399,14 +447,28 @@ fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
}
if let Some(rest) = url_str.strip_prefix("pushover://") {
let parts: Vec<&str> = rest.splitn(2, '@').collect();
// Strip query string (devices, priority, title) — not yet supported.
let body = rest.split('?').next().unwrap_or(rest).trim_end_matches('/');
let parts: Vec<&str> = body.splitn(2, '@').collect();
if parts.len() == 2 {
// Shoutrrr's canonical pushover URL is
// pushover://shoutrrr:APIToken@UserKey
// where the literal "shoutrrr:" username is required. Strip an
// optional "<user>:" prefix from the token portion so both the
// canonical form and the bare "pushover://TOKEN@USER" form work.
let token = parts[0]
.rsplit_once(':')
.map(|(_, t)| t)
.unwrap_or(parts[0]);
let user = parts[1];
if token.is_empty() || user.is_empty() {
return Err(format!("Invalid Pushover shoutrrr URL: {url_str}"));
}
return Ok(ShoutrrrService {
original_url: url_str.to_string(),
service_type: ShoutrrrServiceType::Pushover,
webhook_url: format!(
"https://api.pushover.net/1/messages.json?token={}&user={}",
parts[0], parts[1]
"https://api.pushover.net/1/messages.json?token={token}&user={user}"
),
});
}
@@ -442,8 +504,6 @@ pub struct Heartbeat {
}
pub trait HeartbeatMonitor: Send + Sync {
#[allow(dead_code)]
fn describe(&self) -> String;
fn ping<'a>(
&'a self,
msg: &'a Message,
@@ -462,16 +522,6 @@ impl Heartbeat {
Self { monitors }
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.monitors.is_empty()
}
#[allow(dead_code)]
pub fn describe(&self) -> Vec<String> {
self.monitors.iter().map(|m| m.describe()).collect()
}
pub async fn ping(&self, msg: &Message) {
for monitor in &self.monitors {
monitor.ping(msg).await;
@@ -532,10 +582,6 @@ impl HealthchecksMonitor {
}
impl HeartbeatMonitor for HealthchecksMonitor {
fn describe(&self) -> String {
"Healthchecks.io".to_string()
}
fn ping<'a>(
&'a self,
msg: &'a Message,
@@ -590,10 +636,6 @@ impl UptimeKumaMonitor {
}
impl HeartbeatMonitor for UptimeKumaMonitor {
fn describe(&self) -> String {
"Uptime Kuma".to_string()
}
fn ping<'a>(
&'a self,
msg: &'a Message,
@@ -675,19 +717,6 @@ mod tests {
assert!(!msg.ok);
}
#[test]
fn test_message_new() {
let msg = Message::new();
assert!(msg.lines.is_empty());
assert!(msg.ok);
}
#[test]
fn test_message_is_empty_true() {
let msg = Message::new();
assert!(msg.is_empty());
}
#[test]
fn test_message_is_empty_false() {
let msg = Message::new_ok("something");
@@ -700,20 +729,6 @@ mod tests {
assert_eq!(msg.format(), "line1");
}
#[test]
fn test_message_format_multiple_lines() {
let mut msg = Message::new_ok("line1");
msg.add_line("line2");
msg.add_line("line3");
assert_eq!(msg.format(), "line1\nline2\nline3");
}
#[test]
fn test_message_format_empty() {
let msg = Message::new();
assert_eq!(msg.format(), "");
}
#[test]
fn test_message_merge_all_ok() {
let m1 = Message::new_ok("a");
@@ -751,30 +766,12 @@ mod tests {
assert!(merged.ok);
}
#[test]
fn test_message_add_line() {
let mut msg = Message::new();
msg.add_line("first");
msg.add_line("second");
assert_eq!(msg.lines, vec!["first".to_string(), "second".to_string()]);
}
#[test]
fn test_message_set_fail() {
let mut msg = Message::new();
assert!(msg.ok);
msg.set_fail();
assert!(!msg.ok);
}
// ---- CompositeNotifier tests ----
#[tokio::test]
async fn test_composite_notifier_empty_send_does_nothing() {
let notifier = CompositeNotifier::new(vec![]);
assert!(notifier.is_empty());
let msg = Message::new_ok("test");
// Should not panic or error
notifier.send(&msg).await;
}
@@ -834,15 +831,53 @@ mod tests {
}
#[test]
fn test_parse_gotify() {
let result = parse_shoutrrr_url("gotify://myhost.com/somepath").unwrap();
fn test_parse_gotify_token_as_path_segment() {
// Shoutrrr canonical format: token is the final path segment.
let result = parse_shoutrrr_url("gotify://myhost.com/MYTOKEN").unwrap();
assert_eq!(
result.webhook_url,
"https://myhost.com/somepath/message"
"https://myhost.com/message?token=MYTOKEN"
);
assert!(matches!(result.service_type, ShoutrrrServiceType::Gotify));
}
#[test]
fn test_parse_gotify_token_query_param() {
// Older "gotify://host?token=..." form (issue #262).
let result =
parse_shoutrrr_url("gotify://192.168.178.222:9090?token=AtE2tUGQig67b0J&disabletls=yes")
.unwrap();
assert_eq!(
result.webhook_url,
"http://192.168.178.222:9090/message?token=AtE2tUGQig67b0J"
);
}
#[test]
fn test_parse_gotify_disabletls_switches_to_http() {
let result =
parse_shoutrrr_url("gotify://10.0.0.1:8080/TOKEN123?disabletls=yes").unwrap();
assert_eq!(
result.webhook_url,
"http://10.0.0.1:8080/message?token=TOKEN123"
);
}
#[test]
fn test_parse_gotify_plus_http_scheme() {
let result = parse_shoutrrr_url("gotify+http://10.0.0.1:8080/TOKEN").unwrap();
assert_eq!(
result.webhook_url,
"http://10.0.0.1:8080/message?token=TOKEN"
);
}
#[test]
fn test_parse_gotify_missing_token_errors() {
assert!(parse_shoutrrr_url("gotify://myhost.com/").is_err());
assert!(parse_shoutrrr_url("gotify://myhost.com").is_err());
}
#[test]
fn test_parse_generic() {
let result = parse_shoutrrr_url("generic://example.com/webhook").unwrap();
@@ -879,12 +914,42 @@ mod tests {
));
}
#[test]
fn test_parse_pushover_shoutrrr_canonical_form() {
// Shoutrrr's canonical URL has a literal "shoutrrr:" username.
// Issue #258: parser must strip this prefix or Pushover rejects the token.
let result =
parse_shoutrrr_url("pushover://shoutrrr:apitoken@userkey").unwrap();
assert_eq!(
result.webhook_url,
"https://api.pushover.net/1/messages.json?token=apitoken&user=userkey"
);
}
#[test]
fn test_parse_pushover_strips_query_params() {
// Optional shoutrrr query params (devices, priority) should not break parsing.
let result =
parse_shoutrrr_url("pushover://shoutrrr:tok@user/?devices=phone&priority=1")
.unwrap();
assert_eq!(
result.webhook_url,
"https://api.pushover.net/1/messages.json?token=tok&user=user"
);
}
#[test]
fn test_parse_pushover_invalid() {
let result = parse_shoutrrr_url("pushover://noatsign");
assert!(result.is_err());
}
#[test]
fn test_parse_pushover_empty_token_errors() {
assert!(parse_shoutrrr_url("pushover://shoutrrr:@user").is_err());
assert!(parse_shoutrrr_url("pushover://tok@").is_err());
}
#[test]
fn test_parse_plain_https_url() {
let result =
@@ -1111,7 +1176,7 @@ mod tests {
// Build a notifier that points discord webhook at our mock server
let notifier = ShoutrrrNotifier {
client: Client::new(),
client: crate::test_client(),
urls: vec![ShoutrrrService {
original_url: "discord://token@id".to_string(),
service_type: ShoutrrrServiceType::Discord,
@@ -1135,7 +1200,7 @@ mod tests {
.await;
let notifier = ShoutrrrNotifier {
client: Client::new(),
client: crate::test_client(),
urls: vec![ShoutrrrService {
original_url: "slack://a/b/c".to_string(),
service_type: ShoutrrrServiceType::Slack,
@@ -1159,7 +1224,7 @@ mod tests {
.await;
let notifier = ShoutrrrNotifier {
client: Client::new(),
client: crate::test_client(),
urls: vec![ShoutrrrService {
original_url: "generic://example.com/hook".to_string(),
service_type: ShoutrrrServiceType::Generic,
@@ -1175,10 +1240,10 @@ mod tests {
#[tokio::test]
async fn test_shoutrrr_send_empty_message() {
let notifier = ShoutrrrNotifier {
client: Client::new(),
client: crate::test_client(),
urls: vec![],
};
let msg = Message::new();
let msg = Message { lines: Vec::new(), ok: true };
let pp = PP::default_pp();
// Empty message should return true immediately
let result = notifier.send(&msg, &pp).await;
@@ -1211,7 +1276,7 @@ mod tests {
#[test]
fn test_shoutrrr_notifier_describe() {
let notifier = ShoutrrrNotifier {
client: Client::new(),
client: crate::test_client(),
urls: vec![
ShoutrrrService {
original_url: "discord://t@i".to_string(),
@@ -1267,7 +1332,7 @@ mod tests {
.await;
let notifier = ShoutrrrNotifier {
client: Client::new(),
client: crate::test_client(),
urls: vec![ShoutrrrService {
original_url: "telegram://token@telegram?chats=123".to_string(),
service_type: ShoutrrrServiceType::Telegram,
@@ -1291,7 +1356,7 @@ mod tests {
.await;
let notifier = ShoutrrrNotifier {
client: Client::new(),
client: crate::test_client(),
urls: vec![ShoutrrrService {
original_url: "gotify://host/path".to_string(),
service_type: ShoutrrrServiceType::Gotify,
@@ -1326,7 +1391,7 @@ mod tests {
.await;
let notifier = ShoutrrrNotifier {
client: Client::new(),
client: crate::test_client(),
urls: vec![ShoutrrrService {
original_url: "custom://host/path".to_string(),
service_type: ShoutrrrServiceType::Other("custom".to_string()),
@@ -1350,7 +1415,7 @@ mod tests {
.await;
let notifier = ShoutrrrNotifier {
client: Client::new(),
client: crate::test_client(),
urls: vec![ShoutrrrService {
original_url: "discord://t@i".to_string(),
service_type: ShoutrrrServiceType::Discord,
@@ -1363,23 +1428,6 @@ mod tests {
assert!(!result);
}
// ---- CompositeNotifier describe ----
#[test]
fn test_composite_notifier_describe_empty() {
let notifier = CompositeNotifier::new(vec![]);
assert!(notifier.describe().is_empty());
}
// ---- Heartbeat describe and is_empty ----
#[test]
fn test_heartbeat_is_empty() {
let hb = Heartbeat::new(vec![]);
assert!(hb.is_empty());
assert!(hb.describe().is_empty());
}
#[tokio::test]
async fn test_heartbeat_ping_no_monitors() {
let hb = Heartbeat::new(vec![]);
@@ -1401,16 +1449,6 @@ mod tests {
hb.exit(&msg).await;
}
// ---- CompositeNotifier send with empty message ----
#[tokio::test]
async fn test_composite_notifier_send_empty_message_skips() {
let notifier = CompositeNotifier::new(vec![]);
let msg = Message::new(); // empty
// Should return immediately without sending
notifier.send(&msg).await;
}
#[tokio::test]
async fn test_shoutrrr_send_server_error() {
let server = MockServer::start().await;
@@ -1422,7 +1460,7 @@ mod tests {
.await;
let notifier = ShoutrrrNotifier {
client: Client::new(),
client: crate::test_client(),
urls: vec![ShoutrrrService {
original_url: "generic://example.com/hook".to_string(),
service_type: ShoutrrrServiceType::Generic,

200
src/pp.rs
View File

@@ -1,6 +1,3 @@
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
// Verbosity levels
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Verbosity {
@@ -11,12 +8,8 @@ pub enum Verbosity {
}
// Emoji constants
#[allow(dead_code)]
pub const EMOJI_GLOBE: &str = "\u{1F30D}";
pub const EMOJI_WARNING: &str = "\u{26A0}\u{FE0F}";
pub const EMOJI_ERROR: &str = "\u{274C}";
#[allow(dead_code)]
pub const EMOJI_SUCCESS: &str = "\u{2705}";
pub const EMOJI_LAUNCH: &str = "\u{1F680}";
pub const EMOJI_STOP: &str = "\u{1F6D1}";
pub const EMOJI_SLEEP: &str = "\u{1F634}";
@@ -28,8 +21,6 @@ pub const EMOJI_SKIP: &str = "\u{23ED}\u{FE0F}";
pub const EMOJI_NOTIFY: &str = "\u{1F514}";
pub const EMOJI_HEARTBEAT: &str = "\u{1F493}";
pub const EMOJI_CONFIG: &str = "\u{2699}\u{FE0F}";
#[allow(dead_code)]
pub const EMOJI_HINT: &str = "\u{1F4A1}";
const INDENT_PREFIX: &str = " ";
@@ -37,7 +28,6 @@ pub struct PP {
pub verbosity: Verbosity,
pub emoji: bool,
indent: usize,
seen: Arc<Mutex<HashSet<String>>>,
}
impl PP {
@@ -46,7 +36,6 @@ impl PP {
verbosity: if quiet { Verbosity::Quiet } else { Verbosity::Verbose },
emoji,
indent: 0,
seen: Arc::new(Mutex::new(HashSet::new())),
}
}
@@ -63,7 +52,6 @@ impl PP {
verbosity: self.verbosity,
emoji: self.emoji,
indent: self.indent + 1,
seen: Arc::clone(&self.seen),
}
}
@@ -104,54 +92,12 @@ impl PP {
pub fn errorf(&self, emoji: &str, msg: &str) {
self.output_err(emoji, msg);
}
#[allow(dead_code)]
pub fn info_once(&self, key: &str, emoji: &str, msg: &str) {
if self.is_showing(Verbosity::Info) {
let mut seen = self.seen.lock().unwrap();
if seen.insert(key.to_string()) {
self.output(emoji, msg);
}
}
}
#[allow(dead_code)]
pub fn notice_once(&self, key: &str, emoji: &str, msg: &str) {
if self.is_showing(Verbosity::Notice) {
let mut seen = self.seen.lock().unwrap();
if seen.insert(key.to_string()) {
self.output(emoji, msg);
}
}
}
#[allow(dead_code)]
pub fn blank_line_if_verbose(&self) {
if self.is_showing(Verbosity::Verbose) {
println!();
}
}
}
#[allow(dead_code)]
pub fn english_join(items: &[String]) -> String {
match items.len() {
0 => String::new(),
1 => items[0].clone(),
2 => format!("{} and {}", items[0], items[1]),
_ => {
let (last, rest) = items.split_last().unwrap();
format!("{}, and {last}", rest.join(", "))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// ---- PP::new with emoji flag ----
#[test]
fn new_with_emoji_true() {
let pp = PP::new(true, false);
@@ -164,8 +110,6 @@ mod tests {
assert!(!pp.emoji);
}
// ---- PP::new with quiet flag (verbosity levels) ----
#[test]
fn new_quiet_true_sets_verbosity_quiet() {
let pp = PP::new(false, true);
@@ -178,8 +122,6 @@ mod tests {
assert_eq!(pp.verbosity, Verbosity::Verbose);
}
// ---- PP::is_showing at different verbosity levels ----
#[test]
fn quiet_shows_only_quiet_level() {
let pp = PP::new(false, true);
@@ -218,8 +160,6 @@ mod tests {
assert!(!pp.is_showing(Verbosity::Verbose));
}
// ---- PP::indent ----
#[test]
fn indent_increments_indent_level() {
let pp = PP::new(true, false);
@@ -238,26 +178,6 @@ mod tests {
assert_eq!(child.emoji, pp.emoji);
}
#[test]
fn indent_shares_seen_state() {
let pp = PP::new(false, false);
let child = pp.indent();
// Insert via parent's seen set
pp.seen.lock().unwrap().insert("key1".to_string());
// Child should observe the same entry
assert!(child.seen.lock().unwrap().contains("key1"));
// Insert via child
child.seen.lock().unwrap().insert("key2".to_string());
// Parent should observe it too
assert!(pp.seen.lock().unwrap().contains("key2"));
}
// ---- PP::infof, noticef, warningf, errorf - no panic and verbosity gating ----
#[test]
fn infof_does_not_panic_when_verbose() {
let pp = PP::new(false, false);
@@ -267,7 +187,6 @@ mod tests {
#[test]
fn infof_does_not_panic_when_quiet() {
let pp = PP::new(false, true);
// Should simply not print, and not panic
pp.infof("", "test info message");
}
@@ -291,7 +210,6 @@ mod tests {
#[test]
fn warningf_does_not_panic_when_quiet() {
// warningf always outputs (no verbosity check), just verify no panic
let pp = PP::new(false, true);
pp.warningf("", "test warning");
}
@@ -308,124 +226,6 @@ mod tests {
pp.errorf("", "test error");
}
// ---- PP::info_once and notice_once ----
#[test]
fn info_once_suppresses_duplicates() {
let pp = PP::new(false, false);
// First call inserts the key
pp.info_once("dup_key", "", "first");
// The key should now be in the seen set
assert!(pp.seen.lock().unwrap().contains("dup_key"));
// Calling again with the same key should not insert again (set unchanged)
let size_before = pp.seen.lock().unwrap().len();
pp.info_once("dup_key", "", "second");
let size_after = pp.seen.lock().unwrap().len();
assert_eq!(size_before, size_after);
}
#[test]
fn info_once_allows_different_keys() {
let pp = PP::new(false, false);
pp.info_once("key_a", "", "msg a");
pp.info_once("key_b", "", "msg b");
let seen = pp.seen.lock().unwrap();
assert!(seen.contains("key_a"));
assert!(seen.contains("key_b"));
assert_eq!(seen.len(), 2);
}
#[test]
fn info_once_skipped_when_quiet() {
let pp = PP::new(false, true);
pp.info_once("quiet_key", "", "should not register");
// Because verbosity is Quiet, info_once should not even insert the key
assert!(!pp.seen.lock().unwrap().contains("quiet_key"));
}
#[test]
fn notice_once_suppresses_duplicates() {
let pp = PP::new(false, false);
pp.notice_once("notice_dup", "", "first");
assert!(pp.seen.lock().unwrap().contains("notice_dup"));
let size_before = pp.seen.lock().unwrap().len();
pp.notice_once("notice_dup", "", "second");
let size_after = pp.seen.lock().unwrap().len();
assert_eq!(size_before, size_after);
}
#[test]
fn notice_once_skipped_when_quiet() {
let pp = PP::new(false, true);
pp.notice_once("quiet_notice", "", "should not register");
assert!(!pp.seen.lock().unwrap().contains("quiet_notice"));
}
#[test]
fn info_once_shared_via_indent() {
let pp = PP::new(false, false);
let child = pp.indent();
// Mark a key via the parent
pp.info_once("shared_key", "", "parent");
assert!(pp.seen.lock().unwrap().contains("shared_key"));
// Child should see it as already present, so set size stays the same
let size_before = child.seen.lock().unwrap().len();
child.info_once("shared_key", "", "child duplicate");
let size_after = child.seen.lock().unwrap().len();
assert_eq!(size_before, size_after);
// Child can add a new key visible to parent
child.info_once("child_key", "", "child new");
assert!(pp.seen.lock().unwrap().contains("child_key"));
}
// ---- english_join ----
#[test]
fn english_join_empty() {
let items: Vec<String> = vec![];
assert_eq!(english_join(&items), "");
}
#[test]
fn english_join_single() {
let items = vec!["alpha".to_string()];
assert_eq!(english_join(&items), "alpha");
}
#[test]
fn english_join_two() {
let items = vec!["alpha".to_string(), "beta".to_string()];
assert_eq!(english_join(&items), "alpha and beta");
}
#[test]
fn english_join_three() {
let items = vec![
"alpha".to_string(),
"beta".to_string(),
"gamma".to_string(),
];
assert_eq!(english_join(&items), "alpha, beta, and gamma");
}
#[test]
fn english_join_four() {
let items = vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
];
assert_eq!(english_join(&items), "a, b, c, and d");
}
// ---- default_pp ----
#[test]
fn default_pp_is_verbose_no_emoji() {
let pp = PP::default_pp();

View File

@@ -1,6 +1,7 @@
use crate::pp::{self, PP};
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use reqwest::Client;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, UdpSocket};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket};
use std::time::Duration;
/// IP type: IPv4 or IPv6
@@ -25,10 +26,6 @@ impl IpType {
}
}
#[allow(dead_code)]
pub fn all() -> &'static [IpType] {
&[IpType::V4, IpType::V6]
}
}
/// All supported provider types
@@ -145,14 +142,14 @@ impl ProviderType {
// --- Cloudflare Trace ---
/// Primary trace URLs use literal IPs to guarantee the correct address family.
/// api.cloudflare.com is dual-stack, so on dual-stack hosts (e.g. Docker
/// --net=host with IPv6) the connection may go via IPv6 even when detecting
/// IPv4, causing the trace endpoint to return the wrong address family.
const CF_TRACE_V4_PRIMARY: &str = "https://1.0.0.1/cdn-cgi/trace";
const CF_TRACE_V6_PRIMARY: &str = "https://[2606:4700:4700::1001]/cdn-cgi/trace";
/// Fallback uses a hostname, which works when literal IPs are intercepted
/// (e.g. Cloudflare WARP/Zero Trust).
/// Primary trace URL uses cloudflare.com (the CDN endpoint, not the DNS
/// resolver). The `build_split_client` forces the correct address family by
/// filtering DNS results, so a dual-stack hostname is safe.
/// Using literal DNS-resolver IPs (1.0.0.1 / [2606:4700:4700::1001]) caused
/// TLS SNI mismatches and returned Cloudflare proxy IPs for some users.
const CF_TRACE_PRIMARY: &str = "https://cloudflare.com/cdn-cgi/trace";
/// Fallback uses api.cloudflare.com, which works when cloudflare.com is
/// intercepted (e.g. Cloudflare WARP/Zero Trust).
const CF_TRACE_FALLBACK: &str = "https://api.cloudflare.com/cdn-cgi/trace";
pub fn parse_trace_ip(body: &str) -> Option<String> {
@@ -180,16 +177,45 @@ async fn fetch_trace_ip(
ip_str.parse::<IpAddr>().ok()
}
/// A DNS resolver that filters lookup results to a single address family.
/// This is the Rust equivalent of favonia/cloudflare-ddns's "split dialer"
/// pattern: by removing addresses of the wrong family *before* the HTTP
/// client sees them, we guarantee it can only establish connections over the
/// desired protocol — no happy-eyeballs race, no fallback to the wrong family.
struct FilteredResolver {
ip_type: IpType,
}
impl Resolve for FilteredResolver {
fn resolve(&self, name: Name) -> Resolving {
let ip_type = self.ip_type;
Box::pin(async move {
let addrs: Vec<SocketAddr> = tokio::net::lookup_host((name.as_str(), 0))
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
.filter(|addr| match ip_type {
IpType::V4 => addr.is_ipv4(),
IpType::V6 => addr.is_ipv6(),
})
.collect();
if addrs.is_empty() {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::AddrNotAvailable,
format!("no {} addresses found", ip_type.describe()),
)) as Box<dyn std::error::Error + Send + Sync>);
}
Ok(Box::new(addrs.into_iter()) as Addrs)
})
}
}
/// Build an HTTP client that only connects via the given IP family.
/// Binding to 0.0.0.0 forces IPv4-only; binding to [::] forces IPv6-only.
/// This ensures the trace endpoint sees the correct address family.
/// Uses a DNS-level filter to strip addresses of the wrong family from
/// resolution results, ensuring the client never attempts a connection
/// over the wrong protocol.
pub fn build_split_client(ip_type: IpType, timeout: Duration) -> Client {
let local_addr: IpAddr = match ip_type {
IpType::V4 => Ipv4Addr::UNSPECIFIED.into(),
IpType::V6 => Ipv6Addr::UNSPECIFIED.into(),
};
Client::builder()
.local_address(local_addr)
.dns_resolver(FilteredResolver { ip_type })
.timeout(timeout)
.build()
.unwrap_or_default()
@@ -218,13 +244,8 @@ async fn detect_cloudflare_trace(
return Vec::new();
}
let primary = match ip_type {
IpType::V4 => CF_TRACE_V4_PRIMARY,
IpType::V6 => CF_TRACE_V6_PRIMARY,
};
// Try primary (literal IP — guarantees correct address family)
if let Some(ip) = fetch_trace_ip(&client, primary, timeout, Some("one.one.one.one")).await {
// Try primary (cloudflare.com — the CDN trace endpoint)
if let Some(ip) = fetch_trace_ip(&client, CF_TRACE_PRIMARY, timeout, None).await {
if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip];
}
@@ -854,7 +875,7 @@ mod tests {
.mount(&server)
.await;
let client = Client::new();
let client = crate::test_client();
let ppfmt = PP::default_pp();
let url = format!("{}/cdn-cgi/trace", server.uri());
let timeout = Duration::from_secs(5);
@@ -894,7 +915,7 @@ mod tests {
// We can't override the hardcoded primary/fallback URLs, but we can test
// the custom URL path: first with a failing URL, then a succeeding one.
let client = Client::new();
let client = crate::test_client();
let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5);
@@ -926,21 +947,46 @@ mod tests {
#[test]
fn test_trace_urls() {
// Primary URLs use literal IPs to guarantee correct address family.
assert!(CF_TRACE_V4_PRIMARY.contains("1.0.0.1"));
assert!(CF_TRACE_V6_PRIMARY.contains("2606:4700:4700::1001"));
// Fallback uses a hostname for when literal IPs are intercepted (WARP/Zero Trust).
// Primary uses cloudflare.com CDN endpoint (not DNS resolver IPs).
assert_eq!(CF_TRACE_PRIMARY, "https://cloudflare.com/cdn-cgi/trace");
// Fallback uses api.cloudflare.com for when cloudflare.com is intercepted (WARP/Zero Trust).
assert_eq!(CF_TRACE_FALLBACK, "https://api.cloudflare.com/cdn-cgi/trace");
assert!(CF_TRACE_FALLBACK.contains("api.cloudflare.com"));
}
// ---- build_split_client ----
// ---- FilteredResolver + build_split_client ----
#[tokio::test]
async fn test_filtered_resolver_v4() {
let resolver = FilteredResolver { ip_type: IpType::V4 };
let name: Name = "cloudflare.com".parse().unwrap();
let addrs: Vec<SocketAddr> = resolver
.resolve(name)
.await
.expect("DNS lookup failed")
.collect();
assert!(!addrs.is_empty(), "should resolve at least one address");
for addr in &addrs {
assert!(addr.is_ipv4(), "all addresses should be IPv4, got {addr}");
}
}
#[tokio::test]
async fn test_filtered_resolver_v6() {
let resolver = FilteredResolver { ip_type: IpType::V6 };
let name: Name = "cloudflare.com".parse().unwrap();
// IPv6 may not be available in all test environments, so we just
// verify the resolver doesn't panic and returns only v6 if any.
if let Ok(addrs) = resolver.resolve(name).await {
for addr in addrs {
assert!(addr.is_ipv6(), "all addresses should be IPv6, got {addr}");
}
}
}
#[test]
fn test_build_split_client_v4() {
let client = build_split_client(IpType::V4, Duration::from_secs(5));
// Client should build successfully — we can't inspect local_address,
// but we verify it doesn't panic.
// Client should build successfully with filtered resolver.
drop(client);
}
@@ -962,7 +1008,7 @@ mod tests {
.mount(&server)
.await;
let client = Client::new();
let client = crate::test_client();
let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5);
@@ -985,7 +1031,7 @@ mod tests {
.mount(&server)
.await;
let client = Client::new();
let client = crate::test_client();
let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5);
@@ -1006,7 +1052,7 @@ mod tests {
.mount(&server)
.await;
let client = Client::new();
let client = crate::test_client();
let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5);
let url = format!("{}/my-ip", server.uri());
@@ -1026,7 +1072,7 @@ mod tests {
.mount(&server)
.await;
let client = Client::new();
let client = crate::test_client();
let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5);
let url = format!("{}/my-ip", server.uri());
@@ -1090,7 +1136,7 @@ mod tests {
.mount(&server)
.await;
let client = Client::new();
let client = crate::test_client();
let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5);
let url = format!("{}/my-ip", server.uri());
@@ -1301,7 +1347,7 @@ mod tests {
"5.6.7.8".parse().unwrap(),
],
};
let client = Client::new();
let client = crate::test_client();
let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5);
@@ -1319,7 +1365,7 @@ mod tests {
"2001:db8::1".parse().unwrap(),
],
};
let client = Client::new();
let client = crate::test_client();
let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5);
@@ -1333,7 +1379,7 @@ mod tests {
#[tokio::test]
async fn test_none_detect_ips_returns_empty() {
let provider = ProviderType::None;
let client = Client::new();
let client = crate::test_client();
let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5);

View File

@@ -26,7 +26,11 @@ pub async fn update_once(
let mut notify = false; // NEW: track meaningful events
if config.legacy_mode {
all_ok = update_legacy(config, cf_cache, ppfmt, noop_reported, detection_client).await;
let (ok, legacy_msgs, legacy_notify) =
update_legacy(config, cf_cache, ppfmt, noop_reported, detection_client).await;
all_ok = ok;
messages = legacy_msgs;
notify = legacy_notify;
} else {
// Detect IPs for each provider
let mut detected_ips: HashMap<IpType, Vec<IpAddr>> = HashMap::new();
@@ -111,6 +115,19 @@ pub async fn update_once(
// Update DNS records (env var mode - domain-based)
for (ip_type, domains) in &config.domains {
let ips = detected_ips.get(ip_type).cloned().unwrap_or_default();
if ips.is_empty() && !config.delete_on_failure {
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!(
"Skipping {} domain update for {}",
ip_type.describe(),
domains.join(", ")
),
);
continue;
}
let record_type = ip_type.record_type();
for domain_str in domains {
@@ -251,10 +268,10 @@ async fn update_legacy(
ppfmt: &PP,
noop_reported: &mut HashSet<String>,
detection_client: &Client,
) -> bool {
) -> (bool, Vec<Message>, bool) {
let legacy = match &config.legacy_config {
Some(l) => l,
None => return false,
None => return (false, Vec::new(), false),
};
let ddns = LegacyDdnsClient {
@@ -341,7 +358,8 @@ async fn update_legacy(
}
}
ddns.update_ips(
let (msgs, should_notify) = ddns
.update_ips(
&ips,
&legacy.cloudflare,
legacy.ttl,
@@ -350,7 +368,7 @@ async fn update_legacy(
)
.await;
true
(true, msgs, should_notify)
}
/// Delete records on stop (for env var mode).
@@ -499,12 +517,20 @@ impl LegacyDdnsClient {
ttl: i64,
purge_unknown_records: bool,
noop_reported: &mut HashSet<String>,
) {
) -> (Vec<Message>, bool) {
let mut messages = Vec::new();
let mut notify = false;
for ip in ips.values() {
self.commit_record(ip, config, ttl, purge_unknown_records, noop_reported)
let (msgs, changed) = self
.commit_record(ip, config, ttl, purge_unknown_records, noop_reported)
.await;
messages.extend(msgs);
if changed {
notify = true;
}
}
(messages, notify)
}
async fn commit_record(
&self,
@@ -513,7 +539,9 @@ impl LegacyDdnsClient {
ttl: i64,
purge_unknown_records: bool,
noop_reported: &mut HashSet<String>,
) {
) -> (Vec<Message>, bool) {
let mut messages = Vec::new();
let mut changed = false;
for entry in config {
let zone_resp: Option<LegacyCfResponse<LegacyZoneResult>> = self
.cf_api(
@@ -592,6 +620,7 @@ impl LegacyDdnsClient {
if let Some(ref id) = identifier {
if modified {
noop_reported.remove(&noop_key);
changed = true;
if self.dry_run {
println!("[DRY RUN] Would update record {fqdn} -> {}", ip.ip);
} else {
@@ -602,6 +631,10 @@ impl LegacyDdnsClient {
.cf_api(&update_endpoint, "PUT", entry, Some(&record))
.await;
}
messages.push(Message::new_ok(&format!(
"Updated {fqdn} -> {}",
ip.ip
)));
} else if noop_reported.insert(noop_key) {
if self.dry_run {
println!("[DRY RUN] Record {fqdn} is up to date");
@@ -611,6 +644,7 @@ impl LegacyDdnsClient {
}
} else {
noop_reported.remove(&noop_key);
changed = true;
if self.dry_run {
println!("[DRY RUN] Would add new record {fqdn} -> {}", ip.ip);
} else {
@@ -620,6 +654,10 @@ impl LegacyDdnsClient {
.cf_api(&create_endpoint, "POST", entry, Some(&record))
.await;
}
messages.push(Message::new_ok(&format!(
"Created {fqdn} -> {}",
ip.ip
)));
}
if purge_unknown_records {
@@ -638,6 +676,7 @@ impl LegacyDdnsClient {
}
}
}
(messages, changed)
}
}
@@ -687,6 +726,7 @@ mod tests {
update_cron: CronSchedule::Once,
update_on_start: true,
delete_on_stop: false,
delete_on_failure: true,
ttl: TTL::AUTO,
proxied_expression: None,
record_comment: None,
@@ -823,7 +863,7 @@ mod tests {
let ppfmt = pp();
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &crate::test_client()).await;
assert!(ok);
}
@@ -876,12 +916,12 @@ mod tests {
let mut noop_reported = HashSet::new();
// First call: noop_reported is empty, so "up to date" is reported and key is inserted
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut noop_reported, &Client::new()).await;
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut noop_reported, &crate::test_client()).await;
assert!(ok);
assert!(noop_reported.contains("home.example.com:A"), "noop_reported should contain the domain key after first noop");
// Second call: noop_reported already has the key, so the message is suppressed
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut noop_reported, &Client::new()).await;
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut noop_reported, &crate::test_client()).await;
assert!(ok);
assert_eq!(noop_reported.len(), 1, "noop_reported should still have exactly one entry");
}
@@ -954,7 +994,7 @@ mod tests {
noop_reported.insert("home.example.com:A".to_string());
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut noop_reported, &Client::new()).await;
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut noop_reported, &crate::test_client()).await;
assert!(ok);
assert!(!noop_reported.contains("home.example.com:A"), "noop_reported should be cleared after an update");
}
@@ -1000,7 +1040,7 @@ mod tests {
// all_ok = true because no zone-level errors occurred (empty ips just noop or warn)
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &crate::test_client()).await;
// Providers with None are not inserted in loop, so no IP detection warning is emitted,
// no detected_ips entry is created, and set_ips is called with empty slice -> Noop.
assert!(ok);
@@ -1050,7 +1090,7 @@ mod tests {
let ppfmt = pp();
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &crate::test_client()).await;
assert!(!ok, "Expected false when zone is not found");
}
@@ -1100,7 +1140,7 @@ mod tests {
// dry_run returns Updated from set_ips (it signals intent), all_ok should be true
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &crate::test_client()).await;
assert!(ok);
}
@@ -1166,7 +1206,7 @@ mod tests {
let ppfmt = pp();
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &crate::test_client()).await;
assert!(ok);
}
@@ -1220,7 +1260,7 @@ mod tests {
let ppfmt = pp();
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &crate::test_client()).await;
assert!(ok);
}
@@ -1260,7 +1300,7 @@ mod tests {
let ppfmt = pp();
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &crate::test_client()).await;
assert!(!ok, "Expected false when WAF list is not found");
}
@@ -1345,7 +1385,7 @@ mod tests {
let ppfmt = pp();
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &crate::test_client()).await;
assert!(ok);
}
@@ -1362,7 +1402,7 @@ mod tests {
let ppfmt = pp();
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &crate::test_client()).await;
assert!(ok);
}
@@ -1747,7 +1787,7 @@ mod tests {
// set_ips with empty ips and no existing records = Noop; all_ok = true
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &crate::test_client()).await;
assert!(ok);
}
// -------------------------------------------------------
@@ -1766,7 +1806,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -1798,7 +1838,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -1827,7 +1867,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -1849,7 +1889,7 @@ mod tests {
#[tokio::test]
async fn test_legacy_cf_api_unknown_method() {
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: "http://localhost".to_string(),
dry_run: false,
};
@@ -1879,7 +1919,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -1935,7 +1975,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -1991,7 +2031,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -2033,7 +2073,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: true,
};
@@ -2084,7 +2124,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -2139,7 +2179,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -2188,7 +2228,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -2232,7 +2272,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -2264,7 +2304,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: true,
};
@@ -2281,6 +2321,272 @@ mod tests {
ddns.delete_entries("A", &config).await;
}
// -------------------------------------------------------
// delete_on_failure tests
// -------------------------------------------------------
/// When IPv4 detection fails but IPv6 succeeds, and delete_on_failure=false, skip V4 domains but update V6
#[tokio::test]
async fn test_skip_v4_domains_when_v4_detection_fails() {
let server = MockServer::start().await;
let zone_id = "zone-abc";
let ip_v6 = "2001:db8::1";
// Zone lookup for V6 domain
Mock::given(method("GET"))
.and(path("/zones"))
.and(query_param("name", "v6.example.com"))
.respond_with(
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
)
.mount(&server)
.await;
// LIST existing records for V6
Mock::given(method("GET"))
.and(path_regex(format!("/zones/{zone_id}/dns_records")))
.respond_with(ResponseTemplate::new(200).set_body_json(dns_records_empty()))
.mount(&server)
.await;
// POST for V6 should be called (V6 succeeds)
Mock::given(method("POST"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.respond_with(ResponseTemplate::new(200).set_body_json(dns_record_created(
"rec-1",
"v6.example.com",
"2001:db8::1",
)))
.expect(1)
.mount(&server)
.await;
// Providers: V4 fails (None), V6 succeeds
let mut providers = HashMap::new();
providers.insert(IpType::V4, ProviderType::None);
providers.insert(
IpType::V6,
ProviderType::Literal {
ips: vec![ip_v6.parse().unwrap()],
},
);
let mut domains = HashMap::new();
domains.insert(IpType::V4, vec!["v4.example.com".to_string()]);
domains.insert(IpType::V6, vec!["v6.example.com".to_string()]);
let mut config = make_config(providers, domains, vec![], false);
config.delete_on_failure = false;
let cf = handle(&server.uri());
let notifier = empty_notifier();
let heartbeat = empty_heartbeat();
let ppfmt = pp();
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(
&config,
&cf,
&notifier,
&heartbeat,
&mut cf_cache,
&ppfmt,
&mut HashSet::new(),
&crate::test_client(),
)
.await;
assert!(ok, "Should succeed with partial detection");
}
/// When IPv6 detection fails but IPv4 succeeds, and delete_on_failure=false, skip V6 domains but update V4
#[tokio::test]
async fn test_skip_v6_domains_when_v6_detection_fails() {
let server = MockServer::start().await;
let zone_id = "zone-abc";
let ip_v4 = "198.51.100.42";
// Zone lookup for V4 domain
Mock::given(method("GET"))
.and(path("/zones"))
.and(query_param("name", "v4.example.com"))
.respond_with(
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
)
.mount(&server)
.await;
// LIST existing records for V4
Mock::given(method("GET"))
.and(path_regex(format!("/zones/{zone_id}/dns_records")))
.respond_with(ResponseTemplate::new(200).set_body_json(dns_records_empty()))
.mount(&server)
.await;
// POST for V4 should be called (V4 succeeds)
Mock::given(method("POST"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.respond_with(ResponseTemplate::new(200).set_body_json(dns_record_created(
"rec-1",
"v4.example.com",
"198.51.100.42",
)))
.expect(1)
.mount(&server)
.await;
// Providers: V4 succeeds, V6 fails (None)
let mut providers = HashMap::new();
providers.insert(
IpType::V4,
ProviderType::Literal {
ips: vec![ip_v4.parse().unwrap()],
},
);
providers.insert(IpType::V6, ProviderType::None);
let mut domains = HashMap::new();
domains.insert(IpType::V4, vec!["v4.example.com".to_string()]);
domains.insert(IpType::V6, vec!["v6.example.com".to_string()]);
let mut config = make_config(providers, domains, vec![], false);
config.delete_on_failure = false;
let cf = handle(&server.uri());
let notifier = empty_notifier();
let heartbeat = empty_heartbeat();
let ppfmt = pp();
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(
&config,
&cf,
&notifier,
&heartbeat,
&mut cf_cache,
&ppfmt,
&mut HashSet::new(),
&crate::test_client(),
)
.await;
assert!(ok, "Should succeed with partial detection");
}
/// When both IPv4 and IPv6 detection fail, and delete_on_failure=false, skip all domains
#[tokio::test]
async fn test_skip_all_domains_when_both_detect_fail() {
let server = MockServer::start().await;
// No POST/DELETE should be called at all
// Providers: both fail (None)
let mut providers = HashMap::new();
providers.insert(IpType::V4, ProviderType::None);
providers.insert(IpType::V6, ProviderType::None);
let mut domains = HashMap::new();
domains.insert(IpType::V4, vec!["v4.example.com".to_string()]);
domains.insert(IpType::V6, vec!["v6.example.com".to_string()]);
let mut config = make_config(providers, domains, vec![], false);
config.delete_on_failure = false;
let cf = handle(&server.uri());
let notifier = empty_notifier();
let heartbeat = empty_heartbeat();
let ppfmt = pp();
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(
&config,
&cf,
&notifier,
&heartbeat,
&mut cf_cache,
&ppfmt,
&mut HashSet::new(),
&crate::test_client(),
)
.await;
assert!(ok, "Should succeed (no updates, no failures)");
}
/// When both IPv4 and IPv6 detection succeed, and delete_on_failure=false, update all domains
#[tokio::test]
async fn test_update_all_domains_when_both_detect() {
let server = MockServer::start().await;
let zone_id = "zone-abc";
let ip_v4 = "198.51.100.42";
let ip_v6 = "2001:db8::1";
// Zone lookups for both domains
Mock::given(method("GET"))
.and(path("/zones"))
.respond_with(
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
)
.mount(&server)
.await;
// LIST existing records (empty for both)
Mock::given(method("GET"))
.and(path_regex(format!("/zones/{zone_id}/dns_records")))
.respond_with(ResponseTemplate::new(200).set_body_json(dns_records_empty()))
.mount(&server)
.await;
// POST for both should be called
Mock::given(method("POST"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.respond_with(ResponseTemplate::new(200).set_body_json(dns_record_created(
"rec-new",
"example.com",
"198.51.100.42",
)))
.expect(2) // Two POSTs: one for V4, one for V6
.mount(&server)
.await;
// Providers: both succeed
let mut providers = HashMap::new();
providers.insert(
IpType::V4,
ProviderType::Literal {
ips: vec![ip_v4.parse().unwrap()],
},
);
providers.insert(
IpType::V6,
ProviderType::Literal {
ips: vec![ip_v6.parse().unwrap()],
},
);
let mut domains = HashMap::new();
domains.insert(IpType::V4, vec!["v4.example.com".to_string()]);
domains.insert(IpType::V6, vec!["v6.example.com".to_string()]);
let mut config = make_config(providers, domains, vec![], false);
config.delete_on_failure = false;
let cf = handle(&server.uri());
let notifier = empty_notifier();
let heartbeat = empty_heartbeat();
let ppfmt = pp();
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(
&config,
&cf,
&notifier,
&heartbeat,
&mut cf_cache,
&ppfmt,
&mut HashSet::new(),
&crate::test_client(),
)
.await;
assert!(ok, "Should succeed with both detections");
}
}
// Legacy types for backwards compatibility