mirror of
https://github.com/timothymiller/cloudflare-ddns.git
synced 2026-03-21 22:48:57 -03:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2446c1d6a0 | ||
|
|
9b8aba5e20 | ||
|
|
83dd454c42 | ||
|
|
f8d5b5cb7e | ||
|
|
bb5cc43651 | ||
|
|
7ff8379cfb | ||
|
|
943e38d70c | ||
|
|
ac982a208e |
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -109,12 +109,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cloudflare-ddns"
|
||||
version = "2.0.4"
|
||||
version = "2.0.8"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"idna",
|
||||
"if-addrs",
|
||||
"ipnet",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cloudflare-ddns"
|
||||
version = "2.0.4"
|
||||
version = "2.0.8"
|
||||
edition = "2021"
|
||||
description = "Access your home network remotely via a custom domain name without a static IP"
|
||||
license = "GPL-3.0"
|
||||
@@ -15,7 +15,6 @@ chrono = { version = "0.4", features = ["clock"] }
|
||||
url = "2"
|
||||
idna = "1"
|
||||
if-addrs = "0.13"
|
||||
ipnet = "2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
|
||||
50
README.md
50
README.md
@@ -28,6 +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** — 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
|
||||
@@ -87,6 +88,18 @@ Available providers:
|
||||
| `literal:<ips>` | 📌 Static IP addresses (comma-separated) |
|
||||
| `none` | 🚫 Disable this IP type |
|
||||
|
||||
## 🚫 Cloudflare IP Rejection
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `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.
|
||||
|
||||
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
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -210,6 +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` | `true` | 🚫 Reject Cloudflare anycast IPs |
|
||||
| `EMOJI` | `true` | 🎨 Enable emoji output |
|
||||
| `QUIET` | `false` | 🤫 Suppress info output |
|
||||
| `HEALTHCHECKS` | — | 💓 Healthchecks.io URL |
|
||||
@@ -356,6 +370,42 @@ Some ISP provided modems only allow port forwarding over IPv4 or IPv6. Disable t
|
||||
| `aaaa` | bool | `true` | Enable IPv6 (AAAA record) updates |
|
||||
| `purgeUnknownRecords` | bool | `false` | Delete stale/duplicate DNS records |
|
||||
| `ttl` | int | `300` | DNS record TTL in seconds (30-86400, values < 30 become auto) |
|
||||
| `ip4_provider` | string | `"cloudflare.trace"` | IPv4 detection provider (same values as `IP4_PROVIDER` env var) |
|
||||
| `ip6_provider` | string | `"cloudflare.trace"` | IPv6 detection provider (same values as `IP6_PROVIDER` env var) |
|
||||
|
||||
### 🚫 Cloudflare IP Rejection (Legacy Mode)
|
||||
|
||||
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=false cloudflare-ddns
|
||||
```
|
||||
|
||||
Or in Docker Compose:
|
||||
|
||||
```yml
|
||||
environment:
|
||||
- REJECT_CLOUDFLARE_IPS=false
|
||||
volumes:
|
||||
- ./config.json:/config.json
|
||||
```
|
||||
|
||||
### 🔍 IP Detection (Legacy Mode)
|
||||
|
||||
Legacy mode now uses the same shared provider abstraction as environment variable mode. By default it uses the `cloudflare.trace` provider, which builds an IP-family-bound HTTP client (`0.0.0.0` for IPv4, `[::]` for IPv6) to guarantee the correct address family on dual-stack hosts.
|
||||
|
||||
You can override the detection method per address family with `ip4_provider` and `ip6_provider` in your `config.json`. Supported values are the same as the `IP4_PROVIDER` / `IP6_PROVIDER` environment variables: `cloudflare.trace`, `cloudflare.doh`, `ipify`, `local`, `local.iface:<name>`, `url:<https://...>`, `none`.
|
||||
|
||||
Set a provider to `"none"` to disable detection for that address family (overrides `a`/`aaaa`):
|
||||
|
||||
```json
|
||||
{
|
||||
"a": true,
|
||||
"aaaa": true,
|
||||
"ip4_provider": "cloudflare.trace",
|
||||
"ip6_provider": "none"
|
||||
}
|
||||
```
|
||||
|
||||
Each zone entry contains:
|
||||
|
||||
|
||||
@@ -24,5 +24,7 @@
|
||||
"a": true,
|
||||
"aaaa": true,
|
||||
"purgeUnknownRecords": false,
|
||||
"ttl": 300
|
||||
"ttl": 300,
|
||||
"ip4_provider": "cloudflare.trace",
|
||||
"ip6_provider": "cloudflare.trace"
|
||||
}
|
||||
|
||||
@@ -1,15 +1,57 @@
|
||||
use crate::pp::{self, PP};
|
||||
use ipnet::IpNet;
|
||||
use reqwest::Client;
|
||||
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_IPV6_URL: &str = "https://www.cloudflare.com/ips-v6";
|
||||
|
||||
/// A CIDR range parsed from "address/prefix" notation.
|
||||
struct CidrRange {
|
||||
addr: IpAddr,
|
||||
prefix_len: u8,
|
||||
}
|
||||
|
||||
impl CidrRange {
|
||||
fn parse(s: &str) -> Option<Self> {
|
||||
let (addr_str, prefix_str) = s.split_once('/')?;
|
||||
let addr: IpAddr = addr_str.parse().ok()?;
|
||||
let prefix_len: u8 = prefix_str.parse().ok()?;
|
||||
match addr {
|
||||
IpAddr::V4(_) if prefix_len > 32 => None,
|
||||
IpAddr::V6(_) if prefix_len > 128 => None,
|
||||
_ => Some(Self { addr, prefix_len }),
|
||||
}
|
||||
}
|
||||
|
||||
fn contains(&self, ip: &IpAddr) -> bool {
|
||||
match (self.addr, ip) {
|
||||
(IpAddr::V4(net), IpAddr::V4(ip)) => {
|
||||
let net_bits = u32::from(net);
|
||||
let ip_bits = u32::from(*ip);
|
||||
if self.prefix_len == 0 {
|
||||
return true;
|
||||
}
|
||||
let mask = !0u32 << (32 - self.prefix_len);
|
||||
(net_bits & mask) == (ip_bits & mask)
|
||||
}
|
||||
(IpAddr::V6(net), IpAddr::V6(ip)) => {
|
||||
let net_bits = u128::from(net);
|
||||
let ip_bits = u128::from(*ip);
|
||||
if self.prefix_len == 0 {
|
||||
return true;
|
||||
}
|
||||
let mask = !0u128 << (128 - self.prefix_len);
|
||||
(net_bits & mask) == (ip_bits & mask)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds parsed Cloudflare CIDR ranges for IP filtering.
|
||||
pub struct CloudflareIpFilter {
|
||||
ranges: Vec<IpNet>,
|
||||
ranges: Vec<CidrRange>,
|
||||
}
|
||||
|
||||
impl CloudflareIpFilter {
|
||||
@@ -17,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() {
|
||||
@@ -26,13 +73,13 @@ impl CloudflareIpFilter {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match line.parse::<IpNet>() {
|
||||
Ok(net) => ranges.push(net),
|
||||
Err(e) => {
|
||||
match CidrRange::parse(line) {
|
||||
Some(range) => ranges.push(range),
|
||||
None => {
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
&format!(
|
||||
"Failed to parse Cloudflare IP range '{line}': {e}"
|
||||
"Failed to parse Cloudflare IP range '{line}'"
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -86,14 +133,14 @@ impl CloudflareIpFilter {
|
||||
/// Parse ranges from raw text lines (for testing).
|
||||
#[cfg(test)]
|
||||
pub fn from_lines(lines: &str) -> Option<Self> {
|
||||
let ranges: Vec<IpNet> = lines
|
||||
let ranges: Vec<CidrRange> = lines
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let l = l.trim();
|
||||
if l.is_empty() {
|
||||
None
|
||||
} else {
|
||||
l.parse().ok()
|
||||
CidrRange::parse(l)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -110,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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -178,4 +281,141 @@ mod tests {
|
||||
// Just outside range (104.24.0.0)
|
||||
assert!(!filter.contains(&IpAddr::V4(Ipv4Addr::new(104, 24, 0, 0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_prefix_rejected() {
|
||||
assert!(CidrRange::parse("10.0.0.0/33").is_none());
|
||||
assert!(CidrRange::parse("::1/129").is_none());
|
||||
assert!(CidrRange::parse("not-an-ip/24").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_v4_does_not_match_v6() {
|
||||
let filter = CloudflareIpFilter::from_lines("104.16.0.0/13").unwrap();
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,7 +467,7 @@ impl CloudflareHandle {
|
||||
self.update_record(zone_id, &record.id, &payload, ppfmt).await;
|
||||
}
|
||||
} 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 {
|
||||
// Find an existing managed record to update, or create new
|
||||
@@ -668,10 +668,7 @@ impl CloudflareHandle {
|
||||
.collect();
|
||||
|
||||
if to_add.is_empty() && ids_to_delete.is_empty() {
|
||||
ppfmt.infof(
|
||||
pp::EMOJI_SKIP,
|
||||
&format!("WAF list {} is up to date", waf_list.describe()),
|
||||
);
|
||||
// Caller handles "up to date" logging based on SetResult::Noop
|
||||
return SetResult::Noop;
|
||||
}
|
||||
|
||||
|
||||
156
src/config.rs
156
src/config.rs
@@ -27,6 +27,10 @@ pub struct LegacyConfig {
|
||||
pub purge_unknown_records: bool,
|
||||
#[serde(default = "default_ttl")]
|
||||
pub ttl: i64,
|
||||
#[serde(default)]
|
||||
pub ip4_provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ip6_provider: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -387,7 +391,7 @@ pub fn parse_legacy_config(content: &str) -> Result<LegacyConfig, String> {
|
||||
}
|
||||
|
||||
/// Convert a legacy config into a unified AppConfig
|
||||
fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> AppConfig {
|
||||
fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Result<AppConfig, String> {
|
||||
// Extract auth from first entry
|
||||
let auth = if let Some(entry) = legacy.cloudflare.first() {
|
||||
if !entry.authentication.api_token.is_empty()
|
||||
@@ -406,13 +410,27 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Ap
|
||||
Auth::Token(String::new())
|
||||
};
|
||||
|
||||
// Build providers
|
||||
// Build providers — ip4_provider/ip6_provider override the default cloudflare.trace
|
||||
let mut providers = HashMap::new();
|
||||
if legacy.a {
|
||||
providers.insert(IpType::V4, ProviderType::CloudflareTrace { url: None });
|
||||
let provider = match &legacy.ip4_provider {
|
||||
Some(s) => ProviderType::parse(s)
|
||||
.map_err(|e| format!("Invalid ip4_provider in config.json: {e}"))?,
|
||||
None => ProviderType::CloudflareTrace { url: None },
|
||||
};
|
||||
if !matches!(provider, ProviderType::None) {
|
||||
providers.insert(IpType::V4, provider);
|
||||
}
|
||||
}
|
||||
if legacy.aaaa {
|
||||
providers.insert(IpType::V6, ProviderType::CloudflareTrace { url: None });
|
||||
let provider = match &legacy.ip6_provider {
|
||||
Some(s) => ProviderType::parse(s)
|
||||
.map_err(|e| format!("Invalid ip6_provider in config.json: {e}"))?,
|
||||
None => ProviderType::CloudflareTrace { url: None },
|
||||
};
|
||||
if !matches!(provider, ProviderType::None) {
|
||||
providers.insert(IpType::V6, provider);
|
||||
}
|
||||
}
|
||||
|
||||
let ttl = TTL::new(legacy.ttl);
|
||||
@@ -423,7 +441,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Ap
|
||||
CronSchedule::Once
|
||||
};
|
||||
|
||||
AppConfig {
|
||||
Ok(AppConfig {
|
||||
auth,
|
||||
providers,
|
||||
domains: HashMap::new(),
|
||||
@@ -440,14 +458,14 @@ 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,
|
||||
reject_cloudflare_ips: getenv_bool("REJECT_CLOUDFLARE_IPS", true),
|
||||
dry_run,
|
||||
emoji: false,
|
||||
quiet: false,
|
||||
legacy_mode: true,
|
||||
legacy_config: Some(legacy),
|
||||
repeat,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -511,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() {
|
||||
@@ -583,7 +601,7 @@ pub fn load_config(dry_run: bool, repeat: bool, ppfmt: &PP) -> Result<AppConfig,
|
||||
} else {
|
||||
ppfmt.infof(pp::EMOJI_CONFIG, "Using config.json configuration");
|
||||
let legacy = load_legacy_config()?;
|
||||
Ok(legacy_to_app_config(legacy, dry_run, repeat))
|
||||
legacy_to_app_config(legacy, dry_run, repeat)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,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 {
|
||||
@@ -995,8 +1013,10 @@ mod tests {
|
||||
aaaa: false,
|
||||
purge_unknown_records: false,
|
||||
ttl: 300,
|
||||
ip4_provider: None,
|
||||
ip6_provider: None,
|
||||
};
|
||||
let config = legacy_to_app_config(legacy, false, false);
|
||||
let config = legacy_to_app_config(legacy, false, false).unwrap();
|
||||
assert!(config.legacy_mode);
|
||||
assert!(matches!(config.auth, Auth::Token(ref t) if t == "my-token"));
|
||||
assert!(config.providers.contains_key(&IpType::V4));
|
||||
@@ -1021,8 +1041,10 @@ mod tests {
|
||||
aaaa: true,
|
||||
purge_unknown_records: false,
|
||||
ttl: 120,
|
||||
ip4_provider: None,
|
||||
ip6_provider: None,
|
||||
};
|
||||
let config = legacy_to_app_config(legacy, true, true);
|
||||
let config = legacy_to_app_config(legacy, true, true).unwrap();
|
||||
assert!(matches!(config.update_cron, CronSchedule::Every(d) if d == Duration::from_secs(120)));
|
||||
assert!(config.repeat);
|
||||
assert!(config.dry_run);
|
||||
@@ -1047,12 +1069,118 @@ mod tests {
|
||||
aaaa: true,
|
||||
purge_unknown_records: false,
|
||||
ttl: 300,
|
||||
ip4_provider: None,
|
||||
ip6_provider: None,
|
||||
};
|
||||
let config = legacy_to_app_config(legacy, false, false);
|
||||
let config = legacy_to_app_config(legacy, false, false).unwrap();
|
||||
assert!(matches!(config.auth, Auth::Key { ref api_key, ref email }
|
||||
if api_key == "key123" && email == "test@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_to_app_config_custom_providers() {
|
||||
let legacy = LegacyConfig {
|
||||
cloudflare: vec![LegacyCloudflareEntry {
|
||||
authentication: LegacyAuthentication {
|
||||
api_token: "tok".to_string(),
|
||||
api_key: None,
|
||||
},
|
||||
zone_id: "z".to_string(),
|
||||
subdomains: vec![],
|
||||
proxied: false,
|
||||
}],
|
||||
a: true,
|
||||
aaaa: true,
|
||||
purge_unknown_records: false,
|
||||
ttl: 300,
|
||||
ip4_provider: Some("ipify".to_string()),
|
||||
ip6_provider: Some("cloudflare.doh".to_string()),
|
||||
};
|
||||
let config = legacy_to_app_config(legacy, false, false).unwrap();
|
||||
assert!(matches!(config.providers[&IpType::V4], ProviderType::Ipify));
|
||||
assert!(matches!(config.providers[&IpType::V6], ProviderType::CloudflareDOH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_to_app_config_provider_none_overrides_a_flag() {
|
||||
let legacy = LegacyConfig {
|
||||
cloudflare: vec![LegacyCloudflareEntry {
|
||||
authentication: LegacyAuthentication {
|
||||
api_token: "tok".to_string(),
|
||||
api_key: None,
|
||||
},
|
||||
zone_id: "z".to_string(),
|
||||
subdomains: vec![],
|
||||
proxied: false,
|
||||
}],
|
||||
a: true,
|
||||
aaaa: true,
|
||||
purge_unknown_records: false,
|
||||
ttl: 300,
|
||||
ip4_provider: Some("none".to_string()),
|
||||
ip6_provider: None,
|
||||
};
|
||||
let config = legacy_to_app_config(legacy, false, false).unwrap();
|
||||
// ip4_provider=none should exclude V4 even though a=true
|
||||
assert!(!config.providers.contains_key(&IpType::V4));
|
||||
assert!(config.providers.contains_key(&IpType::V6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_to_app_config_invalid_provider_returns_error() {
|
||||
let legacy = LegacyConfig {
|
||||
cloudflare: vec![LegacyCloudflareEntry {
|
||||
authentication: LegacyAuthentication {
|
||||
api_token: "tok".to_string(),
|
||||
api_key: None,
|
||||
},
|
||||
zone_id: "z".to_string(),
|
||||
subdomains: vec![],
|
||||
proxied: false,
|
||||
}],
|
||||
a: true,
|
||||
aaaa: false,
|
||||
purge_unknown_records: false,
|
||||
ttl: 300,
|
||||
ip4_provider: Some("totally_invalid".to_string()),
|
||||
ip6_provider: None,
|
||||
};
|
||||
let result = legacy_to_app_config(legacy, false, false);
|
||||
assert!(result.is_err());
|
||||
let err = result.err().unwrap();
|
||||
assert!(err.contains("ip4_provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_config_deserializes_providers() {
|
||||
let json = r#"{
|
||||
"cloudflare": [{
|
||||
"authentication": { "api_token": "tok" },
|
||||
"zone_id": "z",
|
||||
"subdomains": ["@"]
|
||||
}],
|
||||
"ip4_provider": "ipify",
|
||||
"ip6_provider": "none"
|
||||
}"#;
|
||||
let config = parse_legacy_config(json).unwrap();
|
||||
assert_eq!(config.ip4_provider, Some("ipify".to_string()));
|
||||
assert_eq!(config.ip6_provider, Some("none".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_config_deserializes_without_providers() {
|
||||
let json = r#"{
|
||||
"cloudflare": [{
|
||||
"authentication": { "api_token": "tok" },
|
||||
"zone_id": "z",
|
||||
"subdomains": ["@"]
|
||||
}]
|
||||
}"#;
|
||||
let config = parse_legacy_config(json).unwrap();
|
||||
assert!(config.ip4_provider.is_none());
|
||||
assert!(config.ip6_provider.is_none());
|
||||
}
|
||||
|
||||
// --- is_env_config_mode ---
|
||||
|
||||
#[test]
|
||||
|
||||
88
src/main.rs
88
src/main.rs
@@ -11,8 +11,10 @@ use crate::cloudflare::{Auth, CloudflareHandle};
|
||||
use crate::config::{AppConfig, CronSchedule};
|
||||
use crate::notifier::{CompositeNotifier, Heartbeat, Message};
|
||||
use crate::pp::PP;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use reqwest::Client;
|
||||
use tokio::signal;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
@@ -116,12 +118,18 @@ async fn main() {
|
||||
// Start heartbeat
|
||||
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 {
|
||||
// --- 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, &detection_client).await;
|
||||
} else {
|
||||
// --- 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, &detection_client).await;
|
||||
}
|
||||
|
||||
// On shutdown: delete records if configured
|
||||
@@ -143,12 +151,16 @@ async fn run_legacy_mode(
|
||||
heartbeat: &Heartbeat,
|
||||
ppfmt: &PP,
|
||||
running: Arc<AtomicBool>,
|
||||
cf_cache: &mut cf_ip_filter::CachedCloudflareFilter,
|
||||
detection_client: &Client,
|
||||
) {
|
||||
let legacy = match &config.legacy_config {
|
||||
Some(l) => l,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let mut noop_reported = HashSet::new();
|
||||
|
||||
if config.repeat {
|
||||
match (legacy.a, legacy.aaaa) {
|
||||
(true, true) => println!(
|
||||
@@ -165,7 +177,7 @@ async fn run_legacy_mode(
|
||||
}
|
||||
|
||||
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 {
|
||||
if !running.load(Ordering::SeqCst) {
|
||||
@@ -175,7 +187,7 @@ async fn run_legacy_mode(
|
||||
}
|
||||
}
|
||||
} 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,
|
||||
ppfmt: &PP,
|
||||
running: Arc<AtomicBool>,
|
||||
cf_cache: &mut cf_ip_filter::CachedCloudflareFilter,
|
||||
detection_client: &Client,
|
||||
) {
|
||||
let mut noop_reported = HashSet::new();
|
||||
|
||||
match &config.update_cron {
|
||||
CronSchedule::Once => {
|
||||
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 => {
|
||||
@@ -206,7 +222,7 @@ async fn run_env_mode(
|
||||
|
||||
// Update on start if configured
|
||||
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
|
||||
@@ -233,7 +249,7 @@ async fn run_env_mode(
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,6 +317,8 @@ mod tests {
|
||||
aaaa: false,
|
||||
purge_unknown_records: false,
|
||||
ttl: 300,
|
||||
ip4_provider: None,
|
||||
ip6_provider: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,6 +398,7 @@ mod tests {
|
||||
config: &[LegacyCloudflareEntry],
|
||||
ttl: i64,
|
||||
purge_unknown_records: bool,
|
||||
noop_reported: &mut std::collections::HashSet<String>,
|
||||
) {
|
||||
for entry in config {
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -481,8 +500,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
let noop_key = format!("{fqdn}:{record_type}");
|
||||
if let Some(ref id) = identifier {
|
||||
if modified {
|
||||
noop_reported.remove(&noop_key);
|
||||
if self.dry_run {
|
||||
println!("[DRY RUN] Would update record {fqdn} -> {ip}");
|
||||
} else {
|
||||
@@ -498,23 +519,30 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
} else if self.dry_run {
|
||||
println!("[DRY RUN] Record {fqdn} is up to date ({ip})");
|
||||
} else if noop_reported.insert(noop_key) {
|
||||
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 {
|
||||
println!("[DRY RUN] Would add new record {fqdn} -> {ip}");
|
||||
} else {
|
||||
println!("Adding new record {fqdn} -> {ip}");
|
||||
let create_endpoint =
|
||||
format!("zones/{}/dns_records", entry.zone_id);
|
||||
let _: Option<serde_json::Value> = self
|
||||
.cf_api(
|
||||
&create_endpoint,
|
||||
"POST",
|
||||
&entry.authentication.api_token,
|
||||
Some(&record),
|
||||
)
|
||||
.await;
|
||||
noop_reported.remove(&noop_key);
|
||||
if self.dry_run {
|
||||
println!("[DRY RUN] Would add new record {fqdn} -> {ip}");
|
||||
} else {
|
||||
println!("Adding new record {fqdn} -> {ip}");
|
||||
let create_endpoint =
|
||||
format!("zones/{}/dns_records", entry.zone_id);
|
||||
let _: Option<serde_json::Value> = self
|
||||
.cf_api(
|
||||
&create_endpoint,
|
||||
"POST",
|
||||
&entry.authentication.api_token,
|
||||
Some(&record),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
if purge_unknown_records {
|
||||
@@ -634,7 +662,7 @@ mod tests {
|
||||
|
||||
let ddns = TestDdnsClient::new(&mock_server.uri());
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -683,7 +711,7 @@ mod tests {
|
||||
|
||||
let ddns = TestDdnsClient::new(&mock_server.uri());
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -726,7 +754,7 @@ mod tests {
|
||||
|
||||
let ddns = TestDdnsClient::new(&mock_server.uri());
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -760,7 +788,7 @@ mod tests {
|
||||
|
||||
let ddns = TestDdnsClient::new(&mock_server.uri()).dry_run();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -814,8 +842,10 @@ mod tests {
|
||||
aaaa: false,
|
||||
purge_unknown_records: true,
|
||||
ttl: 300,
|
||||
ip4_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;
|
||||
}
|
||||
|
||||
@@ -913,9 +943,11 @@ mod tests {
|
||||
aaaa: false,
|
||||
purge_unknown_records: false,
|
||||
ttl: 300,
|
||||
ip4_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +406,7 @@ fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
|
||||
service_type: ShoutrrrServiceType::Pushover,
|
||||
webhook_url: format!(
|
||||
"https://api.pushover.net/1/messages.json?token={}&user={}",
|
||||
parts[1], parts[0]
|
||||
parts[0], parts[1]
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -868,7 +868,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_pushover() {
|
||||
let result = parse_shoutrrr_url("pushover://userkey@apitoken").unwrap();
|
||||
let result = parse_shoutrrr_url("pushover://apitoken@userkey").unwrap();
|
||||
assert_eq!(
|
||||
result.webhook_url,
|
||||
"https://api.pushover.net/1/messages.json?token=apitoken&user=userkey"
|
||||
@@ -1307,7 +1307,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_pushover_url_query_parsing() {
|
||||
// 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 params: std::collections::HashMap<_, _> = parsed.query_pairs().collect();
|
||||
assert_eq!(params.get("token").unwrap().as_ref(), "mytoken");
|
||||
|
||||
@@ -183,7 +183,7 @@ async fn fetch_trace_ip(
|
||||
/// Build an HTTP client that only connects via the given IP family.
|
||||
/// Binding to 0.0.0.0 forces IPv4-only; binding to [::] forces IPv6-only.
|
||||
/// This ensures the trace endpoint sees the correct address family.
|
||||
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(),
|
||||
|
||||
642
src/updater.rs
642
src/updater.rs
@@ -1,4 +1,4 @@
|
||||
use crate::cf_ip_filter::CloudflareIpFilter;
|
||||
use crate::cf_ip_filter::CachedCloudflareFilter;
|
||||
use crate::cloudflare::{CloudflareHandle, SetResult};
|
||||
use crate::config::{AppConfig, LegacyCloudflareEntry, LegacySubdomainEntry};
|
||||
use crate::domain::make_fqdn;
|
||||
@@ -6,7 +6,7 @@ use crate::notifier::{CompositeNotifier, Heartbeat, Message};
|
||||
use crate::pp::{self, PP};
|
||||
use crate::provider::IpType;
|
||||
use reqwest::Client;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -16,19 +16,17 @@ pub async fn update_once(
|
||||
handle: &CloudflareHandle,
|
||||
notifier: &CompositeNotifier,
|
||||
heartbeat: &Heartbeat,
|
||||
cf_cache: &mut CachedCloudflareFilter,
|
||||
ppfmt: &PP,
|
||||
noop_reported: &mut HashSet<String>,
|
||||
detection_client: &Client,
|
||||
) -> bool {
|
||||
let detection_client = Client::builder()
|
||||
.timeout(config.detection_timeout)
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut all_ok = true;
|
||||
let mut messages = Vec::new();
|
||||
let mut notify = false; // NEW: track meaningful events
|
||||
|
||||
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 {
|
||||
// Detect IPs for each provider
|
||||
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
|
||||
if config.reject_cloudflare_ips {
|
||||
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() {
|
||||
let before_count = ips.len();
|
||||
@@ -101,11 +99,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,9 +150,11 @@ pub async fn update_once(
|
||||
)
|
||||
.await;
|
||||
|
||||
let noop_key = format!("{domain_str}:{record_type}");
|
||||
match result {
|
||||
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();
|
||||
messages.push(Message::new_ok(&format!(
|
||||
"Updated {domain_str} -> {}",
|
||||
@@ -161,13 +162,18 @@ pub async fn update_once(
|
||||
)));
|
||||
}
|
||||
SetResult::Failed => {
|
||||
notify = true; // NEW
|
||||
noop_reported.remove(&noop_key);
|
||||
notify = true;
|
||||
all_ok = false;
|
||||
messages.push(Message::new_fail(&format!(
|
||||
"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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,32 +198,37 @@ pub async fn update_once(
|
||||
)
|
||||
.await;
|
||||
|
||||
let noop_key = format!("waf:{}", waf_list.describe());
|
||||
match result {
|
||||
SetResult::Updated => {
|
||||
notify = true; // NEW
|
||||
noop_reported.remove(&noop_key);
|
||||
notify = true;
|
||||
messages.push(Message::new_ok(&format!(
|
||||
"Updated WAF list {}",
|
||||
waf_list.describe()
|
||||
)));
|
||||
}
|
||||
SetResult::Failed => {
|
||||
notify = true; // NEW
|
||||
noop_reported.remove(&noop_key);
|
||||
notify = true;
|
||||
all_ok = false;
|
||||
messages.push(Message::new_fail(&format!(
|
||||
"Failed to update WAF list {}",
|
||||
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
|
||||
if notify {
|
||||
let heartbeat_msg = Message::merge(messages.clone());
|
||||
heartbeat.ping(&heartbeat_msg).await;
|
||||
}
|
||||
// Always ping heartbeat so monitors know the updater is alive
|
||||
let heartbeat_msg = Message::merge(messages.clone());
|
||||
heartbeat.ping(&heartbeat_msg).await;
|
||||
|
||||
// Send notifications ONLY when IP changed or failed
|
||||
if notify {
|
||||
@@ -229,48 +240,113 @@ pub async fn update_once(
|
||||
}
|
||||
|
||||
/// Run legacy mode update (using the original cloudflare-ddns logic with zone_id-based config).
|
||||
async fn update_legacy(config: &AppConfig, _ppfmt: &PP) -> bool {
|
||||
///
|
||||
/// IP detection uses the shared provider abstraction (`config.providers`), which builds
|
||||
/// 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`
|
||||
/// overrides from config.json.
|
||||
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 {
|
||||
Some(l) => l,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(config.update_timeout)
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
let ddns = LegacyDdnsClient {
|
||||
client,
|
||||
client: Client::builder()
|
||||
.timeout(config.update_timeout)
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
cf_api_base: "https://api.cloudflare.com/client/v4".to_string(),
|
||||
ipv4_urls: vec![
|
||||
"https://api.cloudflare.com/cdn-cgi/trace".to_string(),
|
||||
"https://1.0.0.1/cdn-cgi/trace".to_string(),
|
||||
],
|
||||
ipv6_urls: vec![
|
||||
"https://api.cloudflare.com/cdn-cgi/trace".to_string(),
|
||||
"https://[2606:4700:4700::1001]/cdn-cgi/trace".to_string(),
|
||||
],
|
||||
dry_run: config.dry_run,
|
||||
};
|
||||
|
||||
let mut warnings = LegacyWarningState::default();
|
||||
let mut ips = HashMap::new();
|
||||
|
||||
let ips = ddns
|
||||
.get_ips(
|
||||
legacy.a,
|
||||
legacy.aaaa,
|
||||
legacy.purge_unknown_records,
|
||||
&legacy.cloudflare,
|
||||
&mut warnings,
|
||||
)
|
||||
.await;
|
||||
for (ip_type, provider) in &config.providers {
|
||||
ppfmt.infof(
|
||||
pp::EMOJI_DETECT,
|
||||
&format!("Detecting {} via {}", ip_type.describe(), provider.name()),
|
||||
);
|
||||
let detected = provider
|
||||
.detect_ips(&detection_client, *ip_type, config.detection_timeout, ppfmt)
|
||||
.await;
|
||||
|
||||
if detected.is_empty() {
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
&format!("No {} address detected", ip_type.describe()),
|
||||
);
|
||||
if legacy.purge_unknown_records {
|
||||
ddns.delete_entries(ip_type.record_type(), &legacy.cloudflare)
|
||||
.await;
|
||||
}
|
||||
} else {
|
||||
let key = match ip_type {
|
||||
IpType::V4 => "ipv4",
|
||||
IpType::V6 => "ipv6",
|
||||
};
|
||||
ppfmt.infof(
|
||||
pp::EMOJI_DETECT,
|
||||
&format!("Detected {}: {}", ip_type.describe(), detected[0]),
|
||||
);
|
||||
ips.insert(
|
||||
key.to_string(),
|
||||
LegacyIpInfo {
|
||||
record_type: ip_type.record_type().to_string(),
|
||||
ip: detected[0].to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out Cloudflare IPs if enabled
|
||||
if config.reject_cloudflare_ips {
|
||||
let before_count = ips.len();
|
||||
if let Some(cf_filter) =
|
||||
cf_cache.get(&detection_client, config.detection_timeout, ppfmt).await
|
||||
{
|
||||
ips.retain(|key, ip_info| {
|
||||
if let Ok(addr) = ip_info.ip.parse::<std::net::IpAddr>() {
|
||||
if cf_filter.contains(&addr) {
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
&format!(
|
||||
"Rejected {}: matches Cloudflare IP range ({})",
|
||||
ip_info.ip, key
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
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 update to avoid writing Cloudflare IPs",
|
||||
);
|
||||
ips.clear();
|
||||
}
|
||||
}
|
||||
|
||||
ddns.update_ips(
|
||||
&ips,
|
||||
&legacy.cloudflare,
|
||||
legacy.ttl,
|
||||
legacy.purge_unknown_records,
|
||||
noop_reported,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -323,141 +399,13 @@ pub struct LegacyIpInfo {
|
||||
pub ip: String,
|
||||
}
|
||||
|
||||
struct LegacyWarningState {
|
||||
shown_ipv4: bool,
|
||||
shown_ipv4_secondary: bool,
|
||||
shown_ipv6: bool,
|
||||
shown_ipv6_secondary: bool,
|
||||
}
|
||||
|
||||
impl Default for LegacyWarningState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
shown_ipv4: false,
|
||||
shown_ipv4_secondary: false,
|
||||
shown_ipv6: false,
|
||||
shown_ipv6_secondary: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LegacyDdnsClient {
|
||||
client: Client,
|
||||
cf_api_base: String,
|
||||
ipv4_urls: Vec<String>,
|
||||
ipv6_urls: Vec<String>,
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
impl LegacyDdnsClient {
|
||||
async fn get_ips(
|
||||
&self,
|
||||
ipv4_enabled: bool,
|
||||
ipv6_enabled: bool,
|
||||
purge_unknown_records: bool,
|
||||
config: &[LegacyCloudflareEntry],
|
||||
warnings: &mut LegacyWarningState,
|
||||
) -> HashMap<String, LegacyIpInfo> {
|
||||
let mut ips = HashMap::new();
|
||||
|
||||
if ipv4_enabled {
|
||||
let a = self
|
||||
.try_trace_urls(
|
||||
&self.ipv4_urls,
|
||||
&mut warnings.shown_ipv4,
|
||||
&mut warnings.shown_ipv4_secondary,
|
||||
"IPv4",
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
if a.is_none() && purge_unknown_records {
|
||||
self.delete_entries("A", config).await;
|
||||
}
|
||||
if let Some(ip) = a {
|
||||
ips.insert(
|
||||
"ipv4".to_string(),
|
||||
LegacyIpInfo {
|
||||
record_type: "A".to_string(),
|
||||
ip,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ipv6_enabled {
|
||||
let aaaa = self
|
||||
.try_trace_urls(
|
||||
&self.ipv6_urls,
|
||||
&mut warnings.shown_ipv6,
|
||||
&mut warnings.shown_ipv6_secondary,
|
||||
"IPv6",
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
if aaaa.is_none() && purge_unknown_records {
|
||||
self.delete_entries("AAAA", config).await;
|
||||
}
|
||||
if let Some(ip) = aaaa {
|
||||
ips.insert(
|
||||
"ipv6".to_string(),
|
||||
LegacyIpInfo {
|
||||
record_type: "AAAA".to_string(),
|
||||
ip,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ips
|
||||
}
|
||||
|
||||
async fn try_trace_urls(
|
||||
&self,
|
||||
urls: &[String],
|
||||
shown_primary: &mut bool,
|
||||
shown_secondary: &mut bool,
|
||||
label: &str,
|
||||
expect_v4: bool,
|
||||
) -> Option<String> {
|
||||
for (i, url) in urls.iter().enumerate() {
|
||||
match self.client.get(url).send().await {
|
||||
Ok(resp) => {
|
||||
if let Some(ip) =
|
||||
crate::provider::parse_trace_ip(&resp.text().await.unwrap_or_default())
|
||||
{
|
||||
// Validate the IP matches the expected address family
|
||||
if let Ok(addr) = ip.parse::<std::net::IpAddr>() {
|
||||
if expect_v4 && !addr.is_ipv4() {
|
||||
eprintln!("{label} trace returned IPv6 address, skipping");
|
||||
continue;
|
||||
}
|
||||
if !expect_v4 && !addr.is_ipv6() {
|
||||
eprintln!("{label} trace returned IPv4 address, skipping");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
if i == 0 && !*shown_primary {
|
||||
*shown_primary = true;
|
||||
let next = if urls.len() > 1 {
|
||||
", trying fallback"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
eprintln!("{label} not detected via primary{next}");
|
||||
} else if i > 0 && !*shown_secondary {
|
||||
*shown_secondary = true;
|
||||
eprintln!("{label} not detected via fallback. Verify your ISP or DNS provider isn't blocking Cloudflare's IPs.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn cf_api<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
@@ -550,9 +498,10 @@ impl LegacyDdnsClient {
|
||||
config: &[LegacyCloudflareEntry],
|
||||
ttl: i64,
|
||||
purge_unknown_records: bool,
|
||||
noop_reported: &mut HashSet<String>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -563,6 +512,7 @@ impl LegacyDdnsClient {
|
||||
config: &[LegacyCloudflareEntry],
|
||||
ttl: i64,
|
||||
purge_unknown_records: bool,
|
||||
noop_reported: &mut HashSet<String>,
|
||||
) {
|
||||
for entry in config {
|
||||
let zone_resp: Option<LegacyCfResponse<LegacyZoneResult>> = self
|
||||
@@ -638,8 +588,10 @@ impl LegacyDdnsClient {
|
||||
}
|
||||
}
|
||||
|
||||
let noop_key = format!("{fqdn}:{}", ip.record_type);
|
||||
if let Some(ref id) = identifier {
|
||||
if modified {
|
||||
noop_reported.remove(&noop_key);
|
||||
if self.dry_run {
|
||||
println!("[DRY RUN] Would update record {fqdn} -> {}", ip.ip);
|
||||
} else {
|
||||
@@ -650,17 +602,24 @@ impl LegacyDdnsClient {
|
||||
.cf_api(&update_endpoint, "PUT", entry, Some(&record))
|
||||
.await;
|
||||
}
|
||||
} else if self.dry_run {
|
||||
println!("[DRY RUN] Record {fqdn} is up to date ({})", ip.ip);
|
||||
} else if noop_reported.insert(noop_key) {
|
||||
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 {
|
||||
println!("[DRY RUN] Would add new record {fqdn} -> {}", ip.ip);
|
||||
} else {
|
||||
println!("Adding new record {fqdn} -> {}", ip.ip);
|
||||
let create_endpoint = format!("zones/{}/dns_records", entry.zone_id);
|
||||
let _: Option<serde_json::Value> = self
|
||||
.cf_api(&create_endpoint, "POST", entry, Some(&record))
|
||||
.await;
|
||||
noop_reported.remove(&noop_key);
|
||||
if self.dry_run {
|
||||
println!("[DRY RUN] Would add new record {fqdn} -> {}", ip.ip);
|
||||
} else {
|
||||
println!("Adding new record {fqdn} -> {}", ip.ip);
|
||||
let create_endpoint = format!("zones/{}/dns_records", entry.zone_id);
|
||||
let _: Option<serde_json::Value> = self
|
||||
.cf_api(&create_endpoint, "POST", entry, Some(&record))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
if purge_unknown_records {
|
||||
@@ -863,11 +822,13 @@ mod tests {
|
||||
let heartbeat = empty_heartbeat();
|
||||
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, &mut HashSet::new(), &Client::new()).await;
|
||||
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]
|
||||
async fn test_update_once_noop_when_record_up_to_date() {
|
||||
let server = MockServer::start().await;
|
||||
@@ -911,8 +872,91 @@ mod tests {
|
||||
let heartbeat = empty_heartbeat();
|
||||
let ppfmt = pp();
|
||||
|
||||
let ok = update_once(&config, &cf, ¬ifier, &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, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt, &mut noop_reported, &Client::new()).await;
|
||||
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, ¬ifier, &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, ¬ifier, &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),
|
||||
@@ -955,7 +999,8 @@ mod tests {
|
||||
let ppfmt = pp();
|
||||
|
||||
// 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, &mut HashSet::new(), &Client::new()).await;
|
||||
// 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.
|
||||
assert!(ok);
|
||||
@@ -1004,7 +1049,8 @@ mod tests {
|
||||
let heartbeat = empty_heartbeat();
|
||||
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, &mut HashSet::new(), &Client::new()).await;
|
||||
assert!(!ok, "Expected false when zone is not found");
|
||||
}
|
||||
|
||||
@@ -1053,7 +1099,8 @@ mod tests {
|
||||
let ppfmt = pp();
|
||||
|
||||
// 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, &mut HashSet::new(), &Client::new()).await;
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
@@ -1118,7 +1165,8 @@ mod tests {
|
||||
let heartbeat = empty_heartbeat();
|
||||
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, &mut HashSet::new(), &Client::new()).await;
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
@@ -1171,7 +1219,8 @@ mod tests {
|
||||
let heartbeat = empty_heartbeat();
|
||||
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, &mut HashSet::new(), &Client::new()).await;
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
@@ -1210,7 +1259,8 @@ mod tests {
|
||||
let heartbeat = empty_heartbeat();
|
||||
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, &mut HashSet::new(), &Client::new()).await;
|
||||
assert!(!ok, "Expected false when WAF list is not found");
|
||||
}
|
||||
|
||||
@@ -1294,7 +1344,8 @@ mod tests {
|
||||
let heartbeat = empty_heartbeat();
|
||||
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, &mut HashSet::new(), &Client::new()).await;
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
@@ -1310,7 +1361,8 @@ mod tests {
|
||||
let heartbeat = empty_heartbeat();
|
||||
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, &mut HashSet::new(), &Client::new()).await;
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
@@ -1694,93 +1746,14 @@ mod tests {
|
||||
let ppfmt = pp();
|
||||
|
||||
// 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, &mut HashSet::new(), &Client::new()).await;
|
||||
assert!(ok);
|
||||
}
|
||||
// -------------------------------------------------------
|
||||
// LegacyDdnsClient tests (internal/private struct)
|
||||
// -------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_legacy_try_trace_urls_primary_success() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/trace"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string("fl=1\nh=mock\nip=198.51.100.1\nts=0\n"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![format!("{}/trace", server.uri())],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let mut shown_primary = false;
|
||||
let mut shown_secondary = false;
|
||||
let result = ddns
|
||||
.try_trace_urls(&ddns.ipv4_urls, &mut shown_primary, &mut shown_secondary, "IPv4", true)
|
||||
.await;
|
||||
assert_eq!(result, Some("198.51.100.1".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_legacy_try_trace_urls_primary_fails_fallback_succeeds() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/fallback"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string("fl=1\nh=mock\nip=198.51.100.2\nts=0\n"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![
|
||||
"http://127.0.0.1:1/nonexistent".to_string(), // will fail
|
||||
format!("{}/fallback", server.uri()),
|
||||
],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let mut shown_primary = false;
|
||||
let mut shown_secondary = false;
|
||||
let result = ddns
|
||||
.try_trace_urls(&ddns.ipv4_urls, &mut shown_primary, &mut shown_secondary, "IPv4", true)
|
||||
.await;
|
||||
assert_eq!(result, Some("198.51.100.2".to_string()));
|
||||
assert!(shown_primary);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_legacy_try_trace_urls_all_fail() {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::builder().timeout(Duration::from_millis(100)).build().unwrap(),
|
||||
cf_api_base: String::new(),
|
||||
ipv4_urls: vec![
|
||||
"http://127.0.0.1:1/fail1".to_string(),
|
||||
"http://127.0.0.1:1/fail2".to_string(),
|
||||
],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let mut shown_primary = false;
|
||||
let mut shown_secondary = false;
|
||||
let result = ddns
|
||||
.try_trace_urls(&ddns.ipv4_urls, &mut shown_primary, &mut shown_secondary, "IPv4", true)
|
||||
.await;
|
||||
assert!(result.is_none());
|
||||
assert!(shown_primary);
|
||||
assert!(shown_secondary);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_legacy_cf_api_get_success() {
|
||||
let server = MockServer::start().await;
|
||||
@@ -1795,8 +1768,6 @@ mod tests {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let entry = crate::config::LegacyCloudflareEntry {
|
||||
@@ -1829,8 +1800,6 @@ mod tests {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let entry = crate::config::LegacyCloudflareEntry {
|
||||
@@ -1860,8 +1829,6 @@ mod tests {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let entry = crate::config::LegacyCloudflareEntry {
|
||||
@@ -1884,8 +1851,6 @@ mod tests {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: "http://localhost".to_string(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let entry = crate::config::LegacyCloudflareEntry {
|
||||
@@ -1916,8 +1881,6 @@ mod tests {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let entry = crate::config::LegacyCloudflareEntry {
|
||||
@@ -1938,75 +1901,6 @@ mod tests {
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_legacy_get_ips_ipv4_enabled() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/trace"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string("ip=198.51.100.42\n"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![format!("{}/trace", server.uri())],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let mut warnings = LegacyWarningState::default();
|
||||
let config: Vec<crate::config::LegacyCloudflareEntry> = vec![];
|
||||
let ips = ddns.get_ips(true, false, false, &config, &mut warnings).await;
|
||||
assert!(ips.contains_key("ipv4"));
|
||||
assert_eq!(ips["ipv4"].ip, "198.51.100.42");
|
||||
assert_eq!(ips["ipv4"].record_type, "A");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_legacy_get_ips_ipv6_enabled() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/trace6"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string("ip=2001:db8::1\n"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![format!("{}/trace6", server.uri())],
|
||||
dry_run: false,
|
||||
};
|
||||
let mut warnings = LegacyWarningState::default();
|
||||
let config: Vec<crate::config::LegacyCloudflareEntry> = vec![];
|
||||
let ips = ddns.get_ips(false, true, false, &config, &mut warnings).await;
|
||||
assert!(ips.contains_key("ipv6"));
|
||||
assert_eq!(ips["ipv6"].ip, "2001:db8::1");
|
||||
assert_eq!(ips["ipv6"].record_type, "AAAA");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_legacy_get_ips_both_disabled() {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: String::new(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let mut warnings = LegacyWarningState::default();
|
||||
let config: Vec<crate::config::LegacyCloudflareEntry> = vec![];
|
||||
let ips = ddns.get_ips(false, false, false, &config, &mut warnings).await;
|
||||
assert!(ips.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_legacy_commit_record_creates_new() {
|
||||
let server = MockServer::start().await;
|
||||
@@ -2043,8 +1937,6 @@ mod tests {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let ip = LegacyIpInfo {
|
||||
@@ -2060,7 +1952,7 @@ mod tests {
|
||||
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
|
||||
proxied: false,
|
||||
}];
|
||||
ddns.commit_record(&ip, &config, 300, false).await;
|
||||
ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2101,8 +1993,6 @@ mod tests {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let ip = LegacyIpInfo {
|
||||
@@ -2118,7 +2008,7 @@ mod tests {
|
||||
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
|
||||
proxied: false,
|
||||
}];
|
||||
ddns.commit_record(&ip, &config, 300, false).await;
|
||||
ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2145,8 +2035,6 @@ mod tests {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: true,
|
||||
};
|
||||
let ip = LegacyIpInfo {
|
||||
@@ -2163,7 +2051,7 @@ mod tests {
|
||||
proxied: false,
|
||||
}];
|
||||
// Should not POST
|
||||
ddns.commit_record(&ip, &config, 300, false).await;
|
||||
ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2198,8 +2086,6 @@ mod tests {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let ip = LegacyIpInfo {
|
||||
@@ -2218,7 +2104,7 @@ mod tests {
|
||||
}],
|
||||
proxied: false,
|
||||
}];
|
||||
ddns.commit_record(&ip, &config, 300, false).await;
|
||||
ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2255,8 +2141,6 @@ mod tests {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let ip = LegacyIpInfo {
|
||||
@@ -2272,7 +2156,7 @@ mod tests {
|
||||
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
|
||||
proxied: false,
|
||||
}];
|
||||
ddns.commit_record(&ip, &config, 300, true).await;
|
||||
ddns.commit_record(&ip, &config, 300, true, &mut HashSet::new()).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2306,8 +2190,6 @@ mod tests {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let mut ips = HashMap::new();
|
||||
@@ -2324,7 +2206,7 @@ mod tests {
|
||||
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
|
||||
proxied: false,
|
||||
}];
|
||||
ddns.update_ips(&ips, &config, 300, false).await;
|
||||
ddns.update_ips(&ips, &config, 300, false, &mut HashSet::new()).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2352,8 +2234,6 @@ mod tests {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: false,
|
||||
};
|
||||
let config = vec![crate::config::LegacyCloudflareEntry {
|
||||
@@ -2386,8 +2266,6 @@ mod tests {
|
||||
let ddns = LegacyDdnsClient {
|
||||
client: Client::new(),
|
||||
cf_api_base: server.uri(),
|
||||
ipv4_urls: vec![],
|
||||
ipv6_urls: vec![],
|
||||
dry_run: true,
|
||||
};
|
||||
let config = vec![crate::config::LegacyCloudflareEntry {
|
||||
@@ -2403,14 +2281,6 @@ mod tests {
|
||||
ddns.delete_entries("A", &config).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_warning_state_default() {
|
||||
let w = LegacyWarningState::default();
|
||||
assert!(!w.shown_ipv4);
|
||||
assert!(!w.shown_ipv4_secondary);
|
||||
assert!(!w.shown_ipv6);
|
||||
assert!(!w.shown_ipv6_secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy types for backwards compatibility
|
||||
|
||||
Reference in New Issue
Block a user