mirror of
https://github.com/timothymiller/cloudflare-ddns.git
synced 2026-03-21 22:48:57 -03:00
Compare commits
2 Commits
v2.0.5
...
9b8aba5e20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b8aba5e20 | ||
|
|
83dd454c42 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -109,7 +109,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cloudflare-ddns"
|
name = "cloudflare-ddns"
|
||||||
version = "2.0.5"
|
version = "2.0.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"idna",
|
"idna",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cloudflare-ddns"
|
name = "cloudflare-ddns"
|
||||||
version = "2.0.5"
|
version = "2.0.7"
|
||||||
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"
|
||||||
|
|||||||
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
|
- 🎨 **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
|
- 🔒 **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
|
- 🏠 **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
|
- 🤏 **Tiny static binary** — ~1.9 MB Docker image built from scratch, zero runtime dependencies
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
@@ -92,11 +92,13 @@ Available providers:
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| 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.
|
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
|
## ⏱️ 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 |
|
| `MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX` | — | 🎯 Managed WAF items regex |
|
||||||
| `DETECTION_TIMEOUT` | `5s` | ⏳ IP detection timeout |
|
| `DETECTION_TIMEOUT` | `5s` | ⏳ IP detection timeout |
|
||||||
| `UPDATE_TIMEOUT` | `30s` | ⏳ API request 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 |
|
| `EMOJI` | `true` | 🎨 Enable emoji output |
|
||||||
| `QUIET` | `false` | 🤫 Suppress info output |
|
| `QUIET` | `false` | 🤫 Suppress info output |
|
||||||
| `HEALTHCHECKS` | — | 💓 Healthchecks.io URL |
|
| `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)
|
### 🚫 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
|
```bash
|
||||||
REJECT_CLOUDFLARE_IPS=true cloudflare-ddns
|
REJECT_CLOUDFLARE_IPS=false cloudflare-ddns
|
||||||
```
|
```
|
||||||
|
|
||||||
Or in Docker Compose:
|
Or in Docker Compose:
|
||||||
|
|
||||||
```yml
|
```yml
|
||||||
environment:
|
environment:
|
||||||
- REJECT_CLOUDFLARE_IPS=true
|
- REJECT_CLOUDFLARE_IPS=false
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.json:/config.json
|
- ./config.json:/config.json
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -59,8 +59,13 @@ impl CloudflareIpFilter {
|
|||||||
pub async fn fetch(client: &Client, timeout: Duration, ppfmt: &PP) -> Option<Self> {
|
pub async fn fetch(client: &Client, timeout: Duration, ppfmt: &PP) -> Option<Self> {
|
||||||
let mut ranges = Vec::new();
|
let mut ranges = Vec::new();
|
||||||
|
|
||||||
for url in [CF_IPV4_URL, CF_IPV6_URL] {
|
let (v4_result, v6_result) = tokio::join!(
|
||||||
match client.get(url).timeout(timeout).send().await {
|
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(resp) if resp.status().is_success() => match resp.text().await {
|
||||||
Ok(body) => {
|
Ok(body) => {
|
||||||
for line in body.lines() {
|
for line in body.lines() {
|
||||||
@@ -152,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::*;
|
||||||
@@ -234,4 +295,127 @@ mod tests {
|
|||||||
let ip: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0, 0, 0, 0, 0, 1));
|
let ip: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0, 0, 0, 0, 0, 1));
|
||||||
assert!(!filter.contains(&ip));
|
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,
|
managed_waf_comment_regex: None,
|
||||||
detection_timeout: Duration::from_secs(5),
|
detection_timeout: Duration::from_secs(5),
|
||||||
update_timeout: Duration::from_secs(30),
|
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,
|
dry_run,
|
||||||
emoji: false,
|
emoji: false,
|
||||||
quiet: false,
|
quiet: false,
|
||||||
@@ -529,7 +529,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
|
|||||||
|
|
||||||
let emoji = getenv_bool("EMOJI", true);
|
let emoji = getenv_bool("EMOJI", true);
|
||||||
let quiet = getenv_bool("QUIET", false);
|
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
|
// Validate: must have at least one update target
|
||||||
if domains.is_empty() && waf_lists.is_empty() {
|
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");
|
inner.infof("", "Delete on stop: enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.reject_cloudflare_ips {
|
if !config.reject_cloudflare_ips {
|
||||||
inner.infof("", "Reject Cloudflare IPs: enabled");
|
inner.warningf("", "Cloudflare IP rejection: DISABLED (REJECT_CLOUDFLARE_IPS=false)");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref comment) = config.record_comment {
|
if let Some(ref comment) = config.record_comment {
|
||||||
|
|||||||
18
src/main.rs
18
src/main.rs
@@ -116,12 +116,14 @@ async fn main() {
|
|||||||
// Start heartbeat
|
// Start heartbeat
|
||||||
heartbeat.start().await;
|
heartbeat.start().await;
|
||||||
|
|
||||||
|
let mut cf_cache = cf_ip_filter::CachedCloudflareFilter::new();
|
||||||
|
|
||||||
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, ¬ifier, &heartbeat, &ppfmt, running).await;
|
run_legacy_mode(&app_config, &handle, ¬ifier, &heartbeat, &ppfmt, running, &mut cf_cache).await;
|
||||||
} else {
|
} else {
|
||||||
// --- Env var mode (cf-ddns behavior) ---
|
// --- Env var mode (cf-ddns behavior) ---
|
||||||
run_env_mode(&app_config, &handle, ¬ifier, &heartbeat, &ppfmt, running).await;
|
run_env_mode(&app_config, &handle, ¬ifier, &heartbeat, &ppfmt, running, &mut cf_cache).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// On shutdown: delete records if configured
|
// On shutdown: delete records if configured
|
||||||
@@ -143,6 +145,7 @@ 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,
|
||||||
) {
|
) {
|
||||||
let legacy = match &config.legacy_config {
|
let legacy = match &config.legacy_config {
|
||||||
Some(l) => l,
|
Some(l) => l,
|
||||||
@@ -165,7 +168,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).await;
|
||||||
|
|
||||||
for _ in 0..legacy.ttl {
|
for _ in 0..legacy.ttl {
|
||||||
if !running.load(Ordering::SeqCst) {
|
if !running.load(Ordering::SeqCst) {
|
||||||
@@ -175,7 +178,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).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,11 +189,12 @@ 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,
|
||||||
) {
|
) {
|
||||||
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).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
schedule => {
|
schedule => {
|
||||||
@@ -206,7 +210,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).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main loop
|
// Main loop
|
||||||
@@ -233,7 +237,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).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -16,6 +16,7 @@ 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,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let detection_client = Client::builder()
|
let detection_client = Client::builder()
|
||||||
@@ -28,7 +29,7 @@ pub async fn update_once(
|
|||||||
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).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 +70,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();
|
||||||
@@ -101,11 +102,12 @@ pub async fn update_once(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if !detected_ips.is_empty() {
|
||||||
ppfmt.warningf(
|
ppfmt.warningf(
|
||||||
pp::EMOJI_WARNING,
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +236,7 @@ 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) -> 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,
|
||||||
@@ -298,8 +300,9 @@ async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool {
|
|||||||
|
|
||||||
// Filter out Cloudflare IPs if enabled
|
// Filter out Cloudflare IPs if enabled
|
||||||
if config.reject_cloudflare_ips {
|
if config.reject_cloudflare_ips {
|
||||||
|
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>() {
|
||||||
@@ -316,12 +319,19 @@ async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool {
|
|||||||
}
|
}
|
||||||
true
|
true
|
||||||
});
|
});
|
||||||
} else {
|
if ips.is_empty() && before_count > 0 {
|
||||||
ppfmt.warningf(
|
ppfmt.warningf(
|
||||||
pp::EMOJI_WARNING,
|
pp::EMOJI_WARNING,
|
||||||
"Could not fetch Cloudflare IP ranges; skipping filter",
|
"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 update to avoid writing Cloudflare IPs",
|
||||||
|
);
|
||||||
|
ips.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ddns.update_ips(
|
ddns.update_ips(
|
||||||
@@ -793,7 +803,8 @@ mod tests {
|
|||||||
let heartbeat = empty_heartbeat();
|
let heartbeat = empty_heartbeat();
|
||||||
let ppfmt = pp();
|
let ppfmt = pp();
|
||||||
|
|
||||||
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await;
|
let mut cf_cache = CachedCloudflareFilter::new();
|
||||||
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await;
|
||||||
assert!(ok);
|
assert!(ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -841,7 +852,8 @@ mod tests {
|
|||||||
let heartbeat = empty_heartbeat();
|
let heartbeat = empty_heartbeat();
|
||||||
let ppfmt = pp();
|
let ppfmt = pp();
|
||||||
|
|
||||||
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await;
|
let mut cf_cache = CachedCloudflareFilter::new();
|
||||||
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await;
|
||||||
assert!(ok);
|
assert!(ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,7 +897,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, ¬ifier, &heartbeat, &ppfmt).await;
|
let mut cf_cache = CachedCloudflareFilter::new();
|
||||||
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).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);
|
||||||
@@ -934,7 +947,8 @@ mod tests {
|
|||||||
let heartbeat = empty_heartbeat();
|
let heartbeat = empty_heartbeat();
|
||||||
let ppfmt = pp();
|
let ppfmt = pp();
|
||||||
|
|
||||||
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await;
|
let mut cf_cache = CachedCloudflareFilter::new();
|
||||||
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await;
|
||||||
assert!(!ok, "Expected false when zone is not found");
|
assert!(!ok, "Expected false when zone is not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,7 +997,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, ¬ifier, &heartbeat, &ppfmt).await;
|
let mut cf_cache = CachedCloudflareFilter::new();
|
||||||
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await;
|
||||||
assert!(ok);
|
assert!(ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,7 +1063,8 @@ mod tests {
|
|||||||
let heartbeat = empty_heartbeat();
|
let heartbeat = empty_heartbeat();
|
||||||
let ppfmt = pp();
|
let ppfmt = pp();
|
||||||
|
|
||||||
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await;
|
let mut cf_cache = CachedCloudflareFilter::new();
|
||||||
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await;
|
||||||
assert!(ok);
|
assert!(ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,7 +1117,8 @@ mod tests {
|
|||||||
let heartbeat = empty_heartbeat();
|
let heartbeat = empty_heartbeat();
|
||||||
let ppfmt = pp();
|
let ppfmt = pp();
|
||||||
|
|
||||||
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await;
|
let mut cf_cache = CachedCloudflareFilter::new();
|
||||||
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await;
|
||||||
assert!(ok);
|
assert!(ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1140,7 +1157,8 @@ mod tests {
|
|||||||
let heartbeat = empty_heartbeat();
|
let heartbeat = empty_heartbeat();
|
||||||
let ppfmt = pp();
|
let ppfmt = pp();
|
||||||
|
|
||||||
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await;
|
let mut cf_cache = CachedCloudflareFilter::new();
|
||||||
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await;
|
||||||
assert!(!ok, "Expected false when WAF list is not found");
|
assert!(!ok, "Expected false when WAF list is not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1224,7 +1242,8 @@ mod tests {
|
|||||||
let heartbeat = empty_heartbeat();
|
let heartbeat = empty_heartbeat();
|
||||||
let ppfmt = pp();
|
let ppfmt = pp();
|
||||||
|
|
||||||
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await;
|
let mut cf_cache = CachedCloudflareFilter::new();
|
||||||
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await;
|
||||||
assert!(ok);
|
assert!(ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1240,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, ¬ifier, &heartbeat, &ppfmt).await;
|
let mut cf_cache = CachedCloudflareFilter::new();
|
||||||
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await;
|
||||||
assert!(ok);
|
assert!(ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1624,7 +1644,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, ¬ifier, &heartbeat, &ppfmt).await;
|
let mut cf_cache = CachedCloudflareFilter::new();
|
||||||
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await;
|
||||||
assert!(ok);
|
assert!(ok);
|
||||||
}
|
}
|
||||||
// -------------------------------------------------------
|
// -------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user