17 Commits

Author SHA1 Message Date
Timothy Miller
f8d5b5cb7e Bump version to 2.0.5 2026-03-19 18:19:41 -04:00
Timothy Miller
bb5cc43651 Add ip4_provider and ip6_provider for legacy mode
Use the shared provider abstraction for IPv4/IPv6 detection in legacy
mode.
Allow per-family provider overrides in config.json (ip4_provider /
ip6_provider)
and support disabling a family with "none". Update config parsing,
examples,
and the legacy update flow to use the provider-based detection client.
2026-03-19 18:18:53 -04:00
Timothy Miller
7ff8379cfb Filter Cloudflare IPs in legacy mode
Add support for REJECT_CLOUDFLARE_IPS in legacy config and fetch
Cloudflare
IP ranges to drop matching detected addresses. Improve IP detection in
legacy mode by using literal-IP primary trace URLs with hostname
fallbacks, binding dedicated IPv4/IPv6 HTTP clients, and setting a Host
override for literal-IP trace endpoints so TLS SNI works. Expose
build_split_client and update tests accordingly.
2026-03-19 18:18:32 -04:00
Timothy Miller
943e38d70c Update README.md 2026-03-18 20:12:25 -04:00
Timothy Miller
ac982a208e Replace ipnet dependency with inline CidrRange for CIDR matching
Remove the ipnet crate and implement a lightweight CidrRange struct
  that handles IPv4/IPv6 CIDR parsing and containment checks using
  bitwise masking. Adds tests for invalid prefixes and cross-family
  non-matching.
2026-03-18 19:53:51 -04:00
Timothy Miller
4b1875b0cd Add REJECT_CLOUDFLARE_IPS flag to filter out Cloudflare-owned IPs from
DNS updates

  IP detection providers can sometimes return a Cloudflare anycast IP
  instead
  of the user's real public IP, causing incorrect DNS updates. When
  REJECT_CLOUDFLARE_IPS=true, detected IPs are checked against
  Cloudflare's
  published IP ranges (ips-v4/ips-v6) and rejected if they match.
2026-03-18 19:44:06 -04:00
Timothy Miller
54ca4a5eae Bump version to 2.0.3 and update GitHub Actions to Node.js 24
Update all Docker GitHub Actions to their latest major versions to
  resolve Node.js 20 deprecation warnings ahead of the June 2026 cutoff.
2026-03-18 19:01:50 -04:00
Timothy Miller
94ce10fccc Only set Host header for literal-IP trace URLs
The fallback hostname-based URL and custom URLs resolve correctly
without a Host override, so restrict the header to the cases that
need it (direct IP connections to 1.1.1.1 / [2606:4700:4700::1111]).
2026-03-18 18:19:55 -04:00
Timothy Miller
7e96816740 Merge pull request #240 from masterwishx/dev-test
Fix proxyIP + Notify
2026-03-18 16:34:28 -04:00
DaRK AnGeL
8a4b57c163 undo FIX: remove duplicates so CloudflareHandle::set_ips sees stable input
Signed-off-by: DaRK AnGeL <28630321+masterwishx@users.noreply.github.com>
2026-03-17 10:10:00 +02:00
DaRK AnGeL
3c7072f4b6 Merge branch 'master' of https://github.com/masterwishx/cloudflare-ddns 2026-03-17 10:05:15 +02:00
DaRK AnGeL
3d796d470c Deduplicate IPs before DNS record update
Remove duplicate IPs before updating DNS records to ensure stable input.

Signed-off-by: DaRK AnGeL <28630321+masterwishx@users.noreply.github.com>
2026-03-17 10:04:20 +02:00
DaRK AnGeL
36bdbea568 Deduplicate IPs before DNS record update
Remove duplicate IPs before updating DNS records to ensure stable input.
2026-03-16 20:28:26 +02:00
DaRK AnGeL
6085ba0cc2 Add Host header to fetch_trace_ip function 2026-03-16 09:02:10 +02:00
Timothy Miller
560a3b7b28 Bump version to 2.0.2 2026-03-13 00:10:31 -04:00
Timothy Miller
1b3928865b Use literal IP trace URLs as primary
Primary trace endpoints now use literal IPs per address family to
guarantee correct address family selection. Fallback uses
api.cloudflare.com to work around WARP/Zero Trust interception. Rename
constants and update tests accordingly.
2026-03-13 00:04:08 -04:00
Timothy Miller
93d351d997 Use Cloudflare trace by default and validate IPs
Default IPv4 provider is now CloudflareTrace.
Primary uses api.cloudflare.com; fallbacks are literal IPs.
Build per-family HTTP clients by binding to 0.0.0.0/[::] so the trace
endpoint observes the requested address family. Add validate_detected_ip
to reject wrong-family or non-global addresses (loopback, link-local,
private, documentation ranges, etc). Update tests and legacy updater
URLs.
Default to Cloudflare trace and validate IPs

Use api.cloudflare.com as the primary trace endpoint (fallbacks
remain literal IPs) to avoid WARP/Zero Trust interception. Build
IP-family-specific HTTP clients by binding to the unspecified
address so the trace endpoint sees the correct family. Add
validate_detected_ip to reject non-global or wrong-family addresses
and expand tests. Bump crate version and tempfile dev-dependency.
2026-03-11 18:42:46 -04:00
10 changed files with 992 additions and 404 deletions

View File

@@ -15,14 +15,14 @@ jobs:
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -35,7 +35,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: timothyjmiller/cloudflare-ddns
tags: |
@@ -46,7 +46,7 @@ jobs:
type=raw,enable=${{ github.ref == 'refs/heads/master' }},value=${{ steps.version.outputs.version }}
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

231
Cargo.lock generated
View File

@@ -20,6 +20,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@@ -68,9 +74,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cc"
version = "1.2.56"
version = "1.2.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -103,7 +109,7 @@ dependencies = [
[[package]]
name = "cloudflare-ddns"
version = "2.0.0"
version = "2.0.5"
dependencies = [
"chrono",
"idna",
@@ -187,6 +193,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -306,11 +318,24 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
[[package]]
name = "h2"
version = "0.4.13"
@@ -330,12 +355,27 @@ dependencies = [
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
@@ -555,6 +595,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
@@ -593,7 +639,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
@@ -634,6 +682,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.183"
@@ -702,9 +756,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.21.3"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
@@ -742,6 +796,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -821,6 +885,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.2"
@@ -997,6 +1067,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
@@ -1135,12 +1211,12 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.26.0"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -1178,9 +1254,9 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.10.0"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
@@ -1317,6 +1393,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -1365,6 +1447,15 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
@@ -1424,6 +1515,40 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.91"
@@ -1705,6 +1830,88 @@ name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "writeable"

View File

@@ -1,6 +1,6 @@
[package]
name = "cloudflare-ddns"
version = "2.0.0"
version = "2.0.5"
edition = "2021"
description = "Access your home network remotely via a custom domain name without a static IP"
license = "GPL-3.0"
@@ -24,5 +24,5 @@ strip = true
panic = "abort"
[dev-dependencies]
tempfile = "3.26.0"
tempfile = "3.27.0"
wiremock = "0.6"

View File

@@ -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** — Optionally reject 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,16 @@ Available providers:
| `literal:<ips>` | 📌 Static IP addresses (comma-separated) |
| `none` | 🚫 Disable this IP type |
## 🚫 Cloudflare IP Rejection
| Variable | Default | Description |
|----------|---------|-------------|
| `REJECT_CLOUDFLARE_IPS` | `false` | Reject detected IPs that fall within Cloudflare's IP ranges |
Some IP detection providers occasionally return a Cloudflare anycast IP instead of your real public IP. When this happens, your DNS record gets updated to point at Cloudflare infrastructure rather than your actual address.
Setting `REJECT_CLOUDFLARE_IPS=true` prevents this. Each update cycle fetches [Cloudflare's published IP ranges](https://www.cloudflare.com/ips/) and skips any detected IP that falls within them. A warning is logged for every rejected IP.
## ⏱️ Scheduling
| Variable | Default | Description |
@@ -210,6 +221,7 @@ Heartbeats are sent after each update cycle. On failure, a fail signal is sent.
| `MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX` | — | 🎯 Managed WAF items regex |
| `DETECTION_TIMEOUT` | `5s` | ⏳ IP detection timeout |
| `UPDATE_TIMEOUT` | `30s` | ⏳ API request timeout |
| `REJECT_CLOUDFLARE_IPS` | `false` | 🚫 Reject Cloudflare anycast IPs |
| `EMOJI` | `true` | 🎨 Enable emoji output |
| `QUIET` | `false` | 🤫 Suppress info output |
| `HEALTHCHECKS` | — | 💓 Healthchecks.io URL |
@@ -356,6 +368,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)
The `REJECT_CLOUDFLARE_IPS` environment variable is supported in legacy config mode. Set it alongside your `config.json`:
```bash
REJECT_CLOUDFLARE_IPS=true cloudflare-ddns
```
Or in Docker Compose:
```yml
environment:
- REJECT_CLOUDFLARE_IPS=true
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:

View File

@@ -24,5 +24,7 @@
"a": true,
"aaaa": true,
"purgeUnknownRecords": false,
"ttl": 300
"ttl": 300,
"ip4_provider": "cloudflare.trace",
"ip6_provider": "cloudflare.trace"
}

237
src/cf_ip_filter.rs Normal file
View File

@@ -0,0 +1,237 @@
use crate::pp::{self, PP};
use reqwest::Client;
use std::net::IpAddr;
use std::time::Duration;
const CF_IPV4_URL: &str = "https://www.cloudflare.com/ips-v4";
const CF_IPV6_URL: &str = "https://www.cloudflare.com/ips-v6";
/// 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<CidrRange>,
}
impl CloudflareIpFilter {
/// Fetch Cloudflare IP ranges from their published URLs and parse them.
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 {
Ok(resp) if resp.status().is_success() => match resp.text().await {
Ok(body) => {
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match CidrRange::parse(line) {
Some(range) => ranges.push(range),
None => {
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!(
"Failed to parse Cloudflare IP range '{line}'"
),
);
}
}
}
}
Err(e) => {
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!("Failed to read Cloudflare IP ranges from {url}: {e}"),
);
return None;
}
},
Ok(resp) => {
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!(
"Failed to fetch Cloudflare IP ranges from {url}: HTTP {}",
resp.status()
),
);
return None;
}
Err(e) => {
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!("Failed to fetch Cloudflare IP ranges from {url}: {e}"),
);
return None;
}
}
}
if ranges.is_empty() {
ppfmt.warningf(
pp::EMOJI_WARNING,
"No Cloudflare IP ranges loaded; skipping filter",
);
return None;
}
ppfmt.infof(
pp::EMOJI_DETECT,
&format!("Loaded {} Cloudflare IP ranges for filtering", ranges.len()),
);
Some(Self { ranges })
}
/// Parse ranges from raw text lines (for testing).
#[cfg(test)]
pub fn from_lines(lines: &str) -> Option<Self> {
let ranges: Vec<CidrRange> = lines
.lines()
.filter_map(|l| {
let l = l.trim();
if l.is_empty() {
None
} else {
CidrRange::parse(l)
}
})
.collect();
if ranges.is_empty() {
None
} else {
Some(Self { ranges })
}
}
/// Check if an IP address falls within any Cloudflare range.
pub fn contains(&self, ip: &IpAddr) -> bool {
self.ranges.iter().any(|net| net.contains(ip))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
const SAMPLE_RANGES: &str = "\
173.245.48.0/20
103.21.244.0/22
103.22.200.0/22
104.16.0.0/13
2400:cb00::/32
2606:4700::/32
";
#[test]
fn test_parse_ranges() {
let filter = CloudflareIpFilter::from_lines(SAMPLE_RANGES).unwrap();
assert_eq!(filter.ranges.len(), 6);
}
#[test]
fn test_contains_cloudflare_ipv4() {
let filter = CloudflareIpFilter::from_lines(SAMPLE_RANGES).unwrap();
// 104.16.0.1 is within 104.16.0.0/13
let ip: IpAddr = IpAddr::V4(Ipv4Addr::new(104, 16, 0, 1));
assert!(filter.contains(&ip));
}
#[test]
fn test_rejects_non_cloudflare_ipv4() {
let filter = CloudflareIpFilter::from_lines(SAMPLE_RANGES).unwrap();
// 203.0.113.42 is a documentation IP, not Cloudflare
let ip: IpAddr = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 42));
assert!(!filter.contains(&ip));
}
#[test]
fn test_contains_cloudflare_ipv6() {
let filter = CloudflareIpFilter::from_lines(SAMPLE_RANGES).unwrap();
// 2606:4700::1 is within 2606:4700::/32
let ip: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0, 0, 0, 0, 0, 1));
assert!(filter.contains(&ip));
}
#[test]
fn test_rejects_non_cloudflare_ipv6() {
let filter = CloudflareIpFilter::from_lines(SAMPLE_RANGES).unwrap();
// 2001:db8::1 is a documentation address, not Cloudflare
let ip: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
assert!(!filter.contains(&ip));
}
#[test]
fn test_empty_input() {
assert!(CloudflareIpFilter::from_lines("").is_none());
assert!(CloudflareIpFilter::from_lines(" \n \n").is_none());
}
#[test]
fn test_edge_of_range() {
let filter = CloudflareIpFilter::from_lines("104.16.0.0/13").unwrap();
// First IP in range
assert!(filter.contains(&IpAddr::V4(Ipv4Addr::new(104, 16, 0, 0))));
// Last IP in range (104.23.255.255)
assert!(filter.contains(&IpAddr::V4(Ipv4Addr::new(104, 23, 255, 255))));
// Just outside range (104.24.0.0)
assert!(!filter.contains(&IpAddr::V4(Ipv4Addr::new(104, 24, 0, 0))));
}
#[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));
}
}

View File

@@ -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 {
@@ -89,6 +93,7 @@ pub struct AppConfig {
pub managed_waf_comment_regex: Option<regex::Regex>,
pub detection_timeout: Duration,
pub update_timeout: Duration,
pub reject_cloudflare_ips: bool,
pub dry_run: bool,
pub emoji: bool,
pub quiet: bool,
@@ -243,7 +248,7 @@ fn read_providers_from_env(ppfmt: &PP) -> Result<HashMap<IpType, ProviderType>,
let ip4_provider = match ip4_str {
Some(s) => ProviderType::parse(&s)
.map_err(|e| format!("Invalid IP4_PROVIDER: {e}"))?,
None => ProviderType::Ipify,
None => ProviderType::CloudflareTrace { url: None },
};
let ip6_provider = match ip6_str {
@@ -386,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()
@@ -405,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);
@@ -422,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(),
@@ -439,13 +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: getenv_bool("REJECT_CLOUDFLARE_IPS", false),
dry_run,
emoji: false,
quiet: false,
legacy_mode: true,
legacy_config: Some(legacy),
repeat,
}
})
}
// ============================================================
@@ -509,6 +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);
// Validate: must have at least one update target
if domains.is_empty() && waf_lists.is_empty() {
@@ -559,6 +580,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
managed_waf_comment_regex,
detection_timeout,
update_timeout,
reject_cloudflare_ips,
dry_run: false, // Set later from CLI args
emoji,
quiet,
@@ -579,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)
}
}
@@ -659,6 +681,10 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) {
inner.infof("", "Delete on stop: enabled");
}
if config.reject_cloudflare_ips {
inner.infof("", "Reject Cloudflare IPs: enabled");
}
if let Some(ref comment) = config.record_comment {
inner.infof("", &format!("Record comment: {comment}"));
}
@@ -987,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));
@@ -1013,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);
@@ -1039,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]
@@ -1190,6 +1326,7 @@ mod tests {
managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(30),
reject_cloudflare_ips: false,
dry_run: false,
emoji: false,
quiet: false,
@@ -1223,6 +1360,7 @@ mod tests {
managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(30),
reject_cloudflare_ips: false,
dry_run: false,
emoji: false,
quiet: false,
@@ -1429,12 +1567,12 @@ mod tests {
let pp = PP::new(false, true);
let providers = read_providers_from_env(&pp).unwrap();
drop(g);
// V4 defaults to Ipify, V6 defaults to CloudflareTrace.
// Both V4 and V6 default to CloudflareTrace.
assert!(providers.contains_key(&IpType::V4));
assert!(providers.contains_key(&IpType::V6));
assert!(matches!(
providers[&IpType::V4],
ProviderType::Ipify
ProviderType::CloudflareTrace { url: None }
));
assert!(matches!(
providers[&IpType::V6],
@@ -1881,6 +2019,7 @@ mod tests {
managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(30),
reject_cloudflare_ips: false,
dry_run: false,
emoji: false,
quiet: false,
@@ -1916,6 +2055,7 @@ mod tests {
managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(30),
reject_cloudflare_ips: false,
dry_run: false,
emoji: false,
quiet: true,
@@ -1948,6 +2088,7 @@ mod tests {
managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(30),
reject_cloudflare_ips: false,
dry_run: false,
emoji: false,
quiet: false,

View File

@@ -1,3 +1,4 @@
mod cf_ip_filter;
mod cloudflare;
mod config;
mod domain;
@@ -300,6 +301,8 @@ mod tests {
aaaa: false,
purge_unknown_records: false,
ttl: 300,
ip4_provider: None,
ip6_provider: None,
}
}
@@ -813,6 +816,8 @@ 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)
.await;
@@ -912,6 +917,8 @@ 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)

View File

@@ -145,10 +145,15 @@ impl ProviderType {
// --- Cloudflare Trace ---
const CF_TRACE_V4_PRIMARY: &str = "https://1.1.1.1/cdn-cgi/trace";
const CF_TRACE_V4_FALLBACK: &str = "https://1.0.0.1/cdn-cgi/trace";
const CF_TRACE_V6_PRIMARY: &str = "https://[2606:4700:4700::1111]/cdn-cgi/trace";
const CF_TRACE_V6_FALLBACK: &str = "https://[2606:4700:4700::1001]/cdn-cgi/trace";
/// Primary trace URLs use literal IPs to guarantee the correct address family.
/// api.cloudflare.com is dual-stack, so on dual-stack hosts (e.g. Docker
/// --net=host with IPv6) the connection may go via IPv6 even when detecting
/// IPv4, causing the trace endpoint to return the wrong address family.
const CF_TRACE_V4_PRIMARY: &str = "https://1.0.0.1/cdn-cgi/trace";
const CF_TRACE_V6_PRIMARY: &str = "https://[2606:4700:4700::1001]/cdn-cgi/trace";
/// Fallback uses a hostname, which works when literal IPs are intercepted
/// (e.g. Cloudflare WARP/Zero Trust).
const CF_TRACE_FALLBACK: &str = "https://api.cloudflare.com/cdn-cgi/trace";
pub fn parse_trace_ip(body: &str) -> Option<String> {
for line in body.lines() {
@@ -159,28 +164,50 @@ pub fn parse_trace_ip(body: &str) -> Option<String> {
None
}
async fn fetch_trace_ip(client: &Client, url: &str, timeout: Duration) -> Option<IpAddr> {
let resp = client
.get(url)
.timeout(timeout)
.send()
.await
.ok()?;
async fn fetch_trace_ip(
client: &Client,
url: &str,
timeout: Duration,
host_override: Option<&str>,
) -> Option<IpAddr> {
let mut req = client.get(url).timeout(timeout);
if let Some(host) = host_override {
req = req.header("Host", host);
}
let resp = req.send().await.ok()?;
let body = resp.text().await.ok()?;
let ip_str = parse_trace_ip(&body)?;
ip_str.parse::<IpAddr>().ok()
}
/// 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.
pub fn build_split_client(ip_type: IpType, timeout: Duration) -> Client {
let local_addr: IpAddr = match ip_type {
IpType::V4 => Ipv4Addr::UNSPECIFIED.into(),
IpType::V6 => Ipv6Addr::UNSPECIFIED.into(),
};
Client::builder()
.local_address(local_addr)
.timeout(timeout)
.build()
.unwrap_or_default()
}
async fn detect_cloudflare_trace(
client: &Client,
_client: &Client,
ip_type: IpType,
timeout: Duration,
custom_url: Option<&str>,
ppfmt: &PP,
) -> Vec<IpAddr> {
// Use an IP-family-specific client so the trace endpoint sees the right address family.
let client = build_split_client(ip_type, timeout);
if let Some(url) = custom_url {
if let Some(ip) = fetch_trace_ip(client, url, timeout).await {
if matches_ip_type(&ip, ip_type) {
if let Some(ip) = fetch_trace_ip(&client, url, timeout, None).await {
if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip];
}
}
@@ -191,14 +218,14 @@ async fn detect_cloudflare_trace(
return Vec::new();
}
let (primary, fallback) = match ip_type {
IpType::V4 => (CF_TRACE_V4_PRIMARY, CF_TRACE_V4_FALLBACK),
IpType::V6 => (CF_TRACE_V6_PRIMARY, CF_TRACE_V6_FALLBACK),
let primary = match ip_type {
IpType::V4 => CF_TRACE_V4_PRIMARY,
IpType::V6 => CF_TRACE_V6_PRIMARY,
};
// Try primary
if let Some(ip) = fetch_trace_ip(client, primary, timeout).await {
if matches_ip_type(&ip, ip_type) {
// Try primary (literal IP — guarantees correct address family)
if let Some(ip) = fetch_trace_ip(&client, primary, timeout, Some("one.one.one.one")).await {
if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip];
}
}
@@ -207,9 +234,9 @@ async fn detect_cloudflare_trace(
&format!("{} not detected via primary, trying fallback", ip_type.describe()),
);
// Try fallback
if let Some(ip) = fetch_trace_ip(client, fallback, timeout).await {
if matches_ip_type(&ip, ip_type) {
// Try fallback (hostname-based — works when literal IPs are intercepted by WARP/Zero Trust)
if let Some(ip) = fetch_trace_ip(&client, CF_TRACE_FALLBACK, timeout, None).await {
if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip];
}
}
@@ -249,7 +276,7 @@ async fn detect_cloudflare_doh(
if let Ok(body) = r.bytes().await {
if let Some(ip_str) = parse_dns_txt_response(&body) {
if let Ok(ip) = ip_str.parse::<IpAddr>() {
if matches_ip_type(&ip, ip_type) {
if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip];
}
}
@@ -379,7 +406,7 @@ async fn detect_ipify(
if let Ok(body) = resp.text().await {
let ip_str = body.trim();
if let Ok(ip) = ip_str.parse::<IpAddr>() {
if matches_ip_type(&ip, ip_type) {
if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip];
}
}
@@ -491,7 +518,7 @@ async fn detect_custom_url(
if let Ok(body) = resp.text().await {
let ip_str = body.trim();
if let Ok(ip) = ip_str.parse::<IpAddr>() {
if matches_ip_type(&ip, ip_type) {
if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip];
}
}
@@ -516,6 +543,34 @@ fn matches_ip_type(ip: &IpAddr, ip_type: IpType) -> bool {
}
}
/// Validate a detected IP: must match the requested address family and be a
/// global unicast address. Mirrors the checks in favonia/cloudflare-ddns's
/// NormalizeDetectedIPs — rejects loopback, link-local, multicast,
/// unspecified, and non-global addresses.
fn validate_detected_ip(ip: &IpAddr, ip_type: IpType, ppfmt: &PP) -> bool {
if !matches_ip_type(ip, ip_type) {
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!(
"Detected IP {} does not match expected type {}",
ip, ip_type.describe()
),
);
return false;
}
if !ip.is_global_() {
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!(
"Detected {} address {} is not a global unicast address",
ip_type.describe(), ip
),
);
return false;
}
true
}
fn filter_ips_by_type(ips: &[IpAddr], ip_type: IpType) -> Vec<IpAddr> {
ips.iter()
.copied()
@@ -867,6 +922,34 @@ mod tests {
assert_eq!(result_ok[0], "93.184.216.34".parse::<IpAddr>().unwrap());
}
// ---- trace URL constants ----
#[test]
fn test_trace_urls() {
// Primary URLs use literal IPs to guarantee correct address family.
assert!(CF_TRACE_V4_PRIMARY.contains("1.0.0.1"));
assert!(CF_TRACE_V6_PRIMARY.contains("2606:4700:4700::1001"));
// Fallback uses a hostname for when literal IPs are intercepted (WARP/Zero Trust).
assert_eq!(CF_TRACE_FALLBACK, "https://api.cloudflare.com/cdn-cgi/trace");
assert!(CF_TRACE_FALLBACK.contains("api.cloudflare.com"));
}
// ---- build_split_client ----
#[test]
fn test_build_split_client_v4() {
let client = build_split_client(IpType::V4, Duration::from_secs(5));
// Client should build successfully — we can't inspect local_address,
// but we verify it doesn't panic.
drop(client);
}
#[test]
fn test_build_split_client_v6() {
let client = build_split_client(IpType::V6, Duration::from_secs(5));
drop(client);
}
// ---- detect_ipify with wiremock ----
#[tokio::test]
@@ -875,7 +958,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_string("198.51.100.1\n"))
.respond_with(ResponseTemplate::new(200).set_body_string("93.184.216.34\n"))
.mount(&server)
.await;
@@ -887,7 +970,7 @@ mod tests {
// which uses the same logic
let result = detect_custom_url(&client, &server.uri(), IpType::V4, timeout, &ppfmt).await;
assert_eq!(result.len(), 1);
assert_eq!(result[0], "198.51.100.1".parse::<IpAddr>().unwrap());
assert_eq!(result[0], "93.184.216.34".parse::<IpAddr>().unwrap());
}
#[tokio::test]
@@ -897,7 +980,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(200).set_body_string("2001:db8::1\n"),
ResponseTemplate::new(200).set_body_string("2606:4700:4700::1111\n"),
)
.mount(&server)
.await;
@@ -908,7 +991,7 @@ mod tests {
let result = detect_custom_url(&client, &server.uri(), IpType::V6, timeout, &ppfmt).await;
assert_eq!(result.len(), 1);
assert_eq!(result[0], "2001:db8::1".parse::<IpAddr>().unwrap());
assert_eq!(result[0], "2606:4700:4700::1111".parse::<IpAddr>().unwrap());
}
// ---- detect_custom_url with wiremock ----
@@ -919,7 +1002,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/my-ip"))
.respond_with(ResponseTemplate::new(200).set_body_string("10.0.0.1"))
.respond_with(ResponseTemplate::new(200).set_body_string("93.184.216.34"))
.mount(&server)
.await;
@@ -928,16 +1011,79 @@ mod tests {
let timeout = Duration::from_secs(5);
let url = format!("{}/my-ip", server.uri());
// 10.0.0.1 is a valid IPv4, should match V4
let result = detect_custom_url(&client, &url, IpType::V4, timeout, &ppfmt).await;
assert_eq!(result.len(), 1);
assert_eq!(result[0], "10.0.0.1".parse::<IpAddr>().unwrap());
assert_eq!(result[0], "93.184.216.34".parse::<IpAddr>().unwrap());
}
#[tokio::test]
async fn test_detect_custom_url_wrong_ip_type() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/my-ip"))
.respond_with(ResponseTemplate::new(200).set_body_string("93.184.216.34"))
.mount(&server)
.await;
let client = Client::new();
let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5);
let url = format!("{}/my-ip", server.uri());
// 93.184.216.34 is IPv4 but we ask for V6 -> empty
let result = detect_custom_url(&client, &url, IpType::V6, timeout, &ppfmt).await;
assert!(result.is_empty());
}
// ---- validate_detected_ip ----
#[test]
fn test_validate_detected_ip_accepts_global() {
let ppfmt = PP::default_pp();
assert!(validate_detected_ip(&"93.184.216.34".parse().unwrap(), IpType::V4, &ppfmt));
assert!(validate_detected_ip(&"2606:4700:4700::1111".parse().unwrap(), IpType::V6, &ppfmt));
}
#[test]
fn test_validate_detected_ip_rejects_wrong_family() {
let ppfmt = PP::default_pp();
assert!(!validate_detected_ip(&"93.184.216.34".parse().unwrap(), IpType::V6, &ppfmt));
assert!(!validate_detected_ip(&"2606:4700:4700::1111".parse().unwrap(), IpType::V4, &ppfmt));
}
#[test]
fn test_validate_detected_ip_rejects_private() {
let ppfmt = PP::default_pp();
assert!(!validate_detected_ip(&"10.0.0.1".parse().unwrap(), IpType::V4, &ppfmt));
assert!(!validate_detected_ip(&"192.168.1.1".parse().unwrap(), IpType::V4, &ppfmt));
assert!(!validate_detected_ip(&"172.16.0.1".parse().unwrap(), IpType::V4, &ppfmt));
}
#[test]
fn test_validate_detected_ip_rejects_loopback() {
let ppfmt = PP::default_pp();
assert!(!validate_detected_ip(&"127.0.0.1".parse().unwrap(), IpType::V4, &ppfmt));
assert!(!validate_detected_ip(&"::1".parse().unwrap(), IpType::V6, &ppfmt));
}
#[test]
fn test_validate_detected_ip_rejects_link_local() {
let ppfmt = PP::default_pp();
assert!(!validate_detected_ip(&"169.254.0.1".parse().unwrap(), IpType::V4, &ppfmt));
}
#[test]
fn test_validate_detected_ip_rejects_documentation() {
let ppfmt = PP::default_pp();
assert!(!validate_detected_ip(&"198.51.100.1".parse().unwrap(), IpType::V4, &ppfmt));
assert!(!validate_detected_ip(&"203.0.113.1".parse().unwrap(), IpType::V4, &ppfmt));
}
#[tokio::test]
async fn test_detect_custom_url_rejects_private_ip() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/my-ip"))
.respond_with(ResponseTemplate::new(200).set_body_string("10.0.0.1"))
@@ -949,8 +1095,7 @@ mod tests {
let timeout = Duration::from_secs(5);
let url = format!("{}/my-ip", server.uri());
// 10.0.0.1 is IPv4 but we ask for V6 -> empty
let result = detect_custom_url(&client, &url, IpType::V6, timeout, &ppfmt).await;
let result = detect_custom_url(&client, &url, IpType::V4, timeout, &ppfmt).await;
assert!(result.is_empty());
}

View File

@@ -1,3 +1,4 @@
use crate::cf_ip_filter::CloudflareIpFilter;
use crate::cloudflare::{CloudflareHandle, SetResult};
use crate::config::{AppConfig, LegacyCloudflareEntry, LegacySubdomainEntry};
use crate::domain::make_fqdn;
@@ -24,6 +25,7 @@ pub async fn update_once(
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;
@@ -64,6 +66,49 @@ pub async fn update_once(
}
}
// Filter out Cloudflare IPs if enabled
if config.reject_cloudflare_ips {
if let Some(cf_filter) =
CloudflareIpFilter::fetch(&detection_client, config.detection_timeout, ppfmt).await
{
for (ip_type, ips) in detected_ips.iter_mut() {
let before_count = ips.len();
ips.retain(|ip| {
if cf_filter.contains(ip) {
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!(
"Rejected {ip}: matches Cloudflare IP range ({})",
ip_type.describe()
),
);
false
} else {
true
}
});
if ips.is_empty() && before_count > 0 {
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!(
"All detected {} addresses were Cloudflare IPs; skipping updates for this type",
ip_type.describe()
),
);
messages.push(Message::new_fail(&format!(
"All {} addresses rejected (Cloudflare IPs)",
ip_type.describe()
)));
}
}
} else {
ppfmt.warningf(
pp::EMOJI_WARNING,
"Could not fetch Cloudflare IP ranges; skipping filter",
);
}
}
// Update DNS records (env var mode - domain-based)
for (ip_type, domains) in &config.domains {
let ips = detected_ips.get(ip_type).cloned().unwrap_or_default();
@@ -108,6 +153,7 @@ pub async fn update_once(
match result {
SetResult::Updated => {
notify = true; // NEW
let ip_strs: Vec<String> = ips.iter().map(|ip| ip.to_string()).collect();
messages.push(Message::new_ok(&format!(
"Updated {domain_str} -> {}",
@@ -115,6 +161,7 @@ pub async fn update_once(
)));
}
SetResult::Failed => {
notify = true; // NEW
all_ok = false;
messages.push(Message::new_fail(&format!(
"Failed to update {domain_str}"
@@ -147,12 +194,14 @@ pub async fn update_once(
match result {
SetResult::Updated => {
notify = true; // NEW
messages.push(Message::new_ok(&format!(
"Updated WAF list {}",
waf_list.describe()
)));
}
SetResult::Failed => {
notify = true; // NEW
all_ok = false;
messages.push(Message::new_fail(&format!(
"Failed to update WAF list {}",
@@ -164,19 +213,28 @@ pub async fn update_once(
}
}
// Send heartbeat
let heartbeat_msg = Message::merge(messages.clone());
heartbeat.ping(&heartbeat_msg).await;
// Send heartbeat ONLY if something meaningful happened
if notify {
let heartbeat_msg = Message::merge(messages.clone());
heartbeat.ping(&heartbeat_msg).await;
}
// Send notifications
let notifier_msg = Message::merge(messages);
notifier.send(&notifier_msg).await;
// Send notifications ONLY when IP changed or failed
if notify {
let notifier_msg = Message::merge(messages);
notifier.send(&notifier_msg).await;
}
all_ok
}
/// 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, ppfmt: &PP) -> bool {
let legacy = match &config.legacy_config {
Some(l) => l,
None => return false,
@@ -190,28 +248,81 @@ async fn update_legacy(config: &AppConfig, _ppfmt: &PP) -> bool {
let ddns = LegacyDdnsClient {
client,
cf_api_base: "https://api.cloudflare.com/client/v4".to_string(),
ipv4_urls: vec![
"https://1.1.1.1/cdn-cgi/trace".to_string(),
"https://1.0.0.1/cdn-cgi/trace".to_string(),
],
ipv6_urls: vec![
"https://[2606:4700:4700::1111]/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();
// Detect IPs using the shared provider abstraction
let detection_client = Client::builder()
.timeout(config.detection_timeout)
.build()
.unwrap_or_default();
let ips = ddns
.get_ips(
legacy.a,
legacy.aaaa,
legacy.purge_unknown_records,
&legacy.cloudflare,
&mut warnings,
)
.await;
let mut ips = HashMap::new();
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 {
if let Some(cf_filter) =
CloudflareIpFilter::fetch(&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
});
} else {
ppfmt.warningf(
pp::EMOJI_WARNING,
"Could not fetch Cloudflare IP ranges; skipping filter",
);
}
}
ddns.update_ips(
&ips,
@@ -270,141 +381,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,
@@ -684,6 +667,7 @@ mod tests {
managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(5),
reject_cloudflare_ips: false,
dry_run,
emoji: false,
quiet: true,
@@ -1647,86 +1631,6 @@ mod tests {
// 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;
@@ -1741,8 +1645,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 {
@@ -1775,8 +1677,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 {
@@ -1806,8 +1706,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 {
@@ -1830,8 +1728,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 {
@@ -1862,8 +1758,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,75 +1778,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;
@@ -1989,8 +1814,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 {
@@ -2047,8 +1870,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 {
@@ -2091,8 +1912,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 {
@@ -2144,8 +1963,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 {
@@ -2201,8 +2018,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 {
@@ -2252,8 +2067,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();
@@ -2298,8 +2111,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 {
@@ -2332,8 +2143,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 {
@@ -2349,14 +2158,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