3 Commits

Author SHA1 Message Date
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
14 changed files with 192 additions and 1228 deletions

6
.dockerignore Normal file
View File

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

468
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,34 +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 = "aws-lc-rs"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "base64"
version = "0.22.1"
@@ -101,8 +64,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -118,34 +79,14 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
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"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "cloudflare-ddns"
version = "2.0.9"
version = "2.1.0"
dependencies = [
"chrono",
"idna",
"if-addrs",
"regex",
"regex-lite",
"reqwest",
"rustls",
"serde",
"serde_json",
"tempfile",
@@ -154,15 +95,6 @@ dependencies = [
"wiremock",
]
[[package]]
name = "cmake"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
dependencies = [
"cc",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -218,12 +150,6 @@ dependencies = [
"syn",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -273,12 +199,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.32"
@@ -374,24 +294,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]]
@@ -402,7 +306,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"r-efi",
"wasip2",
"wasip3",
]
@@ -560,30 +464,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"
@@ -747,7 +627,7 @@ dependencies = [
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"thiserror",
"walkdir",
"windows-sys 0.45.0",
]
@@ -780,16 +660,6 @@ dependencies = [
"syn",
]
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.91"
@@ -836,12 +706,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"
@@ -859,15 +723,6 @@ dependencies = [
"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"
@@ -917,15 +772,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"
@@ -945,62 +791,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 2.0.18",
"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 = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"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"
@@ -1010,47 +800,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
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",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "regex"
version = "1.12.3"
@@ -1074,6 +829,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"
@@ -1099,7 +860,6 @@ dependencies = [
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
@@ -1132,12 +892,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"
@@ -1157,8 +911,8 @@ version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -1183,7 +937,6 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
@@ -1220,7 +973,6 @@ version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -1440,16 +1192,7 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
"thiserror-impl",
]
[[package]]
@@ -1463,17 +1206,6 @@ dependencies = [
"syn",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tinystr"
version = "0.8.2"
@@ -1484,21 +1216,6 @@ 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"
@@ -1801,16 +1518,6 @@ dependencies = [
"wasm-bindgen",
]
[[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-root-certs"
version = "1.0.6"
@@ -1829,65 +1536,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
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",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
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",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
@@ -1906,15 +1560,6 @@ 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"
@@ -1948,30 +1593,13 @@ 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",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
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",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
@@ -1984,12 +1612,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -2002,12 +1624,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -2020,24 +1636,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -2050,12 +1654,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -2068,12 +1666,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -2086,12 +1678,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -2104,12 +1690,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"
@@ -2250,26 +1830,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerofrom"
version = "0.1.6"

View File

@@ -1,23 +1,22 @@
[package]
name = "cloudflare-ddns"
version = "2.0.9"
version = "2.1.0"
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.13", features = ["json", "form", "rustls"], 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", "net"] }
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.15"
[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

@@ -363,6 +363,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 |

View File

@@ -47,7 +47,7 @@ This project handles **Cloudflare API tokens** that grant DNS editing privileges
- 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.9`) rather than using `latest` in production.
- Pin image tags to a specific version (e.g., `timothyjmiller/cloudflare-ddns:v2.0.10`) rather than using `latest` in production.
### Network Security

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()
}
@@ -372,7 +301,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 +313,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 +324,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 +479,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 +491,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 +503,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 +723,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 +732,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,
}
}
@@ -1424,7 +1354,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 +1373,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 +1388,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 +1403,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

@@ -87,10 +87,10 @@ pub struct AppConfig {
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 +330,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}"));
@@ -1931,19 +1931,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 +1953,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 +1962,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 +1971,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 +1980,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());
}
// ============================================================

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

@@ -20,8 +20,12 @@ 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 +233,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 {
@@ -282,6 +284,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 +350,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,

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,
@@ -442,8 +408,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 +426,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 +486,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 +540,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 +621,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 +633,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 +670,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;
}
@@ -1111,7 +1012,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 +1036,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 +1060,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 +1076,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 +1112,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 +1168,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 +1192,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 +1227,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 +1251,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 +1264,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 +1285,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 +1296,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

@@ -26,10 +26,6 @@ impl IpType {
}
}
#[allow(dead_code)]
pub fn all() -> &'static [IpType] {
&[IpType::V4, IpType::V6]
}
}
/// All supported provider types
@@ -879,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);
@@ -919,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);
@@ -1012,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);
@@ -1035,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);
@@ -1056,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());
@@ -1076,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());
@@ -1140,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());
@@ -1351,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);
@@ -1369,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);
@@ -1383,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();
@@ -251,10 +255,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 +345,8 @@ async fn update_legacy(
}
}
ddns.update_ips(
let (msgs, should_notify) = ddns
.update_ips(
&ips,
&legacy.cloudflare,
legacy.ttl,
@@ -350,7 +355,7 @@ async fn update_legacy(
)
.await;
true
(true, msgs, should_notify)
}
/// Delete records on stop (for env var mode).
@@ -499,12 +504,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 +526,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 +607,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 +618,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 +631,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 +641,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 +663,7 @@ impl LegacyDdnsClient {
}
}
}
(messages, changed)
}
}
@@ -823,7 +849,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 +902,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 +980,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 +1026,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 +1076,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 +1126,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 +1192,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 +1246,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 +1286,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 +1371,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 +1388,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 +1773,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 +1792,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -1798,7 +1824,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -1827,7 +1853,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -1849,7 +1875,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 +1905,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -1935,7 +1961,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -1991,7 +2017,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -2033,7 +2059,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: true,
};
@@ -2084,7 +2110,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -2139,7 +2165,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -2188,7 +2214,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -2232,7 +2258,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: false,
};
@@ -2264,7 +2290,7 @@ mod tests {
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
client: crate::test_client(),
cf_api_base: server.uri(),
dry_run: true,
};