mirror of
https://github.com/timothymiller/cloudflare-ddns.git
synced 2026-03-21 14:38:56 -03:00
Fetch CF ranges concurrently and prevent writes
Use tokio::join to fetch IPv4 and IPv6 Cloudflare ranges in parallel. When range fetch fails, avoid performing updates that could write Cloudflare addresses by clearing detected/filtered IP lists and emitting warnings. Add unit tests to validate parsing and boundary checks for the current Cloudflare ranges. Bump crate version to 2.0.6. Fetch Cloudflare ranges concurrently; avoid writes Skip updates (clear detected IPs) if Cloudflare ranges can't be retrieved to avoid writing Cloudflare anycast addresses. Default REJECT_CLOUDFLARE_IPS=true, update README, add comprehensive CF-range tests, and bump crate version Fetch CF ranges concurrently and avoid updates Enable rejecting Cloudflare IPs by default and skip any updates if the published ranges cannot be fetched to avoid writing Cloudflare anycast addresses. Fetch IPv4 and IPv6 ranges concurrently, add parsing/matching tests, and update README and version.
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -109,7 +109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cloudflare-ddns"
|
||||
version = "2.0.5"
|
||||
version = "2.0.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"idna",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cloudflare-ddns"
|
||||
version = "2.0.5"
|
||||
version = "2.0.6"
|
||||
edition = "2021"
|
||||
description = "Access your home network remotely via a custom domain name without a static IP"
|
||||
license = "GPL-3.0"
|
||||
|
||||
16
README.md
16
README.md
@@ -28,7 +28,7 @@ Configure everything with environment variables. Supports notifications, heartbe
|
||||
- 🎨 **Pretty output with emoji** — Configurable emoji and verbosity levels
|
||||
- 🔒 **Zero-log IP detection** — Uses Cloudflare's [cdn-cgi/trace](https://www.cloudflare.com/cdn-cgi/trace) by default
|
||||
- 🏠 **CGNAT-aware local detection** — Filters out shared address space (100.64.0.0/10) and private ranges
|
||||
- 🚫 **Cloudflare IP rejection** — Optionally reject Cloudflare anycast IPs to prevent incorrect DNS updates
|
||||
- 🚫 **Cloudflare IP rejection** — Automatically rejects Cloudflare anycast IPs to prevent incorrect DNS updates
|
||||
- 🤏 **Tiny static binary** — ~1.9 MB Docker image built from scratch, zero runtime dependencies
|
||||
|
||||
## 🚀 Quick Start
|
||||
@@ -92,11 +92,13 @@ Available providers:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `REJECT_CLOUDFLARE_IPS` | `false` | Reject detected IPs that fall within Cloudflare's IP ranges |
|
||||
| `REJECT_CLOUDFLARE_IPS` | `true` | Reject detected IPs that fall within Cloudflare's IP ranges |
|
||||
|
||||
Some IP detection providers occasionally return a Cloudflare anycast IP instead of your real public IP. When this happens, your DNS record gets updated to point at Cloudflare infrastructure rather than your actual address.
|
||||
|
||||
Setting `REJECT_CLOUDFLARE_IPS=true` prevents this. Each update cycle fetches [Cloudflare's published IP ranges](https://www.cloudflare.com/ips/) and skips any detected IP that falls within them. A warning is logged for every rejected IP.
|
||||
By default, each update cycle fetches [Cloudflare's published IP ranges](https://www.cloudflare.com/ips/) and skips any detected IP that falls within them. A warning is logged for every rejected IP. If the ranges cannot be fetched, the update is skipped entirely to prevent writing a Cloudflare IP.
|
||||
|
||||
To disable this protection, set `REJECT_CLOUDFLARE_IPS=false`.
|
||||
|
||||
## ⏱️ Scheduling
|
||||
|
||||
@@ -221,7 +223,7 @@ Heartbeats are sent after each update cycle. On failure, a fail signal is sent.
|
||||
| `MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX` | — | 🎯 Managed WAF items regex |
|
||||
| `DETECTION_TIMEOUT` | `5s` | ⏳ IP detection timeout |
|
||||
| `UPDATE_TIMEOUT` | `30s` | ⏳ API request timeout |
|
||||
| `REJECT_CLOUDFLARE_IPS` | `false` | 🚫 Reject Cloudflare anycast IPs |
|
||||
| `REJECT_CLOUDFLARE_IPS` | `true` | 🚫 Reject Cloudflare anycast IPs |
|
||||
| `EMOJI` | `true` | 🎨 Enable emoji output |
|
||||
| `QUIET` | `false` | 🤫 Suppress info output |
|
||||
| `HEALTHCHECKS` | — | 💓 Healthchecks.io URL |
|
||||
@@ -373,17 +375,17 @@ Some ISP provided modems only allow port forwarding over IPv4 or IPv6. Disable t
|
||||
|
||||
### 🚫 Cloudflare IP Rejection (Legacy Mode)
|
||||
|
||||
The `REJECT_CLOUDFLARE_IPS` environment variable is supported in legacy config mode. Set it alongside your `config.json`:
|
||||
Cloudflare IP rejection is enabled by default in legacy mode too. To disable it, set `REJECT_CLOUDFLARE_IPS=false` alongside your `config.json`:
|
||||
|
||||
```bash
|
||||
REJECT_CLOUDFLARE_IPS=true cloudflare-ddns
|
||||
REJECT_CLOUDFLARE_IPS=false cloudflare-ddns
|
||||
```
|
||||
|
||||
Or in Docker Compose:
|
||||
|
||||
```yml
|
||||
environment:
|
||||
- REJECT_CLOUDFLARE_IPS=true
|
||||
- REJECT_CLOUDFLARE_IPS=false
|
||||
volumes:
|
||||
- ./config.json:/config.json
|
||||
```
|
||||
|
||||
@@ -59,8 +59,13 @@ impl CloudflareIpFilter {
|
||||
pub async fn fetch(client: &Client, timeout: Duration, ppfmt: &PP) -> Option<Self> {
|
||||
let mut ranges = Vec::new();
|
||||
|
||||
for url in [CF_IPV4_URL, CF_IPV6_URL] {
|
||||
match client.get(url).timeout(timeout).send().await {
|
||||
let (v4_result, v6_result) = tokio::join!(
|
||||
client.get(CF_IPV4_URL).timeout(timeout).send(),
|
||||
client.get(CF_IPV6_URL).timeout(timeout).send(),
|
||||
);
|
||||
|
||||
for (url, result) in [(CF_IPV4_URL, v4_result), (CF_IPV6_URL, v6_result)] {
|
||||
match result {
|
||||
Ok(resp) if resp.status().is_success() => match resp.text().await {
|
||||
Ok(body) => {
|
||||
for line in body.lines() {
|
||||
@@ -234,4 +239,127 @@ mod tests {
|
||||
let ip: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0, 0, 0, 0, 0, 1));
|
||||
assert!(!filter.contains(&ip));
|
||||
}
|
||||
|
||||
/// All real Cloudflare ranges as of 2026-03. Verifies every range parses
|
||||
/// and that the first and last IP in each range is matched while the
|
||||
/// address just past the end is not.
|
||||
const ALL_CF_RANGES: &str = "\
|
||||
173.245.48.0/20
|
||||
103.21.244.0/22
|
||||
103.22.200.0/22
|
||||
103.31.4.0/22
|
||||
141.101.64.0/18
|
||||
108.162.192.0/18
|
||||
190.93.240.0/20
|
||||
188.114.96.0/20
|
||||
197.234.240.0/22
|
||||
198.41.128.0/17
|
||||
162.158.0.0/15
|
||||
104.16.0.0/13
|
||||
104.24.0.0/14
|
||||
172.64.0.0/13
|
||||
131.0.72.0/22
|
||||
2400:cb00::/32
|
||||
2606:4700::/32
|
||||
2803:f800::/32
|
||||
2405:b500::/32
|
||||
2405:8100::/32
|
||||
2a06:98c0::/29
|
||||
2c0f:f248::/32
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn test_all_real_ranges_parse() {
|
||||
let filter = CloudflareIpFilter::from_lines(ALL_CF_RANGES).unwrap();
|
||||
assert_eq!(filter.ranges.len(), 22);
|
||||
}
|
||||
|
||||
/// For a /N IPv4 range starting at `base`, return (first, last, just_outside).
|
||||
fn v4_range_bounds(a: u8, b: u8, c: u8, d: u8, prefix: u8) -> (Ipv4Addr, Ipv4Addr, Ipv4Addr) {
|
||||
let base = u32::from(Ipv4Addr::new(a, b, c, d));
|
||||
let size = 1u32 << (32 - prefix);
|
||||
let first = Ipv4Addr::from(base);
|
||||
let last = Ipv4Addr::from(base + size - 1);
|
||||
let outside = Ipv4Addr::from(base + size);
|
||||
(first, last, outside)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_real_ipv4_ranges_match() {
|
||||
// Test each range individually so adjacent ranges (e.g. 104.16.0.0/13
|
||||
// and 104.24.0.0/14) don't cause false failures on boundary checks.
|
||||
let ranges: &[(u8, u8, u8, u8, u8)] = &[
|
||||
(173, 245, 48, 0, 20),
|
||||
(103, 21, 244, 0, 22),
|
||||
(103, 22, 200, 0, 22),
|
||||
(103, 31, 4, 0, 22),
|
||||
(141, 101, 64, 0, 18),
|
||||
(108, 162, 192, 0, 18),
|
||||
(190, 93, 240, 0, 20),
|
||||
(188, 114, 96, 0, 20),
|
||||
(197, 234, 240, 0, 22),
|
||||
(198, 41, 128, 0, 17),
|
||||
(162, 158, 0, 0, 15),
|
||||
(104, 16, 0, 0, 13),
|
||||
(104, 24, 0, 0, 14),
|
||||
(172, 64, 0, 0, 13),
|
||||
(131, 0, 72, 0, 22),
|
||||
];
|
||||
|
||||
for &(a, b, c, d, prefix) in ranges {
|
||||
let cidr = format!("{a}.{b}.{c}.{d}/{prefix}");
|
||||
let filter = CloudflareIpFilter::from_lines(&cidr).unwrap();
|
||||
let (first, last, outside) = v4_range_bounds(a, b, c, d, prefix);
|
||||
assert!(
|
||||
filter.contains(&IpAddr::V4(first)),
|
||||
"First IP {first} should be in {cidr}"
|
||||
);
|
||||
assert!(
|
||||
filter.contains(&IpAddr::V4(last)),
|
||||
"Last IP {last} should be in {cidr}"
|
||||
);
|
||||
assert!(
|
||||
!filter.contains(&IpAddr::V4(outside)),
|
||||
"IP {outside} should NOT be in {cidr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_real_ipv6_ranges_match() {
|
||||
let filter = CloudflareIpFilter::from_lines(ALL_CF_RANGES).unwrap();
|
||||
|
||||
// (base high 16-bit segment, prefix len)
|
||||
let ranges: &[(u16, u16, u8)] = &[
|
||||
(0x2400, 0xcb00, 32),
|
||||
(0x2606, 0x4700, 32),
|
||||
(0x2803, 0xf800, 32),
|
||||
(0x2405, 0xb500, 32),
|
||||
(0x2405, 0x8100, 32),
|
||||
(0x2a06, 0x98c0, 29),
|
||||
(0x2c0f, 0xf248, 32),
|
||||
];
|
||||
|
||||
for &(seg0, seg1, prefix) in ranges {
|
||||
let base = u128::from(Ipv6Addr::new(seg0, seg1, 0, 0, 0, 0, 0, 0));
|
||||
let size = 1u128 << (128 - prefix);
|
||||
|
||||
let first = Ipv6Addr::from(base);
|
||||
let last = Ipv6Addr::from(base + size - 1);
|
||||
let outside = Ipv6Addr::from(base + size);
|
||||
|
||||
assert!(
|
||||
filter.contains(&IpAddr::V6(first)),
|
||||
"First IP {first} should be in {seg0:x}:{seg1:x}::/{prefix}"
|
||||
);
|
||||
assert!(
|
||||
filter.contains(&IpAddr::V6(last)),
|
||||
"Last IP {last} should be in {seg0:x}:{seg1:x}::/{prefix}"
|
||||
);
|
||||
assert!(
|
||||
!filter.contains(&IpAddr::V6(outside)),
|
||||
"IP {outside} should NOT be in {seg0:x}:{seg1:x}::/{prefix}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,7 +458,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Re
|
||||
managed_waf_comment_regex: None,
|
||||
detection_timeout: Duration::from_secs(5),
|
||||
update_timeout: Duration::from_secs(30),
|
||||
reject_cloudflare_ips: getenv_bool("REJECT_CLOUDFLARE_IPS", false),
|
||||
reject_cloudflare_ips: getenv_bool("REJECT_CLOUDFLARE_IPS", true),
|
||||
dry_run,
|
||||
emoji: false,
|
||||
quiet: false,
|
||||
@@ -529,7 +529,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
|
||||
|
||||
let emoji = getenv_bool("EMOJI", true);
|
||||
let quiet = getenv_bool("QUIET", false);
|
||||
let reject_cloudflare_ips = getenv_bool("REJECT_CLOUDFLARE_IPS", false);
|
||||
let reject_cloudflare_ips = getenv_bool("REJECT_CLOUDFLARE_IPS", true);
|
||||
|
||||
// Validate: must have at least one update target
|
||||
if domains.is_empty() && waf_lists.is_empty() {
|
||||
@@ -681,8 +681,8 @@ 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 !config.reject_cloudflare_ips {
|
||||
inner.warningf("", "Cloudflare IP rejection: DISABLED (REJECT_CLOUDFLARE_IPS=false)");
|
||||
}
|
||||
|
||||
if let Some(ref comment) = config.record_comment {
|
||||
|
||||
@@ -101,11 +101,12 @@ pub async fn update_once(
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if !detected_ips.is_empty() {
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
"Could not fetch Cloudflare IP ranges; skipping filter",
|
||||
"Could not fetch Cloudflare IP ranges; skipping update to avoid writing Cloudflare IPs",
|
||||
);
|
||||
detected_ips.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +299,7 @@ async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool {
|
||||
|
||||
// Filter out Cloudflare IPs if enabled
|
||||
if config.reject_cloudflare_ips {
|
||||
let before_count = ips.len();
|
||||
if let Some(cf_filter) =
|
||||
CloudflareIpFilter::fetch(&detection_client, config.detection_timeout, ppfmt).await
|
||||
{
|
||||
@@ -316,11 +318,18 @@ async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool {
|
||||
}
|
||||
true
|
||||
});
|
||||
} else {
|
||||
if ips.is_empty() && before_count > 0 {
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
"All detected addresses were Cloudflare IPs; skipping updates",
|
||||
);
|
||||
}
|
||||
} else if !ips.is_empty() {
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
"Could not fetch Cloudflare IP ranges; skipping filter",
|
||||
"Could not fetch Cloudflare IP ranges; skipping update to avoid writing Cloudflare IPs",
|
||||
);
|
||||
ips.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user