13 Commits

Author SHA1 Message Date
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
9 changed files with 581 additions and 51 deletions

View File

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

227
Cargo.lock generated
View File

@@ -20,6 +20,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]] [[package]]
name = "assert-json-diff" name = "assert-json-diff"
version = "2.0.2" version = "2.0.2"
@@ -68,9 +74,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.56" version = "1.2.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -103,7 +109,7 @@ dependencies = [
[[package]] [[package]]
name = "cloudflare-ddns" name = "cloudflare-ddns"
version = "2.0.1" version = "2.0.4"
dependencies = [ dependencies = [
"chrono", "chrono",
"idna", "idna",
@@ -187,6 +193,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -306,11 +318,24 @@ dependencies = [
"cfg-if", "cfg-if",
"js-sys", "js-sys",
"libc", "libc",
"r-efi", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen", "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]] [[package]]
name = "h2" name = "h2"
version = "0.4.13" version = "0.4.13"
@@ -330,12 +355,27 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.5.2" version = "0.5.2"
@@ -555,6 +595,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.1.0" version = "1.1.0"
@@ -593,7 +639,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.16.1",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -634,6 +682,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.183" version = "0.2.183"
@@ -702,9 +756,9 @@ dependencies = [
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
@@ -742,6 +796,16 @@ dependencies = [
"zerocopy", "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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -821,6 +885,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.2" version = "0.9.2"
@@ -997,6 +1067,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@@ -1140,7 +1216,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.3.4", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -1178,9 +1254,9 @@ dependencies = [
[[package]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.10.0" version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [ dependencies = [
"tinyvec_macros", "tinyvec_macros",
] ]
@@ -1317,6 +1393,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -1365,6 +1447,15 @@ dependencies = [
"wit-bindgen", "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]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.114" version = "0.2.114"
@@ -1424,6 +1515,40 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.91" version = "0.3.91"
@@ -1705,6 +1830,88 @@ name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" 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]] [[package]]
name = "writeable" name = "writeable"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cloudflare-ddns" name = "cloudflare-ddns"
version = "2.0.1" version = "2.0.4"
edition = "2021" edition = "2021"
description = "Access your home network remotely via a custom domain name without a static IP" description = "Access your home network remotely via a custom domain name without a static IP"
license = "GPL-3.0" license = "GPL-3.0"

View File

@@ -28,6 +28,7 @@ Configure everything with environment variables. Supports notifications, heartbe
- 🎨 **Pretty output with emoji** — Configurable emoji and verbosity levels - 🎨 **Pretty output with emoji** — Configurable emoji and verbosity levels
- 🔒 **Zero-log IP detection** — Uses Cloudflare's [cdn-cgi/trace](https://www.cloudflare.com/cdn-cgi/trace) by default - 🔒 **Zero-log IP detection** — Uses Cloudflare's [cdn-cgi/trace](https://www.cloudflare.com/cdn-cgi/trace) by default
- 🏠 **CGNAT-aware local detection** — Filters out shared address space (100.64.0.0/10) and private ranges - 🏠 **CGNAT-aware local detection** — Filters out shared address space (100.64.0.0/10) and private ranges
- 🚫 **Cloudflare IP rejection** — Optionally reject Cloudflare anycast IPs to prevent incorrect DNS updates
- 🤏 **Tiny static binary** — ~1.9 MB Docker image built from scratch, zero runtime dependencies - 🤏 **Tiny static binary** — ~1.9 MB Docker image built from scratch, zero runtime dependencies
## 🚀 Quick Start ## 🚀 Quick Start
@@ -87,6 +88,16 @@ Available providers:
| `literal:<ips>` | 📌 Static IP addresses (comma-separated) | | `literal:<ips>` | 📌 Static IP addresses (comma-separated) |
| `none` | 🚫 Disable this IP type | | `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 ## ⏱️ Scheduling
| Variable | Default | Description | | 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 | | `MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX` | — | 🎯 Managed WAF items regex |
| `DETECTION_TIMEOUT` | `5s` | ⏳ IP detection timeout | | `DETECTION_TIMEOUT` | `5s` | ⏳ IP detection timeout |
| `UPDATE_TIMEOUT` | `30s` | ⏳ API request timeout | | `UPDATE_TIMEOUT` | `30s` | ⏳ API request timeout |
| `REJECT_CLOUDFLARE_IPS` | `false` | 🚫 Reject Cloudflare anycast IPs |
| `EMOJI` | `true` | 🎨 Enable emoji output | | `EMOJI` | `true` | 🎨 Enable emoji output |
| `QUIET` | `false` | 🤫 Suppress info output | | `QUIET` | `false` | 🤫 Suppress info output |
| `HEALTHCHECKS` | — | 💓 Healthchecks.io URL | | `HEALTHCHECKS` | — | 💓 Healthchecks.io URL |

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

@@ -89,6 +89,7 @@ pub struct AppConfig {
pub managed_waf_comment_regex: Option<regex::Regex>, pub managed_waf_comment_regex: Option<regex::Regex>,
pub detection_timeout: Duration, pub detection_timeout: Duration,
pub update_timeout: Duration, pub update_timeout: Duration,
pub reject_cloudflare_ips: bool,
pub dry_run: bool, pub dry_run: bool,
pub emoji: bool, pub emoji: bool,
pub quiet: bool, pub quiet: bool,
@@ -439,6 +440,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Ap
managed_waf_comment_regex: None, managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5), detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(30), update_timeout: Duration::from_secs(30),
reject_cloudflare_ips: false,
dry_run, dry_run,
emoji: false, emoji: false,
quiet: false, quiet: false,
@@ -509,6 +511,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
let emoji = getenv_bool("EMOJI", true); let emoji = getenv_bool("EMOJI", true);
let quiet = getenv_bool("QUIET", false); let quiet = getenv_bool("QUIET", false);
let reject_cloudflare_ips = getenv_bool("REJECT_CLOUDFLARE_IPS", false);
// Validate: must have at least one update target // Validate: must have at least one update target
if domains.is_empty() && waf_lists.is_empty() { if domains.is_empty() && waf_lists.is_empty() {
@@ -559,6 +562,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
managed_waf_comment_regex, managed_waf_comment_regex,
detection_timeout, detection_timeout,
update_timeout, update_timeout,
reject_cloudflare_ips,
dry_run: false, // Set later from CLI args dry_run: false, // Set later from CLI args
emoji, emoji,
quiet, quiet,
@@ -659,6 +663,10 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) {
inner.infof("", "Delete on stop: enabled"); inner.infof("", "Delete on stop: enabled");
} }
if config.reject_cloudflare_ips {
inner.infof("", "Reject Cloudflare IPs: enabled");
}
if let Some(ref comment) = config.record_comment { if let Some(ref comment) = config.record_comment {
inner.infof("", &format!("Record comment: {comment}")); inner.infof("", &format!("Record comment: {comment}"));
} }
@@ -1190,6 +1198,7 @@ mod tests {
managed_waf_comment_regex: None, managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5), detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(30), update_timeout: Duration::from_secs(30),
reject_cloudflare_ips: false,
dry_run: false, dry_run: false,
emoji: false, emoji: false,
quiet: false, quiet: false,
@@ -1223,6 +1232,7 @@ mod tests {
managed_waf_comment_regex: None, managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5), detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(30), update_timeout: Duration::from_secs(30),
reject_cloudflare_ips: false,
dry_run: false, dry_run: false,
emoji: false, emoji: false,
quiet: false, quiet: false,
@@ -1881,6 +1891,7 @@ mod tests {
managed_waf_comment_regex: None, managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5), detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(30), update_timeout: Duration::from_secs(30),
reject_cloudflare_ips: false,
dry_run: false, dry_run: false,
emoji: false, emoji: false,
quiet: false, quiet: false,
@@ -1916,6 +1927,7 @@ mod tests {
managed_waf_comment_regex: None, managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5), detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(30), update_timeout: Duration::from_secs(30),
reject_cloudflare_ips: false,
dry_run: false, dry_run: false,
emoji: false, emoji: false,
quiet: true, quiet: true,
@@ -1948,6 +1960,7 @@ mod tests {
managed_waf_comment_regex: None, managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5), detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(30), update_timeout: Duration::from_secs(30),
reject_cloudflare_ips: false,
dry_run: false, dry_run: false,
emoji: false, emoji: false,
quiet: false, quiet: false,

View File

@@ -1,3 +1,4 @@
mod cf_ip_filter;
mod cloudflare; mod cloudflare;
mod config; mod config;
mod domain; mod domain;

View File

@@ -145,12 +145,15 @@ impl ProviderType {
// --- Cloudflare Trace --- // --- Cloudflare Trace ---
/// Primary trace URL uses a hostname so DNS resolves normally, avoiding the /// Primary trace URLs use literal IPs to guarantee the correct address family.
/// problem where WARP/Zero Trust intercepts requests to literal 1.1.1.1. /// api.cloudflare.com is dual-stack, so on dual-stack hosts (e.g. Docker
const CF_TRACE_PRIMARY: &str = "https://api.cloudflare.com/cdn-cgi/trace"; /// --net=host with IPv6) the connection may go via IPv6 even when detecting
/// Fallback URLs use literal IPs for when api.cloudflare.com is unreachable. /// IPv4, causing the trace endpoint to return the wrong address family.
const CF_TRACE_V4_FALLBACK: &str = "https://1.0.0.1/cdn-cgi/trace"; const CF_TRACE_V4_PRIMARY: &str = "https://1.0.0.1/cdn-cgi/trace";
const CF_TRACE_V6_FALLBACK: &str = "https://[2606:4700:4700::1001]/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> { pub fn parse_trace_ip(body: &str) -> Option<String> {
for line in body.lines() { for line in body.lines() {
@@ -161,13 +164,17 @@ pub fn parse_trace_ip(body: &str) -> Option<String> {
None None
} }
async fn fetch_trace_ip(client: &Client, url: &str, timeout: Duration) -> Option<IpAddr> { async fn fetch_trace_ip(
let resp = client client: &Client,
.get(url) url: &str,
.timeout(timeout) timeout: Duration,
.send() host_override: Option<&str>,
.await ) -> Option<IpAddr> {
.ok()?; 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 body = resp.text().await.ok()?;
let ip_str = parse_trace_ip(&body)?; let ip_str = parse_trace_ip(&body)?;
ip_str.parse::<IpAddr>().ok() ip_str.parse::<IpAddr>().ok()
@@ -199,7 +206,7 @@ async fn detect_cloudflare_trace(
let client = build_split_client(ip_type, timeout); let client = build_split_client(ip_type, timeout);
if let Some(url) = custom_url { if let Some(url) = custom_url {
if let Some(ip) = fetch_trace_ip(&client, url, timeout).await { if let Some(ip) = fetch_trace_ip(&client, url, timeout, None).await {
if validate_detected_ip(&ip, ip_type, ppfmt) { if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip]; return vec![ip];
} }
@@ -211,13 +218,13 @@ async fn detect_cloudflare_trace(
return Vec::new(); return Vec::new();
} }
let fallback = match ip_type { let primary = match ip_type {
IpType::V4 => CF_TRACE_V4_FALLBACK, IpType::V4 => CF_TRACE_V4_PRIMARY,
IpType::V6 => CF_TRACE_V6_FALLBACK, IpType::V6 => CF_TRACE_V6_PRIMARY,
}; };
// Try primary (api.cloudflare.com — resolves via DNS, avoids literal-IP interception) // Try primary (literal IP — guarantees correct address family)
if let Some(ip) = fetch_trace_ip(&client, CF_TRACE_PRIMARY, timeout).await { if let Some(ip) = fetch_trace_ip(&client, primary, timeout, Some("one.one.one.one")).await {
if validate_detected_ip(&ip, ip_type, ppfmt) { if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip]; return vec![ip];
} }
@@ -227,8 +234,8 @@ async fn detect_cloudflare_trace(
&format!("{} not detected via primary, trying fallback", ip_type.describe()), &format!("{} not detected via primary, trying fallback", ip_type.describe()),
); );
// Try fallback (literal IP — useful when DNS is broken) // Try fallback (hostname-based — works when literal IPs are intercepted by WARP/Zero Trust)
if let Some(ip) = fetch_trace_ip(&client, fallback, timeout).await { if let Some(ip) = fetch_trace_ip(&client, CF_TRACE_FALLBACK, timeout, None).await {
if validate_detected_ip(&ip, ip_type, ppfmt) { if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip]; return vec![ip];
} }
@@ -918,14 +925,13 @@ mod tests {
// ---- trace URL constants ---- // ---- trace URL constants ----
#[test] #[test]
fn test_trace_primary_uses_hostname_not_ip() { fn test_trace_urls() {
// Primary must use a hostname (api.cloudflare.com) so DNS resolves normally // Primary URLs use literal IPs to guarantee correct address family.
// and WARP/Zero Trust doesn't intercept the request. assert!(CF_TRACE_V4_PRIMARY.contains("1.0.0.1"));
assert_eq!(CF_TRACE_PRIMARY, "https://api.cloudflare.com/cdn-cgi/trace"); assert!(CF_TRACE_V6_PRIMARY.contains("2606:4700:4700::1001"));
assert!(CF_TRACE_PRIMARY.contains("api.cloudflare.com")); // Fallback uses a hostname for when literal IPs are intercepted (WARP/Zero Trust).
// Fallbacks use literal IPs for when DNS is broken. assert_eq!(CF_TRACE_FALLBACK, "https://api.cloudflare.com/cdn-cgi/trace");
assert!(CF_TRACE_V4_FALLBACK.contains("1.0.0.1")); assert!(CF_TRACE_FALLBACK.contains("api.cloudflare.com"));
assert!(CF_TRACE_V6_FALLBACK.contains("2606:4700:4700::1001"));
} }
// ---- build_split_client ---- // ---- build_split_client ----

View File

@@ -1,3 +1,4 @@
use crate::cf_ip_filter::CloudflareIpFilter;
use crate::cloudflare::{CloudflareHandle, SetResult}; use crate::cloudflare::{CloudflareHandle, SetResult};
use crate::config::{AppConfig, LegacyCloudflareEntry, LegacySubdomainEntry}; use crate::config::{AppConfig, LegacyCloudflareEntry, LegacySubdomainEntry};
use crate::domain::make_fqdn; use crate::domain::make_fqdn;
@@ -24,6 +25,7 @@ pub async fn update_once(
let mut all_ok = true; let mut all_ok = true;
let mut messages = Vec::new(); let mut messages = Vec::new();
let mut notify = false; // NEW: track meaningful events
if config.legacy_mode { if config.legacy_mode {
all_ok = update_legacy(config, ppfmt).await; all_ok = update_legacy(config, 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) // Update DNS records (env var mode - domain-based)
for (ip_type, domains) in &config.domains { for (ip_type, domains) in &config.domains {
let ips = detected_ips.get(ip_type).cloned().unwrap_or_default(); let ips = detected_ips.get(ip_type).cloned().unwrap_or_default();
@@ -108,6 +153,7 @@ pub async fn update_once(
match result { match result {
SetResult::Updated => { SetResult::Updated => {
notify = true; // NEW
let ip_strs: Vec<String> = ips.iter().map(|ip| ip.to_string()).collect(); let ip_strs: Vec<String> = ips.iter().map(|ip| ip.to_string()).collect();
messages.push(Message::new_ok(&format!( messages.push(Message::new_ok(&format!(
"Updated {domain_str} -> {}", "Updated {domain_str} -> {}",
@@ -115,6 +161,7 @@ pub async fn update_once(
))); )));
} }
SetResult::Failed => { SetResult::Failed => {
notify = true; // NEW
all_ok = false; all_ok = false;
messages.push(Message::new_fail(&format!( messages.push(Message::new_fail(&format!(
"Failed to update {domain_str}" "Failed to update {domain_str}"
@@ -147,12 +194,14 @@ pub async fn update_once(
match result { match result {
SetResult::Updated => { SetResult::Updated => {
notify = true; // NEW
messages.push(Message::new_ok(&format!( messages.push(Message::new_ok(&format!(
"Updated WAF list {}", "Updated WAF list {}",
waf_list.describe() waf_list.describe()
))); )));
} }
SetResult::Failed => { SetResult::Failed => {
notify = true; // NEW
all_ok = false; all_ok = false;
messages.push(Message::new_fail(&format!( messages.push(Message::new_fail(&format!(
"Failed to update WAF list {}", "Failed to update WAF list {}",
@@ -164,13 +213,17 @@ pub async fn update_once(
} }
} }
// Send heartbeat // Send heartbeat ONLY if something meaningful happened
let heartbeat_msg = Message::merge(messages.clone()); if notify {
heartbeat.ping(&heartbeat_msg).await; let heartbeat_msg = Message::merge(messages.clone());
heartbeat.ping(&heartbeat_msg).await;
}
// Send notifications // Send notifications ONLY when IP changed or failed
let notifier_msg = Message::merge(messages); if notify {
notifier.send(&notifier_msg).await; let notifier_msg = Message::merge(messages);
notifier.send(&notifier_msg).await;
}
all_ok all_ok
} }
@@ -684,6 +737,7 @@ mod tests {
managed_waf_comment_regex: None, managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5), detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(5),
reject_cloudflare_ips: false,
dry_run, dry_run,
emoji: false, emoji: false,
quiet: true, quiet: true,