mirror of
https://github.com/timothymiller/cloudflare-ddns.git
synced 2026-09-20 06:29:03 -03:00
Compare commits
96 Commits
93d351d997
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a54b57932d | ||
|
|
7c6d5b43c1 | ||
|
|
9f4e37f175 | ||
|
|
848da5acd1 | ||
|
|
5289d2067e | ||
|
|
6d7e4d644e | ||
|
|
1faef32f1e | ||
|
|
ee46eb9c4c | ||
|
|
da4b20e252 | ||
|
|
3307adaede | ||
|
|
65f5629157 | ||
|
|
da3b90ff93 | ||
|
|
4ef6ba1b74 | ||
|
|
4cf7987f73 | ||
|
|
70a562b734 | ||
|
|
4c4a5e544a | ||
|
|
9a3c86c9bc | ||
|
|
7ea89cd973 | ||
|
|
a59d787e89 | ||
|
|
f0be440d00 | ||
|
|
e3678c6e24 | ||
|
|
8e79fc8798 | ||
|
|
6cb1e0c874 | ||
|
|
d697c28aa1 | ||
|
|
e67cc4fbca | ||
|
|
b1840ffdf2 | ||
|
|
385f77688e | ||
|
|
166c45873b | ||
|
|
a00a9d66f9 | ||
|
|
344c96e8d9 | ||
|
|
f4100bfe76 | ||
|
|
23bea452ee | ||
|
|
bc69fc42a6 | ||
|
|
ea85b0eadb | ||
|
|
bc14e8b0ca | ||
|
|
d607204884 | ||
|
|
45522f4ceb | ||
|
|
a0ce9812b3 | ||
|
|
7564cb14f0 | ||
|
|
20dbb9495f | ||
|
|
64ff319af5 | ||
|
|
bbe2ae4543 | ||
|
|
572f94b9cf | ||
|
|
9574f67b98 | ||
|
|
ac11623127 | ||
|
|
fddabc7a3d | ||
|
|
548d89dacf | ||
|
|
22320bea79 | ||
|
|
1bb347bea7 | ||
|
|
1d5ad2738c | ||
|
|
08ff76f443 | ||
|
|
199bbae2bd | ||
|
|
591f3e4905 | ||
|
|
687d299bda | ||
|
|
25122d2ce3 | ||
|
|
64c971b198 | ||
|
|
b1d8721e8d | ||
|
|
278f8ae629 | ||
|
|
896e08e38e | ||
|
|
85d060678d | ||
|
|
8501a35c82 | ||
|
|
0f2b772ecb | ||
|
|
b748e80592 | ||
|
|
714ec4f11f | ||
|
|
d344ae0174 | ||
|
|
c76a141f58 | ||
|
|
5eb93b45d1 | ||
|
|
e816cce5a8 | ||
|
|
7b20b7a477 | ||
|
|
38d7023987 | ||
|
|
3e2b8a3a40 | ||
|
|
9b140d2350 | ||
|
|
2913ce379c | ||
|
|
697089b43d | ||
|
|
766e1ac0d4 | ||
|
|
8c7af02698 | ||
|
|
245ac0b061 | ||
|
|
2446c1d6a0 | ||
|
|
9b8aba5e20 | ||
|
|
83dd454c42 | ||
|
|
f8d5b5cb7e | ||
|
|
bb5cc43651 | ||
|
|
7ff8379cfb | ||
|
|
943e38d70c | ||
|
|
ac982a208e | ||
|
|
4b1875b0cd | ||
|
|
54ca4a5eae | ||
|
|
94ce10fccc | ||
|
|
7e96816740 | ||
|
|
8a4b57c163 | ||
|
|
3c7072f4b6 | ||
|
|
3d796d470c | ||
|
|
36bdbea568 | ||
|
|
6085ba0cc2 | ||
|
|
560a3b7b28 | ||
|
|
1b3928865b |
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
target/
|
||||
.git/
|
||||
.github/
|
||||
.gitignore
|
||||
*.md
|
||||
LICENSE
|
||||
54
.github/workflows/helm.yml
vendored
Normal file
54
.github/workflows/helm.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Publish cloudflare-ddns Helm chart
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: publish
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v5
|
||||
|
||||
- name: Lint chart
|
||||
run: helm lint charts/cloudflare-ddns
|
||||
|
||||
- name: Package chart
|
||||
run: |
|
||||
mkdir -p /tmp/helm-charts
|
||||
helm package charts/cloudflare-ddns --destination /tmp/helm-charts
|
||||
|
||||
- name: Extract chart version
|
||||
id: chart_version
|
||||
run: |
|
||||
VERSION=$(grep '^version:' charts/cloudflare-ddns/Chart.yaml | awk '{print $2}')
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io \
|
||||
--username "${{ github.actor }}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Push chart to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
helm push /tmp/helm-charts/cloudflare-ddns-${{ steps.chart_version.outputs.version }}.tgz \
|
||||
oci://ghcr.io/${{ github.repository_owner }}
|
||||
14
.github/workflows/image.yml
vendored
14
.github/workflows/image.yml
vendored
@@ -9,20 +9,22 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -35,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: timothyjmiller/cloudflare-ddns
|
||||
tags: |
|
||||
@@ -46,7 +48,7 @@ jobs:
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/master' }},value=${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,3 +8,6 @@ debug/
|
||||
|
||||
# Git History
|
||||
**/.history/*
|
||||
|
||||
# JetBrains IDE
|
||||
.idea/
|
||||
857
Cargo.lock
generated
857
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
@@ -1,23 +1,23 @@
|
||||
[package]
|
||||
name = "cloudflare-ddns"
|
||||
version = "2.0.1"
|
||||
version = "2.2.0"
|
||||
edition = "2021"
|
||||
description = "Access your home network remotely via a custom domain name without a static IP"
|
||||
license = "GPL-3.0"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
reqwest = { version = "0.13", features = ["json", "form", "rustls-no-provider"], default-features = false }
|
||||
rustls = { version = "0.23", features = ["ring"], default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "signal"] }
|
||||
regex = "1"
|
||||
chrono = { version = "0.4", features = ["clock"] }
|
||||
tokio = { version = "1", features = ["rt", "macros", "time", "signal", "net"] }
|
||||
regex-lite = "0.1"
|
||||
url = "2"
|
||||
idna = "1"
|
||||
if-addrs = "0.13"
|
||||
if-addrs = "0.15"
|
||||
rand = "0.10"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
@@ -5,6 +5,7 @@ WORKDIR /build
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY src ./src
|
||||
RUN cargo build --release
|
||||
RUN apk add --no-cache upx && upx --best --lzma target/release/cloudflare-ddns
|
||||
|
||||
# ---- Release ----
|
||||
FROM scratch AS release
|
||||
|
||||
161
README.md
161
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
Access your home network remotely via a custom domain name without a static IP!
|
||||
|
||||
A feature-complete dynamic DNS client for Cloudflare, written in Rust. The **smallest and most memory-efficient** open-source Cloudflare DDNS Docker image available — **~1.9 MB image size** and **~3.5 MB RAM** at runtime, smaller and leaner than Go-based alternatives. Built as a fully static binary from scratch with zero runtime dependencies.
|
||||
A feature-complete dynamic DNS client for Cloudflare, written in Rust. The **smallest and most memory-efficient** open-source Cloudflare DDNS Docker image available — **~1.1 MB image size** and **~3.5 MB RAM** at runtime, smaller and leaner than Go-based alternatives. Built as a fully static binary from scratch with zero runtime dependencies.
|
||||
|
||||
Configure everything with environment variables. Supports notifications, heartbeat monitoring, WAF list management, flexible scheduling, and more.
|
||||
|
||||
@@ -18,7 +18,7 @@ Configure everything with environment variables. Supports notifications, heartbe
|
||||
- 🃏 **Wildcard domains** — Support for `*.example.com` records
|
||||
- 🌍 **Internationalized domain names** — Full IDN/punycode support (e.g. `münchen.de`)
|
||||
- 🛡️ **WAF list management** — Automatically update Cloudflare WAF IP lists
|
||||
- 🔔 **Notifications** — Shoutrrr-compatible notifications (Discord, Slack, Telegram, Gotify, Pushover, generic webhooks)
|
||||
- 🔔 **Notifications** — Shoutrrr-compatible notifications (Discord, Slack, Telegram, Gotify, Pushover, Zulip, generic webhooks)
|
||||
- 💓 **Heartbeat monitoring** — Healthchecks.io and Uptime Kuma integration
|
||||
- ⏱️ **Cron scheduling** — Flexible update intervals via cron expressions
|
||||
- 🧪 **Dry-run mode** — Preview changes without modifying DNS records
|
||||
@@ -28,7 +28,9 @@ Configure everything with environment variables. Supports notifications, heartbe
|
||||
- 🎨 **Pretty output with emoji** — Configurable emoji and verbosity levels
|
||||
- 🔒 **Zero-log IP detection** — Uses Cloudflare's [cdn-cgi/trace](https://www.cloudflare.com/cdn-cgi/trace) by default
|
||||
- 🏠 **CGNAT-aware local detection** — Filters out shared address space (100.64.0.0/10) and private ranges
|
||||
- 🤏 **Tiny static binary** — ~1.9 MB Docker image built from scratch, zero runtime dependencies
|
||||
- 🚫 **Cloudflare IP rejection** — Automatically rejects Cloudflare anycast IPs to prevent incorrect DNS updates
|
||||
- 🛟 **Outage-proof updates** — Transient IP detection failures never delete or overwrite existing DNS records
|
||||
- 🤏 **Tiny static binary** — ~1.1 MB Docker image built from scratch, zero runtime dependencies
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
@@ -83,10 +85,23 @@ Available providers:
|
||||
| `ipify` | 🌎 ipify.org API |
|
||||
| `local` | 🏠 Local IP via system routing table (no network traffic, CGNAT-aware) |
|
||||
| `local.iface:<name>` | 🔌 IP from a specific network interface (e.g., `local.iface:eth0`) |
|
||||
| `local.iface.stable:<name>` | 🔌 Preferred stable IPv6 address from a Linux network interface, excluding temporary/deprecated addresses |
|
||||
| `url:<url>` | 🔗 Custom HTTP(S) endpoint that returns an IP address |
|
||||
| `literal:<ips>` | 📌 Static IP addresses (comma-separated) |
|
||||
| `none` | 🚫 Disable this IP type |
|
||||
|
||||
## 🚫 Cloudflare IP Rejection
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `REJECT_CLOUDFLARE_IPS` | `true` | Reject detected IPs that fall within Cloudflare's IP ranges |
|
||||
|
||||
Some IP detection providers occasionally return a Cloudflare anycast IP instead of your real public IP. When this happens, your DNS record gets updated to point at Cloudflare infrastructure rather than your actual address.
|
||||
|
||||
By default, each update cycle fetches [Cloudflare's published IP ranges](https://www.cloudflare.com/ips/) and skips any detected IP that falls within them. A warning is logged for every rejected IP. If the ranges cannot be fetched, the update is skipped entirely to prevent writing a Cloudflare IP.
|
||||
|
||||
To disable this protection, set `REJECT_CLOUDFLARE_IPS=false`.
|
||||
|
||||
## ⏱️ Scheduling
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -94,6 +109,7 @@ Available providers:
|
||||
| `UPDATE_CRON` | `@every 5m` | Update schedule |
|
||||
| `UPDATE_ON_START` | `true` | Run an update immediately on startup |
|
||||
| `DELETE_ON_STOP` | `false` | Delete managed DNS records on shutdown |
|
||||
| `DELETE_ON_FAILURE` | `false` | Delete managed DNS records when a provider definitively reports no address of that family (see below) |
|
||||
|
||||
Schedule formats:
|
||||
|
||||
@@ -104,6 +120,13 @@ Schedule formats:
|
||||
|
||||
When `UPDATE_CRON=@once`, `UPDATE_ON_START` must be `true` and `DELETE_ON_STOP` must be `false`.
|
||||
|
||||
### 🛟 Detection Failure Behavior
|
||||
|
||||
A failed IP detection never breaks your DNS. Two cases are distinguished:
|
||||
|
||||
- **Transient failure** — a network-based provider (`cloudflare.trace`, `cloudflare.doh`, `ipify`, `url:`) errored, or all detected IPs were rejected as Cloudflare IPs. The real IP is unknown, so the update is skipped and existing DNS records and WAF list items are always preserved, regardless of `DELETE_ON_FAILURE`. If detection fails for one address family, WAF list updates are skipped entirely so the failed family's IPs aren't stripped from the list.
|
||||
- **Definitive absence** — a deterministic provider (`none`, `literal:`, `local`, `local.iface:`) reports that the host has no address of that family. With `DELETE_ON_FAILURE=true` the managed records for that family are deleted; with the default `false` the update is skipped and existing records are preserved.
|
||||
|
||||
## 📝 DNS Record Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -152,8 +175,13 @@ Supported services:
|
||||
| ✈️ Telegram | `telegram://bot-token@telegram?chats=chat-id` |
|
||||
| 📡 Gotify | `gotify://host/path?token=app-token` |
|
||||
| 📲 Pushover | `pushover://user-key@api-token` |
|
||||
| 💬 Zulip | `zulip://bot-mail:bot-key@host/?stream=stream-name&topic=topic-name` |
|
||||
| 🌐 Generic webhook | `generic://host/path` or `generic+https://host/path` |
|
||||
|
||||
For Zulip, the `@` in the bot email may be written literally or percent-encoded (`%40`), and `topic` is optional (defaults to `Cloudflare DDNS`).
|
||||
|
||||
Generic webhooks send a JSON payload of `{"message": "..."}`. Use `?messagekey=<field>` to rename the field, e.g. `generic://host/path?messagekey=text` for services expecting Slack-style payloads.
|
||||
|
||||
Notifications are sent when DNS records are updated, created, deleted, or when errors occur.
|
||||
|
||||
## 💓 Heartbeat Monitoring
|
||||
@@ -200,6 +228,7 @@ Heartbeats are sent after each update cycle. On failure, a fail signal is sent.
|
||||
| `UPDATE_CRON` | `@every 5m` | ⏱️ Update schedule |
|
||||
| `UPDATE_ON_START` | `true` | 🚀 Update on startup |
|
||||
| `DELETE_ON_STOP` | `false` | 🧹 Delete records on shutdown |
|
||||
| `DELETE_ON_FAILURE` | `false` | 🧹 Delete records when provider definitively reports no IP |
|
||||
| `TTL` | `1` | ⏳ DNS record TTL |
|
||||
| `PROXIED` | `false` | ☁️ Proxied expression |
|
||||
| `RECORD_COMMENT` | — | 💬 DNS record comment |
|
||||
@@ -210,6 +239,7 @@ Heartbeats are sent after each update cycle. On failure, a fail signal is sent.
|
||||
| `MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX` | — | 🎯 Managed WAF items regex |
|
||||
| `DETECTION_TIMEOUT` | `5s` | ⏳ IP detection timeout |
|
||||
| `UPDATE_TIMEOUT` | `30s` | ⏳ API request timeout |
|
||||
| `REJECT_CLOUDFLARE_IPS` | `true` | 🚫 Reject Cloudflare anycast IPs |
|
||||
| `EMOJI` | `true` | 🎨 Enable emoji output |
|
||||
| `QUIET` | `false` | 🤫 Suppress info output |
|
||||
| `HEALTHCHECKS` | — | 💓 Healthchecks.io URL |
|
||||
@@ -244,7 +274,77 @@ services:
|
||||
|
||||
### ☸️ Kubernetes
|
||||
|
||||
The included manifest uses the legacy JSON config mode. Create a secret containing your `config.json` and apply:
|
||||
#### Helm (recommended)
|
||||
|
||||
The chart is published to GitHub Container Registry as an OCI artifact.
|
||||
|
||||
**1. Quick install (single domain):**
|
||||
|
||||
```bash
|
||||
helm install cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \
|
||||
--namespace ddns --create-namespace \
|
||||
--set cloudflare.apiToken=your-api-token \
|
||||
--set domains=example.com
|
||||
```
|
||||
|
||||
> For multiple domains, use a `values.yaml` file — Helm's `--set` treats commas as value-list separators.
|
||||
|
||||
**2. Or use a `values.yaml` for a full configuration:**
|
||||
|
||||
```yaml
|
||||
cloudflare:
|
||||
apiToken: your-api-token # or use existingSecret
|
||||
|
||||
domains: example.com,www.example.com
|
||||
ip4Provider: cloudflare.trace
|
||||
ip6Provider: cloudflare.trace # set to none if IPv6 is not needed
|
||||
|
||||
proxied: "true"
|
||||
updateCron: "@every 5m"
|
||||
|
||||
healthchecks: https://hc-ping.com/your-uuid # optional
|
||||
```
|
||||
|
||||
```bash
|
||||
helm install cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \
|
||||
--namespace ddns --create-namespace \
|
||||
-f values.yaml
|
||||
```
|
||||
|
||||
**Upgrade:**
|
||||
|
||||
```bash
|
||||
helm upgrade cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \
|
||||
--namespace ddns -f values.yaml
|
||||
```
|
||||
|
||||
**Uninstall:**
|
||||
|
||||
```bash
|
||||
helm uninstall cloudflare-ddns --namespace ddns
|
||||
```
|
||||
|
||||
> ⚠️ `hostNetwork: true` is set by default so the pod can detect IPv6 addresses. Disable it with `--set hostNetwork=false` if you only need IPv4.
|
||||
|
||||
**Key values:**
|
||||
|
||||
| Value | Default | Description |
|
||||
|---|---|---|
|
||||
| `cloudflare.apiToken` | `""` | API token (required unless `existingSecret` is set) |
|
||||
| `cloudflare.existingSecret` | `""` | Use a pre-existing Secret instead |
|
||||
| `domains` | `""` | Comma-separated domains for A+AAAA records |
|
||||
| `ip4Domains` / `ip6Domains` | `""` | IPv4-only or IPv6-only domains |
|
||||
| `ip4Provider` / `ip6Provider` | `cloudflare.trace` | IP detection provider |
|
||||
| `proxied` | `"false"` | Proxy through Cloudflare (boolean expression) |
|
||||
| `updateCron` | `@every 5m` | Update schedule |
|
||||
| `hostNetwork` | `true` | Required for local IPv6 detection |
|
||||
| `extraEnv` | `[]` | Additional env vars for advanced settings |
|
||||
|
||||
See [`charts/cloudflare-ddns/values.yaml`](charts/cloudflare-ddns/values.yaml) for all options.
|
||||
|
||||
#### Raw manifest (legacy)
|
||||
|
||||
The `k8s/cloudflare-ddns.yml` manifest uses the legacy JSON config mode. Create a secret containing your `config.json` and apply:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic config-cloudflare-ddns --from-file=config.json -n ddns
|
||||
@@ -299,7 +399,7 @@ The binary is at `target/release/cloudflare-ddns`.
|
||||
|
||||
- 🐳 [Docker](https://docs.docker.com/get-docker/) (amd64, arm64, ppc64le)
|
||||
- 🐙 [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
- ☸️ [Kubernetes](https://kubernetes.io/docs/tasks/tools/)
|
||||
- ☸️ [Kubernetes](https://kubernetes.io/docs/tasks/tools/) + [Helm](https://helm.sh) (OCI chart at `ghcr.io/timothymiller/cloudflare-ddns`)
|
||||
- 🐧 [Systemd](https://www.freedesktop.org/wiki/Software/systemd/)
|
||||
- 🍎 macOS, 🪟 Windows, 🐧 Linux — anywhere Rust compiles
|
||||
|
||||
@@ -349,6 +449,21 @@ Some ISP provided modems only allow port forwarding over IPv4 or IPv6. Disable t
|
||||
|
||||
### ⚙️ Config Options
|
||||
|
||||
By default, the legacy config file is loaded from `./config.json`. Set the `CONFIG_PATH` environment variable to change the directory:
|
||||
|
||||
```bash
|
||||
CONFIG_PATH=/etc/cloudflare-ddns cloudflare-ddns
|
||||
```
|
||||
|
||||
Or in Docker Compose:
|
||||
|
||||
```yml
|
||||
environment:
|
||||
- CONFIG_PATH=/config
|
||||
volumes:
|
||||
- /your/path/config.json:/config/config.json
|
||||
```
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `cloudflare` | array | required | List of zone configurations |
|
||||
@@ -356,6 +471,42 @@ Some ISP provided modems only allow port forwarding over IPv4 or IPv6. Disable t
|
||||
| `aaaa` | bool | `true` | Enable IPv6 (AAAA record) updates |
|
||||
| `purgeUnknownRecords` | bool | `false` | Delete stale/duplicate DNS records |
|
||||
| `ttl` | int | `300` | DNS record TTL in seconds (30-86400, values < 30 become auto) |
|
||||
| `ip4_provider` | string | `"cloudflare.trace"` | IPv4 detection provider (same values as `IP4_PROVIDER` env var) |
|
||||
| `ip6_provider` | string | `"cloudflare.trace"` | IPv6 detection provider (same values as `IP6_PROVIDER` env var) |
|
||||
|
||||
### 🚫 Cloudflare IP Rejection (Legacy Mode)
|
||||
|
||||
Cloudflare IP rejection is enabled by default in legacy mode too. To disable it, set `REJECT_CLOUDFLARE_IPS=false` alongside your `config.json`:
|
||||
|
||||
```bash
|
||||
REJECT_CLOUDFLARE_IPS=false cloudflare-ddns
|
||||
```
|
||||
|
||||
Or in Docker Compose:
|
||||
|
||||
```yml
|
||||
environment:
|
||||
- REJECT_CLOUDFLARE_IPS=false
|
||||
volumes:
|
||||
- ./config.json:/config.json
|
||||
```
|
||||
|
||||
### 🔍 IP Detection (Legacy Mode)
|
||||
|
||||
Legacy mode now uses the same shared provider abstraction as environment variable mode. By default it uses the `cloudflare.trace` provider, which builds an IP-family-bound HTTP client (`0.0.0.0` for IPv4, `[::]` for IPv6) to guarantee the correct address family on dual-stack hosts.
|
||||
|
||||
You can override the detection method per address family with `ip4_provider` and `ip6_provider` in your `config.json`. Supported values are the same as the `IP4_PROVIDER` / `IP6_PROVIDER` environment variables: `cloudflare.trace`, `cloudflare.doh`, `ipify`, `local`, `local.iface:<name>`, `local.iface.stable:<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:
|
||||
|
||||
|
||||
49
RELEASE_NOTES_2.1.1.md
Normal file
49
RELEASE_NOTES_2.1.1.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# cloudflare-ddns v2.1.1
|
||||
|
||||
Maintenance release. Bug fix for `rand` 0.10 API change, plus opt-in failure-safe deletion behavior contributed in the v2.1.0 → v2.1.1 window, dependency refresh, and proportional jitter for IP detection.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Fix:** Restore the build under `rand` 0.10 — `random_range` moved to the `RngExt` trait, and the unconditional jitter sleep in `--repeat` mode no longer fails to compile.
|
||||
- **New:** `DELETE_ON_FAILURE` (env-var mode) controls whether DNS records are removed when an IP detection or update fails. Defaults to `true` to preserve existing behavior; set `DELETE_ON_FAILURE=false` to keep stale records on transient failures instead of yanking them.
|
||||
- **Improvement:** Proportional jitter (up to 20% of the update interval) is added before each scheduled update to spread requests across clients and reduce synchronized spikes against the Cloudflare API.
|
||||
|
||||
## Changes since v2.1.0
|
||||
|
||||
### Features
|
||||
- `DELETE_ON_FAILURE` env var to prevent DNS record deletion on failed updates (#263, thanks @DMaxter)
|
||||
- Proportional jitter on update intervals to desynchronize API traffic (#253, thanks @jhutchings1)
|
||||
|
||||
### Fixes
|
||||
- Compile fix for `rand` 0.10: import `RngExt` so `random_range` resolves
|
||||
- `delete_on_failure` regression test coverage added
|
||||
|
||||
### Dependencies
|
||||
- `rustls` 0.23.37 → 0.23.40
|
||||
- `rustls-webpki` 0.103.10 → 0.103.13
|
||||
- `tokio` 1.50.0 → 1.52.1
|
||||
- `reqwest` 0.13.2 → 0.13.3
|
||||
- `rand` 0.9.2 → 0.10.1
|
||||
|
||||
### Docs
|
||||
- Document `DELETE_ON_FAILURE` in the README
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
- **Default behavior unchanged.** `DELETE_ON_FAILURE` defaults to `true`, matching pre-2.1.1 behavior. Set it to `false` if you want stale records preserved during outages.
|
||||
- No config file schema changes. Existing `config.json` deployments continue to work without edits.
|
||||
|
||||
## Docker
|
||||
|
||||
```sh
|
||||
docker pull timothyjmiller/cloudflare-ddns:2.1.1
|
||||
docker pull timothyjmiller/cloudflare-ddns:latest
|
||||
```
|
||||
|
||||
Multi-arch: `linux/amd64`, `linux/arm64`, `linux/ppc64le`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo test` — 352 tests pass
|
||||
- Release build succeeds, binary size ~1.7 MiB (pre-UPX)
|
||||
- Smoke tested in both legacy `config.json` mode and env-var mode against the live Cloudflare API
|
||||
48
RELEASE_NOTES_2.1.2.md
Normal file
48
RELEASE_NOTES_2.1.2.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# cloudflare-ddns v2.1.2 — Notification & Domain Casing Fixes
|
||||
|
||||
This patch release fixes three bugs reported on GitHub.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- **Mixed-case domains now match existing DNS records (#255).**
|
||||
In env-var mode, configuring a domain with mixed casing (for example
|
||||
`ExaMple.com`) caused every update cycle to attempt a duplicate record
|
||||
create and fail with Cloudflare error `81058: An identical record already
|
||||
exists.` Cloudflare normalizes record names to lowercase server-side, so
|
||||
the lookup is now case-insensitive.
|
||||
|
||||
- **Pushover notifications work again (#258).**
|
||||
The shoutrrr-style URL `pushover://shoutrrr:TOKEN@USER` (the canonical form
|
||||
from `containrrr/shoutrrr`) was being parsed with the literal `shoutrrr:`
|
||||
username included in the API token, which Pushover rejected. The parser
|
||||
now strips the optional `<user>:` prefix from the token segment, restoring
|
||||
the v2.0.7 behavior. Optional shoutrrr query parameters (`?devices=...`,
|
||||
`?priority=...`) are tolerated.
|
||||
|
||||
- **Gotify notifications now produce a valid request URL (#262).**
|
||||
The Gotify URL parser blindly appended `/message` after any query string,
|
||||
producing malformed webhook URLs like
|
||||
`https://host:9090?token=XYZ/message`. The parser now follows shoutrrr's
|
||||
canonical layout — token as the final path segment or `?token=` query —
|
||||
and supports `?disabletls=yes` to switch the resulting webhook from HTTPS
|
||||
to HTTP for typical home-LAN setups, plus the `gotify+http://` /
|
||||
`gotify+https://` aliases.
|
||||
|
||||
## Already addressed (closing #257)
|
||||
|
||||
The robust public-IP discovery enhancements requested in #257 (multi-endpoint
|
||||
trace fallback, strict address-family validation, API request timeouts,
|
||||
duplicate record cleanup) were already folded into the Rust port shipped in
|
||||
v2.0.8 — see `src/provider.rs` (`CF_TRACE_PRIMARY` / `CF_TRACE_FALLBACK`,
|
||||
`validate_detected_ip`, `build_split_client`) and `src/cloudflare.rs`
|
||||
(`set_ips` dedup behavior, per-request `timeout`).
|
||||
|
||||
## Upgrade
|
||||
|
||||
```bash
|
||||
docker pull timothyjmiller/cloudflare-ddns:2.1.2
|
||||
# or
|
||||
docker pull timothyjmiller/cloudflare-ddns:latest
|
||||
```
|
||||
|
||||
No configuration changes are required.
|
||||
64
RELEASE_NOTES_2.2.0.md
Normal file
64
RELEASE_NOTES_2.2.0.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# cloudflare-ddns v2.2.0 — Zulip Notifications, Safer Failure Handling & Helm Chart
|
||||
|
||||
This minor release adds new notification and deployment options, a safer
|
||||
default when IP detection fails, and a stable IPv6 provider for Linux hosts.
|
||||
|
||||
## ⚠️ Behavior change: `DELETE_ON_FAILURE` now defaults to `false` (#277)
|
||||
|
||||
Previously, when a provider definitively reported no address for an IP
|
||||
family, managed DNS records for that family were **deleted** by default —
|
||||
which could take services offline after a transient misdetection.
|
||||
|
||||
- `DELETE_ON_FAILURE` now defaults to **`false`**: on detection failure the
|
||||
update is skipped and existing records are preserved.
|
||||
- Transient detection errors (network failures) always preserve existing
|
||||
records, regardless of this setting.
|
||||
- WAF list updates are now skipped when any configured IP family fails
|
||||
detection, preventing a partial failure from silently stripping that
|
||||
family's IPs from the list.
|
||||
|
||||
If you relied on the old behavior, set `DELETE_ON_FAILURE=true` explicitly.
|
||||
|
||||
## New features
|
||||
|
||||
- **Zulip notifications (#271).**
|
||||
Native `zulip://` shoutrrr URL support:
|
||||
|
||||
```text
|
||||
zulip://bot-mail:bot-key@host/?stream=stream-name&topic=topic-name
|
||||
```
|
||||
|
||||
Messages are sent to the Zulip API (`/api/v1/messages`) with Basic auth.
|
||||
The `@` in the bot email may be written literally or percent-encoded
|
||||
(`%40`); `topic` is optional and defaults to `Cloudflare DDNS`.
|
||||
|
||||
- **Configurable JSON field for generic webhooks (#271).**
|
||||
Generic webhooks send `{"message": "..."}` by default. Append
|
||||
`?messagekey=<field>` to rename the field — e.g.
|
||||
`generic://host/path?messagekey=text` for services expecting Slack-style
|
||||
payloads (including Zulip's slack-compatible endpoints).
|
||||
|
||||
- **Stable local IPv6 provider (#273).**
|
||||
New `local.iface.stable:<name>` provider selects the preferred stable
|
||||
IPv6 address from a Linux network interface, excluding temporary
|
||||
(privacy-extension) and deprecated addresses.
|
||||
|
||||
- **Helm chart (#278).**
|
||||
A Helm chart is now available under `charts/cloudflare-ddns`, published
|
||||
as an OCI artifact to GHCR via CI.
|
||||
|
||||
## Dependency updates
|
||||
|
||||
- reqwest 0.13.4, rustls 0.23.42, tokio 1.52.4, rand 0.10.2,
|
||||
serde_json 1.0.150, actions/checkout 7
|
||||
|
||||
## Upgrade
|
||||
|
||||
```bash
|
||||
docker pull timothyjmiller/cloudflare-ddns:2.2.0
|
||||
# or
|
||||
docker pull timothyjmiller/cloudflare-ddns:latest
|
||||
```
|
||||
|
||||
No configuration changes are required unless you depend on records being
|
||||
deleted when IP detection fails — in that case set `DELETE_ON_FAILURE=true`.
|
||||
78
SECURITY.md
Normal file
78
SECURITY.md
Normal 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.10`) 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
|
||||
4
charts/cloudflare-ddns/.helmignore
Normal file
4
charts/cloudflare-ddns/.helmignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
.git
|
||||
.gitignore
|
||||
*.orig
|
||||
16
charts/cloudflare-ddns/Chart.yaml
Normal file
16
charts/cloudflare-ddns/Chart.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
apiVersion: v2
|
||||
name: cloudflare-ddns
|
||||
description: Dynamic DNS client for Cloudflare — keeps A/AAAA records in sync with your public IP
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "2.1.2"
|
||||
home: https://github.com/timothymiller/cloudflare-ddns
|
||||
sources:
|
||||
- https://github.com/timothymiller/cloudflare-ddns
|
||||
keywords:
|
||||
- ddns
|
||||
- cloudflare
|
||||
- dns
|
||||
maintainers:
|
||||
- name: timothymiller
|
||||
url: https://github.com/timothymiller
|
||||
34
charts/cloudflare-ddns/templates/_helpers.tpl
Normal file
34
charts/cloudflare-ddns/templates/_helpers.tpl
Normal file
@@ -0,0 +1,34 @@
|
||||
{{- define "cloudflare-ddns.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cloudflare-ddns.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cloudflare-ddns.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cloudflare-ddns.labels" -}}
|
||||
helm.sh/chart: {{ include "cloudflare-ddns.chart" . }}
|
||||
{{ include "cloudflare-ddns.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "cloudflare-ddns.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "cloudflare-ddns.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
106
charts/cloudflare-ddns/templates/deployment.yaml
Normal file
106
charts/cloudflare-ddns/templates/deployment.yaml
Normal file
@@ -0,0 +1,106 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "cloudflare-ddns.fullname" . }}
|
||||
labels:
|
||||
{{- include "cloudflare-ddns.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "cloudflare-ddns.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "cloudflare-ddns.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
hostNetwork: {{ .Values.hostNetwork }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
env:
|
||||
- name: CLOUDFLARE_API_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (include "cloudflare-ddns.fullname" .) .Values.cloudflare.existingSecret }}
|
||||
key: {{ .Values.cloudflare.existingSecretKey }}
|
||||
{{- if .Values.domains }}
|
||||
- name: DOMAINS
|
||||
value: {{ .Values.domains | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.ip4Domains }}
|
||||
- name: IP4_DOMAINS
|
||||
value: {{ .Values.ip4Domains | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.ip6Domains }}
|
||||
- name: IP6_DOMAINS
|
||||
value: {{ .Values.ip6Domains | quote }}
|
||||
{{- end }}
|
||||
- name: IP4_PROVIDER
|
||||
value: {{ .Values.ip4Provider | quote }}
|
||||
- name: IP6_PROVIDER
|
||||
value: {{ .Values.ip6Provider | quote }}
|
||||
- name: UPDATE_CRON
|
||||
value: {{ .Values.updateCron | quote }}
|
||||
- name: UPDATE_ON_START
|
||||
value: {{ .Values.updateOnStart | quote }}
|
||||
- name: DELETE_ON_STOP
|
||||
value: {{ .Values.deleteOnStop | quote }}
|
||||
- name: TTL
|
||||
value: {{ .Values.ttl | quote }}
|
||||
- name: PROXIED
|
||||
value: {{ .Values.proxied | quote }}
|
||||
{{- if .Values.recordComment }}
|
||||
- name: RECORD_COMMENT
|
||||
value: {{ .Values.recordComment | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.managedRecordsCommentRegex }}
|
||||
- name: MANAGED_RECORDS_COMMENT_REGEX
|
||||
value: {{ .Values.managedRecordsCommentRegex | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.wafLists }}
|
||||
- name: WAF_LISTS
|
||||
value: {{ .Values.wafLists | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.shoutrrr }}
|
||||
- name: SHOUTRRR
|
||||
value: {{ .Values.shoutrrr | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.healthchecks }}
|
||||
- name: HEALTHCHECKS
|
||||
value: {{ .Values.healthchecks | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.uptimeKuma }}
|
||||
- name: UPTIMEKUMA
|
||||
value: {{ .Values.uptimeKuma | quote }}
|
||||
{{- end }}
|
||||
- name: DETECTION_TIMEOUT
|
||||
value: {{ .Values.detectionTimeout | quote }}
|
||||
- name: UPDATE_TIMEOUT
|
||||
value: {{ .Values.updateTimeout | quote }}
|
||||
- name: EMOJI
|
||||
value: {{ .Values.emoji | quote }}
|
||||
- name: QUIET
|
||||
value: {{ .Values.quiet | quote }}
|
||||
{{- with .Values.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
11
charts/cloudflare-ddns/templates/secret.yaml
Normal file
11
charts/cloudflare-ddns/templates/secret.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
{{- if not .Values.cloudflare.existingSecret }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "cloudflare-ddns.fullname" . }}
|
||||
labels:
|
||||
{{- include "cloudflare-ddns.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
CLOUDFLARE_API_TOKEN: {{ required "cloudflare.apiToken is required when cloudflare.existingSecret is not set" .Values.cloudflare.apiToken | quote }}
|
||||
{{- end }}
|
||||
81
charts/cloudflare-ddns/values.yaml
Normal file
81
charts/cloudflare-ddns/values.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
image:
|
||||
repository: timothyjmiller/cloudflare-ddns
|
||||
pullPolicy: IfNotPresent
|
||||
# Overrides the image tag — defaults to chart appVersion
|
||||
tag: ""
|
||||
|
||||
# Must stay at 1. Multiple replicas cause duplicate DNS updates.
|
||||
replicaCount: 1
|
||||
|
||||
# Required for IPv6 detection via local interface.
|
||||
# Safe to disable if you only need IPv4 (IP6_PROVIDER=none).
|
||||
hostNetwork: true
|
||||
|
||||
# --- Authentication ---
|
||||
# Supply apiToken directly (creates a Secret) OR reference an existing one.
|
||||
cloudflare:
|
||||
apiToken: ""
|
||||
existingSecret: ""
|
||||
existingSecretKey: "CLOUDFLARE_API_TOKEN"
|
||||
|
||||
# --- Domains ---
|
||||
# Comma-separated. At least one of domains / ip4Domains / ip6Domains must be set.
|
||||
domains: ""
|
||||
ip4Domains: ""
|
||||
ip6Domains: ""
|
||||
|
||||
# --- IP Detection ---
|
||||
# Options: cloudflare.trace, cloudflare.doh, ipify, local,
|
||||
# local.iface:<name>, local.iface.stable:<name>,
|
||||
# url:<url>, literal:<ip1,ip2>, none
|
||||
ip4Provider: "cloudflare.trace"
|
||||
ip6Provider: "cloudflare.trace"
|
||||
|
||||
# --- Scheduling ---
|
||||
updateCron: "@every 5m"
|
||||
updateOnStart: true
|
||||
deleteOnStop: false
|
||||
|
||||
# --- DNS Record Settings ---
|
||||
ttl: 1
|
||||
# Boolean expression: true, false, is(domain), sub(domain), and combos
|
||||
proxied: "false"
|
||||
recordComment: ""
|
||||
managedRecordsCommentRegex: ""
|
||||
|
||||
# --- WAF Lists ---
|
||||
# Comma-separated, format: account-id/list-name
|
||||
wafLists: ""
|
||||
|
||||
# --- Notifications (Shoutrrr) ---
|
||||
# Newline-separated URLs: discord://, slack://, telegram://, gotify://,
|
||||
# pushover://, zulip://, generic+https://...
|
||||
shoutrrr: ""
|
||||
|
||||
# --- Heartbeat Monitoring ---
|
||||
healthchecks: ""
|
||||
uptimeKuma: ""
|
||||
|
||||
# --- Timeouts ---
|
||||
detectionTimeout: "5s"
|
||||
updateTimeout: "30s"
|
||||
|
||||
# --- Output ---
|
||||
emoji: true
|
||||
quiet: false
|
||||
|
||||
# --- Resources ---
|
||||
resources:
|
||||
limits:
|
||||
memory: 32Mi
|
||||
cpu: 50m
|
||||
|
||||
# Additional env vars for any setting not exposed above
|
||||
extraEnv: []
|
||||
# - name: REJECT_CLOUDFLARE_IPS
|
||||
# value: "false"
|
||||
|
||||
podAnnotations: {}
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
@@ -24,5 +24,7 @@
|
||||
"a": true,
|
||||
"aaaa": true,
|
||||
"purgeUnknownRecords": false,
|
||||
"ttl": 300
|
||||
"ttl": 300,
|
||||
"ip4_provider": "cloudflare.trace",
|
||||
"ip6_provider": "cloudflare.trace"
|
||||
}
|
||||
|
||||
10
env-example
10
env-example
@@ -19,10 +19,12 @@ DOMAINS=example.com,www.example.com
|
||||
|
||||
# Provider for IPv4 detection (default: cloudflare.trace)
|
||||
# Options: cloudflare.trace, cloudflare.doh, ipify, local, local.iface:<name>,
|
||||
# url:<custom-url>, literal:<ip1>,<ip2>, none
|
||||
# local.iface.stable:<name>, url:<custom-url>, literal:<ip1>,<ip2>, none
|
||||
# IP4_PROVIDER=cloudflare.trace
|
||||
|
||||
# Provider for IPv6 detection (default: cloudflare.trace)
|
||||
# Use local.iface.stable:<name> on Linux to publish a stable address instead
|
||||
# of temporary privacy addresses from the selected interface.
|
||||
# IP6_PROVIDER=cloudflare.trace
|
||||
|
||||
# === Scheduling ===
|
||||
@@ -37,6 +39,12 @@ DOMAINS=example.com,www.example.com
|
||||
# Delete managed DNS records on shutdown (default: false)
|
||||
# DELETE_ON_STOP=false
|
||||
|
||||
# Delete managed DNS records when a provider definitively reports no address
|
||||
# of that family, e.g. "none" or an interface without one (default: false).
|
||||
# Transient detection errors (network failures) always preserve existing
|
||||
# records, regardless of this setting.
|
||||
# DELETE_ON_FAILURE=false
|
||||
|
||||
# === DNS Records ===
|
||||
|
||||
# TTL in seconds: 1=auto, or 30-86400 (default: 1)
|
||||
|
||||
419
src/cf_ip_filter.rs
Normal file
419
src/cf_ip_filter.rs
Normal file
@@ -0,0 +1,419 @@
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
339
src/config.rs
339
src/config.rs
@@ -1,7 +1,7 @@
|
||||
use crate::cloudflare::{Auth, TTL, WAFList};
|
||||
use crate::cloudflare::{Auth, WAFList, TTL};
|
||||
use crate::domain;
|
||||
use crate::notifier::{
|
||||
CompositeNotifier, Heartbeat, HeartbeatMonitor, HealthchecksMonitor, NotifierDyn,
|
||||
CompositeNotifier, HealthchecksMonitor, Heartbeat, HeartbeatMonitor, NotifierDyn,
|
||||
ShoutrrrNotifier, UptimeKumaMonitor,
|
||||
};
|
||||
use crate::pp::{self, PP};
|
||||
@@ -27,6 +27,12 @@ pub struct LegacyConfig {
|
||||
pub purge_unknown_records: bool,
|
||||
#[serde(default = "default_ttl")]
|
||||
pub ttl: i64,
|
||||
#[serde(default)]
|
||||
pub ip4_provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ip6_provider: Option<String>,
|
||||
#[serde(rename = "recordComment", alias = "record_comment", default)]
|
||||
pub record_comment: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -80,15 +86,17 @@ pub struct AppConfig {
|
||||
pub update_cron: CronSchedule,
|
||||
pub update_on_start: bool,
|
||||
pub delete_on_stop: bool,
|
||||
pub delete_on_failure: bool,
|
||||
pub ttl: TTL,
|
||||
pub proxied_expression: Option<Box<dyn Fn(&str) -> bool + Send + Sync>>,
|
||||
pub record_comment: Option<String>,
|
||||
pub managed_comment_regex: Option<regex::Regex>,
|
||||
pub managed_comment_regex: Option<regex_lite::Regex>,
|
||||
pub waf_list_description: Option<String>,
|
||||
pub waf_list_item_comment: Option<String>,
|
||||
pub managed_waf_comment_regex: Option<regex::Regex>,
|
||||
pub managed_waf_comment_regex: Option<regex_lite::Regex>,
|
||||
pub detection_timeout: Duration,
|
||||
pub update_timeout: Duration,
|
||||
pub reject_cloudflare_ips: bool,
|
||||
pub dry_run: bool,
|
||||
pub emoji: bool,
|
||||
pub quiet: bool,
|
||||
@@ -124,9 +132,15 @@ impl CronSchedule {
|
||||
fn parse_duration_string(s: &str) -> Option<Duration> {
|
||||
let s = s.trim();
|
||||
if let Some(minutes) = s.strip_suffix('m') {
|
||||
minutes.parse::<u64>().ok().map(|m| Duration::from_secs(m * 60))
|
||||
minutes
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|m| Duration::from_secs(m * 60))
|
||||
} else if let Some(hours) = s.strip_suffix('h') {
|
||||
hours.parse::<u64>().ok().map(|h| Duration::from_secs(h * 3600))
|
||||
hours
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|h| Duration::from_secs(h * 3600))
|
||||
} else if let Some(secs) = s.strip_suffix('s') {
|
||||
secs.parse::<u64>().ok().map(Duration::from_secs)
|
||||
} else {
|
||||
@@ -140,7 +154,10 @@ fn parse_duration_string(s: &str) -> Option<Duration> {
|
||||
// ============================================================
|
||||
|
||||
fn getenv(key: &str) -> Option<String> {
|
||||
env::var(key).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
|
||||
env::var(key)
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn getenv_bool(key: &str, default: bool) -> bool {
|
||||
@@ -181,7 +198,10 @@ fn read_auth_from_env(ppfmt: &PP) -> Option<Auth> {
|
||||
val
|
||||
}) {
|
||||
if token == "YOUR-CLOUDFLARE-API-TOKEN" {
|
||||
ppfmt.errorf(pp::EMOJI_ERROR, "Please set CLOUDFLARE_API_TOKEN to your actual API token");
|
||||
ppfmt.errorf(
|
||||
pp::EMOJI_ERROR,
|
||||
"Please set CLOUDFLARE_API_TOKEN to your actual API token",
|
||||
);
|
||||
return None;
|
||||
}
|
||||
return Some(Auth::Token(token));
|
||||
@@ -206,7 +226,10 @@ fn read_auth_from_env(ppfmt: &PP) -> Option<Auth> {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
ppfmt.errorf(pp::EMOJI_ERROR, &format!("Failed to read API token file '{path}': {e}"));
|
||||
ppfmt.errorf(
|
||||
pp::EMOJI_ERROR,
|
||||
&format!("Failed to read API token file '{path}': {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,27 +251,31 @@ fn read_providers_from_env(ppfmt: &PP) -> Result<HashMap<IpType, ProviderType>,
|
||||
let ip4_str = getenv("IP4_PROVIDER").or_else(|| {
|
||||
let val = getenv("IP4_POLICY");
|
||||
if val.is_some() {
|
||||
ppfmt.warningf(pp::EMOJI_WARNING, "IP4_POLICY is deprecated; use IP4_PROVIDER instead");
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
"IP4_POLICY is deprecated; use IP4_PROVIDER instead",
|
||||
);
|
||||
}
|
||||
val
|
||||
});
|
||||
let ip6_str = getenv("IP6_PROVIDER").or_else(|| {
|
||||
let val = getenv("IP6_POLICY");
|
||||
if val.is_some() {
|
||||
ppfmt.warningf(pp::EMOJI_WARNING, "IP6_POLICY is deprecated; use IP6_PROVIDER instead");
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
"IP6_POLICY is deprecated; use IP6_PROVIDER instead",
|
||||
);
|
||||
}
|
||||
val
|
||||
});
|
||||
|
||||
let ip4_provider = match ip4_str {
|
||||
Some(s) => ProviderType::parse(&s)
|
||||
.map_err(|e| format!("Invalid IP4_PROVIDER: {e}"))?,
|
||||
Some(s) => ProviderType::parse(&s).map_err(|e| format!("Invalid IP4_PROVIDER: {e}"))?,
|
||||
None => ProviderType::CloudflareTrace { url: None },
|
||||
};
|
||||
|
||||
let ip6_provider = match ip6_str {
|
||||
Some(s) => ProviderType::parse(&s)
|
||||
.map_err(|e| format!("Invalid IP6_PROVIDER: {e}"))?,
|
||||
Some(s) => ProviderType::parse(&s).map_err(|e| format!("Invalid IP6_PROVIDER: {e}"))?,
|
||||
None => ProviderType::CloudflareTrace { url: None },
|
||||
};
|
||||
|
||||
@@ -325,9 +352,9 @@ fn read_cron_from_env(ppfmt: &PP) -> Result<CronSchedule, String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_regex(key: &str, ppfmt: &PP) -> Option<regex::Regex> {
|
||||
fn read_regex(key: &str, ppfmt: &PP) -> Option<regex_lite::Regex> {
|
||||
match getenv(key) {
|
||||
Some(s) if !s.is_empty() => match regex::Regex::new(&s) {
|
||||
Some(s) if !s.is_empty() => match regex_lite::Regex::new(&s) {
|
||||
Ok(r) => Some(r),
|
||||
Err(e) => {
|
||||
ppfmt.errorf(pp::EMOJI_ERROR, &format!("Invalid regex in {key}: {e}"));
|
||||
@@ -386,7 +413,11 @@ pub fn parse_legacy_config(content: &str) -> Result<LegacyConfig, String> {
|
||||
}
|
||||
|
||||
/// Convert a legacy config into a unified AppConfig
|
||||
fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> AppConfig {
|
||||
fn legacy_to_app_config(
|
||||
legacy: LegacyConfig,
|
||||
dry_run: bool,
|
||||
repeat: bool,
|
||||
) -> Result<AppConfig, String> {
|
||||
// Extract auth from first entry
|
||||
let auth = if let Some(entry) = legacy.cloudflare.first() {
|
||||
if !entry.authentication.api_token.is_empty()
|
||||
@@ -405,13 +436,27 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Ap
|
||||
Auth::Token(String::new())
|
||||
};
|
||||
|
||||
// Build providers
|
||||
// Build providers — ip4_provider/ip6_provider override the default cloudflare.trace
|
||||
let mut providers = HashMap::new();
|
||||
if legacy.a {
|
||||
providers.insert(IpType::V4, ProviderType::CloudflareTrace { url: None });
|
||||
let provider = match &legacy.ip4_provider {
|
||||
Some(s) => ProviderType::parse(s)
|
||||
.map_err(|e| format!("Invalid ip4_provider in config.json: {e}"))?,
|
||||
None => ProviderType::CloudflareTrace { url: None },
|
||||
};
|
||||
if !matches!(provider, ProviderType::None) {
|
||||
providers.insert(IpType::V4, provider);
|
||||
}
|
||||
}
|
||||
if legacy.aaaa {
|
||||
providers.insert(IpType::V6, ProviderType::CloudflareTrace { url: None });
|
||||
let provider = match &legacy.ip6_provider {
|
||||
Some(s) => ProviderType::parse(s)
|
||||
.map_err(|e| format!("Invalid ip6_provider in config.json: {e}"))?,
|
||||
None => ProviderType::CloudflareTrace { url: None },
|
||||
};
|
||||
if !matches!(provider, ProviderType::None) {
|
||||
providers.insert(IpType::V6, provider);
|
||||
}
|
||||
}
|
||||
|
||||
let ttl = TTL::new(legacy.ttl);
|
||||
@@ -422,7 +467,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Ap
|
||||
CronSchedule::Once
|
||||
};
|
||||
|
||||
AppConfig {
|
||||
Ok(AppConfig {
|
||||
auth,
|
||||
providers,
|
||||
domains: HashMap::new(),
|
||||
@@ -430,22 +475,24 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Ap
|
||||
update_cron: schedule,
|
||||
update_on_start: true,
|
||||
delete_on_stop: false,
|
||||
delete_on_failure: false,
|
||||
ttl,
|
||||
proxied_expression: None,
|
||||
record_comment: None,
|
||||
record_comment: legacy.record_comment.clone(),
|
||||
managed_comment_regex: None,
|
||||
waf_list_description: None,
|
||||
waf_list_item_comment: None,
|
||||
managed_waf_comment_regex: None,
|
||||
detection_timeout: Duration::from_secs(5),
|
||||
update_timeout: Duration::from_secs(30),
|
||||
reject_cloudflare_ips: getenv_bool("REJECT_CLOUDFLARE_IPS", true),
|
||||
dry_run,
|
||||
emoji: false,
|
||||
quiet: false,
|
||||
legacy_mode: true,
|
||||
legacy_config: Some(legacy),
|
||||
repeat,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -483,6 +530,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
|
||||
let update_cron = read_cron_from_env(ppfmt)?;
|
||||
let update_on_start = getenv_bool("UPDATE_ON_START", true);
|
||||
let delete_on_stop = getenv_bool("DELETE_ON_STOP", false);
|
||||
let delete_on_failure = getenv_bool("DELETE_ON_FAILURE", false);
|
||||
|
||||
let ttl_val = getenv("TTL")
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
@@ -509,6 +557,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
|
||||
|
||||
let emoji = getenv_bool("EMOJI", true);
|
||||
let quiet = getenv_bool("QUIET", false);
|
||||
let reject_cloudflare_ips = getenv_bool("REJECT_CLOUDFLARE_IPS", true);
|
||||
|
||||
// Validate: must have at least one update target
|
||||
if domains.is_empty() && waf_lists.is_empty() {
|
||||
@@ -550,6 +599,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
|
||||
update_cron,
|
||||
update_on_start,
|
||||
delete_on_stop,
|
||||
delete_on_failure,
|
||||
ttl,
|
||||
proxied_expression,
|
||||
record_comment,
|
||||
@@ -559,6 +609,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
|
||||
managed_waf_comment_regex,
|
||||
detection_timeout,
|
||||
update_timeout,
|
||||
reject_cloudflare_ips,
|
||||
dry_run: false, // Set later from CLI args
|
||||
emoji,
|
||||
quiet,
|
||||
@@ -579,7 +630,7 @@ pub fn load_config(dry_run: bool, repeat: bool, ppfmt: &PP) -> Result<AppConfig,
|
||||
} else {
|
||||
ppfmt.infof(pp::EMOJI_CONFIG, "Using config.json configuration");
|
||||
let legacy = load_legacy_config()?;
|
||||
Ok(legacy_to_app_config(legacy, dry_run, repeat))
|
||||
legacy_to_app_config(legacy, dry_run, repeat)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,11 +645,17 @@ pub fn setup_notifiers(ppfmt: &PP) -> CompositeNotifier {
|
||||
if !shoutrrr_urls.is_empty() {
|
||||
match ShoutrrrNotifier::new(&shoutrrr_urls) {
|
||||
Ok(n) => {
|
||||
ppfmt.infof(pp::EMOJI_NOTIFY, &format!("Notifications: {}", n.describe()));
|
||||
ppfmt.infof(
|
||||
pp::EMOJI_NOTIFY,
|
||||
&format!("Notifications: {}", n.describe()),
|
||||
);
|
||||
notifiers.push(Box::new(n));
|
||||
}
|
||||
Err(e) => {
|
||||
ppfmt.errorf(pp::EMOJI_ERROR, &format!("Failed to setup notifications: {e}"));
|
||||
ppfmt.errorf(
|
||||
pp::EMOJI_ERROR,
|
||||
&format!("Failed to setup notifications: {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -637,7 +694,10 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) {
|
||||
if !config.domains.is_empty() {
|
||||
ppfmt.noticef(pp::EMOJI_CONFIG, "Domains to update:");
|
||||
for (ip_type, domains) in &config.domains {
|
||||
inner.noticef("", &format!("{}: {}", ip_type.describe(), domains.join(", ")));
|
||||
inner.noticef(
|
||||
"",
|
||||
&format!("{}: {}", ip_type.describe(), domains.join(", ")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,7 +709,10 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) {
|
||||
}
|
||||
|
||||
for (ip_type, provider) in &config.providers {
|
||||
inner.infof("", &format!("{} provider: {}", ip_type.describe(), provider.name()));
|
||||
inner.infof(
|
||||
"",
|
||||
&format!("{} provider: {}", ip_type.describe(), provider.name()),
|
||||
);
|
||||
}
|
||||
|
||||
inner.infof("", &format!("TTL: {}", config.ttl.describe()));
|
||||
@@ -659,6 +722,13 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) {
|
||||
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 {
|
||||
inner.infof("", &format!("Record comment: {comment}"));
|
||||
}
|
||||
@@ -736,7 +806,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_string_whitespace() {
|
||||
assert_eq!(parse_duration_string(" 5m "), Some(Duration::from_secs(300)));
|
||||
assert_eq!(
|
||||
parse_duration_string(" 5m "),
|
||||
Some(Duration::from_secs(300))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -932,7 +1005,10 @@ mod tests {
|
||||
std::env::remove_var("IP6_DOMAINS");
|
||||
let pp = PP::new(false, false);
|
||||
let domains = read_domains_from_env(&pp);
|
||||
assert_eq!(domains.get(&IpType::V4).unwrap(), &vec!["v4.example.com".to_string()]);
|
||||
assert_eq!(
|
||||
domains.get(&IpType::V4).unwrap(),
|
||||
&vec!["v4.example.com".to_string()]
|
||||
);
|
||||
assert!(domains.get(&IpType::V6).is_none());
|
||||
std::env::remove_var("IP4_DOMAINS");
|
||||
}
|
||||
@@ -987,8 +1063,11 @@ mod tests {
|
||||
aaaa: false,
|
||||
purge_unknown_records: false,
|
||||
ttl: 300,
|
||||
ip4_provider: None,
|
||||
ip6_provider: None,
|
||||
record_comment: None,
|
||||
};
|
||||
let config = legacy_to_app_config(legacy, false, false);
|
||||
let config = legacy_to_app_config(legacy, false, false).unwrap();
|
||||
assert!(config.legacy_mode);
|
||||
assert!(matches!(config.auth, Auth::Token(ref t) if t == "my-token"));
|
||||
assert!(config.providers.contains_key(&IpType::V4));
|
||||
@@ -1013,9 +1092,14 @@ mod tests {
|
||||
aaaa: true,
|
||||
purge_unknown_records: false,
|
||||
ttl: 120,
|
||||
ip4_provider: None,
|
||||
ip6_provider: None,
|
||||
record_comment: None,
|
||||
};
|
||||
let config = legacy_to_app_config(legacy, true, true);
|
||||
assert!(matches!(config.update_cron, CronSchedule::Every(d) if d == Duration::from_secs(120)));
|
||||
let config = legacy_to_app_config(legacy, true, true).unwrap();
|
||||
assert!(
|
||||
matches!(config.update_cron, CronSchedule::Every(d) if d == Duration::from_secs(120))
|
||||
);
|
||||
assert!(config.repeat);
|
||||
assert!(config.dry_run);
|
||||
}
|
||||
@@ -1039,12 +1123,163 @@ mod tests {
|
||||
aaaa: true,
|
||||
purge_unknown_records: false,
|
||||
ttl: 300,
|
||||
ip4_provider: None,
|
||||
ip6_provider: None,
|
||||
record_comment: None,
|
||||
};
|
||||
let config = legacy_to_app_config(legacy, false, false);
|
||||
let config = legacy_to_app_config(legacy, false, false).unwrap();
|
||||
assert!(matches!(config.auth, Auth::Key { ref api_key, ref email }
|
||||
if api_key == "key123" && email == "test@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_to_app_config_custom_providers() {
|
||||
let legacy = LegacyConfig {
|
||||
cloudflare: vec![LegacyCloudflareEntry {
|
||||
authentication: LegacyAuthentication {
|
||||
api_token: "tok".to_string(),
|
||||
api_key: None,
|
||||
},
|
||||
zone_id: "z".to_string(),
|
||||
subdomains: vec![],
|
||||
proxied: false,
|
||||
}],
|
||||
a: true,
|
||||
aaaa: true,
|
||||
purge_unknown_records: false,
|
||||
ttl: 300,
|
||||
ip4_provider: Some("ipify".to_string()),
|
||||
ip6_provider: Some("cloudflare.doh".to_string()),
|
||||
record_comment: None,
|
||||
};
|
||||
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,
|
||||
record_comment: 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,
|
||||
record_comment: 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_to_app_config_with_record_comment() {
|
||||
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: None,
|
||||
ip6_provider: None,
|
||||
record_comment: Some("managed by cloudflare-ddns".to_string()),
|
||||
};
|
||||
let config = legacy_to_app_config(legacy, false, false).unwrap();
|
||||
assert_eq!(config.record_comment, Some("managed by cloudflare-ddns".to_string()));
|
||||
}
|
||||
|
||||
#[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_record_comment() {
|
||||
let json = r#"{
|
||||
"cloudflare": [{
|
||||
"authentication": { "api_token": "tok" },
|
||||
"zone_id": "z",
|
||||
"subdomains": ["@"]
|
||||
}],
|
||||
"recordComment": "managed by cloudflare-ddns"
|
||||
}"#;
|
||||
let config = parse_legacy_config(json).unwrap();
|
||||
assert_eq!(config.record_comment, Some("managed by cloudflare-ddns".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_config_deserializes_without_providers() {
|
||||
let json = r#"{
|
||||
"cloudflare": [{
|
||||
"authentication": { "api_token": "tok" },
|
||||
"zone_id": "z",
|
||||
"subdomains": ["@"]
|
||||
}]
|
||||
}"#;
|
||||
let config = parse_legacy_config(json).unwrap();
|
||||
assert!(config.ip4_provider.is_none());
|
||||
assert!(config.ip6_provider.is_none());
|
||||
}
|
||||
|
||||
// --- is_env_config_mode ---
|
||||
|
||||
#[test]
|
||||
@@ -1181,6 +1416,7 @@ mod tests {
|
||||
update_cron: CronSchedule::Once,
|
||||
update_on_start: true,
|
||||
delete_on_stop: false,
|
||||
delete_on_failure: true,
|
||||
ttl: TTL::AUTO,
|
||||
proxied_expression: None,
|
||||
record_comment: None,
|
||||
@@ -1190,6 +1426,7 @@ mod tests {
|
||||
managed_waf_comment_regex: None,
|
||||
detection_timeout: Duration::from_secs(5),
|
||||
update_timeout: Duration::from_secs(30),
|
||||
reject_cloudflare_ips: false,
|
||||
dry_run: false,
|
||||
emoji: false,
|
||||
quiet: false,
|
||||
@@ -1214,6 +1451,7 @@ mod tests {
|
||||
update_cron: CronSchedule::Every(Duration::from_secs(300)),
|
||||
update_on_start: true,
|
||||
delete_on_stop: true,
|
||||
delete_on_failure: true,
|
||||
ttl: TTL::new(60),
|
||||
proxied_expression: None,
|
||||
record_comment: Some("managed".to_string()),
|
||||
@@ -1223,6 +1461,7 @@ mod tests {
|
||||
managed_waf_comment_regex: None,
|
||||
detection_timeout: Duration::from_secs(5),
|
||||
update_timeout: Duration::from_secs(30),
|
||||
reject_cloudflare_ips: false,
|
||||
dry_run: false,
|
||||
emoji: false,
|
||||
quiet: false,
|
||||
@@ -1251,7 +1490,10 @@ mod tests {
|
||||
fn set(key: &str, value: &str) -> Self {
|
||||
let lock = ENV_MUTEX.lock().unwrap();
|
||||
std::env::set_var(key, value);
|
||||
Self { keys: vec![key.to_string()], _lock: lock }
|
||||
Self {
|
||||
keys: vec![key.to_string()],
|
||||
_lock: lock,
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, key: &str, value: &str) {
|
||||
@@ -1793,19 +2035,16 @@ mod tests {
|
||||
let mut g = EnvGuard::set("_PLACEHOLDER_SN", "x");
|
||||
g.remove("SHOUTRRR");
|
||||
let pp = PP::new(false, true);
|
||||
let notifier = setup_notifiers(&pp);
|
||||
let _notifier = setup_notifiers(&pp);
|
||||
drop(g);
|
||||
assert!(notifier.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_setup_notifiers_empty_shoutrrr_returns_empty() {
|
||||
let g = EnvGuard::set("SHOUTRRR", "");
|
||||
let pp = PP::new(false, true);
|
||||
let notifier = setup_notifiers(&pp);
|
||||
let _notifier = setup_notifiers(&pp);
|
||||
drop(g);
|
||||
// Empty string is treated as unset by getenv_list.
|
||||
assert!(notifier.is_empty());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -1818,9 +2057,8 @@ mod tests {
|
||||
g.remove("HEALTHCHECKS");
|
||||
g.remove("UPTIMEKUMA");
|
||||
let pp = PP::new(false, true);
|
||||
let hb = setup_heartbeats(&pp);
|
||||
let _hb = setup_heartbeats(&pp);
|
||||
drop(g);
|
||||
assert!(hb.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1828,9 +2066,8 @@ mod tests {
|
||||
let mut g = EnvGuard::set("HEALTHCHECKS", "https://hc-ping.com/abc123");
|
||||
g.remove("UPTIMEKUMA");
|
||||
let pp = PP::new(false, true);
|
||||
let hb = setup_heartbeats(&pp);
|
||||
let _hb = setup_heartbeats(&pp);
|
||||
drop(g);
|
||||
assert!(!hb.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1838,9 +2075,8 @@ mod tests {
|
||||
let mut g = EnvGuard::set("UPTIMEKUMA", "https://status.example.com/api/push/abc");
|
||||
g.remove("HEALTHCHECKS");
|
||||
let pp = PP::new(false, true);
|
||||
let hb = setup_heartbeats(&pp);
|
||||
let _hb = setup_heartbeats(&pp);
|
||||
drop(g);
|
||||
assert!(!hb.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1848,9 +2084,8 @@ mod tests {
|
||||
let mut g = EnvGuard::set("HEALTHCHECKS", "https://hc-ping.com/abc");
|
||||
g.add("UPTIMEKUMA", "https://status.example.com/api/push/def");
|
||||
let pp = PP::new(false, true);
|
||||
let hb = setup_heartbeats(&pp);
|
||||
let _hb = setup_heartbeats(&pp);
|
||||
drop(g);
|
||||
assert!(!hb.is_empty());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -1872,6 +2107,7 @@ mod tests {
|
||||
update_cron: CronSchedule::Every(Duration::from_secs(300)),
|
||||
update_on_start: true,
|
||||
delete_on_stop: false,
|
||||
delete_on_failure: true,
|
||||
ttl: TTL::AUTO,
|
||||
proxied_expression: None,
|
||||
record_comment: None,
|
||||
@@ -1881,6 +2117,7 @@ mod tests {
|
||||
managed_waf_comment_regex: None,
|
||||
detection_timeout: Duration::from_secs(5),
|
||||
update_timeout: Duration::from_secs(30),
|
||||
reject_cloudflare_ips: false,
|
||||
dry_run: false,
|
||||
emoji: false,
|
||||
quiet: false,
|
||||
@@ -1907,6 +2144,7 @@ mod tests {
|
||||
update_cron: CronSchedule::Every(Duration::from_secs(600)),
|
||||
update_on_start: true,
|
||||
delete_on_stop: true,
|
||||
delete_on_failure: true,
|
||||
ttl: TTL::new(120),
|
||||
proxied_expression: None,
|
||||
record_comment: Some("cf-ddns".to_string()),
|
||||
@@ -1916,6 +2154,7 @@ mod tests {
|
||||
managed_waf_comment_regex: None,
|
||||
detection_timeout: Duration::from_secs(5),
|
||||
update_timeout: Duration::from_secs(30),
|
||||
reject_cloudflare_ips: false,
|
||||
dry_run: false,
|
||||
emoji: false,
|
||||
quiet: true,
|
||||
@@ -1939,6 +2178,7 @@ mod tests {
|
||||
update_cron: CronSchedule::Once,
|
||||
update_on_start: true,
|
||||
delete_on_stop: false,
|
||||
delete_on_failure: true,
|
||||
ttl: TTL::AUTO,
|
||||
proxied_expression: None,
|
||||
record_comment: None,
|
||||
@@ -1948,6 +2188,7 @@ mod tests {
|
||||
managed_waf_comment_regex: None,
|
||||
detection_timeout: Duration::from_secs(5),
|
||||
update_timeout: Duration::from_secs(30),
|
||||
reject_cloudflare_ips: false,
|
||||
dry_run: false,
|
||||
emoji: false,
|
||||
quiet: false,
|
||||
|
||||
284
src/domain.rs
284
src/domain.rs
@@ -1,134 +1,21 @@
|
||||
use std::fmt;
|
||||
|
||||
/// Represents a DNS domain - either a regular FQDN or a wildcard.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum Domain {
|
||||
FQDN(String),
|
||||
Wildcard(String),
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Domain {
|
||||
/// Parse a domain string. Handles:
|
||||
/// - "@" or "" -> root domain (handled at FQDN construction time)
|
||||
/// - "*.example.com" -> wildcard
|
||||
/// - "sub.example.com" -> regular FQDN
|
||||
pub fn new(input: &str) -> Result<Self, String> {
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
if trimmed.starts_with("*.") {
|
||||
let base = &trimmed[2..];
|
||||
let ascii = domain_to_ascii(base)?;
|
||||
Ok(Domain::Wildcard(ascii))
|
||||
} else {
|
||||
let ascii = domain_to_ascii(&trimmed)?;
|
||||
Ok(Domain::FQDN(ascii))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the DNS name in ASCII form suitable for API calls.
|
||||
pub fn dns_name_ascii(&self) -> String {
|
||||
match self {
|
||||
Domain::FQDN(s) => s.clone(),
|
||||
Domain::Wildcard(s) => format!("*.{s}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a human-readable description of the domain.
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
Domain::FQDN(s) => describe_domain(s),
|
||||
Domain::Wildcard(s) => format!("*.{}", describe_domain(s)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the zones (parent domains) for this domain, from most specific to least.
|
||||
pub fn zones(&self) -> Vec<String> {
|
||||
let base = match self {
|
||||
Domain::FQDN(s) => s.as_str(),
|
||||
Domain::Wildcard(s) => s.as_str(),
|
||||
};
|
||||
let mut zones = Vec::new();
|
||||
let mut current = base.to_string();
|
||||
while !current.is_empty() {
|
||||
zones.push(current.clone());
|
||||
if let Some(pos) = current.find('.') {
|
||||
current = current[pos + 1..].to_string();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
zones
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Domain {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.describe())
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct an FQDN from a subdomain name and base domain.
|
||||
pub fn make_fqdn(subdomain: &str, base_domain: &str) -> String {
|
||||
let name = subdomain.to_lowercase();
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name == "@" {
|
||||
base_domain.to_lowercase()
|
||||
} else if name.starts_with("*.") {
|
||||
// Wildcard subdomain
|
||||
format!("{name}.{}", base_domain.to_lowercase())
|
||||
} else {
|
||||
format!("{name}.{}", base_domain.to_lowercase())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a domain to ASCII using IDNA encoding.
|
||||
#[allow(dead_code)]
|
||||
fn domain_to_ascii(domain: &str) -> Result<String, String> {
|
||||
if domain.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
// Try IDNA encoding for internationalized domain names
|
||||
match idna::domain_to_ascii(domain) {
|
||||
Ok(ascii) => Ok(ascii),
|
||||
Err(_) => {
|
||||
// Fallback: if it's already ASCII, just return it
|
||||
if domain.is_ascii() {
|
||||
Ok(domain.to_string())
|
||||
} else {
|
||||
Err(format!("Invalid domain name: {domain}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert ASCII domain back to Unicode for display.
|
||||
#[allow(dead_code)]
|
||||
fn describe_domain(ascii: &str) -> String {
|
||||
// Try to convert punycode back to unicode for display
|
||||
match idna::domain_to_unicode(ascii) {
|
||||
(unicode, Ok(())) => unicode,
|
||||
_ => ascii.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a comma-separated list of domain strings.
|
||||
#[allow(dead_code)]
|
||||
pub fn parse_domain_list(input: &str) -> Result<Vec<Domain>, String> {
|
||||
if input.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
input
|
||||
.split(',')
|
||||
.map(|s| Domain::new(s.trim()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// --- Domain Expression Evaluator ---
|
||||
// Supports: true, false, is(domain,...), sub(domain,...), !, &&, ||, ()
|
||||
|
||||
/// Parse and evaluate a domain expression to determine if a domain should be proxied.
|
||||
pub fn parse_proxied_expression(expr: &str) -> Result<Box<dyn Fn(&str) -> bool + Send + Sync>, String> {
|
||||
pub fn parse_proxied_expression(
|
||||
expr: &str,
|
||||
) -> Result<Box<dyn Fn(&str) -> bool + Send + Sync>, String> {
|
||||
let expr = expr.trim();
|
||||
if expr.is_empty() || expr == "false" {
|
||||
return Ok(Box::new(|_: &str| false));
|
||||
@@ -140,7 +27,10 @@ pub fn parse_proxied_expression(expr: &str) -> Result<Box<dyn Fn(&str) -> bool +
|
||||
let tokens = tokenize_expr(expr)?;
|
||||
let (predicate, rest) = parse_or_expr(&tokens)?;
|
||||
if !rest.is_empty() {
|
||||
return Err(format!("Unexpected tokens in proxied expression: {}", rest.join(" ")));
|
||||
return Err(format!(
|
||||
"Unexpected tokens in proxied expression: {}",
|
||||
rest.join(" ")
|
||||
));
|
||||
}
|
||||
Ok(predicate)
|
||||
}
|
||||
@@ -178,7 +68,13 @@ fn tokenize_expr(input: &str) -> Result<Vec<String>, String> {
|
||||
_ => {
|
||||
let mut word = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '*' || c == '@' {
|
||||
if c.is_alphanumeric()
|
||||
|| c == '.'
|
||||
|| c == '-'
|
||||
|| c == '_'
|
||||
|| c == '*'
|
||||
|| c == '@'
|
||||
{
|
||||
word.push(c);
|
||||
chars.next();
|
||||
} else {
|
||||
@@ -259,9 +155,9 @@ fn parse_atom(tokens: &[String]) -> Result<(Predicate, &[String]), String> {
|
||||
let (domains, rest) = parse_domain_args(&tokens[1..])?;
|
||||
let pred: Predicate = Box::new(move |d: &str| {
|
||||
let d_lower = d.to_lowercase();
|
||||
domains.iter().any(|dom| {
|
||||
d_lower == *dom || d_lower.ends_with(&format!(".{dom}"))
|
||||
})
|
||||
domains
|
||||
.iter()
|
||||
.any(|dom| d_lower == *dom || d_lower.ends_with(&format!(".{dom}")))
|
||||
});
|
||||
Ok((pred, rest))
|
||||
}
|
||||
@@ -305,18 +201,6 @@ mod tests {
|
||||
assert_eq!(make_fqdn("VPN", "Example.COM"), "vpn.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_wildcard() {
|
||||
let d = Domain::new("*.example.com").unwrap();
|
||||
assert_eq!(d.dns_name_ascii(), "*.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_domain_list() {
|
||||
let domains = parse_domain_list("example.com, *.example.com, sub.example.com").unwrap();
|
||||
assert_eq!(domains.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_proxied_expr_true() {
|
||||
let pred = parse_proxied_expression("true").unwrap();
|
||||
@@ -359,129 +243,6 @@ mod tests {
|
||||
assert!(pred("public.com"));
|
||||
}
|
||||
|
||||
// --- Domain::new with regular FQDN ---
|
||||
#[test]
|
||||
fn test_domain_new_fqdn() {
|
||||
let d = Domain::new("example.com").unwrap();
|
||||
assert_eq!(d, Domain::FQDN("example.com".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_new_fqdn_uppercase() {
|
||||
let d = Domain::new("EXAMPLE.COM").unwrap();
|
||||
assert_eq!(d, Domain::FQDN("example.com".to_string()));
|
||||
}
|
||||
|
||||
// --- Domain::dns_name_ascii for FQDN ---
|
||||
#[test]
|
||||
fn test_dns_name_ascii_fqdn() {
|
||||
let d = Domain::FQDN("example.com".to_string());
|
||||
assert_eq!(d.dns_name_ascii(), "example.com");
|
||||
}
|
||||
|
||||
// --- Domain::describe for both variants ---
|
||||
#[test]
|
||||
fn test_describe_fqdn() {
|
||||
let d = Domain::FQDN("example.com".to_string());
|
||||
// ASCII domain should round-trip through describe unchanged
|
||||
assert_eq!(d.describe(), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_describe_wildcard() {
|
||||
let d = Domain::Wildcard("example.com".to_string());
|
||||
assert_eq!(d.describe(), "*.example.com");
|
||||
}
|
||||
|
||||
// --- Domain::zones ---
|
||||
#[test]
|
||||
fn test_zones_fqdn() {
|
||||
let d = Domain::FQDN("sub.example.com".to_string());
|
||||
let zones = d.zones();
|
||||
assert_eq!(zones, vec!["sub.example.com", "example.com", "com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zones_wildcard() {
|
||||
let d = Domain::Wildcard("example.com".to_string());
|
||||
let zones = d.zones();
|
||||
assert_eq!(zones, vec!["example.com", "com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zones_single_label() {
|
||||
let d = Domain::FQDN("localhost".to_string());
|
||||
let zones = d.zones();
|
||||
assert_eq!(zones, vec!["localhost"]);
|
||||
}
|
||||
|
||||
// --- Domain Display trait ---
|
||||
#[test]
|
||||
fn test_display_fqdn() {
|
||||
let d = Domain::FQDN("example.com".to_string());
|
||||
assert_eq!(format!("{d}"), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_wildcard() {
|
||||
let d = Domain::Wildcard("example.com".to_string());
|
||||
assert_eq!(format!("{d}"), "*.example.com");
|
||||
}
|
||||
|
||||
// --- domain_to_ascii (tested indirectly via Domain::new) ---
|
||||
#[test]
|
||||
fn test_domain_new_empty_string() {
|
||||
// empty string -> domain_to_ascii returns Ok("") -> Domain::FQDN("")
|
||||
let d = Domain::new("").unwrap();
|
||||
assert_eq!(d, Domain::FQDN("".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_new_ascii_domain() {
|
||||
let d = Domain::new("www.example.org").unwrap();
|
||||
assert_eq!(d.dns_name_ascii(), "www.example.org");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_new_internationalized() {
|
||||
// "münchen.de" should be encoded to punycode
|
||||
let d = Domain::new("münchen.de").unwrap();
|
||||
let ascii = d.dns_name_ascii();
|
||||
// The punycode-encoded form should start with "xn--"
|
||||
assert!(ascii.contains("xn--"), "expected punycode, got: {ascii}");
|
||||
}
|
||||
|
||||
// --- describe_domain (tested indirectly via Domain::describe) ---
|
||||
#[test]
|
||||
fn test_describe_punycode_roundtrip() {
|
||||
// Build a domain with a known punycode label and confirm describe decodes it
|
||||
let d = Domain::new("münchen.de").unwrap();
|
||||
let described = d.describe();
|
||||
// Should contain the Unicode form, not the raw punycode
|
||||
assert!(described.contains("münchen") || described.contains("xn--"),
|
||||
"describe returned: {described}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_describe_regular_ascii() {
|
||||
let d = Domain::FQDN("example.com".to_string());
|
||||
assert_eq!(d.describe(), "example.com");
|
||||
}
|
||||
|
||||
// --- parse_domain_list with empty input ---
|
||||
#[test]
|
||||
fn test_parse_domain_list_empty() {
|
||||
let result = parse_domain_list("").unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_domain_list_whitespace_only() {
|
||||
let result = parse_domain_list(" ").unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
// --- Tokenizer edge cases (via parse_proxied_expression) ---
|
||||
#[test]
|
||||
fn test_tokenizer_single_ampersand_error() {
|
||||
let result = parse_proxied_expression("is(a.com) & is(b.com)");
|
||||
@@ -504,14 +265,14 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- Parser edge cases ---
|
||||
#[test]
|
||||
fn test_parse_and_expr_double_ampersand() {
|
||||
let pred = parse_proxied_expression("is(a.com) && is(b.com)").unwrap();
|
||||
assert!(!pred("a.com"));
|
||||
assert!(!pred("b.com"));
|
||||
|
||||
let pred2 = parse_proxied_expression("sub(example.com) && !is(internal.example.com)").unwrap();
|
||||
let pred2 =
|
||||
parse_proxied_expression("sub(example.com) && !is(internal.example.com)").unwrap();
|
||||
assert!(pred2("www.example.com"));
|
||||
assert!(!pred2("internal.example.com"));
|
||||
}
|
||||
@@ -529,7 +290,10 @@ mod tests {
|
||||
let result = parse_proxied_expression("(is(a.com)");
|
||||
assert!(result.is_err());
|
||||
let err = result.err().unwrap();
|
||||
assert!(err.contains("parenthesis") || err.contains(")"), "error was: {err}");
|
||||
assert!(
|
||||
err.contains("parenthesis") || err.contains(")"),
|
||||
"error was: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -538,10 +302,8 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- make_fqdn with wildcard subdomain ---
|
||||
#[test]
|
||||
fn test_make_fqdn_wildcard_subdomain() {
|
||||
// A name starting with "*." is treated as a wildcard subdomain
|
||||
assert_eq!(make_fqdn("*.sub", "example.com"), "*.sub.example.com");
|
||||
}
|
||||
}
|
||||
|
||||
294
src/main.rs
294
src/main.rs
@@ -1,3 +1,4 @@
|
||||
mod cf_ip_filter;
|
||||
mod cloudflare;
|
||||
mod config;
|
||||
mod domain;
|
||||
@@ -10,6 +11,9 @@ use crate::cloudflare::{Auth, CloudflareHandle};
|
||||
use crate::config::{AppConfig, CronSchedule};
|
||||
use crate::notifier::{CompositeNotifier, Heartbeat, Message};
|
||||
use crate::pp::PP;
|
||||
use rand::RngExt;
|
||||
use reqwest::Client;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::signal;
|
||||
@@ -17,8 +21,12 @@ use tokio::time::{sleep, Duration};
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[tokio::main]
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.expect("Failed to install rustls crypto provider");
|
||||
|
||||
// Parse CLI args
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let dry_run = args.iter().any(|a| a == "--dry-run");
|
||||
@@ -115,12 +123,38 @@ async fn main() {
|
||||
// Start heartbeat
|
||||
heartbeat.start().await;
|
||||
|
||||
let mut cf_cache = cf_ip_filter::CachedCloudflareFilter::new();
|
||||
let detection_client = Client::builder()
|
||||
.timeout(app_config.detection_timeout)
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
if app_config.legacy_mode {
|
||||
// --- Legacy mode (original cloudflare-ddns behavior) ---
|
||||
run_legacy_mode(&app_config, &handle, ¬ifier, &heartbeat, &ppfmt, running).await;
|
||||
run_legacy_mode(
|
||||
&app_config,
|
||||
&handle,
|
||||
¬ifier,
|
||||
&heartbeat,
|
||||
&ppfmt,
|
||||
running,
|
||||
&mut cf_cache,
|
||||
&detection_client,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
// --- Env var mode (cf-ddns behavior) ---
|
||||
run_env_mode(&app_config, &handle, ¬ifier, &heartbeat, &ppfmt, running).await;
|
||||
run_env_mode(
|
||||
&app_config,
|
||||
&handle,
|
||||
¬ifier,
|
||||
&heartbeat,
|
||||
&ppfmt,
|
||||
running,
|
||||
&mut cf_cache,
|
||||
&detection_client,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// On shutdown: delete records if configured
|
||||
@@ -130,9 +164,7 @@ async fn main() {
|
||||
}
|
||||
|
||||
// Exit heartbeat
|
||||
heartbeat
|
||||
.exit(&Message::new_ok("Shutting down"))
|
||||
.await;
|
||||
heartbeat.exit(&Message::new_ok("Shutting down")).await;
|
||||
}
|
||||
|
||||
async fn run_legacy_mode(
|
||||
@@ -142,12 +174,16 @@ async fn run_legacy_mode(
|
||||
heartbeat: &Heartbeat,
|
||||
ppfmt: &PP,
|
||||
running: Arc<AtomicBool>,
|
||||
cf_cache: &mut cf_ip_filter::CachedCloudflareFilter,
|
||||
detection_client: &Client,
|
||||
) {
|
||||
let legacy = match &config.legacy_config {
|
||||
Some(l) => l,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let mut noop_reported = HashSet::new();
|
||||
|
||||
if config.repeat {
|
||||
match (legacy.a, legacy.aaaa) {
|
||||
(true, true) => println!(
|
||||
@@ -164,7 +200,17 @@ async fn run_legacy_mode(
|
||||
}
|
||||
|
||||
while running.load(Ordering::SeqCst) {
|
||||
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
|
||||
updater::update_once(
|
||||
config,
|
||||
handle,
|
||||
notifier,
|
||||
heartbeat,
|
||||
cf_cache,
|
||||
ppfmt,
|
||||
&mut noop_reported,
|
||||
detection_client,
|
||||
)
|
||||
.await;
|
||||
|
||||
for _ in 0..legacy.ttl {
|
||||
if !running.load(Ordering::SeqCst) {
|
||||
@@ -174,7 +220,17 @@ async fn run_legacy_mode(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
|
||||
updater::update_once(
|
||||
config,
|
||||
handle,
|
||||
notifier,
|
||||
heartbeat,
|
||||
cf_cache,
|
||||
ppfmt,
|
||||
&mut noop_reported,
|
||||
detection_client,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,11 +241,25 @@ async fn run_env_mode(
|
||||
heartbeat: &Heartbeat,
|
||||
ppfmt: &PP,
|
||||
running: Arc<AtomicBool>,
|
||||
cf_cache: &mut cf_ip_filter::CachedCloudflareFilter,
|
||||
detection_client: &Client,
|
||||
) {
|
||||
let mut noop_reported = HashSet::new();
|
||||
|
||||
match &config.update_cron {
|
||||
CronSchedule::Once => {
|
||||
if config.update_on_start {
|
||||
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
|
||||
updater::update_once(
|
||||
config,
|
||||
handle,
|
||||
notifier,
|
||||
heartbeat,
|
||||
cf_cache,
|
||||
ppfmt,
|
||||
&mut noop_reported,
|
||||
detection_client,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
schedule => {
|
||||
@@ -205,20 +275,28 @@ async fn run_env_mode(
|
||||
|
||||
// Update on start if configured
|
||||
if config.update_on_start {
|
||||
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
|
||||
updater::update_once(
|
||||
config,
|
||||
handle,
|
||||
notifier,
|
||||
heartbeat,
|
||||
cf_cache,
|
||||
ppfmt,
|
||||
&mut noop_reported,
|
||||
detection_client,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Main loop
|
||||
while running.load(Ordering::SeqCst) {
|
||||
// Sleep for interval, checking running flag each second
|
||||
let secs = interval.as_secs();
|
||||
let next_time = chrono::Local::now() + chrono::Duration::seconds(secs as i64);
|
||||
let mins = secs / 60;
|
||||
let rem_secs = secs % 60;
|
||||
ppfmt.infof(
|
||||
pp::EMOJI_SLEEP,
|
||||
&format!(
|
||||
"Next update at {}",
|
||||
next_time.format("%Y-%m-%d %H:%M:%S %Z")
|
||||
),
|
||||
&format!("Next update in {}m {}s", mins, rem_secs),
|
||||
);
|
||||
|
||||
for _ in 0..secs {
|
||||
@@ -232,12 +310,38 @@ async fn run_env_mode(
|
||||
return;
|
||||
}
|
||||
|
||||
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
|
||||
// Apply proportional jitter before each update to spread API calls
|
||||
// across clients and reduce synchronized traffic spikes at Cloudflare.
|
||||
let max_jitter = interval.as_secs() / 5;
|
||||
if max_jitter > 0 {
|
||||
let jitter_secs = rand::rng().random_range(0..=max_jitter);
|
||||
sleep(std::time::Duration::from_secs(jitter_secs)).await;
|
||||
}
|
||||
|
||||
updater::update_once(
|
||||
config,
|
||||
handle,
|
||||
notifier,
|
||||
heartbeat,
|
||||
cf_cache,
|
||||
ppfmt,
|
||||
&mut noop_reported,
|
||||
detection_client,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn jitter_duration(interval_secs: u64, rand_val: u64) -> std::time::Duration {
|
||||
let max_jitter = interval_secs / 5;
|
||||
if max_jitter == 0 {
|
||||
return std::time::Duration::ZERO;
|
||||
}
|
||||
std::time::Duration::from_secs(rand_val % (max_jitter + 1))
|
||||
}
|
||||
|
||||
fn describe_duration(d: Duration) -> String {
|
||||
let secs = d.as_secs();
|
||||
if secs >= 3600 {
|
||||
@@ -265,11 +369,26 @@ fn describe_duration(d: Duration) -> String {
|
||||
// Tests (backwards compatible with original test suite)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn init_crypto() {
|
||||
use std::sync::Once;
|
||||
static INIT: Once = Once::new();
|
||||
INIT.call_once(|| {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_client() -> reqwest::Client {
|
||||
init_crypto();
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::{
|
||||
LegacyAuthentication, LegacyCloudflareEntry, LegacyConfig, LegacySubdomainEntry,
|
||||
parse_legacy_config,
|
||||
parse_legacy_config, LegacyAuthentication, LegacyCloudflareEntry, LegacyConfig,
|
||||
LegacySubdomainEntry,
|
||||
};
|
||||
use crate::provider::parse_trace_ip;
|
||||
use reqwest::Client;
|
||||
@@ -300,6 +419,9 @@ mod tests {
|
||||
aaaa: false,
|
||||
purge_unknown_records: false,
|
||||
ttl: 300,
|
||||
ip4_provider: None,
|
||||
ip6_provider: None,
|
||||
record_comment: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +436,7 @@ mod tests {
|
||||
impl TestDdnsClient {
|
||||
fn new(base_url: &str) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
client: crate::test_client(),
|
||||
cf_api_base: base_url.to_string(),
|
||||
ipv4_urls: vec![format!("{base_url}/cdn-cgi/trace")],
|
||||
dry_run: false,
|
||||
@@ -379,6 +501,7 @@ mod tests {
|
||||
config: &[LegacyCloudflareEntry],
|
||||
ttl: i64,
|
||||
purge_unknown_records: bool,
|
||||
noop_reported: &mut std::collections::HashSet<String>,
|
||||
) {
|
||||
for entry in config {
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -480,8 +603,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
let noop_key = format!("{fqdn}:{record_type}");
|
||||
if let Some(ref id) = identifier {
|
||||
if modified {
|
||||
noop_reported.remove(&noop_key);
|
||||
if self.dry_run {
|
||||
println!("[DRY RUN] Would update record {fqdn} -> {ip}");
|
||||
} else {
|
||||
@@ -497,23 +622,29 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
} else if self.dry_run {
|
||||
println!("[DRY RUN] Record {fqdn} is up to date ({ip})");
|
||||
} else if noop_reported.insert(noop_key) {
|
||||
if self.dry_run {
|
||||
println!("[DRY RUN] Record {fqdn} is up to date");
|
||||
} else {
|
||||
println!("Record {fqdn} is up to date");
|
||||
}
|
||||
}
|
||||
} else if self.dry_run {
|
||||
println!("[DRY RUN] Would add new record {fqdn} -> {ip}");
|
||||
} else {
|
||||
println!("Adding new record {fqdn} -> {ip}");
|
||||
let create_endpoint =
|
||||
format!("zones/{}/dns_records", entry.zone_id);
|
||||
let _: Option<serde_json::Value> = self
|
||||
.cf_api(
|
||||
&create_endpoint,
|
||||
"POST",
|
||||
&entry.authentication.api_token,
|
||||
Some(&record),
|
||||
)
|
||||
.await;
|
||||
noop_reported.remove(&noop_key);
|
||||
if self.dry_run {
|
||||
println!("[DRY RUN] Would add new record {fqdn} -> {ip}");
|
||||
} else {
|
||||
println!("Adding new record {fqdn} -> {ip}");
|
||||
let create_endpoint = format!("zones/{}/dns_records", entry.zone_id);
|
||||
let _: Option<serde_json::Value> = self
|
||||
.cf_api(
|
||||
&create_endpoint,
|
||||
"POST",
|
||||
&entry.authentication.api_token,
|
||||
Some(&record),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
if purge_unknown_records {
|
||||
@@ -633,8 +764,15 @@ mod tests {
|
||||
|
||||
let ddns = TestDdnsClient::new(&mock_server.uri());
|
||||
let config = test_config(zone_id);
|
||||
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
|
||||
.await;
|
||||
ddns.commit_record(
|
||||
"198.51.100.7",
|
||||
"A",
|
||||
&config.cloudflare,
|
||||
300,
|
||||
false,
|
||||
&mut std::collections::HashSet::new(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -682,8 +820,15 @@ mod tests {
|
||||
|
||||
let ddns = TestDdnsClient::new(&mock_server.uri());
|
||||
let config = test_config(zone_id);
|
||||
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
|
||||
.await;
|
||||
ddns.commit_record(
|
||||
"198.51.100.7",
|
||||
"A",
|
||||
&config.cloudflare,
|
||||
300,
|
||||
false,
|
||||
&mut std::collections::HashSet::new(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -725,8 +870,15 @@ mod tests {
|
||||
|
||||
let ddns = TestDdnsClient::new(&mock_server.uri());
|
||||
let config = test_config(zone_id);
|
||||
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
|
||||
.await;
|
||||
ddns.commit_record(
|
||||
"198.51.100.7",
|
||||
"A",
|
||||
&config.cloudflare,
|
||||
300,
|
||||
false,
|
||||
&mut std::collections::HashSet::new(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -759,8 +911,15 @@ mod tests {
|
||||
|
||||
let ddns = TestDdnsClient::new(&mock_server.uri()).dry_run();
|
||||
let config = test_config(zone_id);
|
||||
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
|
||||
.await;
|
||||
ddns.commit_record(
|
||||
"198.51.100.7",
|
||||
"A",
|
||||
&config.cloudflare,
|
||||
300,
|
||||
false,
|
||||
&mut std::collections::HashSet::new(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -813,9 +972,42 @@ mod tests {
|
||||
aaaa: false,
|
||||
purge_unknown_records: true,
|
||||
ttl: 300,
|
||||
ip4_provider: None,
|
||||
ip6_provider: None,
|
||||
record_comment: None,
|
||||
};
|
||||
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, true)
|
||||
.await;
|
||||
ddns.commit_record(
|
||||
"198.51.100.7",
|
||||
"A",
|
||||
&config.cloudflare,
|
||||
300,
|
||||
true,
|
||||
&mut std::collections::HashSet::new(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- jitter_duration tests ---
|
||||
#[test]
|
||||
fn test_jitter_duration_standard() {
|
||||
// 5-minute interval: max jitter = 60s
|
||||
let d = super::jitter_duration(300, 30);
|
||||
assert_eq!(d, std::time::Duration::from_secs(30));
|
||||
let d = super::jitter_duration(300, 61);
|
||||
assert_eq!(d, std::time::Duration::from_secs(61 % 61)); // wraps within [0, 60]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jitter_duration_short_interval() {
|
||||
// interval < 5s: must return zero
|
||||
assert_eq!(super::jitter_duration(4, 99), std::time::Duration::ZERO);
|
||||
assert_eq!(super::jitter_duration(0, 99), std::time::Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jitter_duration_deterministic() {
|
||||
// rand_val=0 always returns zero duration
|
||||
assert_eq!(super::jitter_duration(300, 0), std::time::Duration::ZERO);
|
||||
}
|
||||
|
||||
// --- describe_duration tests ---
|
||||
@@ -912,9 +1104,19 @@ mod tests {
|
||||
aaaa: false,
|
||||
purge_unknown_records: false,
|
||||
ttl: 300,
|
||||
ip4_provider: None,
|
||||
ip6_provider: None,
|
||||
record_comment: None,
|
||||
};
|
||||
|
||||
ddns.commit_record("203.0.113.99", "A", &config.cloudflare, 300, false)
|
||||
.await;
|
||||
ddns.commit_record(
|
||||
"203.0.113.99",
|
||||
"A",
|
||||
&config.cloudflare,
|
||||
300,
|
||||
false,
|
||||
&mut std::collections::HashSet::new(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
838
src/notifier.rs
838
src/notifier.rs
File diff suppressed because it is too large
Load Diff
206
src/pp.rs
206
src/pp.rs
@@ -1,6 +1,3 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// Verbosity levels
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Verbosity {
|
||||
@@ -11,12 +8,8 @@ pub enum Verbosity {
|
||||
}
|
||||
|
||||
// Emoji constants
|
||||
#[allow(dead_code)]
|
||||
pub const EMOJI_GLOBE: &str = "\u{1F30D}";
|
||||
pub const EMOJI_WARNING: &str = "\u{26A0}\u{FE0F}";
|
||||
pub const EMOJI_ERROR: &str = "\u{274C}";
|
||||
#[allow(dead_code)]
|
||||
pub const EMOJI_SUCCESS: &str = "\u{2705}";
|
||||
pub const EMOJI_LAUNCH: &str = "\u{1F680}";
|
||||
pub const EMOJI_STOP: &str = "\u{1F6D1}";
|
||||
pub const EMOJI_SLEEP: &str = "\u{1F634}";
|
||||
@@ -28,8 +21,6 @@ pub const EMOJI_SKIP: &str = "\u{23ED}\u{FE0F}";
|
||||
pub const EMOJI_NOTIFY: &str = "\u{1F514}";
|
||||
pub const EMOJI_HEARTBEAT: &str = "\u{1F493}";
|
||||
pub const EMOJI_CONFIG: &str = "\u{2699}\u{FE0F}";
|
||||
#[allow(dead_code)]
|
||||
pub const EMOJI_HINT: &str = "\u{1F4A1}";
|
||||
|
||||
const INDENT_PREFIX: &str = " ";
|
||||
|
||||
@@ -37,16 +28,18 @@ pub struct PP {
|
||||
pub verbosity: Verbosity,
|
||||
pub emoji: bool,
|
||||
indent: usize,
|
||||
seen: Arc<Mutex<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl PP {
|
||||
pub fn new(emoji: bool, quiet: bool) -> Self {
|
||||
Self {
|
||||
verbosity: if quiet { Verbosity::Quiet } else { Verbosity::Verbose },
|
||||
verbosity: if quiet {
|
||||
Verbosity::Quiet
|
||||
} else {
|
||||
Verbosity::Verbose
|
||||
},
|
||||
emoji,
|
||||
indent: 0,
|
||||
seen: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +56,6 @@ impl PP {
|
||||
verbosity: self.verbosity,
|
||||
emoji: self.emoji,
|
||||
indent: self.indent + 1,
|
||||
seen: Arc::clone(&self.seen),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,54 +96,12 @@ impl PP {
|
||||
pub fn errorf(&self, emoji: &str, msg: &str) {
|
||||
self.output_err(emoji, msg);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn info_once(&self, key: &str, emoji: &str, msg: &str) {
|
||||
if self.is_showing(Verbosity::Info) {
|
||||
let mut seen = self.seen.lock().unwrap();
|
||||
if seen.insert(key.to_string()) {
|
||||
self.output(emoji, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn notice_once(&self, key: &str, emoji: &str, msg: &str) {
|
||||
if self.is_showing(Verbosity::Notice) {
|
||||
let mut seen = self.seen.lock().unwrap();
|
||||
if seen.insert(key.to_string()) {
|
||||
self.output(emoji, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn blank_line_if_verbose(&self) {
|
||||
if self.is_showing(Verbosity::Verbose) {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn english_join(items: &[String]) -> String {
|
||||
match items.len() {
|
||||
0 => String::new(),
|
||||
1 => items[0].clone(),
|
||||
2 => format!("{} and {}", items[0], items[1]),
|
||||
_ => {
|
||||
let (last, rest) = items.split_last().unwrap();
|
||||
format!("{}, and {last}", rest.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ---- PP::new with emoji flag ----
|
||||
|
||||
#[test]
|
||||
fn new_with_emoji_true() {
|
||||
let pp = PP::new(true, false);
|
||||
@@ -164,8 +114,6 @@ mod tests {
|
||||
assert!(!pp.emoji);
|
||||
}
|
||||
|
||||
// ---- PP::new with quiet flag (verbosity levels) ----
|
||||
|
||||
#[test]
|
||||
fn new_quiet_true_sets_verbosity_quiet() {
|
||||
let pp = PP::new(false, true);
|
||||
@@ -178,8 +126,6 @@ mod tests {
|
||||
assert_eq!(pp.verbosity, Verbosity::Verbose);
|
||||
}
|
||||
|
||||
// ---- PP::is_showing at different verbosity levels ----
|
||||
|
||||
#[test]
|
||||
fn quiet_shows_only_quiet_level() {
|
||||
let pp = PP::new(false, true);
|
||||
@@ -218,8 +164,6 @@ mod tests {
|
||||
assert!(!pp.is_showing(Verbosity::Verbose));
|
||||
}
|
||||
|
||||
// ---- PP::indent ----
|
||||
|
||||
#[test]
|
||||
fn indent_increments_indent_level() {
|
||||
let pp = PP::new(true, false);
|
||||
@@ -238,26 +182,6 @@ mod tests {
|
||||
assert_eq!(child.emoji, pp.emoji);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_shares_seen_state() {
|
||||
let pp = PP::new(false, false);
|
||||
let child = pp.indent();
|
||||
|
||||
// Insert via parent's seen set
|
||||
pp.seen.lock().unwrap().insert("key1".to_string());
|
||||
|
||||
// Child should observe the same entry
|
||||
assert!(child.seen.lock().unwrap().contains("key1"));
|
||||
|
||||
// Insert via child
|
||||
child.seen.lock().unwrap().insert("key2".to_string());
|
||||
|
||||
// Parent should observe it too
|
||||
assert!(pp.seen.lock().unwrap().contains("key2"));
|
||||
}
|
||||
|
||||
// ---- PP::infof, noticef, warningf, errorf - no panic and verbosity gating ----
|
||||
|
||||
#[test]
|
||||
fn infof_does_not_panic_when_verbose() {
|
||||
let pp = PP::new(false, false);
|
||||
@@ -267,7 +191,6 @@ mod tests {
|
||||
#[test]
|
||||
fn infof_does_not_panic_when_quiet() {
|
||||
let pp = PP::new(false, true);
|
||||
// Should simply not print, and not panic
|
||||
pp.infof("", "test info message");
|
||||
}
|
||||
|
||||
@@ -291,7 +214,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn warningf_does_not_panic_when_quiet() {
|
||||
// warningf always outputs (no verbosity check), just verify no panic
|
||||
let pp = PP::new(false, true);
|
||||
pp.warningf("", "test warning");
|
||||
}
|
||||
@@ -308,124 +230,6 @@ mod tests {
|
||||
pp.errorf("", "test error");
|
||||
}
|
||||
|
||||
// ---- PP::info_once and notice_once ----
|
||||
|
||||
#[test]
|
||||
fn info_once_suppresses_duplicates() {
|
||||
let pp = PP::new(false, false);
|
||||
// First call inserts the key
|
||||
pp.info_once("dup_key", "", "first");
|
||||
// The key should now be in the seen set
|
||||
assert!(pp.seen.lock().unwrap().contains("dup_key"));
|
||||
|
||||
// Calling again with the same key should not insert again (set unchanged)
|
||||
let size_before = pp.seen.lock().unwrap().len();
|
||||
pp.info_once("dup_key", "", "second");
|
||||
let size_after = pp.seen.lock().unwrap().len();
|
||||
assert_eq!(size_before, size_after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_once_allows_different_keys() {
|
||||
let pp = PP::new(false, false);
|
||||
pp.info_once("key_a", "", "msg a");
|
||||
pp.info_once("key_b", "", "msg b");
|
||||
let seen = pp.seen.lock().unwrap();
|
||||
assert!(seen.contains("key_a"));
|
||||
assert!(seen.contains("key_b"));
|
||||
assert_eq!(seen.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_once_skipped_when_quiet() {
|
||||
let pp = PP::new(false, true);
|
||||
pp.info_once("quiet_key", "", "should not register");
|
||||
// Because verbosity is Quiet, info_once should not even insert the key
|
||||
assert!(!pp.seen.lock().unwrap().contains("quiet_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notice_once_suppresses_duplicates() {
|
||||
let pp = PP::new(false, false);
|
||||
pp.notice_once("notice_dup", "", "first");
|
||||
assert!(pp.seen.lock().unwrap().contains("notice_dup"));
|
||||
|
||||
let size_before = pp.seen.lock().unwrap().len();
|
||||
pp.notice_once("notice_dup", "", "second");
|
||||
let size_after = pp.seen.lock().unwrap().len();
|
||||
assert_eq!(size_before, size_after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notice_once_skipped_when_quiet() {
|
||||
let pp = PP::new(false, true);
|
||||
pp.notice_once("quiet_notice", "", "should not register");
|
||||
assert!(!pp.seen.lock().unwrap().contains("quiet_notice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_once_shared_via_indent() {
|
||||
let pp = PP::new(false, false);
|
||||
let child = pp.indent();
|
||||
|
||||
// Mark a key via the parent
|
||||
pp.info_once("shared_key", "", "parent");
|
||||
assert!(pp.seen.lock().unwrap().contains("shared_key"));
|
||||
|
||||
// Child should see it as already present, so set size stays the same
|
||||
let size_before = child.seen.lock().unwrap().len();
|
||||
child.info_once("shared_key", "", "child duplicate");
|
||||
let size_after = child.seen.lock().unwrap().len();
|
||||
assert_eq!(size_before, size_after);
|
||||
|
||||
// Child can add a new key visible to parent
|
||||
child.info_once("child_key", "", "child new");
|
||||
assert!(pp.seen.lock().unwrap().contains("child_key"));
|
||||
}
|
||||
|
||||
// ---- english_join ----
|
||||
|
||||
#[test]
|
||||
fn english_join_empty() {
|
||||
let items: Vec<String> = vec![];
|
||||
assert_eq!(english_join(&items), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_join_single() {
|
||||
let items = vec!["alpha".to_string()];
|
||||
assert_eq!(english_join(&items), "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_join_two() {
|
||||
let items = vec!["alpha".to_string(), "beta".to_string()];
|
||||
assert_eq!(english_join(&items), "alpha and beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_join_three() {
|
||||
let items = vec![
|
||||
"alpha".to_string(),
|
||||
"beta".to_string(),
|
||||
"gamma".to_string(),
|
||||
];
|
||||
assert_eq!(english_join(&items), "alpha, beta, and gamma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_join_four() {
|
||||
let items = vec![
|
||||
"a".to_string(),
|
||||
"b".to_string(),
|
||||
"c".to_string(),
|
||||
"d".to_string(),
|
||||
];
|
||||
assert_eq!(english_join(&items), "a, b, c, and d");
|
||||
}
|
||||
|
||||
// ---- default_pp ----
|
||||
|
||||
#[test]
|
||||
fn default_pp_is_verbose_no_emoji() {
|
||||
let pp = PP::default_pp();
|
||||
|
||||
563
src/provider.rs
563
src/provider.rs
@@ -1,6 +1,8 @@
|
||||
use crate::pp::{self, PP};
|
||||
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||
use reqwest::Client;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, UdpSocket};
|
||||
use std::fs;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket};
|
||||
use std::time::Duration;
|
||||
|
||||
/// IP type: IPv4 or IPv6
|
||||
@@ -24,11 +26,6 @@ impl IpType {
|
||||
IpType::V6 => "AAAA",
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn all() -> &'static [IpType] {
|
||||
&[IpType::V4, IpType::V6]
|
||||
}
|
||||
}
|
||||
|
||||
/// All supported provider types
|
||||
@@ -39,6 +36,7 @@ pub enum ProviderType {
|
||||
Ipify,
|
||||
Local,
|
||||
LocalIface { interface: String },
|
||||
StableLocalIface { interface: String },
|
||||
CustomURL { url: String },
|
||||
Literal { ips: Vec<IpAddr> },
|
||||
None,
|
||||
@@ -52,6 +50,7 @@ impl ProviderType {
|
||||
ProviderType::Ipify => "ipify",
|
||||
ProviderType::Local => "local",
|
||||
ProviderType::LocalIface { .. } => "local.iface",
|
||||
ProviderType::StableLocalIface { .. } => "local.iface.stable",
|
||||
ProviderType::CustomURL { .. } => "url:",
|
||||
ProviderType::Literal { .. } => "literal:",
|
||||
ProviderType::None => "none",
|
||||
@@ -81,6 +80,11 @@ impl ProviderType {
|
||||
if input == "local" {
|
||||
return Ok(ProviderType::Local);
|
||||
}
|
||||
if let Some(iface) = input.strip_prefix("local.iface.stable:") {
|
||||
return Ok(ProviderType::StableLocalIface {
|
||||
interface: iface.to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(iface) = input.strip_prefix("local.iface:") {
|
||||
return Ok(ProviderType::LocalIface {
|
||||
interface: iface.to_string(),
|
||||
@@ -114,6 +118,38 @@ impl ProviderType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect IPs using this provider, distinguishing a transient detection
|
||||
/// failure from a definitive "this host has no address of this family".
|
||||
///
|
||||
/// Network-based providers (trace, DoH, ipify, custom URL) can only fail —
|
||||
/// an empty result means the lookup errored and the real IP is unknown, so
|
||||
/// callers must not touch existing DNS records. Local sources (interfaces,
|
||||
/// routing table, literals, `none`) are deterministic: an empty result is a
|
||||
/// true absence and `delete_on_failure` semantics may apply.
|
||||
pub async fn detect(
|
||||
&self,
|
||||
client: &Client,
|
||||
ip_type: IpType,
|
||||
timeout: Duration,
|
||||
ppfmt: &PP,
|
||||
) -> DetectionOutcome {
|
||||
let ips = self.detect_ips(client, ip_type, timeout, ppfmt).await;
|
||||
if !ips.is_empty() {
|
||||
return DetectionOutcome::Ips(ips);
|
||||
}
|
||||
match self {
|
||||
ProviderType::None
|
||||
| ProviderType::Literal { .. }
|
||||
| ProviderType::Local
|
||||
| ProviderType::LocalIface { .. }
|
||||
| ProviderType::StableLocalIface { .. } => DetectionOutcome::NoIp,
|
||||
ProviderType::CloudflareTrace { .. }
|
||||
| ProviderType::CloudflareDOH
|
||||
| ProviderType::Ipify
|
||||
| ProviderType::CustomURL { .. } => DetectionOutcome::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect IPs using this provider.
|
||||
pub async fn detect_ips(
|
||||
&self,
|
||||
@@ -131,8 +167,9 @@ impl ProviderType {
|
||||
}
|
||||
ProviderType::Ipify => detect_ipify(client, ip_type, timeout, ppfmt).await,
|
||||
ProviderType::Local => detect_local(ip_type, ppfmt),
|
||||
ProviderType::LocalIface { interface } => {
|
||||
detect_local_iface(interface, ip_type, ppfmt)
|
||||
ProviderType::LocalIface { interface } => detect_local_iface(interface, ip_type, ppfmt),
|
||||
ProviderType::StableLocalIface { interface } => {
|
||||
detect_stable_local_iface(interface, ip_type, ppfmt)
|
||||
}
|
||||
ProviderType::CustomURL { url } => {
|
||||
detect_custom_url(client, url, ip_type, timeout, ppfmt).await
|
||||
@@ -143,14 +180,29 @@ impl ProviderType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a provider detection attempt (see [`ProviderType::detect`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DetectionOutcome {
|
||||
/// One or more addresses of the requested family were detected.
|
||||
Ips(Vec<IpAddr>),
|
||||
/// The provider ran and definitively reports no address of this family.
|
||||
NoIp,
|
||||
/// Detection errored (network failure, bad response); the real IP is
|
||||
/// unknown and existing DNS records must be preserved.
|
||||
Failed,
|
||||
}
|
||||
|
||||
// --- Cloudflare Trace ---
|
||||
|
||||
/// Primary trace URL uses a hostname so DNS resolves normally, avoiding the
|
||||
/// problem where WARP/Zero Trust intercepts requests to literal 1.1.1.1.
|
||||
const CF_TRACE_PRIMARY: &str = "https://api.cloudflare.com/cdn-cgi/trace";
|
||||
/// Fallback URLs use literal IPs for when api.cloudflare.com is unreachable.
|
||||
const CF_TRACE_V4_FALLBACK: &str = "https://1.0.0.1/cdn-cgi/trace";
|
||||
const CF_TRACE_V6_FALLBACK: &str = "https://[2606:4700:4700::1001]/cdn-cgi/trace";
|
||||
/// Primary trace URL uses cloudflare.com (the CDN endpoint, not the DNS
|
||||
/// resolver). The `build_split_client` forces the correct address family by
|
||||
/// filtering DNS results, so a dual-stack hostname is safe.
|
||||
/// Using literal DNS-resolver IPs (1.0.0.1 / [2606:4700:4700::1001]) caused
|
||||
/// TLS SNI mismatches and returned Cloudflare proxy IPs for some users.
|
||||
const CF_TRACE_PRIMARY: &str = "https://cloudflare.com/cdn-cgi/trace";
|
||||
/// Fallback uses api.cloudflare.com, which works when cloudflare.com is
|
||||
/// intercepted (e.g. Cloudflare WARP/Zero Trust).
|
||||
const CF_TRACE_FALLBACK: &str = "https://api.cloudflare.com/cdn-cgi/trace";
|
||||
|
||||
pub fn parse_trace_ip(body: &str) -> Option<String> {
|
||||
for line in body.lines() {
|
||||
@@ -161,28 +213,62 @@ pub fn parse_trace_ip(body: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn fetch_trace_ip(client: &Client, url: &str, timeout: Duration) -> Option<IpAddr> {
|
||||
let resp = client
|
||||
.get(url)
|
||||
.timeout(timeout)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
async fn fetch_trace_ip(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
timeout: Duration,
|
||||
host_override: Option<&str>,
|
||||
) -> Option<IpAddr> {
|
||||
let mut req = client.get(url).timeout(timeout);
|
||||
if let Some(host) = host_override {
|
||||
req = req.header("Host", host);
|
||||
}
|
||||
let resp = req.send().await.ok()?;
|
||||
let body = resp.text().await.ok()?;
|
||||
let ip_str = parse_trace_ip(&body)?;
|
||||
ip_str.parse::<IpAddr>().ok()
|
||||
}
|
||||
|
||||
/// A DNS resolver that filters lookup results to a single address family.
|
||||
/// This is the Rust equivalent of favonia/cloudflare-ddns's "split dialer"
|
||||
/// pattern: by removing addresses of the wrong family *before* the HTTP
|
||||
/// client sees them, we guarantee it can only establish connections over the
|
||||
/// desired protocol — no happy-eyeballs race, no fallback to the wrong family.
|
||||
struct FilteredResolver {
|
||||
ip_type: IpType,
|
||||
}
|
||||
|
||||
impl Resolve for FilteredResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let ip_type = self.ip_type;
|
||||
Box::pin(async move {
|
||||
let addrs: Vec<SocketAddr> = tokio::net::lookup_host((name.as_str(), 0))
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
|
||||
.filter(|addr| match ip_type {
|
||||
IpType::V4 => addr.is_ipv4(),
|
||||
IpType::V6 => addr.is_ipv6(),
|
||||
})
|
||||
.collect();
|
||||
if addrs.is_empty() {
|
||||
return Err(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::AddrNotAvailable,
|
||||
format!("no {} addresses found", ip_type.describe()),
|
||||
))
|
||||
as Box<dyn std::error::Error + Send + Sync>);
|
||||
}
|
||||
Ok(Box::new(addrs.into_iter()) as Addrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an HTTP client that only connects via the given IP family.
|
||||
/// Binding to 0.0.0.0 forces IPv4-only; binding to [::] forces IPv6-only.
|
||||
/// This ensures the trace endpoint sees the correct address family.
|
||||
fn build_split_client(ip_type: IpType, timeout: Duration) -> Client {
|
||||
let local_addr: IpAddr = match ip_type {
|
||||
IpType::V4 => Ipv4Addr::UNSPECIFIED.into(),
|
||||
IpType::V6 => Ipv6Addr::UNSPECIFIED.into(),
|
||||
};
|
||||
/// Uses a DNS-level filter to strip addresses of the wrong family from
|
||||
/// resolution results, ensuring the client never attempts a connection
|
||||
/// over the wrong protocol.
|
||||
pub fn build_split_client(ip_type: IpType, timeout: Duration) -> Client {
|
||||
Client::builder()
|
||||
.local_address(local_addr)
|
||||
.dns_resolver(FilteredResolver { ip_type })
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
@@ -199,36 +285,37 @@ async fn detect_cloudflare_trace(
|
||||
let client = build_split_client(ip_type, timeout);
|
||||
|
||||
if let Some(url) = custom_url {
|
||||
if let Some(ip) = fetch_trace_ip(&client, url, timeout).await {
|
||||
if let Some(ip) = fetch_trace_ip(&client, url, timeout, None).await {
|
||||
if validate_detected_ip(&ip, ip_type, ppfmt) {
|
||||
return vec![ip];
|
||||
}
|
||||
}
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
&format!("{} not detected via custom Cloudflare trace URL", ip_type.describe()),
|
||||
&format!(
|
||||
"{} not detected via custom Cloudflare trace URL",
|
||||
ip_type.describe()
|
||||
),
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let fallback = match ip_type {
|
||||
IpType::V4 => CF_TRACE_V4_FALLBACK,
|
||||
IpType::V6 => CF_TRACE_V6_FALLBACK,
|
||||
};
|
||||
|
||||
// Try primary (api.cloudflare.com — resolves via DNS, avoids literal-IP interception)
|
||||
if let Some(ip) = fetch_trace_ip(&client, CF_TRACE_PRIMARY, timeout).await {
|
||||
// Try primary (cloudflare.com — the CDN trace endpoint)
|
||||
if let Some(ip) = fetch_trace_ip(&client, CF_TRACE_PRIMARY, timeout, None).await {
|
||||
if validate_detected_ip(&ip, ip_type, ppfmt) {
|
||||
return vec![ip];
|
||||
}
|
||||
}
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
&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)
|
||||
if let Some(ip) = fetch_trace_ip(&client, fallback, timeout).await {
|
||||
// Try fallback (hostname-based — works when literal IPs are intercepted by WARP/Zero Trust)
|
||||
if let Some(ip) = fetch_trace_ip(&client, CF_TRACE_FALLBACK, timeout, None).await {
|
||||
if validate_detected_ip(&ip, ip_type, ppfmt) {
|
||||
return vec![ip];
|
||||
}
|
||||
@@ -279,7 +366,10 @@ async fn detect_cloudflare_doh(
|
||||
Err(e) => {
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
&format!("{} not detected via Cloudflare DoH: {e}", ip_type.describe()),
|
||||
&format!(
|
||||
"{} not detected via Cloudflare DoH: {e}",
|
||||
ip_type.describe()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -296,7 +386,7 @@ fn build_dns_query(name: &[u8], qtype: u16, qclass: u16) -> Vec<u8> {
|
||||
buf.extend_from_slice(&[0x00, 0x00]); // Answer RRs: 0
|
||||
buf.extend_from_slice(&[0x00, 0x00]); // Authority RRs: 0
|
||||
buf.extend_from_slice(&[0x00, 0x00]); // Additional RRs: 0
|
||||
// Question section
|
||||
// Question section
|
||||
buf.extend_from_slice(name);
|
||||
buf.extend_from_slice(&qtype.to_be_bytes());
|
||||
buf.extend_from_slice(&qclass.to_be_bytes());
|
||||
@@ -456,7 +546,10 @@ fn detect_local(ip_type: IpType, ppfmt: &PP) -> Vec<IpAddr> {
|
||||
Err(e) => {
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
&format!("Failed to bind socket for {} detection: {e}", ip_type.describe()),
|
||||
&format!(
|
||||
"Failed to bind socket for {} detection: {e}",
|
||||
ip_type.describe()
|
||||
),
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
@@ -497,6 +590,105 @@ fn detect_local_iface(interface: &str, ip_type: IpType, ppfmt: &PP) -> Vec<IpAdd
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stable Local Interface ---
|
||||
|
||||
const IF_INET6_PATH: &str = "/proc/net/if_inet6";
|
||||
const IFA_F_TEMPORARY: u32 = 0x01;
|
||||
const IFA_F_DADFAILED: u32 = 0x08;
|
||||
const IFA_F_DEPRECATED: u32 = 0x20;
|
||||
const IFA_F_TENTATIVE: u32 = 0x40;
|
||||
const IPV6_SCOPE_GLOBAL: u8 = 0x00;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct IfInet6Address {
|
||||
ip: Ipv6Addr,
|
||||
prefix_len: u8,
|
||||
scope: u8,
|
||||
flags: u32,
|
||||
interface: String,
|
||||
}
|
||||
|
||||
fn detect_stable_local_iface(interface: &str, ip_type: IpType, ppfmt: &PP) -> Vec<IpAddr> {
|
||||
if ip_type == IpType::V4 {
|
||||
return detect_local_iface(interface, ip_type, ppfmt);
|
||||
}
|
||||
|
||||
let contents = match fs::read_to_string(IF_INET6_PATH) {
|
||||
Ok(contents) => contents,
|
||||
Err(e) => {
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
&format!("Failed to read {IF_INET6_PATH} for stable IPv6 detection: {e}"),
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let ip = stable_ipv6_addresses_from_if_inet6(&contents, interface)
|
||||
.into_iter()
|
||||
.next();
|
||||
if ip.is_none() {
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
&format!("No stable global IPv6 address found on interface {interface}"),
|
||||
);
|
||||
}
|
||||
ip.into_iter().map(IpAddr::V6).collect()
|
||||
}
|
||||
|
||||
fn stable_ipv6_addresses_from_if_inet6(contents: &str, interface: &str) -> Vec<Ipv6Addr> {
|
||||
let mut entries: Vec<IfInet6Address> = contents
|
||||
.lines()
|
||||
.filter_map(parse_if_inet6_line)
|
||||
.filter(|addr| addr.interface == interface && is_stable_global_ipv6(addr))
|
||||
.collect();
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
a.prefix_len
|
||||
.cmp(&b.prefix_len)
|
||||
.then_with(|| a.ip.to_string().cmp(&b.ip.to_string()))
|
||||
});
|
||||
|
||||
let mut ips: Vec<Ipv6Addr> = entries.into_iter().map(|addr| addr.ip).collect();
|
||||
ips.dedup();
|
||||
ips
|
||||
}
|
||||
|
||||
fn is_stable_global_ipv6(addr: &IfInet6Address) -> bool {
|
||||
addr.scope == IPV6_SCOPE_GLOBAL
|
||||
&& IpAddr::V6(addr.ip).is_global_()
|
||||
&& addr.flags & (IFA_F_TEMPORARY | IFA_F_DADFAILED | IFA_F_DEPRECATED | IFA_F_TENTATIVE)
|
||||
== 0
|
||||
}
|
||||
|
||||
fn parse_if_inet6_line(line: &str) -> Option<IfInet6Address> {
|
||||
let mut fields = line.split_whitespace();
|
||||
let addr_hex = fields.next()?;
|
||||
let _ifindex = fields.next()?;
|
||||
let prefix_hex = fields.next()?;
|
||||
let scope_hex = fields.next()?;
|
||||
let flags_hex = fields.next()?;
|
||||
let interface = fields.next()?.to_string();
|
||||
|
||||
if addr_hex.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut octets = [0_u8; 16];
|
||||
for (index, octet) in octets.iter_mut().enumerate() {
|
||||
let start = index * 2;
|
||||
*octet = u8::from_str_radix(&addr_hex[start..start + 2], 16).ok()?;
|
||||
}
|
||||
|
||||
Some(IfInet6Address {
|
||||
ip: Ipv6Addr::from(octets),
|
||||
prefix_len: u8::from_str_radix(prefix_hex, 16).ok()?,
|
||||
scope: u8::from_str_radix(scope_hex, 16).ok()?,
|
||||
flags: u32::from_str_radix(flags_hex, 16).ok()?,
|
||||
interface,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Custom URL ---
|
||||
|
||||
async fn detect_custom_url(
|
||||
@@ -546,7 +738,8 @@ fn validate_detected_ip(ip: &IpAddr, ip_type: IpType, ppfmt: &PP) -> bool {
|
||||
pp::EMOJI_WARNING,
|
||||
&format!(
|
||||
"Detected IP {} does not match expected type {}",
|
||||
ip, ip_type.describe()
|
||||
ip,
|
||||
ip_type.describe()
|
||||
),
|
||||
);
|
||||
return false;
|
||||
@@ -556,7 +749,8 @@ fn validate_detected_ip(ip: &IpAddr, ip_type: IpType, ppfmt: &PP) -> bool {
|
||||
pp::EMOJI_WARNING,
|
||||
&format!(
|
||||
"Detected {} address {} is not a global unicast address",
|
||||
ip_type.describe(), ip
|
||||
ip_type.describe(),
|
||||
ip
|
||||
),
|
||||
);
|
||||
return false;
|
||||
@@ -667,6 +861,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_parse_stable_local_iface() {
|
||||
match ProviderType::parse("local.iface.stable:eth0").unwrap() {
|
||||
ProviderType::StableLocalIface { interface } => {
|
||||
assert_eq!(interface, "eth0");
|
||||
}
|
||||
_ => panic!("Expected StableLocalIface provider"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_parse_custom_url() {
|
||||
match ProviderType::parse("url:https://example.com/ip").unwrap() {
|
||||
@@ -726,11 +930,11 @@ mod tests {
|
||||
data.extend_from_slice(&[0x00, 0x01]); // ANCOUNT=1
|
||||
data.extend_from_slice(&[0x00, 0x00]); // NSCOUNT=0
|
||||
data.extend_from_slice(&[0x00, 0x00]); // ARCOUNT=0
|
||||
// Question section: name = \x04test\x00
|
||||
// Question section: name = \x04test\x00
|
||||
data.extend_from_slice(b"\x04test\x00");
|
||||
data.extend_from_slice(&[0x00, 0x10]); // QTYPE=TXT
|
||||
data.extend_from_slice(&[0x00, 0x01]); // QCLASS=IN
|
||||
// Answer section: name pointer to offset 12
|
||||
// Answer section: name pointer to offset 12
|
||||
data.extend_from_slice(&[0xC0, 0x0C]); // pointer to question name
|
||||
data.extend_from_slice(&[0x00, 0x10]); // TYPE=TXT
|
||||
data.extend_from_slice(&[0x00, 0x01]); // CLASS=IN
|
||||
@@ -833,8 +1037,11 @@ mod tests {
|
||||
|
||||
// ---- detect_cloudflare_trace with wiremock ----
|
||||
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::{method, path}};
|
||||
use crate::pp::PP;
|
||||
use wiremock::{
|
||||
matchers::{method, path},
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_detect_cloudflare_trace_primary_succeeds() {
|
||||
@@ -847,19 +1054,13 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = Client::new();
|
||||
let client = crate::test_client();
|
||||
let ppfmt = PP::default_pp();
|
||||
let url = format!("{}/cdn-cgi/trace", server.uri());
|
||||
let timeout = Duration::from_secs(5);
|
||||
|
||||
let result = detect_cloudflare_trace(
|
||||
&client,
|
||||
IpType::V4,
|
||||
timeout,
|
||||
Some(&url),
|
||||
&ppfmt,
|
||||
)
|
||||
.await;
|
||||
let result =
|
||||
detect_cloudflare_trace(&client, IpType::V4, timeout, Some(&url), &ppfmt).await;
|
||||
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0], "93.184.216.34".parse::<IpAddr>().unwrap());
|
||||
@@ -887,7 +1088,7 @@ mod tests {
|
||||
|
||||
// We can't override the hardcoded primary/fallback URLs, but we can test
|
||||
// the custom URL path: first with a failing URL, then a succeeding one.
|
||||
let client = Client::new();
|
||||
let client = crate::test_client();
|
||||
let ppfmt = PP::default_pp();
|
||||
let timeout = Duration::from_secs(5);
|
||||
|
||||
@@ -918,23 +1119,54 @@ mod tests {
|
||||
// ---- trace URL constants ----
|
||||
|
||||
#[test]
|
||||
fn test_trace_primary_uses_hostname_not_ip() {
|
||||
// Primary must use a hostname (api.cloudflare.com) so DNS resolves normally
|
||||
// and WARP/Zero Trust doesn't intercept the request.
|
||||
assert_eq!(CF_TRACE_PRIMARY, "https://api.cloudflare.com/cdn-cgi/trace");
|
||||
assert!(CF_TRACE_PRIMARY.contains("api.cloudflare.com"));
|
||||
// Fallbacks use literal IPs for when DNS is broken.
|
||||
assert!(CF_TRACE_V4_FALLBACK.contains("1.0.0.1"));
|
||||
assert!(CF_TRACE_V6_FALLBACK.contains("2606:4700:4700::1001"));
|
||||
fn test_trace_urls() {
|
||||
// Primary uses cloudflare.com CDN endpoint (not DNS resolver IPs).
|
||||
assert_eq!(CF_TRACE_PRIMARY, "https://cloudflare.com/cdn-cgi/trace");
|
||||
// Fallback uses api.cloudflare.com for when cloudflare.com is intercepted (WARP/Zero Trust).
|
||||
assert_eq!(
|
||||
CF_TRACE_FALLBACK,
|
||||
"https://api.cloudflare.com/cdn-cgi/trace"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- build_split_client ----
|
||||
// ---- FilteredResolver + build_split_client ----
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_filtered_resolver_v4() {
|
||||
let resolver = FilteredResolver {
|
||||
ip_type: IpType::V4,
|
||||
};
|
||||
let name: Name = "cloudflare.com".parse().unwrap();
|
||||
let addrs: Vec<SocketAddr> = resolver
|
||||
.resolve(name)
|
||||
.await
|
||||
.expect("DNS lookup failed")
|
||||
.collect();
|
||||
assert!(!addrs.is_empty(), "should resolve at least one address");
|
||||
for addr in &addrs {
|
||||
assert!(addr.is_ipv4(), "all addresses should be IPv4, got {addr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_filtered_resolver_v6() {
|
||||
let resolver = FilteredResolver {
|
||||
ip_type: IpType::V6,
|
||||
};
|
||||
let name: Name = "cloudflare.com".parse().unwrap();
|
||||
// IPv6 may not be available in all test environments, so we just
|
||||
// verify the resolver doesn't panic and returns only v6 if any.
|
||||
if let Ok(addrs) = resolver.resolve(name).await {
|
||||
for addr in addrs {
|
||||
assert!(addr.is_ipv6(), "all addresses should be IPv6, got {addr}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_split_client_v4() {
|
||||
let client = build_split_client(IpType::V4, Duration::from_secs(5));
|
||||
// Client should build successfully — we can't inspect local_address,
|
||||
// but we verify it doesn't panic.
|
||||
// Client should build successfully with filtered resolver.
|
||||
drop(client);
|
||||
}
|
||||
|
||||
@@ -956,7 +1188,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = Client::new();
|
||||
let client = crate::test_client();
|
||||
let ppfmt = PP::default_pp();
|
||||
let timeout = Duration::from_secs(5);
|
||||
|
||||
@@ -973,13 +1205,11 @@ mod tests {
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string("2606:4700:4700::1111\n"),
|
||||
)
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("2606:4700:4700::1111\n"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = Client::new();
|
||||
let client = crate::test_client();
|
||||
let ppfmt = PP::default_pp();
|
||||
let timeout = Duration::from_secs(5);
|
||||
|
||||
@@ -1000,7 +1230,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = Client::new();
|
||||
let client = crate::test_client();
|
||||
let ppfmt = PP::default_pp();
|
||||
let timeout = Duration::from_secs(5);
|
||||
let url = format!("{}/my-ip", server.uri());
|
||||
@@ -1020,7 +1250,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = Client::new();
|
||||
let client = crate::test_client();
|
||||
let ppfmt = PP::default_pp();
|
||||
let timeout = Duration::from_secs(5);
|
||||
let url = format!("{}/my-ip", server.uri());
|
||||
@@ -1035,43 +1265,91 @@ mod tests {
|
||||
#[test]
|
||||
fn test_validate_detected_ip_accepts_global() {
|
||||
let ppfmt = PP::default_pp();
|
||||
assert!(validate_detected_ip(&"93.184.216.34".parse().unwrap(), IpType::V4, &ppfmt));
|
||||
assert!(validate_detected_ip(&"2606:4700:4700::1111".parse().unwrap(), IpType::V6, &ppfmt));
|
||||
assert!(validate_detected_ip(
|
||||
&"93.184.216.34".parse().unwrap(),
|
||||
IpType::V4,
|
||||
&ppfmt
|
||||
));
|
||||
assert!(validate_detected_ip(
|
||||
&"2606:4700:4700::1111".parse().unwrap(),
|
||||
IpType::V6,
|
||||
&ppfmt
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_detected_ip_rejects_wrong_family() {
|
||||
let ppfmt = PP::default_pp();
|
||||
assert!(!validate_detected_ip(&"93.184.216.34".parse().unwrap(), IpType::V6, &ppfmt));
|
||||
assert!(!validate_detected_ip(&"2606:4700:4700::1111".parse().unwrap(), IpType::V4, &ppfmt));
|
||||
assert!(!validate_detected_ip(
|
||||
&"93.184.216.34".parse().unwrap(),
|
||||
IpType::V6,
|
||||
&ppfmt
|
||||
));
|
||||
assert!(!validate_detected_ip(
|
||||
&"2606:4700:4700::1111".parse().unwrap(),
|
||||
IpType::V4,
|
||||
&ppfmt
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_detected_ip_rejects_private() {
|
||||
let ppfmt = PP::default_pp();
|
||||
assert!(!validate_detected_ip(&"10.0.0.1".parse().unwrap(), IpType::V4, &ppfmt));
|
||||
assert!(!validate_detected_ip(&"192.168.1.1".parse().unwrap(), IpType::V4, &ppfmt));
|
||||
assert!(!validate_detected_ip(&"172.16.0.1".parse().unwrap(), IpType::V4, &ppfmt));
|
||||
assert!(!validate_detected_ip(
|
||||
&"10.0.0.1".parse().unwrap(),
|
||||
IpType::V4,
|
||||
&ppfmt
|
||||
));
|
||||
assert!(!validate_detected_ip(
|
||||
&"192.168.1.1".parse().unwrap(),
|
||||
IpType::V4,
|
||||
&ppfmt
|
||||
));
|
||||
assert!(!validate_detected_ip(
|
||||
&"172.16.0.1".parse().unwrap(),
|
||||
IpType::V4,
|
||||
&ppfmt
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_detected_ip_rejects_loopback() {
|
||||
let ppfmt = PP::default_pp();
|
||||
assert!(!validate_detected_ip(&"127.0.0.1".parse().unwrap(), IpType::V4, &ppfmt));
|
||||
assert!(!validate_detected_ip(&"::1".parse().unwrap(), IpType::V6, &ppfmt));
|
||||
assert!(!validate_detected_ip(
|
||||
&"127.0.0.1".parse().unwrap(),
|
||||
IpType::V4,
|
||||
&ppfmt
|
||||
));
|
||||
assert!(!validate_detected_ip(
|
||||
&"::1".parse().unwrap(),
|
||||
IpType::V6,
|
||||
&ppfmt
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_detected_ip_rejects_link_local() {
|
||||
let ppfmt = PP::default_pp();
|
||||
assert!(!validate_detected_ip(&"169.254.0.1".parse().unwrap(), IpType::V4, &ppfmt));
|
||||
assert!(!validate_detected_ip(
|
||||
&"169.254.0.1".parse().unwrap(),
|
||||
IpType::V4,
|
||||
&ppfmt
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_detected_ip_rejects_documentation() {
|
||||
let ppfmt = PP::default_pp();
|
||||
assert!(!validate_detected_ip(&"198.51.100.1".parse().unwrap(), IpType::V4, &ppfmt));
|
||||
assert!(!validate_detected_ip(&"203.0.113.1".parse().unwrap(), IpType::V4, &ppfmt));
|
||||
assert!(!validate_detected_ip(
|
||||
&"198.51.100.1".parse().unwrap(),
|
||||
IpType::V4,
|
||||
&ppfmt
|
||||
));
|
||||
assert!(!validate_detected_ip(
|
||||
&"203.0.113.1".parse().unwrap(),
|
||||
IpType::V4,
|
||||
&ppfmt
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1084,7 +1362,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = Client::new();
|
||||
let client = crate::test_client();
|
||||
let ppfmt = PP::default_pp();
|
||||
let timeout = Duration::from_secs(5);
|
||||
let url = format!("{}/my-ip", server.uri());
|
||||
@@ -1178,9 +1456,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_is_global_v4_documentation() {
|
||||
assert!(!is_global_v4(&Ipv4Addr::new(192, 0, 2, 1))); // 192.0.2.0/24
|
||||
assert!(!is_global_v4(&Ipv4Addr::new(192, 0, 2, 1))); // 192.0.2.0/24
|
||||
assert!(!is_global_v4(&Ipv4Addr::new(198, 51, 100, 1))); // 198.51.100.0/24
|
||||
assert!(!is_global_v4(&Ipv4Addr::new(203, 0, 113, 1))); // 203.0.113.0/24
|
||||
assert!(!is_global_v4(&Ipv4Addr::new(203, 0, 113, 1))); // 203.0.113.0/24
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1228,36 +1506,97 @@ mod tests {
|
||||
#[test]
|
||||
fn test_is_global_v6_global() {
|
||||
// 2606:4700:4700::1111 (Cloudflare DNS)
|
||||
assert!(is_global_v6(&Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111)));
|
||||
assert!(is_global_v6(&Ipv6Addr::new(
|
||||
0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111
|
||||
)));
|
||||
// 2001:db8::1 is documentation, but our impl doesn't explicitly exclude it
|
||||
// so it should be considered global by our function
|
||||
assert!(is_global_v6(&Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1)));
|
||||
assert!(is_global_v6(&Ipv6Addr::new(
|
||||
0x2001, 0x0db8, 0, 0, 0, 0, 0, 1
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_if_inet6_line() {
|
||||
let addr =
|
||||
parse_if_inet6_line("20010db8000000011111222233334444 03 40 00 00 eth0").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
addr.ip,
|
||||
"2001:db8:0:1:1111:2222:3333:4444"
|
||||
.parse::<Ipv6Addr>()
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(addr.prefix_len, 64);
|
||||
assert_eq!(addr.scope, IPV6_SCOPE_GLOBAL);
|
||||
assert_eq!(addr.flags, 0);
|
||||
assert_eq!(addr.interface, "eth0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stable_ipv6_addresses_from_if_inet6_filters_privacy_addresses() {
|
||||
let contents = "\
|
||||
20010db8000000015555666677778888 03 40 00 01 eth0
|
||||
20010db8000000010000000000003486 03 80 00 00 eth0
|
||||
20010db8000000011111222233334444 03 40 00 00 eth0
|
||||
20010db8000000019999aaaabbbbcccc 03 40 00 21 eth0
|
||||
fe80000000000000d399115858c872af 03 40 20 80 eth0
|
||||
fdaa149d3b9900000000000000000001 0a 40 00 82 br-990e55930a86
|
||||
";
|
||||
|
||||
let ips = stable_ipv6_addresses_from_if_inet6(contents, "eth0");
|
||||
|
||||
assert_eq!(
|
||||
ips,
|
||||
vec![
|
||||
"2001:db8:0:1:1111:2222:3333:4444"
|
||||
.parse::<Ipv6Addr>()
|
||||
.unwrap(),
|
||||
"2001:db8:0:1::3486".parse::<Ipv6Addr>().unwrap(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- ProviderType::name ----
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_name() {
|
||||
assert_eq!(ProviderType::CloudflareTrace { url: None }.name(), "cloudflare.trace");
|
||||
assert_eq!(
|
||||
ProviderType::CloudflareTrace { url: Some("https://x".into()) }.name(),
|
||||
ProviderType::CloudflareTrace { url: None }.name(),
|
||||
"cloudflare.trace"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::CloudflareTrace {
|
||||
url: Some("https://x".into())
|
||||
}
|
||||
.name(),
|
||||
"cloudflare.trace"
|
||||
);
|
||||
assert_eq!(ProviderType::CloudflareDOH.name(), "cloudflare.doh");
|
||||
assert_eq!(ProviderType::Ipify.name(), "ipify");
|
||||
assert_eq!(ProviderType::Local.name(), "local");
|
||||
assert_eq!(
|
||||
ProviderType::LocalIface { interface: "eth0".into() }.name(),
|
||||
ProviderType::LocalIface {
|
||||
interface: "eth0".into()
|
||||
}
|
||||
.name(),
|
||||
"local.iface"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::CustomURL { url: "https://x".into() }.name(),
|
||||
"url:"
|
||||
ProviderType::StableLocalIface {
|
||||
interface: "eth0".into()
|
||||
}
|
||||
.name(),
|
||||
"local.iface.stable"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::Literal { ips: vec![] }.name(),
|
||||
"literal:"
|
||||
ProviderType::CustomURL {
|
||||
url: "https://x".into()
|
||||
}
|
||||
.name(),
|
||||
"url:"
|
||||
);
|
||||
assert_eq!(ProviderType::Literal { ips: vec![] }.name(), "literal:");
|
||||
assert_eq!(ProviderType::None.name(), "none");
|
||||
}
|
||||
|
||||
@@ -1295,11 +1634,13 @@ mod tests {
|
||||
"5.6.7.8".parse().unwrap(),
|
||||
],
|
||||
};
|
||||
let client = Client::new();
|
||||
let client = crate::test_client();
|
||||
let ppfmt = PP::default_pp();
|
||||
let timeout = Duration::from_secs(5);
|
||||
|
||||
let result = provider.detect_ips(&client, IpType::V4, timeout, &ppfmt).await;
|
||||
let result = provider
|
||||
.detect_ips(&client, IpType::V4, timeout, &ppfmt)
|
||||
.await;
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!(result.iter().all(|ip| ip.is_ipv4()));
|
||||
}
|
||||
@@ -1313,11 +1654,13 @@ mod tests {
|
||||
"2001:db8::1".parse().unwrap(),
|
||||
],
|
||||
};
|
||||
let client = Client::new();
|
||||
let client = crate::test_client();
|
||||
let ppfmt = PP::default_pp();
|
||||
let timeout = Duration::from_secs(5);
|
||||
|
||||
let result = provider.detect_ips(&client, IpType::V6, timeout, &ppfmt).await;
|
||||
let result = provider
|
||||
.detect_ips(&client, IpType::V6, timeout, &ppfmt)
|
||||
.await;
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!(result.iter().all(|ip| ip.is_ipv6()));
|
||||
}
|
||||
@@ -1327,14 +1670,18 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_none_detect_ips_returns_empty() {
|
||||
let provider = ProviderType::None;
|
||||
let client = Client::new();
|
||||
let client = crate::test_client();
|
||||
let ppfmt = PP::default_pp();
|
||||
let timeout = Duration::from_secs(5);
|
||||
|
||||
let result_v4 = provider.detect_ips(&client, IpType::V4, timeout, &ppfmt).await;
|
||||
let result_v4 = provider
|
||||
.detect_ips(&client, IpType::V4, timeout, &ppfmt)
|
||||
.await;
|
||||
assert!(result_v4.is_empty());
|
||||
|
||||
let result_v6 = provider.detect_ips(&client, IpType::V6, timeout, &ppfmt).await;
|
||||
let result_v6 = provider
|
||||
.detect_ips(&client, IpType::V6, timeout, &ppfmt)
|
||||
.await;
|
||||
assert!(result_v6.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
1828
src/updater.rs
1828
src/updater.rs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user