From 4b1875b0cde014ee815d28d0d2633d42976342f6 Mon Sep 17 00:00:00 2001 From: Timothy Miller Date: Wed, 18 Mar 2026 19:44:06 -0400 Subject: [PATCH] Add REJECT_CLOUDFLARE_IPS flag to filter out Cloudflare-owned IPs from DNS updates IP detection providers can sometimes return a Cloudflare anycast IP instead of the user's real public IP, causing incorrect DNS updates. When REJECT_CLOUDFLARE_IPS=true, detected IPs are checked against Cloudflare's published IP ranges (ips-v4/ips-v6) and rejected if they match. --- Cargo.lock | 228 ++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 3 +- src/cf_ip_filter.rs | 181 +++++++++++++++++++++++++++++++++++ src/config.rs | 13 +++ src/main.rs | 1 + src/updater.rs | 45 +++++++++ 6 files changed, 460 insertions(+), 11 deletions(-) create mode 100644 src/cf_ip_filter.rs diff --git a/Cargo.lock b/Cargo.lock index 39a617f..b6666b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -68,9 +74,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", @@ -103,11 +109,12 @@ dependencies = [ [[package]] name = "cloudflare-ddns" -version = "2.0.2" +version = "2.0.4" dependencies = [ "chrono", "idna", "if-addrs", + "ipnet", "regex", "reqwest", "serde", @@ -187,6 +194,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -306,11 +319,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "h2" version = "0.4.13" @@ -330,12 +356,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -555,6 +596,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -593,7 +640,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -634,6 +683,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" @@ -702,9 +757,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "percent-encoding" @@ -742,6 +797,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -821,6 +886,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.9.2" @@ -997,6 +1068,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1140,7 +1217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1178,9 +1255,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -1317,6 +1394,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -1365,6 +1448,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -1424,6 +1516,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.91" @@ -1705,6 +1831,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" diff --git a/Cargo.toml b/Cargo.toml index 66a8040..f94a90e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cloudflare-ddns" -version = "2.0.3" +version = "2.0.4" edition = "2021" description = "Access your home network remotely via a custom domain name without a static IP" license = "GPL-3.0" @@ -15,6 +15,7 @@ chrono = { version = "0.4", features = ["clock"] } url = "2" idna = "1" if-addrs = "0.13" +ipnet = "2" [profile.release] opt-level = "s" diff --git a/src/cf_ip_filter.rs b/src/cf_ip_filter.rs new file mode 100644 index 0000000..30d9e93 --- /dev/null +++ b/src/cf_ip_filter.rs @@ -0,0 +1,181 @@ +use crate::pp::{self, PP}; +use ipnet::IpNet; +use reqwest::Client; +use std::net::IpAddr; +use std::time::Duration; + +const CF_IPV4_URL: &str = "https://www.cloudflare.com/ips-v4"; +const CF_IPV6_URL: &str = "https://www.cloudflare.com/ips-v6"; + +/// Holds parsed Cloudflare CIDR ranges for IP filtering. +pub struct CloudflareIpFilter { + ranges: Vec, +} + +impl CloudflareIpFilter { + /// Fetch Cloudflare IP ranges from their published URLs and parse them. + pub async fn fetch(client: &Client, timeout: Duration, ppfmt: &PP) -> Option { + let mut ranges = Vec::new(); + + for url in [CF_IPV4_URL, CF_IPV6_URL] { + match client.get(url).timeout(timeout).send().await { + Ok(resp) if resp.status().is_success() => match resp.text().await { + Ok(body) => { + for line in body.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + match line.parse::() { + Ok(net) => ranges.push(net), + Err(e) => { + ppfmt.warningf( + pp::EMOJI_WARNING, + &format!( + "Failed to parse Cloudflare IP range '{line}': {e}" + ), + ); + } + } + } + } + Err(e) => { + ppfmt.warningf( + pp::EMOJI_WARNING, + &format!("Failed to read Cloudflare IP ranges from {url}: {e}"), + ); + return None; + } + }, + Ok(resp) => { + ppfmt.warningf( + pp::EMOJI_WARNING, + &format!( + "Failed to fetch Cloudflare IP ranges from {url}: HTTP {}", + resp.status() + ), + ); + return None; + } + Err(e) => { + ppfmt.warningf( + pp::EMOJI_WARNING, + &format!("Failed to fetch Cloudflare IP ranges from {url}: {e}"), + ); + return None; + } + } + } + + if ranges.is_empty() { + ppfmt.warningf( + pp::EMOJI_WARNING, + "No Cloudflare IP ranges loaded; skipping filter", + ); + return None; + } + + ppfmt.infof( + pp::EMOJI_DETECT, + &format!("Loaded {} Cloudflare IP ranges for filtering", ranges.len()), + ); + + Some(Self { ranges }) + } + + /// Parse ranges from raw text lines (for testing). + #[cfg(test)] + pub fn from_lines(lines: &str) -> Option { + let ranges: Vec = lines + .lines() + .filter_map(|l| { + let l = l.trim(); + if l.is_empty() { + None + } else { + l.parse().ok() + } + }) + .collect(); + if ranges.is_empty() { + None + } else { + Some(Self { ranges }) + } + } + + /// Check if an IP address falls within any Cloudflare range. + pub fn contains(&self, ip: &IpAddr) -> bool { + self.ranges.iter().any(|net| net.contains(ip)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, Ipv6Addr}; + + const SAMPLE_RANGES: &str = "\ +173.245.48.0/20 +103.21.244.0/22 +103.22.200.0/22 +104.16.0.0/13 +2400:cb00::/32 +2606:4700::/32 +"; + + #[test] + fn test_parse_ranges() { + let filter = CloudflareIpFilter::from_lines(SAMPLE_RANGES).unwrap(); + assert_eq!(filter.ranges.len(), 6); + } + + #[test] + fn test_contains_cloudflare_ipv4() { + let filter = CloudflareIpFilter::from_lines(SAMPLE_RANGES).unwrap(); + // 104.16.0.1 is within 104.16.0.0/13 + let ip: IpAddr = IpAddr::V4(Ipv4Addr::new(104, 16, 0, 1)); + assert!(filter.contains(&ip)); + } + + #[test] + fn test_rejects_non_cloudflare_ipv4() { + let filter = CloudflareIpFilter::from_lines(SAMPLE_RANGES).unwrap(); + // 203.0.113.42 is a documentation IP, not Cloudflare + let ip: IpAddr = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 42)); + assert!(!filter.contains(&ip)); + } + + #[test] + fn test_contains_cloudflare_ipv6() { + let filter = CloudflareIpFilter::from_lines(SAMPLE_RANGES).unwrap(); + // 2606:4700::1 is within 2606:4700::/32 + let ip: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0, 0, 0, 0, 0, 1)); + assert!(filter.contains(&ip)); + } + + #[test] + fn test_rejects_non_cloudflare_ipv6() { + let filter = CloudflareIpFilter::from_lines(SAMPLE_RANGES).unwrap(); + // 2001:db8::1 is a documentation address, not Cloudflare + let ip: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)); + assert!(!filter.contains(&ip)); + } + + #[test] + fn test_empty_input() { + assert!(CloudflareIpFilter::from_lines("").is_none()); + assert!(CloudflareIpFilter::from_lines(" \n \n").is_none()); + } + + #[test] + fn test_edge_of_range() { + let filter = CloudflareIpFilter::from_lines("104.16.0.0/13").unwrap(); + // First IP in range + assert!(filter.contains(&IpAddr::V4(Ipv4Addr::new(104, 16, 0, 0)))); + // Last IP in range (104.23.255.255) + assert!(filter.contains(&IpAddr::V4(Ipv4Addr::new(104, 23, 255, 255)))); + // Just outside range (104.24.0.0) + assert!(!filter.contains(&IpAddr::V4(Ipv4Addr::new(104, 24, 0, 0)))); + } +} diff --git a/src/config.rs b/src/config.rs index 99560fd..8ed158b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -89,6 +89,7 @@ pub struct AppConfig { pub managed_waf_comment_regex: Option, pub detection_timeout: Duration, pub update_timeout: Duration, + pub reject_cloudflare_ips: bool, pub dry_run: bool, pub emoji: bool, pub quiet: bool, @@ -439,6 +440,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Ap managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(30), + reject_cloudflare_ips: false, dry_run, emoji: false, quiet: false, @@ -509,6 +511,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result { let emoji = getenv_bool("EMOJI", true); let quiet = getenv_bool("QUIET", false); + let reject_cloudflare_ips = getenv_bool("REJECT_CLOUDFLARE_IPS", false); // Validate: must have at least one update target if domains.is_empty() && waf_lists.is_empty() { @@ -559,6 +562,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result { managed_waf_comment_regex, detection_timeout, update_timeout, + reject_cloudflare_ips, dry_run: false, // Set later from CLI args emoji, quiet, @@ -659,6 +663,10 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) { inner.infof("", "Delete on stop: enabled"); } + if config.reject_cloudflare_ips { + inner.infof("", "Reject Cloudflare IPs: enabled"); + } + if let Some(ref comment) = config.record_comment { inner.infof("", &format!("Record comment: {comment}")); } @@ -1190,6 +1198,7 @@ mod tests { managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(30), + reject_cloudflare_ips: false, dry_run: false, emoji: false, quiet: false, @@ -1223,6 +1232,7 @@ mod tests { managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(30), + reject_cloudflare_ips: false, dry_run: false, emoji: false, quiet: false, @@ -1881,6 +1891,7 @@ mod tests { managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(30), + reject_cloudflare_ips: false, dry_run: false, emoji: false, quiet: false, @@ -1916,6 +1927,7 @@ mod tests { managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(30), + reject_cloudflare_ips: false, dry_run: false, emoji: false, quiet: true, @@ -1948,6 +1960,7 @@ mod tests { managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(30), + reject_cloudflare_ips: false, dry_run: false, emoji: false, quiet: false, diff --git a/src/main.rs b/src/main.rs index 2a3b1af..4e09e32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod cf_ip_filter; mod cloudflare; mod config; mod domain; diff --git a/src/updater.rs b/src/updater.rs index f9bd2e9..4d8ab44 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,3 +1,4 @@ +use crate::cf_ip_filter::CloudflareIpFilter; use crate::cloudflare::{CloudflareHandle, SetResult}; use crate::config::{AppConfig, LegacyCloudflareEntry, LegacySubdomainEntry}; use crate::domain::make_fqdn; @@ -65,6 +66,49 @@ pub async fn update_once( } } + // Filter out Cloudflare IPs if enabled + if config.reject_cloudflare_ips { + if let Some(cf_filter) = + CloudflareIpFilter::fetch(&detection_client, config.detection_timeout, ppfmt).await + { + for (ip_type, ips) in detected_ips.iter_mut() { + let before_count = ips.len(); + ips.retain(|ip| { + if cf_filter.contains(ip) { + ppfmt.warningf( + pp::EMOJI_WARNING, + &format!( + "Rejected {ip}: matches Cloudflare IP range ({})", + ip_type.describe() + ), + ); + false + } else { + true + } + }); + if ips.is_empty() && before_count > 0 { + ppfmt.warningf( + pp::EMOJI_WARNING, + &format!( + "All detected {} addresses were Cloudflare IPs; skipping updates for this type", + ip_type.describe() + ), + ); + messages.push(Message::new_fail(&format!( + "All {} addresses rejected (Cloudflare IPs)", + ip_type.describe() + ))); + } + } + } else { + ppfmt.warningf( + pp::EMOJI_WARNING, + "Could not fetch Cloudflare IP ranges; skipping filter", + ); + } + } + // Update DNS records (env var mode - domain-based) for (ip_type, domains) in &config.domains { let ips = detected_ips.get(ip_type).cloned().unwrap_or_default(); @@ -693,6 +737,7 @@ mod tests { managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(5), + reject_cloudflare_ips: false, dry_run, emoji: false, quiet: true,