3 Commits

Author SHA1 Message Date
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
5 changed files with 465 additions and 81 deletions

368
Cargo.lock generated
View File

@@ -48,6 +48,28 @@ 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"
@@ -79,9 +101,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"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"
@@ -109,7 +139,7 @@ dependencies = [
[[package]]
name = "cloudflare-ddns"
version = "2.0.8"
version = "2.0.10"
dependencies = [
"chrono",
"idna",
@@ -124,6 +154,35 @@ 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"
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"
@@ -159,6 +218,12 @@ 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"
@@ -208,6 +273,12 @@ 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"
@@ -464,7 +535,6 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -624,12 +694,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 +722,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 +732,63 @@ 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 1.0.69",
"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 = "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"
@@ -760,6 +884,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"
@@ -829,7 +959,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
@@ -841,6 +971,7 @@ 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",
@@ -850,7 +981,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
@@ -951,9 +1082,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [
"base64",
"bytes",
@@ -971,6 +1102,7 @@ dependencies = [
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"serde_urlencoded",
@@ -984,7 +1116,6 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -1026,14 +1157,26 @@ 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",
"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"
@@ -1045,11 +1188,39 @@ dependencies = [
]
[[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.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -1067,6 +1238,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"
@@ -1222,13 +1434,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
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",
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@@ -1423,6 +1655,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"
@@ -1570,14 +1812,23 @@ dependencies = [
]
[[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 = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -1639,18 +1890,18 @@ dependencies = [
[[package]]
name = "windows-sys"
version = "0.52.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.52.6",
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
@@ -1673,6 +1924,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"
@@ -1706,6 +1972,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -1718,6 +1990,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -1730,6 +2008,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -1754,6 +2038,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -1766,6 +2056,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -1778,6 +2074,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -1790,6 +2092,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -1944,18 +2252,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.42"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.42"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [
"proc-macro2",
"quote",

View File

@@ -1,20 +1,20 @@
[package]
name = "cloudflare-ddns"
version = "2.0.8"
version = "2.0.10"
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"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "signal"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "signal", "net"] }
regex = "1"
chrono = { version = "0.4", features = ["clock"] }
url = "2"
idna = "1"
if-addrs = "0.13"
if-addrs = "0.15"
[profile.release]
opt-level = "s"

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.8`) 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

@@ -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
@@ -145,14 +146,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 +181,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 +248,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];
}
@@ -926,21 +951,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);
}

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)
}
}