22 Commits

Author SHA1 Message Date
dependabot[bot]
3b272c67a9 Bump rustls-webpki from 0.103.9 to 0.103.10
Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.9 to 0.103.10.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.9...v/0.103.10)

---
updated-dependencies:
- dependency-name: rustls-webpki
  dependency-version: 0.103.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-21 09:41:42 +00:00
Timothy Miller
8c7af02698 Revise SECURITY.md with version support and reporting updates
Updated the security policy to include new version support details and improved reporting guidelines for vulnerabilities.
2026-03-19 23:34:45 -04:00
Timothy Miller
245ac0b061 Potential fix for code scanning alert no. 6: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-19 23:30:56 -04:00
Timothy Miller
2446c1d6a0 Bump crate to 2.0.8 and refine updater behavior
Deduplicate up-to-date messages by tracking noop keys and move logging
to the updater so callers only log the first noop.
Reuse a single reqwest Client for IP detection instead of rebuilding it
for each call.
Always ping heartbeat even when there are no meaningful changes.
Fix Pushover shoutrrr parsing (token@user order) and update tests
2026-03-19 23:22:20 -04:00
Timothy Miller
9b8aba5e20 Add CachedCloudflareFilter
Introduce CachedCloudflareFilter that caches Cloudflare IP ranges and
refreshes every 24 hours. If a refresh fails the previously cached
ranges
are retained and a warning is emitted. Wire the cache through main and
updater so Cloudflare fetches reuse the cached result. Update tests and
bump crate version to 2.0.7
2026-03-19 19:24:44 -04:00
Timothy Miller
83dd454c42 Fetch CF ranges concurrently and prevent writes
Use tokio::join to fetch IPv4 and IPv6 Cloudflare ranges in parallel.
When range fetch fails, avoid performing updates that could write
Cloudflare addresses by clearing detected/filtered IP lists and emitting
warnings. Add unit tests to validate parsing and boundary checks for the
current Cloudflare ranges. Bump crate version to 2.0.6.
Fetch Cloudflare ranges concurrently; avoid writes

Skip updates (clear detected IPs) if Cloudflare ranges can't be
retrieved to avoid writing Cloudflare anycast addresses.
Default REJECT_CLOUDFLARE_IPS=true, update README, add comprehensive
CF-range tests, and bump crate version
Fetch CF ranges concurrently and avoid updates

Enable rejecting Cloudflare IPs by default and skip any updates
if the published ranges cannot be fetched to avoid writing Cloudflare
anycast addresses. Fetch IPv4 and IPv6 ranges concurrently, add
parsing/matching tests, and update README and version.
2026-03-19 18:56:11 -04:00
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
13 changed files with 1334 additions and 472 deletions

View File

@@ -9,20 +9,22 @@ on:
jobs: jobs:
build: build:
permissions:
contents: read
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
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 +37,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 +48,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' }}

231
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.8"
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"
@@ -976,9 +1046,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.9" version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -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.8"
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** — Automatically rejects Cloudflare anycast IPs to prevent incorrect DNS updates
- 🤏 **Tiny static binary** — ~1.9 MB Docker image built from scratch, zero runtime dependencies - 🤏 **Tiny static binary** — ~1.9 MB Docker image built from scratch, zero runtime dependencies
## 🚀 Quick Start ## 🚀 Quick Start
@@ -87,6 +88,18 @@ 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` | `true` | Reject detected IPs that fall within Cloudflare's IP ranges |
Some IP detection providers occasionally return a Cloudflare anycast IP instead of your real public IP. When this happens, your DNS record gets updated to point at Cloudflare infrastructure rather than your actual address.
By default, each update cycle fetches [Cloudflare's published IP ranges](https://www.cloudflare.com/ips/) and skips any detected IP that falls within them. A warning is logged for every rejected IP. If the ranges cannot be fetched, the update is skipped entirely to prevent writing a Cloudflare IP.
To disable this protection, set `REJECT_CLOUDFLARE_IPS=false`.
## ⏱️ Scheduling ## ⏱️ Scheduling
| Variable | Default | Description | | Variable | Default | Description |
@@ -210,6 +223,7 @@ Heartbeats are sent after each update cycle. On failure, a fail signal is sent.
| `MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX` | — | 🎯 Managed WAF items regex | | `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` | `true` | 🚫 Reject Cloudflare anycast IPs |
| `EMOJI` | `true` | 🎨 Enable emoji output | | `EMOJI` | `true` | 🎨 Enable emoji output |
| `QUIET` | `false` | 🤫 Suppress info output | | `QUIET` | `false` | 🤫 Suppress info output |
| `HEALTHCHECKS` | — | 💓 Healthchecks.io URL | | `HEALTHCHECKS` | — | 💓 Healthchecks.io URL |
@@ -356,6 +370,42 @@ Some ISP provided modems only allow port forwarding over IPv4 or IPv6. Disable t
| `aaaa` | bool | `true` | Enable IPv6 (AAAA record) updates | | `aaaa` | bool | `true` | Enable IPv6 (AAAA record) updates |
| `purgeUnknownRecords` | bool | `false` | Delete stale/duplicate DNS records | | `purgeUnknownRecords` | bool | `false` | Delete stale/duplicate DNS records |
| `ttl` | int | `300` | DNS record TTL in seconds (30-86400, values < 30 become auto) | | `ttl` | int | `300` | DNS record TTL in seconds (30-86400, values < 30 become auto) |
| `ip4_provider` | string | `"cloudflare.trace"` | IPv4 detection provider (same values as `IP4_PROVIDER` env var) |
| `ip6_provider` | string | `"cloudflare.trace"` | IPv6 detection provider (same values as `IP6_PROVIDER` env var) |
### 🚫 Cloudflare IP Rejection (Legacy Mode)
Cloudflare IP rejection is enabled by default in legacy mode too. To disable it, set `REJECT_CLOUDFLARE_IPS=false` alongside your `config.json`:
```bash
REJECT_CLOUDFLARE_IPS=false cloudflare-ddns
```
Or in Docker Compose:
```yml
environment:
- REJECT_CLOUDFLARE_IPS=false
volumes:
- ./config.json:/config.json
```
### 🔍 IP Detection (Legacy Mode)
Legacy mode now uses the same shared provider abstraction as environment variable mode. By default it uses the `cloudflare.trace` provider, which builds an IP-family-bound HTTP client (`0.0.0.0` for IPv4, `[::]` for IPv6) to guarantee the correct address family on dual-stack hosts.
You can override the detection method per address family with `ip4_provider` and `ip6_provider` in your `config.json`. Supported values are the same as the `IP4_PROVIDER` / `IP6_PROVIDER` environment variables: `cloudflare.trace`, `cloudflare.doh`, `ipify`, `local`, `local.iface:<name>`, `url:<https://...>`, `none`.
Set a provider to `"none"` to disable detection for that address family (overrides `a`/`aaaa`):
```json
{
"a": true,
"aaaa": true,
"ip4_provider": "cloudflare.trace",
"ip6_provider": "none"
}
```
Each zone entry contains: Each zone entry contains:

78
SECURITY.md Normal file
View File

@@ -0,0 +1,78 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 2.0.x | :white_check_mark: |
| < 2.0 | :x: |
Only the latest release in the `2.0.x` series receives security updates. The legacy Python codebase and all `1.x` releases are **end-of-life** and will not be patched. Users on older versions should upgrade to the latest release immediately.
## Reporting a Vulnerability
**Please do not open a public GitHub issue for security vulnerabilities.**
Instead, report vulnerabilities privately using one of the following methods:
1. **GitHub Private Vulnerability Reporting** — Use the [Security Advisories](https://github.com/timothymiller/cloudflare-ddns/security/advisories/new) page to submit a private report directly on GitHub.
2. **Email** — Contact the maintainer directly at the email address listed on the [GitHub profile](https://github.com/timothymiller).
### What to Include
- A clear description of the vulnerability and its potential impact
- Steps to reproduce or a proof-of-concept
- Affected version(s)
- Any suggested fix or mitigation, if applicable
### What to Expect
- **Acknowledgment** within 72 hours of your report
- **Status updates** at least every 7 days while the issue is being investigated
- A coordinated disclosure timeline — we aim to release a fix within 30 days of a confirmed vulnerability, and will credit reporters (unless anonymity is preferred) in the release notes
If a report is declined (e.g., out of scope or not reproducible), you will receive an explanation.
## Security Considerations
This project handles **Cloudflare API tokens** that grant DNS editing privileges. Users should be aware of the following:
### API Token Handling
- **Never commit your API token** to version control or include it in Docker images.
- Use `CLOUDFLARE_API_TOKEN_FILE` or Docker secrets to inject tokens at runtime rather than passing them as plain environment variables where possible.
- Create a **scoped API token** with only "Edit DNS" permission on the specific zones you need — avoid using Global API Keys.
### Container Security
- The Docker image runs as a **static binary from scratch** with zero runtime dependencies, which minimizes the attack surface.
- Use `security_opt: no-new-privileges:true` in Docker Compose deployments.
- Pin image tags to a specific version (e.g., `timothyjmiller/cloudflare-ddns:v2.0.8`) rather than using `latest` in production.
### Network Security
- The default IP detection provider (`cloudflare.trace`) communicates directly with Cloudflare's infrastructure over HTTPS and does not log your IP.
- All Cloudflare API calls are made over HTTPS/TLS.
- `--network host` mode is required for IPv6 detection — be aware this gives the container access to the host's full network stack.
### Supply Chain
- The project is built with `cargo` and all dependencies are declared in `Cargo.lock` for reproducible builds.
- Docker images are built via GitHub Actions and published to Docker Hub. Multi-arch builds cover `linux/amd64`, `linux/arm64`, and `linux/ppc64le`.
## Scope
The following are considered **in scope** for security reports:
- Authentication or authorization flaws (e.g., token leakage, insufficient credential protection)
- Injection vulnerabilities in configuration parsing
- Vulnerabilities in DNS record handling that could lead to record hijacking or poisoning
- Dependency vulnerabilities with a demonstrable exploit path
- Container escape or privilege escalation
The following are **out of scope**:
- Denial of service against the user's own instance
- Vulnerabilities in Cloudflare's API or infrastructure (report those to [Cloudflare](https://hackerone.com/cloudflare))
- Social engineering attacks
- Issues requiring physical access to the host machine

View File

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

421
src/cf_ip_filter.rs Normal file
View File

@@ -0,0 +1,421 @@
use crate::pp::{self, PP};
use reqwest::Client;
use std::net::IpAddr;
use std::time::{Duration, Instant};
const CF_IPV4_URL: &str = "https://www.cloudflare.com/ips-v4";
const CF_IPV6_URL: &str = "https://www.cloudflare.com/ips-v6";
/// A CIDR range parsed from "address/prefix" notation.
struct CidrRange {
addr: IpAddr,
prefix_len: u8,
}
impl CidrRange {
fn parse(s: &str) -> Option<Self> {
let (addr_str, prefix_str) = s.split_once('/')?;
let addr: IpAddr = addr_str.parse().ok()?;
let prefix_len: u8 = prefix_str.parse().ok()?;
match addr {
IpAddr::V4(_) if prefix_len > 32 => None,
IpAddr::V6(_) if prefix_len > 128 => None,
_ => Some(Self { addr, prefix_len }),
}
}
fn contains(&self, ip: &IpAddr) -> bool {
match (self.addr, ip) {
(IpAddr::V4(net), IpAddr::V4(ip)) => {
let net_bits = u32::from(net);
let ip_bits = u32::from(*ip);
if self.prefix_len == 0 {
return true;
}
let mask = !0u32 << (32 - self.prefix_len);
(net_bits & mask) == (ip_bits & mask)
}
(IpAddr::V6(net), IpAddr::V6(ip)) => {
let net_bits = u128::from(net);
let ip_bits = u128::from(*ip);
if self.prefix_len == 0 {
return true;
}
let mask = !0u128 << (128 - self.prefix_len);
(net_bits & mask) == (ip_bits & mask)
}
_ => false,
}
}
}
/// Holds parsed Cloudflare CIDR ranges for IP filtering.
pub struct CloudflareIpFilter {
ranges: Vec<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();
let (v4_result, v6_result) = tokio::join!(
client.get(CF_IPV4_URL).timeout(timeout).send(),
client.get(CF_IPV6_URL).timeout(timeout).send(),
);
for (url, result) in [(CF_IPV4_URL, v4_result), (CF_IPV6_URL, v6_result)] {
match result {
Ok(resp) if resp.status().is_success() => match resp.text().await {
Ok(body) => {
for line in body.lines() {
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))
}
}
/// Refresh interval for Cloudflare IP ranges (24 hours).
const CF_RANGE_REFRESH: Duration = Duration::from_secs(24 * 60 * 60);
/// Cached wrapper around [`CloudflareIpFilter`].
///
/// Fetches once, then re-uses the cached ranges for [`CF_RANGE_REFRESH`].
/// If a refresh fails, the previously cached ranges are kept.
pub struct CachedCloudflareFilter {
filter: Option<CloudflareIpFilter>,
fetched_at: Option<Instant>,
}
impl CachedCloudflareFilter {
pub fn new() -> Self {
Self {
filter: None,
fetched_at: None,
}
}
/// Return a reference to the current filter, refreshing if stale or absent.
pub async fn get(
&mut self,
client: &Client,
timeout: Duration,
ppfmt: &PP,
) -> Option<&CloudflareIpFilter> {
let stale = match self.fetched_at {
Some(t) => t.elapsed() >= CF_RANGE_REFRESH,
None => true,
};
if stale {
match CloudflareIpFilter::fetch(client, timeout, ppfmt).await {
Some(new_filter) => {
self.filter = Some(new_filter);
self.fetched_at = Some(Instant::now());
}
None => {
if self.filter.is_some() {
ppfmt.warningf(
pp::EMOJI_WARNING,
"Failed to refresh Cloudflare IP ranges; using cached version",
);
// Keep using cached filter, but don't update fetched_at
// so we retry next cycle.
}
// If no cached filter exists, return None (caller handles fail-safe).
}
}
}
self.filter.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
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));
}
/// All real Cloudflare ranges as of 2026-03. Verifies every range parses
/// and that the first and last IP in each range is matched while the
/// address just past the end is not.
const ALL_CF_RANGES: &str = "\
173.245.48.0/20
103.21.244.0/22
103.22.200.0/22
103.31.4.0/22
141.101.64.0/18
108.162.192.0/18
190.93.240.0/20
188.114.96.0/20
197.234.240.0/22
198.41.128.0/17
162.158.0.0/15
104.16.0.0/13
104.24.0.0/14
172.64.0.0/13
131.0.72.0/22
2400:cb00::/32
2606:4700::/32
2803:f800::/32
2405:b500::/32
2405:8100::/32
2a06:98c0::/29
2c0f:f248::/32
";
#[test]
fn test_all_real_ranges_parse() {
let filter = CloudflareIpFilter::from_lines(ALL_CF_RANGES).unwrap();
assert_eq!(filter.ranges.len(), 22);
}
/// For a /N IPv4 range starting at `base`, return (first, last, just_outside).
fn v4_range_bounds(a: u8, b: u8, c: u8, d: u8, prefix: u8) -> (Ipv4Addr, Ipv4Addr, Ipv4Addr) {
let base = u32::from(Ipv4Addr::new(a, b, c, d));
let size = 1u32 << (32 - prefix);
let first = Ipv4Addr::from(base);
let last = Ipv4Addr::from(base + size - 1);
let outside = Ipv4Addr::from(base + size);
(first, last, outside)
}
#[test]
fn test_all_real_ipv4_ranges_match() {
// Test each range individually so adjacent ranges (e.g. 104.16.0.0/13
// and 104.24.0.0/14) don't cause false failures on boundary checks.
let ranges: &[(u8, u8, u8, u8, u8)] = &[
(173, 245, 48, 0, 20),
(103, 21, 244, 0, 22),
(103, 22, 200, 0, 22),
(103, 31, 4, 0, 22),
(141, 101, 64, 0, 18),
(108, 162, 192, 0, 18),
(190, 93, 240, 0, 20),
(188, 114, 96, 0, 20),
(197, 234, 240, 0, 22),
(198, 41, 128, 0, 17),
(162, 158, 0, 0, 15),
(104, 16, 0, 0, 13),
(104, 24, 0, 0, 14),
(172, 64, 0, 0, 13),
(131, 0, 72, 0, 22),
];
for &(a, b, c, d, prefix) in ranges {
let cidr = format!("{a}.{b}.{c}.{d}/{prefix}");
let filter = CloudflareIpFilter::from_lines(&cidr).unwrap();
let (first, last, outside) = v4_range_bounds(a, b, c, d, prefix);
assert!(
filter.contains(&IpAddr::V4(first)),
"First IP {first} should be in {cidr}"
);
assert!(
filter.contains(&IpAddr::V4(last)),
"Last IP {last} should be in {cidr}"
);
assert!(
!filter.contains(&IpAddr::V4(outside)),
"IP {outside} should NOT be in {cidr}"
);
}
}
#[test]
fn test_all_real_ipv6_ranges_match() {
let filter = CloudflareIpFilter::from_lines(ALL_CF_RANGES).unwrap();
// (base high 16-bit segment, prefix len)
let ranges: &[(u16, u16, u8)] = &[
(0x2400, 0xcb00, 32),
(0x2606, 0x4700, 32),
(0x2803, 0xf800, 32),
(0x2405, 0xb500, 32),
(0x2405, 0x8100, 32),
(0x2a06, 0x98c0, 29),
(0x2c0f, 0xf248, 32),
];
for &(seg0, seg1, prefix) in ranges {
let base = u128::from(Ipv6Addr::new(seg0, seg1, 0, 0, 0, 0, 0, 0));
let size = 1u128 << (128 - prefix);
let first = Ipv6Addr::from(base);
let last = Ipv6Addr::from(base + size - 1);
let outside = Ipv6Addr::from(base + size);
assert!(
filter.contains(&IpAddr::V6(first)),
"First IP {first} should be in {seg0:x}:{seg1:x}::/{prefix}"
);
assert!(
filter.contains(&IpAddr::V6(last)),
"Last IP {last} should be in {seg0:x}:{seg1:x}::/{prefix}"
);
assert!(
!filter.contains(&IpAddr::V6(outside)),
"IP {outside} should NOT be in {seg0:x}:{seg1:x}::/{prefix}"
);
}
}
}

View File

@@ -467,7 +467,7 @@ impl CloudflareHandle {
self.update_record(zone_id, &record.id, &payload, ppfmt).await; self.update_record(zone_id, &record.id, &payload, ppfmt).await;
} }
} else { } else {
ppfmt.infof(pp::EMOJI_SKIP, &format!("Record {fqdn} is up to date ({ip_str})")); // Caller handles "up to date" logging based on SetResult::Noop
} }
} else { } else {
// Find an existing managed record to update, or create new // Find an existing managed record to update, or create new
@@ -668,10 +668,7 @@ impl CloudflareHandle {
.collect(); .collect();
if to_add.is_empty() && ids_to_delete.is_empty() { if to_add.is_empty() && ids_to_delete.is_empty() {
ppfmt.infof( // Caller handles "up to date" logging based on SetResult::Noop
pp::EMOJI_SKIP,
&format!("WAF list {} is up to date", waf_list.describe()),
);
return SetResult::Noop; return SetResult::Noop;
} }

View File

@@ -27,6 +27,10 @@ pub struct LegacyConfig {
pub purge_unknown_records: bool, pub purge_unknown_records: bool,
#[serde(default = "default_ttl")] #[serde(default = "default_ttl")]
pub ttl: i64, pub ttl: i64,
#[serde(default)]
pub ip4_provider: Option<String>,
#[serde(default)]
pub ip6_provider: Option<String>,
} }
fn default_true() -> bool { fn default_true() -> bool {
@@ -89,6 +93,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,
@@ -386,7 +391,7 @@ pub fn parse_legacy_config(content: &str) -> Result<LegacyConfig, String> {
} }
/// Convert a legacy config into a unified AppConfig /// 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 // Extract auth from first entry
let auth = if let Some(entry) = legacy.cloudflare.first() { let auth = if let Some(entry) = legacy.cloudflare.first() {
if !entry.authentication.api_token.is_empty() 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()) Auth::Token(String::new())
}; };
// Build providers // Build providers — ip4_provider/ip6_provider override the default cloudflare.trace
let mut providers = HashMap::new(); let mut providers = HashMap::new();
if legacy.a { 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 { 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); 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 CronSchedule::Once
}; };
AppConfig { Ok(AppConfig {
auth, auth,
providers, providers,
domains: HashMap::new(), 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, managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5), detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(30), update_timeout: Duration::from_secs(30),
reject_cloudflare_ips: getenv_bool("REJECT_CLOUDFLARE_IPS", true),
dry_run, dry_run,
emoji: false, emoji: false,
quiet: false, quiet: false,
legacy_mode: true, legacy_mode: true,
legacy_config: Some(legacy), legacy_config: Some(legacy),
repeat, repeat,
} })
} }
// ============================================================ // ============================================================
@@ -509,6 +529,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
let emoji = getenv_bool("EMOJI", true); let emoji = getenv_bool("EMOJI", true);
let quiet = getenv_bool("QUIET", false); let quiet = getenv_bool("QUIET", false);
let reject_cloudflare_ips = getenv_bool("REJECT_CLOUDFLARE_IPS", true);
// Validate: must have at least one update target // Validate: must have at least one update target
if domains.is_empty() && waf_lists.is_empty() { if domains.is_empty() && waf_lists.is_empty() {
@@ -559,6 +580,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,
@@ -579,7 +601,7 @@ pub fn load_config(dry_run: bool, repeat: bool, ppfmt: &PP) -> Result<AppConfig,
} else { } else {
ppfmt.infof(pp::EMOJI_CONFIG, "Using config.json configuration"); ppfmt.infof(pp::EMOJI_CONFIG, "Using config.json configuration");
let legacy = load_legacy_config()?; 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"); inner.infof("", "Delete on stop: enabled");
} }
if !config.reject_cloudflare_ips {
inner.warningf("", "Cloudflare IP rejection: DISABLED (REJECT_CLOUDFLARE_IPS=false)");
}
if let Some(ref comment) = config.record_comment { if let Some(ref comment) = config.record_comment {
inner.infof("", &format!("Record comment: {comment}")); inner.infof("", &format!("Record comment: {comment}"));
} }
@@ -987,8 +1013,10 @@ mod tests {
aaaa: false, aaaa: false,
purge_unknown_records: false, purge_unknown_records: false,
ttl: 300, 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!(config.legacy_mode);
assert!(matches!(config.auth, Auth::Token(ref t) if t == "my-token")); assert!(matches!(config.auth, Auth::Token(ref t) if t == "my-token"));
assert!(config.providers.contains_key(&IpType::V4)); assert!(config.providers.contains_key(&IpType::V4));
@@ -1013,8 +1041,10 @@ mod tests {
aaaa: true, aaaa: true,
purge_unknown_records: false, purge_unknown_records: false,
ttl: 120, 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!(matches!(config.update_cron, CronSchedule::Every(d) if d == Duration::from_secs(120)));
assert!(config.repeat); assert!(config.repeat);
assert!(config.dry_run); assert!(config.dry_run);
@@ -1039,12 +1069,118 @@ mod tests {
aaaa: true, aaaa: true,
purge_unknown_records: false, purge_unknown_records: false,
ttl: 300, 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 } assert!(matches!(config.auth, Auth::Key { ref api_key, ref email }
if api_key == "key123" && email == "test@example.com")); 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 --- // --- is_env_config_mode ---
#[test] #[test]
@@ -1190,6 +1326,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 +1360,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 +2019,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 +2055,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 +2088,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;
@@ -10,8 +11,10 @@ use crate::cloudflare::{Auth, CloudflareHandle};
use crate::config::{AppConfig, CronSchedule}; use crate::config::{AppConfig, CronSchedule};
use crate::notifier::{CompositeNotifier, Heartbeat, Message}; use crate::notifier::{CompositeNotifier, Heartbeat, Message};
use crate::pp::PP; use crate::pp::PP;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use reqwest::Client;
use tokio::signal; use tokio::signal;
use tokio::time::{sleep, Duration}; use tokio::time::{sleep, Duration};
@@ -115,12 +118,18 @@ async fn main() {
// Start heartbeat // Start heartbeat
heartbeat.start().await; heartbeat.start().await;
let mut cf_cache = cf_ip_filter::CachedCloudflareFilter::new();
let detection_client = Client::builder()
.timeout(app_config.detection_timeout)
.build()
.unwrap_or_default();
if app_config.legacy_mode { if app_config.legacy_mode {
// --- Legacy mode (original cloudflare-ddns behavior) --- // --- Legacy mode (original cloudflare-ddns behavior) ---
run_legacy_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running).await; run_legacy_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running, &mut cf_cache, &detection_client).await;
} else { } else {
// --- Env var mode (cf-ddns behavior) --- // --- Env var mode (cf-ddns behavior) ---
run_env_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running).await; run_env_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running, &mut cf_cache, &detection_client).await;
} }
// On shutdown: delete records if configured // On shutdown: delete records if configured
@@ -142,12 +151,16 @@ async fn run_legacy_mode(
heartbeat: &Heartbeat, heartbeat: &Heartbeat,
ppfmt: &PP, ppfmt: &PP,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
cf_cache: &mut cf_ip_filter::CachedCloudflareFilter,
detection_client: &Client,
) { ) {
let legacy = match &config.legacy_config { let legacy = match &config.legacy_config {
Some(l) => l, Some(l) => l,
None => return, None => return,
}; };
let mut noop_reported = HashSet::new();
if config.repeat { if config.repeat {
match (legacy.a, legacy.aaaa) { match (legacy.a, legacy.aaaa) {
(true, true) => println!( (true, true) => println!(
@@ -164,7 +177,7 @@ async fn run_legacy_mode(
} }
while running.load(Ordering::SeqCst) { while running.load(Ordering::SeqCst) {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
for _ in 0..legacy.ttl { for _ in 0..legacy.ttl {
if !running.load(Ordering::SeqCst) { if !running.load(Ordering::SeqCst) {
@@ -174,7 +187,7 @@ async fn run_legacy_mode(
} }
} }
} else { } else {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
} }
} }
@@ -185,11 +198,15 @@ async fn run_env_mode(
heartbeat: &Heartbeat, heartbeat: &Heartbeat,
ppfmt: &PP, ppfmt: &PP,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
cf_cache: &mut cf_ip_filter::CachedCloudflareFilter,
detection_client: &Client,
) { ) {
let mut noop_reported = HashSet::new();
match &config.update_cron { match &config.update_cron {
CronSchedule::Once => { CronSchedule::Once => {
if config.update_on_start { if config.update_on_start {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
} }
} }
schedule => { schedule => {
@@ -205,7 +222,7 @@ async fn run_env_mode(
// Update on start if configured // Update on start if configured
if config.update_on_start { if config.update_on_start {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
} }
// Main loop // Main loop
@@ -232,7 +249,7 @@ async fn run_env_mode(
return; return;
} }
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
} }
} }
} }
@@ -300,6 +317,8 @@ mod tests {
aaaa: false, aaaa: false,
purge_unknown_records: false, purge_unknown_records: false,
ttl: 300, ttl: 300,
ip4_provider: None,
ip6_provider: None,
} }
} }
@@ -379,6 +398,7 @@ mod tests {
config: &[LegacyCloudflareEntry], config: &[LegacyCloudflareEntry],
ttl: i64, ttl: i64,
purge_unknown_records: bool, purge_unknown_records: bool,
noop_reported: &mut std::collections::HashSet<String>,
) { ) {
for entry in config { for entry in config {
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@@ -480,8 +500,10 @@ mod tests {
} }
} }
let noop_key = format!("{fqdn}:{record_type}");
if let Some(ref id) = identifier { if let Some(ref id) = identifier {
if modified { if modified {
noop_reported.remove(&noop_key);
if self.dry_run { if self.dry_run {
println!("[DRY RUN] Would update record {fqdn} -> {ip}"); println!("[DRY RUN] Would update record {fqdn} -> {ip}");
} else { } else {
@@ -497,23 +519,30 @@ mod tests {
) )
.await; .await;
} }
} else if self.dry_run { } else if noop_reported.insert(noop_key) {
println!("[DRY RUN] Record {fqdn} is up to date ({ip})"); if self.dry_run {
println!("[DRY RUN] Record {fqdn} is up to date");
} else {
println!("Record {fqdn} is up to date");
}
} }
} else if self.dry_run {
println!("[DRY RUN] Would add new record {fqdn} -> {ip}");
} else { } else {
println!("Adding new record {fqdn} -> {ip}"); noop_reported.remove(&noop_key);
let create_endpoint = if self.dry_run {
format!("zones/{}/dns_records", entry.zone_id); println!("[DRY RUN] Would add new record {fqdn} -> {ip}");
let _: Option<serde_json::Value> = self } else {
.cf_api( println!("Adding new record {fqdn} -> {ip}");
&create_endpoint, let create_endpoint =
"POST", format!("zones/{}/dns_records", entry.zone_id);
&entry.authentication.api_token, let _: Option<serde_json::Value> = self
Some(&record), .cf_api(
) &create_endpoint,
.await; "POST",
&entry.authentication.api_token,
Some(&record),
)
.await;
}
} }
if purge_unknown_records { if purge_unknown_records {
@@ -633,7 +662,7 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri()); let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id); let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false) ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await; .await;
} }
@@ -682,7 +711,7 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri()); let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id); let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false) ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await; .await;
} }
@@ -725,7 +754,7 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri()); let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id); let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false) ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await; .await;
} }
@@ -759,7 +788,7 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri()).dry_run(); let ddns = TestDdnsClient::new(&mock_server.uri()).dry_run();
let config = test_config(zone_id); let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false) ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await; .await;
} }
@@ -813,8 +842,10 @@ mod tests {
aaaa: false, aaaa: false,
purge_unknown_records: true, purge_unknown_records: true,
ttl: 300, ttl: 300,
ip4_provider: None,
ip6_provider: None,
}; };
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, true) ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, true, &mut std::collections::HashSet::new())
.await; .await;
} }
@@ -912,9 +943,11 @@ mod tests {
aaaa: false, aaaa: false,
purge_unknown_records: false, purge_unknown_records: false,
ttl: 300, ttl: 300,
ip4_provider: None,
ip6_provider: None,
}; };
ddns.commit_record("203.0.113.99", "A", &config.cloudflare, 300, false) ddns.commit_record("203.0.113.99", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await; .await;
} }
} }

View File

@@ -406,7 +406,7 @@ fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
service_type: ShoutrrrServiceType::Pushover, service_type: ShoutrrrServiceType::Pushover,
webhook_url: format!( webhook_url: format!(
"https://api.pushover.net/1/messages.json?token={}&user={}", "https://api.pushover.net/1/messages.json?token={}&user={}",
parts[1], parts[0] parts[0], parts[1]
), ),
}); });
} }
@@ -868,7 +868,7 @@ mod tests {
#[test] #[test]
fn test_parse_pushover() { fn test_parse_pushover() {
let result = parse_shoutrrr_url("pushover://userkey@apitoken").unwrap(); let result = parse_shoutrrr_url("pushover://apitoken@userkey").unwrap();
assert_eq!( assert_eq!(
result.webhook_url, result.webhook_url,
"https://api.pushover.net/1/messages.json?token=apitoken&user=userkey" "https://api.pushover.net/1/messages.json?token=apitoken&user=userkey"
@@ -1307,7 +1307,8 @@ mod tests {
#[test] #[test]
fn test_pushover_url_query_parsing() { fn test_pushover_url_query_parsing() {
// Verify that the pushover webhook URL format contains the right params // Verify that the pushover webhook URL format contains the right params
let service = parse_shoutrrr_url("pushover://myuser@mytoken").unwrap(); // shoutrrr format: pushover://token@user
let service = parse_shoutrrr_url("pushover://mytoken@myuser").unwrap();
let parsed = url::Url::parse(&service.webhook_url).unwrap(); let parsed = url::Url::parse(&service.webhook_url).unwrap();
let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect(); let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect();
assert_eq!(params.get("token").unwrap().as_ref(), "mytoken"); assert_eq!(params.get("token").unwrap().as_ref(), "mytoken");

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()
@@ -176,7 +183,7 @@ async fn fetch_trace_ip(client: &Client, url: &str, timeout: Duration) -> Option
/// Build an HTTP client that only connects via the given IP family. /// 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. /// Binding to 0.0.0.0 forces IPv4-only; binding to [::] forces IPv6-only.
/// This ensures the trace endpoint sees the correct address family. /// This ensures the trace endpoint sees the correct address family.
fn build_split_client(ip_type: IpType, timeout: Duration) -> Client { pub fn build_split_client(ip_type: IpType, timeout: Duration) -> Client {
let local_addr: IpAddr = match ip_type { let local_addr: IpAddr = match ip_type {
IpType::V4 => Ipv4Addr::UNSPECIFIED.into(), IpType::V4 => Ipv4Addr::UNSPECIFIED.into(),
IpType::V6 => Ipv6Addr::UNSPECIFIED.into(), IpType::V6 => Ipv6Addr::UNSPECIFIED.into(),
@@ -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 ----

File diff suppressed because it is too large Load Diff