6 Commits

Author SHA1 Message Date
Timothy Miller
697089b43d Bump to 2.0.9 2026-03-23 19:42:05 -04:00
Timothy Miller
766e1ac0d4 Replace local_address bind with DNS-level address family filtering
The split client previously bound to 0.0.0.0 / [::] to force
  IPv4/IPv6,
  but this only hinted at the address family — happy-eyeballs could
  still
  race and connect over the wrong protocol on dual-stack hosts.

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

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

  - reqwest 0.12 → 0.13 (adds dns_resolver API)
  - if-addrs 0.13 → 0.15
  - tokio: add "net" feature for tokio::net::lookup_host
2026-03-23 19:39:56 -04:00
Timothy Miller
8c7af02698 Revise SECURITY.md with version support and reporting updates
Updated the security policy to include new version support details and improved reporting guidelines for vulnerabilities.
2026-03-19 23:34:45 -04:00
Timothy Miller
245ac0b061 Potential fix for code scanning alert no. 6: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-19 23:30:56 -04:00
Timothy Miller
2446c1d6a0 Bump crate to 2.0.8 and refine updater behavior
Deduplicate up-to-date messages by tracking noop keys and move logging
to the updater so callers only log the first noop.
Reuse a single reqwest Client for IP detection instead of rebuilding it
for each call.
Always ping heartbeat even when there are no meaningful changes.
Fix Pushover shoutrrr parsing (token@user order) and update tests
2026-03-19 23:22:20 -04:00
Timothy Miller
9b8aba5e20 Add CachedCloudflareFilter
Introduce CachedCloudflareFilter that caches Cloudflare IP ranges and
refreshes every 24 hours. If a refresh fails the previously cached
ranges
are retained and a warning is emitted. Wire the cache through main and
updater so Cloudflare fetches reuse the cached result. Update tests and
bump crate version to 2.0.7
2026-03-19 19:24:44 -04:00
10 changed files with 796 additions and 164 deletions

View File

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

368
Cargo.lock generated
View File

@@ -48,6 +48,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 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]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -79,9 +101,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver",
"libc",
"shlex", "shlex",
] ]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
@@ -109,7 +139,7 @@ dependencies = [
[[package]] [[package]]
name = "cloudflare-ddns" name = "cloudflare-ddns"
version = "2.0.6" version = "2.0.9"
dependencies = [ dependencies = [
"chrono", "chrono",
"idna", "idna",
@@ -124,6 +154,35 @@ dependencies = [
"wiremock", "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]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -159,6 +218,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -208,6 +273,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@@ -464,7 +535,6 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -624,12 +694,12 @@ dependencies = [
[[package]] [[package]]
name = "if-addrs" name = "if-addrs"
version = "0.13.4" version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -652,9 +722,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]] [[package]]
name = "iri-string" name = "iri-string"
version = "0.7.10" version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
dependencies = [ dependencies = [
"memchr", "memchr",
"serde", "serde",
@@ -662,9 +732,63 @@ dependencies = [
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.17" version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" 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]] [[package]]
name = "js-sys" name = "js-sys"
@@ -760,6 +884,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -829,7 +959,7 @@ dependencies = [
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2", "socket2",
"thiserror", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
"web-time", "web-time",
@@ -841,6 +971,7 @@ version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [ dependencies = [
"aws-lc-rs",
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.3.4",
"lru-slab", "lru-slab",
@@ -850,7 +981,7 @@ dependencies = [
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"slab", "slab",
"thiserror", "thiserror 2.0.18",
"tinyvec", "tinyvec",
"tracing", "tracing",
"web-time", "web-time",
@@ -951,9 +1082,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.28" version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
@@ -971,6 +1102,7 @@ dependencies = [
"quinn", "quinn",
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
@@ -984,7 +1116,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -1026,14 +1157,26 @@ version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [ dependencies = [
"aws-lc-rs",
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",
"zeroize", "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]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.14.0" version = "1.14.0"
@@ -1045,11 +1188,39 @@ dependencies = [
] ]
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-platform-verifier"
version = "0.103.9" version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [ 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", "ring",
"rustls-pki-types", "rustls-pki-types",
"untrusted", "untrusted",
@@ -1067,6 +1238,47 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 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]] [[package]]
name = "semver" name = "semver"
version = "1.0.27" version = "1.0.27"
@@ -1222,13 +1434,33 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [ 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]] [[package]]
@@ -1423,6 +1655,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 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]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@@ -1570,14 +1812,23 @@ dependencies = [
] ]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-root-certs"
version = "1.0.6" version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
dependencies = [ dependencies = [
"rustls-pki-types", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -1639,18 +1890,18 @@ dependencies = [
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.42.2",
] ]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
@@ -1673,6 +1924,21 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
@@ -1706,6 +1972,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.1", "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]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -1718,6 +1990,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -1730,6 +2008,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -1754,6 +2038,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
@@ -1766,6 +2056,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 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]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
@@ -1778,6 +2074,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -1790,6 +2092,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -1944,18 +2252,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.42" version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.42" version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View File

@@ -1,20 +1,20 @@
[package] [package]
name = "cloudflare-ddns" name = "cloudflare-ddns"
version = "2.0.6" version = "2.0.9"
edition = "2021" edition = "2021"
description = "Access your home network remotely via a custom domain name without a static IP" description = "Access your home network remotely via a custom domain name without a static IP"
license = "GPL-3.0" license = "GPL-3.0"
[dependencies] [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 = { version = "1", features = ["derive"] }
serde_json = "1" 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" regex = "1"
chrono = { version = "0.4", features = ["clock"] } chrono = { version = "0.4", features = ["clock"] }
url = "2" url = "2"
idna = "1" idna = "1"
if-addrs = "0.13" if-addrs = "0.15"
[profile.release] [profile.release]
opt-level = "s" opt-level = "s"

78
SECURITY.md Normal file
View File

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

View File

@@ -1,7 +1,7 @@
use crate::pp::{self, PP}; use crate::pp::{self, PP};
use reqwest::Client; use reqwest::Client;
use std::net::IpAddr; use std::net::IpAddr;
use std::time::Duration; use std::time::{Duration, Instant};
const CF_IPV4_URL: &str = "https://www.cloudflare.com/ips-v4"; const CF_IPV4_URL: &str = "https://www.cloudflare.com/ips-v4";
const CF_IPV6_URL: &str = "https://www.cloudflare.com/ips-v6"; const CF_IPV6_URL: &str = "https://www.cloudflare.com/ips-v6";
@@ -157,6 +157,62 @@ impl CloudflareIpFilter {
} }
} }
/// Refresh interval for Cloudflare IP ranges (24 hours).
const CF_RANGE_REFRESH: Duration = Duration::from_secs(24 * 60 * 60);
/// Cached wrapper around [`CloudflareIpFilter`].
///
/// Fetches once, then re-uses the cached ranges for [`CF_RANGE_REFRESH`].
/// If a refresh fails, the previously cached ranges are kept.
pub struct CachedCloudflareFilter {
filter: Option<CloudflareIpFilter>,
fetched_at: Option<Instant>,
}
impl CachedCloudflareFilter {
pub fn new() -> Self {
Self {
filter: None,
fetched_at: None,
}
}
/// Return a reference to the current filter, refreshing if stale or absent.
pub async fn get(
&mut self,
client: &Client,
timeout: Duration,
ppfmt: &PP,
) -> Option<&CloudflareIpFilter> {
let stale = match self.fetched_at {
Some(t) => t.elapsed() >= CF_RANGE_REFRESH,
None => true,
};
if stale {
match CloudflareIpFilter::fetch(client, timeout, ppfmt).await {
Some(new_filter) => {
self.filter = Some(new_filter);
self.fetched_at = Some(Instant::now());
}
None => {
if self.filter.is_some() {
ppfmt.warningf(
pp::EMOJI_WARNING,
"Failed to refresh Cloudflare IP ranges; using cached version",
);
// Keep using cached filter, but don't update fetched_at
// so we retry next cycle.
}
// If no cached filter exists, return None (caller handles fail-safe).
}
}
}
self.filter.as_ref()
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -467,7 +467,7 @@ impl CloudflareHandle {
self.update_record(zone_id, &record.id, &payload, ppfmt).await; self.update_record(zone_id, &record.id, &payload, ppfmt).await;
} }
} else { } else {
ppfmt.infof(pp::EMOJI_SKIP, &format!("Record {fqdn} is up to date ({ip_str})")); // Caller handles "up to date" logging based on SetResult::Noop
} }
} else { } else {
// Find an existing managed record to update, or create new // Find an existing managed record to update, or create new
@@ -668,10 +668,7 @@ impl CloudflareHandle {
.collect(); .collect();
if to_add.is_empty() && ids_to_delete.is_empty() { if to_add.is_empty() && ids_to_delete.is_empty() {
ppfmt.infof( // Caller handles "up to date" logging based on SetResult::Noop
pp::EMOJI_SKIP,
&format!("WAF list {} is up to date", waf_list.describe()),
);
return SetResult::Noop; return SetResult::Noop;
} }

View File

@@ -11,8 +11,10 @@ use crate::cloudflare::{Auth, CloudflareHandle};
use crate::config::{AppConfig, CronSchedule}; use crate::config::{AppConfig, CronSchedule};
use crate::notifier::{CompositeNotifier, Heartbeat, Message}; use crate::notifier::{CompositeNotifier, Heartbeat, Message};
use crate::pp::PP; use crate::pp::PP;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use reqwest::Client;
use tokio::signal; use tokio::signal;
use tokio::time::{sleep, Duration}; use tokio::time::{sleep, Duration};
@@ -116,12 +118,18 @@ async fn main() {
// Start heartbeat // Start heartbeat
heartbeat.start().await; heartbeat.start().await;
let mut cf_cache = cf_ip_filter::CachedCloudflareFilter::new();
let detection_client = Client::builder()
.timeout(app_config.detection_timeout)
.build()
.unwrap_or_default();
if app_config.legacy_mode { if app_config.legacy_mode {
// --- Legacy mode (original cloudflare-ddns behavior) --- // --- Legacy mode (original cloudflare-ddns behavior) ---
run_legacy_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running).await; run_legacy_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running, &mut cf_cache, &detection_client).await;
} else { } else {
// --- Env var mode (cf-ddns behavior) --- // --- Env var mode (cf-ddns behavior) ---
run_env_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running).await; run_env_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running, &mut cf_cache, &detection_client).await;
} }
// On shutdown: delete records if configured // On shutdown: delete records if configured
@@ -143,12 +151,16 @@ async fn run_legacy_mode(
heartbeat: &Heartbeat, heartbeat: &Heartbeat,
ppfmt: &PP, ppfmt: &PP,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
cf_cache: &mut cf_ip_filter::CachedCloudflareFilter,
detection_client: &Client,
) { ) {
let legacy = match &config.legacy_config { let legacy = match &config.legacy_config {
Some(l) => l, Some(l) => l,
None => return, None => return,
}; };
let mut noop_reported = HashSet::new();
if config.repeat { if config.repeat {
match (legacy.a, legacy.aaaa) { match (legacy.a, legacy.aaaa) {
(true, true) => println!( (true, true) => println!(
@@ -165,7 +177,7 @@ async fn run_legacy_mode(
} }
while running.load(Ordering::SeqCst) { while running.load(Ordering::SeqCst) {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
for _ in 0..legacy.ttl { for _ in 0..legacy.ttl {
if !running.load(Ordering::SeqCst) { if !running.load(Ordering::SeqCst) {
@@ -175,7 +187,7 @@ async fn run_legacy_mode(
} }
} }
} else { } else {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
} }
} }
@@ -186,11 +198,15 @@ async fn run_env_mode(
heartbeat: &Heartbeat, heartbeat: &Heartbeat,
ppfmt: &PP, ppfmt: &PP,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
cf_cache: &mut cf_ip_filter::CachedCloudflareFilter,
detection_client: &Client,
) { ) {
let mut noop_reported = HashSet::new();
match &config.update_cron { match &config.update_cron {
CronSchedule::Once => { CronSchedule::Once => {
if config.update_on_start { if config.update_on_start {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
} }
} }
schedule => { schedule => {
@@ -206,7 +222,7 @@ async fn run_env_mode(
// Update on start if configured // Update on start if configured
if config.update_on_start { if config.update_on_start {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
} }
// Main loop // Main loop
@@ -233,7 +249,7 @@ async fn run_env_mode(
return; return;
} }
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
} }
} }
} }
@@ -382,6 +398,7 @@ mod tests {
config: &[LegacyCloudflareEntry], config: &[LegacyCloudflareEntry],
ttl: i64, ttl: i64,
purge_unknown_records: bool, purge_unknown_records: bool,
noop_reported: &mut std::collections::HashSet<String>,
) { ) {
for entry in config { for entry in config {
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@@ -483,8 +500,10 @@ mod tests {
} }
} }
let noop_key = format!("{fqdn}:{record_type}");
if let Some(ref id) = identifier { if let Some(ref id) = identifier {
if modified { if modified {
noop_reported.remove(&noop_key);
if self.dry_run { if self.dry_run {
println!("[DRY RUN] Would update record {fqdn} -> {ip}"); println!("[DRY RUN] Would update record {fqdn} -> {ip}");
} else { } else {
@@ -500,10 +519,16 @@ mod tests {
) )
.await; .await;
} }
} else if self.dry_run { } else if noop_reported.insert(noop_key) {
println!("[DRY RUN] Record {fqdn} is up to date ({ip})"); if self.dry_run {
println!("[DRY RUN] Record {fqdn} is up to date");
} else {
println!("Record {fqdn} is up to date");
} }
} else if self.dry_run { }
} else {
noop_reported.remove(&noop_key);
if self.dry_run {
println!("[DRY RUN] Would add new record {fqdn} -> {ip}"); println!("[DRY RUN] Would add new record {fqdn} -> {ip}");
} else { } else {
println!("Adding new record {fqdn} -> {ip}"); println!("Adding new record {fqdn} -> {ip}");
@@ -518,6 +543,7 @@ mod tests {
) )
.await; .await;
} }
}
if purge_unknown_records { if purge_unknown_records {
for dup_id in &duplicate_ids { for dup_id in &duplicate_ids {
@@ -636,7 +662,7 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri()); let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id); let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false) ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await; .await;
} }
@@ -685,7 +711,7 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri()); let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id); let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false) ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await; .await;
} }
@@ -728,7 +754,7 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri()); let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id); let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false) ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await; .await;
} }
@@ -762,7 +788,7 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri()).dry_run(); let ddns = TestDdnsClient::new(&mock_server.uri()).dry_run();
let config = test_config(zone_id); let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false) ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await; .await;
} }
@@ -819,7 +845,7 @@ mod tests {
ip4_provider: None, ip4_provider: None,
ip6_provider: None, ip6_provider: None,
}; };
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, true) ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, true, &mut std::collections::HashSet::new())
.await; .await;
} }
@@ -921,7 +947,7 @@ mod tests {
ip6_provider: None, ip6_provider: None,
}; };
ddns.commit_record("203.0.113.99", "A", &config.cloudflare, 300, false) ddns.commit_record("203.0.113.99", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await; .await;
} }
} }

View File

@@ -406,7 +406,7 @@ fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
service_type: ShoutrrrServiceType::Pushover, service_type: ShoutrrrServiceType::Pushover,
webhook_url: format!( webhook_url: format!(
"https://api.pushover.net/1/messages.json?token={}&user={}", "https://api.pushover.net/1/messages.json?token={}&user={}",
parts[1], parts[0] parts[0], parts[1]
), ),
}); });
} }
@@ -868,7 +868,7 @@ mod tests {
#[test] #[test]
fn test_parse_pushover() { fn test_parse_pushover() {
let result = parse_shoutrrr_url("pushover://userkey@apitoken").unwrap(); let result = parse_shoutrrr_url("pushover://apitoken@userkey").unwrap();
assert_eq!( assert_eq!(
result.webhook_url, result.webhook_url,
"https://api.pushover.net/1/messages.json?token=apitoken&user=userkey" "https://api.pushover.net/1/messages.json?token=apitoken&user=userkey"
@@ -1307,7 +1307,8 @@ mod tests {
#[test] #[test]
fn test_pushover_url_query_parsing() { fn test_pushover_url_query_parsing() {
// Verify that the pushover webhook URL format contains the right params // Verify that the pushover webhook URL format contains the right params
let service = parse_shoutrrr_url("pushover://myuser@mytoken").unwrap(); // shoutrrr format: pushover://token@user
let service = parse_shoutrrr_url("pushover://mytoken@myuser").unwrap();
let parsed = url::Url::parse(&service.webhook_url).unwrap(); let parsed = url::Url::parse(&service.webhook_url).unwrap();
let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect(); let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect();
assert_eq!(params.get("token").unwrap().as_ref(), "mytoken"); assert_eq!(params.get("token").unwrap().as_ref(), "mytoken");

View File

@@ -1,6 +1,7 @@
use crate::pp::{self, PP}; use crate::pp::{self, PP};
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use reqwest::Client; use reqwest::Client;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, UdpSocket}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket};
use std::time::Duration; use std::time::Duration;
/// IP type: IPv4 or IPv6 /// IP type: IPv4 or IPv6
@@ -145,14 +146,14 @@ impl ProviderType {
// --- Cloudflare Trace --- // --- Cloudflare Trace ---
/// Primary trace URLs use literal IPs to guarantee the correct address family. /// Primary trace URL uses cloudflare.com (the CDN endpoint, not the DNS
/// api.cloudflare.com is dual-stack, so on dual-stack hosts (e.g. Docker /// resolver). The `build_split_client` forces the correct address family by
/// --net=host with IPv6) the connection may go via IPv6 even when detecting /// filtering DNS results, so a dual-stack hostname is safe.
/// IPv4, causing the trace endpoint to return the wrong address family. /// Using literal DNS-resolver IPs (1.0.0.1 / [2606:4700:4700::1001]) caused
const CF_TRACE_V4_PRIMARY: &str = "https://1.0.0.1/cdn-cgi/trace"; /// TLS SNI mismatches and returned Cloudflare proxy IPs for some users.
const CF_TRACE_V6_PRIMARY: &str = "https://[2606:4700:4700::1001]/cdn-cgi/trace"; const CF_TRACE_PRIMARY: &str = "https://cloudflare.com/cdn-cgi/trace";
/// Fallback uses a hostname, which works when literal IPs are intercepted /// Fallback uses api.cloudflare.com, which works when cloudflare.com is
/// (e.g. Cloudflare WARP/Zero Trust). /// intercepted (e.g. Cloudflare WARP/Zero Trust).
const CF_TRACE_FALLBACK: &str = "https://api.cloudflare.com/cdn-cgi/trace"; const CF_TRACE_FALLBACK: &str = "https://api.cloudflare.com/cdn-cgi/trace";
pub fn parse_trace_ip(body: &str) -> Option<String> { pub fn parse_trace_ip(body: &str) -> Option<String> {
@@ -180,16 +181,45 @@ async fn fetch_trace_ip(
ip_str.parse::<IpAddr>().ok() 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. /// 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. /// Uses a DNS-level filter to strip addresses of the wrong family from
/// This ensures the trace endpoint sees the correct address family. /// resolution results, ensuring the client never attempts a connection
/// over the wrong protocol.
pub fn build_split_client(ip_type: IpType, timeout: Duration) -> Client { 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() Client::builder()
.local_address(local_addr) .dns_resolver(FilteredResolver { ip_type })
.timeout(timeout) .timeout(timeout)
.build() .build()
.unwrap_or_default() .unwrap_or_default()
@@ -218,13 +248,8 @@ async fn detect_cloudflare_trace(
return Vec::new(); return Vec::new();
} }
let primary = match ip_type { // Try primary (cloudflare.com — the CDN trace endpoint)
IpType::V4 => CF_TRACE_V4_PRIMARY, if let Some(ip) = fetch_trace_ip(&client, CF_TRACE_PRIMARY, timeout, None).await {
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 {
if validate_detected_ip(&ip, ip_type, ppfmt) { if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip]; return vec![ip];
} }
@@ -926,21 +951,46 @@ mod tests {
#[test] #[test]
fn test_trace_urls() { fn test_trace_urls() {
// Primary URLs use literal IPs to guarantee correct address family. // Primary uses cloudflare.com CDN endpoint (not DNS resolver IPs).
assert!(CF_TRACE_V4_PRIMARY.contains("1.0.0.1")); assert_eq!(CF_TRACE_PRIMARY, "https://cloudflare.com/cdn-cgi/trace");
assert!(CF_TRACE_V6_PRIMARY.contains("2606:4700:4700::1001")); // Fallback uses api.cloudflare.com for when cloudflare.com is intercepted (WARP/Zero Trust).
// Fallback uses a hostname for when literal IPs are intercepted (WARP/Zero Trust).
assert_eq!(CF_TRACE_FALLBACK, "https://api.cloudflare.com/cdn-cgi/trace"); 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] #[test]
fn test_build_split_client_v4() { fn test_build_split_client_v4() {
let client = build_split_client(IpType::V4, Duration::from_secs(5)); let client = build_split_client(IpType::V4, Duration::from_secs(5));
// Client should build successfully — we can't inspect local_address, // Client should build successfully with filtered resolver.
// but we verify it doesn't panic.
drop(client); drop(client);
} }

View File

@@ -1,4 +1,4 @@
use crate::cf_ip_filter::CloudflareIpFilter; use crate::cf_ip_filter::CachedCloudflareFilter;
use crate::cloudflare::{CloudflareHandle, SetResult}; use crate::cloudflare::{CloudflareHandle, SetResult};
use crate::config::{AppConfig, LegacyCloudflareEntry, LegacySubdomainEntry}; use crate::config::{AppConfig, LegacyCloudflareEntry, LegacySubdomainEntry};
use crate::domain::make_fqdn; use crate::domain::make_fqdn;
@@ -6,7 +6,7 @@ use crate::notifier::{CompositeNotifier, Heartbeat, Message};
use crate::pp::{self, PP}; use crate::pp::{self, PP};
use crate::provider::IpType; use crate::provider::IpType;
use reqwest::Client; use reqwest::Client;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::net::IpAddr; use std::net::IpAddr;
use std::time::Duration; use std::time::Duration;
@@ -16,19 +16,17 @@ pub async fn update_once(
handle: &CloudflareHandle, handle: &CloudflareHandle,
notifier: &CompositeNotifier, notifier: &CompositeNotifier,
heartbeat: &Heartbeat, heartbeat: &Heartbeat,
cf_cache: &mut CachedCloudflareFilter,
ppfmt: &PP, ppfmt: &PP,
noop_reported: &mut HashSet<String>,
detection_client: &Client,
) -> bool { ) -> bool {
let detection_client = Client::builder()
.timeout(config.detection_timeout)
.build()
.unwrap_or_default();
let mut all_ok = true; let mut all_ok = true;
let mut messages = Vec::new(); let mut messages = Vec::new();
let mut notify = false; // NEW: track meaningful events let mut notify = false; // NEW: track meaningful events
if config.legacy_mode { if config.legacy_mode {
all_ok = update_legacy(config, ppfmt).await; all_ok = update_legacy(config, cf_cache, ppfmt, noop_reported, detection_client).await;
} else { } else {
// Detect IPs for each provider // Detect IPs for each provider
let mut detected_ips: HashMap<IpType, Vec<IpAddr>> = HashMap::new(); let mut detected_ips: HashMap<IpType, Vec<IpAddr>> = HashMap::new();
@@ -69,7 +67,7 @@ pub async fn update_once(
// Filter out Cloudflare IPs if enabled // Filter out Cloudflare IPs if enabled
if config.reject_cloudflare_ips { if config.reject_cloudflare_ips {
if let Some(cf_filter) = if let Some(cf_filter) =
CloudflareIpFilter::fetch(&detection_client, config.detection_timeout, ppfmt).await cf_cache.get(&detection_client, config.detection_timeout, ppfmt).await
{ {
for (ip_type, ips) in detected_ips.iter_mut() { for (ip_type, ips) in detected_ips.iter_mut() {
let before_count = ips.len(); let before_count = ips.len();
@@ -152,9 +150,11 @@ pub async fn update_once(
) )
.await; .await;
let noop_key = format!("{domain_str}:{record_type}");
match result { match result {
SetResult::Updated => { SetResult::Updated => {
notify = true; // NEW noop_reported.remove(&noop_key);
notify = true;
let ip_strs: Vec<String> = ips.iter().map(|ip| ip.to_string()).collect(); let ip_strs: Vec<String> = ips.iter().map(|ip| ip.to_string()).collect();
messages.push(Message::new_ok(&format!( messages.push(Message::new_ok(&format!(
"Updated {domain_str} -> {}", "Updated {domain_str} -> {}",
@@ -162,13 +162,18 @@ pub async fn update_once(
))); )));
} }
SetResult::Failed => { SetResult::Failed => {
notify = true; // NEW noop_reported.remove(&noop_key);
notify = true;
all_ok = false; all_ok = false;
messages.push(Message::new_fail(&format!( messages.push(Message::new_fail(&format!(
"Failed to update {domain_str}" "Failed to update {domain_str}"
))); )));
} }
SetResult::Noop => {} SetResult::Noop => {
if noop_reported.insert(noop_key) {
ppfmt.infof(pp::EMOJI_SKIP, &format!("Record {domain_str} is up to date"));
}
}
} }
} }
} }
@@ -193,32 +198,37 @@ pub async fn update_once(
) )
.await; .await;
let noop_key = format!("waf:{}", waf_list.describe());
match result { match result {
SetResult::Updated => { SetResult::Updated => {
notify = true; // NEW noop_reported.remove(&noop_key);
notify = true;
messages.push(Message::new_ok(&format!( messages.push(Message::new_ok(&format!(
"Updated WAF list {}", "Updated WAF list {}",
waf_list.describe() waf_list.describe()
))); )));
} }
SetResult::Failed => { SetResult::Failed => {
notify = true; // NEW noop_reported.remove(&noop_key);
notify = true;
all_ok = false; all_ok = false;
messages.push(Message::new_fail(&format!( messages.push(Message::new_fail(&format!(
"Failed to update WAF list {}", "Failed to update WAF list {}",
waf_list.describe() waf_list.describe()
))); )));
} }
SetResult::Noop => {} SetResult::Noop => {
if noop_reported.insert(noop_key) {
ppfmt.infof(pp::EMOJI_SKIP, &format!("WAF list {} is up to date", waf_list.describe()));
}
}
} }
} }
} }
// Send heartbeat ONLY if something meaningful happened // Always ping heartbeat so monitors know the updater is alive
if notify {
let heartbeat_msg = Message::merge(messages.clone()); let heartbeat_msg = Message::merge(messages.clone());
heartbeat.ping(&heartbeat_msg).await; heartbeat.ping(&heartbeat_msg).await;
}
// Send notifications ONLY when IP changed or failed // Send notifications ONLY when IP changed or failed
if notify { if notify {
@@ -235,29 +245,27 @@ pub async fn update_once(
/// IP-family-bound clients (0.0.0.0 for IPv4, [::] for IPv6). This prevents the old /// IP-family-bound clients (0.0.0.0 for IPv4, [::] for IPv6). This prevents the old
/// wrong-family warning on dual-stack hosts and honours `ip4_provider`/`ip6_provider` /// wrong-family warning on dual-stack hosts and honours `ip4_provider`/`ip6_provider`
/// overrides from config.json. /// overrides from config.json.
async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool { async fn update_legacy(
config: &AppConfig,
cf_cache: &mut CachedCloudflareFilter,
ppfmt: &PP,
noop_reported: &mut HashSet<String>,
detection_client: &Client,
) -> bool {
let legacy = match &config.legacy_config { let legacy = match &config.legacy_config {
Some(l) => l, Some(l) => l,
None => return false, None => return false,
}; };
let client = Client::builder() let ddns = LegacyDdnsClient {
client: Client::builder()
.timeout(config.update_timeout) .timeout(config.update_timeout)
.build() .build()
.unwrap_or_default(); .unwrap_or_default(),
let ddns = LegacyDdnsClient {
client,
cf_api_base: "https://api.cloudflare.com/client/v4".to_string(), cf_api_base: "https://api.cloudflare.com/client/v4".to_string(),
dry_run: config.dry_run, dry_run: config.dry_run,
}; };
// Detect IPs using the shared provider abstraction
let detection_client = Client::builder()
.timeout(config.detection_timeout)
.build()
.unwrap_or_default();
let mut ips = HashMap::new(); let mut ips = HashMap::new();
for (ip_type, provider) in &config.providers { for (ip_type, provider) in &config.providers {
@@ -301,7 +309,7 @@ async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool {
if config.reject_cloudflare_ips { if config.reject_cloudflare_ips {
let before_count = ips.len(); let before_count = ips.len();
if let Some(cf_filter) = if let Some(cf_filter) =
CloudflareIpFilter::fetch(&detection_client, config.detection_timeout, ppfmt).await cf_cache.get(&detection_client, config.detection_timeout, ppfmt).await
{ {
ips.retain(|key, ip_info| { ips.retain(|key, ip_info| {
if let Ok(addr) = ip_info.ip.parse::<std::net::IpAddr>() { if let Ok(addr) = ip_info.ip.parse::<std::net::IpAddr>() {
@@ -338,6 +346,7 @@ async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool {
&legacy.cloudflare, &legacy.cloudflare,
legacy.ttl, legacy.ttl,
legacy.purge_unknown_records, legacy.purge_unknown_records,
noop_reported,
) )
.await; .await;
@@ -489,9 +498,10 @@ impl LegacyDdnsClient {
config: &[LegacyCloudflareEntry], config: &[LegacyCloudflareEntry],
ttl: i64, ttl: i64,
purge_unknown_records: bool, purge_unknown_records: bool,
noop_reported: &mut HashSet<String>,
) { ) {
for ip in ips.values() { for ip in ips.values() {
self.commit_record(ip, config, ttl, purge_unknown_records) self.commit_record(ip, config, ttl, purge_unknown_records, noop_reported)
.await; .await;
} }
} }
@@ -502,6 +512,7 @@ impl LegacyDdnsClient {
config: &[LegacyCloudflareEntry], config: &[LegacyCloudflareEntry],
ttl: i64, ttl: i64,
purge_unknown_records: bool, purge_unknown_records: bool,
noop_reported: &mut HashSet<String>,
) { ) {
for entry in config { for entry in config {
let zone_resp: Option<LegacyCfResponse<LegacyZoneResult>> = self let zone_resp: Option<LegacyCfResponse<LegacyZoneResult>> = self
@@ -577,8 +588,10 @@ impl LegacyDdnsClient {
} }
} }
let noop_key = format!("{fqdn}:{}", ip.record_type);
if let Some(ref id) = identifier { if let Some(ref id) = identifier {
if modified { if modified {
noop_reported.remove(&noop_key);
if self.dry_run { if self.dry_run {
println!("[DRY RUN] Would update record {fqdn} -> {}", ip.ip); println!("[DRY RUN] Would update record {fqdn} -> {}", ip.ip);
} else { } else {
@@ -589,10 +602,16 @@ impl LegacyDdnsClient {
.cf_api(&update_endpoint, "PUT", entry, Some(&record)) .cf_api(&update_endpoint, "PUT", entry, Some(&record))
.await; .await;
} }
} else if self.dry_run { } else if noop_reported.insert(noop_key) {
println!("[DRY RUN] Record {fqdn} is up to date ({})", ip.ip); if self.dry_run {
println!("[DRY RUN] Record {fqdn} is up to date");
} else {
println!("Record {fqdn} is up to date");
} }
} else if self.dry_run { }
} else {
noop_reported.remove(&noop_key);
if self.dry_run {
println!("[DRY RUN] Would add new record {fqdn} -> {}", ip.ip); println!("[DRY RUN] Would add new record {fqdn} -> {}", ip.ip);
} else { } else {
println!("Adding new record {fqdn} -> {}", ip.ip); println!("Adding new record {fqdn} -> {}", ip.ip);
@@ -601,6 +620,7 @@ impl LegacyDdnsClient {
.cf_api(&create_endpoint, "POST", entry, Some(&record)) .cf_api(&create_endpoint, "POST", entry, Some(&record))
.await; .await;
} }
}
if purge_unknown_records { if purge_unknown_records {
for dup_id in &duplicate_ids { for dup_id in &duplicate_ids {
@@ -802,11 +822,13 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
assert!(ok); assert!(ok);
} }
/// update_once returns true (all_ok) when IP is already correct (Noop). /// update_once returns true (all_ok) when IP is already correct (Noop),
/// and populates noop_reported so subsequent calls suppress the message.
#[tokio::test] #[tokio::test]
async fn test_update_once_noop_when_record_up_to_date() { async fn test_update_once_noop_when_record_up_to_date() {
let server = MockServer::start().await; let server = MockServer::start().await;
@@ -850,8 +872,91 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
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;
assert!(ok); 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;
assert!(ok);
assert_eq!(noop_reported.len(), 1, "noop_reported should still have exactly one entry");
}
/// noop_reported is cleared when a record is updated, so "up to date" prints again
/// on the next noop cycle.
#[tokio::test]
async fn test_update_once_noop_reported_cleared_on_change() {
let server = MockServer::start().await;
let zone_id = "zone-abc";
let domain = "home.example.com";
let old_ip = "198.51.100.42";
let new_ip = "198.51.100.99";
// Zone lookup
Mock::given(method("GET"))
.and(path("/zones"))
.and(query_param("name", domain))
.respond_with(
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
)
.mount(&server)
.await;
// List existing records - record has old IP, will be updated
Mock::given(method("GET"))
.and(path_regex(format!("/zones/{zone_id}/dns_records")))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(dns_records_one("rec-1", domain, old_ip)),
)
.mount(&server)
.await;
// Create record (new IP doesn't match existing, so it creates + deletes stale)
Mock::given(method("POST"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(dns_record_created("rec-2", domain, new_ip)),
)
.mount(&server)
.await;
// Delete stale record
Mock::given(method("DELETE"))
.and(path(format!("/zones/{zone_id}/dns_records/rec-1")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"result": {}})))
.mount(&server)
.await;
let mut providers = HashMap::new();
providers.insert(
IpType::V4,
ProviderType::Literal {
ips: vec![new_ip.parse::<IpAddr>().unwrap()],
},
);
let mut domains = HashMap::new();
domains.insert(IpType::V4, vec![domain.to_string()]);
let config = make_config(providers, domains, vec![], false);
let cf = handle(&server.uri());
let notifier = empty_notifier();
let heartbeat = empty_heartbeat();
let ppfmt = pp();
// Pre-populate noop_reported as if a previous cycle reported it
let mut noop_reported = HashSet::new();
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;
assert!(ok);
assert!(!noop_reported.contains("home.example.com:A"), "noop_reported should be cleared after an update");
} }
/// update_once returns true even when IP detection yields empty (no providers configured), /// update_once returns true even when IP detection yields empty (no providers configured),
@@ -894,7 +999,8 @@ mod tests {
let ppfmt = pp(); let ppfmt = pp();
// all_ok = true because no zone-level errors occurred (empty ips just noop or warn) // all_ok = true because no zone-level errors occurred (empty ips just noop or warn)
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
// Providers with None are not inserted in loop, so no IP detection warning is emitted, // 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. // no detected_ips entry is created, and set_ips is called with empty slice -> Noop.
assert!(ok); assert!(ok);
@@ -943,7 +1049,8 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
assert!(!ok, "Expected false when zone is not found"); assert!(!ok, "Expected false when zone is not found");
} }
@@ -992,7 +1099,8 @@ mod tests {
let ppfmt = pp(); let ppfmt = pp();
// dry_run returns Updated from set_ips (it signals intent), all_ok should be true // dry_run returns Updated from set_ips (it signals intent), all_ok should be true
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
assert!(ok); assert!(ok);
} }
@@ -1057,7 +1165,8 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
assert!(ok); assert!(ok);
} }
@@ -1110,7 +1219,8 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
assert!(ok); assert!(ok);
} }
@@ -1149,7 +1259,8 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
assert!(!ok, "Expected false when WAF list is not found"); assert!(!ok, "Expected false when WAF list is not found");
} }
@@ -1233,7 +1344,8 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
assert!(ok); assert!(ok);
} }
@@ -1249,7 +1361,8 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
assert!(ok); assert!(ok);
} }
@@ -1633,7 +1746,8 @@ mod tests {
let ppfmt = pp(); let ppfmt = pp();
// set_ips with empty ips and no existing records = Noop; all_ok = true // set_ips with empty ips and no existing records = Noop; all_ok = true
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
assert!(ok); assert!(ok);
} }
// ------------------------------------------------------- // -------------------------------------------------------
@@ -1838,7 +1952,7 @@ mod tests {
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())], subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
proxied: false, proxied: false,
}]; }];
ddns.commit_record(&ip, &config, 300, false).await; ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
} }
#[tokio::test] #[tokio::test]
@@ -1894,7 +2008,7 @@ mod tests {
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())], subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
proxied: false, proxied: false,
}]; }];
ddns.commit_record(&ip, &config, 300, false).await; ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
} }
#[tokio::test] #[tokio::test]
@@ -1937,7 +2051,7 @@ mod tests {
proxied: false, proxied: false,
}]; }];
// Should not POST // Should not POST
ddns.commit_record(&ip, &config, 300, false).await; ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
} }
#[tokio::test] #[tokio::test]
@@ -1990,7 +2104,7 @@ mod tests {
}], }],
proxied: false, proxied: false,
}]; }];
ddns.commit_record(&ip, &config, 300, false).await; ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
} }
#[tokio::test] #[tokio::test]
@@ -2042,7 +2156,7 @@ mod tests {
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())], subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
proxied: false, proxied: false,
}]; }];
ddns.commit_record(&ip, &config, 300, true).await; ddns.commit_record(&ip, &config, 300, true, &mut HashSet::new()).await;
} }
#[tokio::test] #[tokio::test]
@@ -2092,7 +2206,7 @@ mod tests {
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())], subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
proxied: false, proxied: false,
}]; }];
ddns.update_ips(&ips, &config, 300, false).await; ddns.update_ips(&ips, &config, 300, false, &mut HashSet::new()).await;
} }
#[tokio::test] #[tokio::test]