47 Commits

Author SHA1 Message Date
dependabot[bot]
a54b57932d Bump rustls from 0.23.44 to 0.23.45
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.44 to 0.23.45.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.44...v/0.23.45)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.45
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 07:33:30 +00:00
Timothy Miller
7c6d5b43c1 Merge pull request #296 from timothymiller/dependabot/cargo/rustls-0.23.44
Bump rustls from 0.23.43 to 0.23.44
2026-09-12 01:19:44 -04:00
dependabot[bot]
9f4e37f175 Bump rustls from 0.23.43 to 0.23.44
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.43 to 0.23.44.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.43...v/0.23.44)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.44
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-11 07:34:16 +00:00
Timothy Miller
848da5acd1 Merge pull request #295 from timothymiller/dependabot/github_actions/docker/login-action-4.6.0
Bump docker/login-action from 4.5.2 to 4.6.0
2026-09-04 06:00:07 -04:00
dependabot[bot]
5289d2067e Bump docker/login-action from 4.5.2 to 4.6.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.2 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4.5.2...v4.6.0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 07:32:54 +00:00
Timothy Miller
6d7e4d644e Merge pull request #291 from timothymiller/dependabot/github_actions/docker/login-action-4.5.2
Bump docker/login-action from 4 to 4.5.2
2026-09-02 19:06:49 -04:00
Timothy Miller
1faef32f1e Merge pull request #290 from timothymiller/dependabot/cargo/rustls-0.23.43
Bump rustls from 0.23.42 to 0.23.43
2026-09-02 19:06:38 -04:00
Timothy Miller
ee46eb9c4c Merge pull request #289 from cliffordwhansen/comment_support_legacy_config
Comment support legacy config
2026-09-02 19:06:28 -04:00
Clifford W. Hansen
da4b20e252 Added .idea to .gitignore 2026-08-18 11:29:35 +02:00
Clifford W. Hansen
3307adaede Add support for record_comment in configuration, deserialization, and API integration. 2026-08-18 11:29:35 +02:00
dependabot[bot]
65f5629157 Bump docker/login-action from 4 to 4.5.2
Bumps [docker/login-action](https://github.com/docker/login-action) from 4 to 4.5.2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4...v4.5.2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 07:32:53 +00:00
dependabot[bot]
da3b90ff93 Bump rustls from 0.23.42 to 0.23.43
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.42 to 0.23.43.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.42...v/0.23.43)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-30 07:33:24 +00:00
Timothy Miller
4ef6ba1b74 Merge pull request #288 from timothymiller/dependabot/cargo/serde-1.0.229
Bump serde from 1.0.228 to 1.0.229
2026-07-22 14:31:07 -04:00
Timothy Miller
4cf7987f73 Merge pull request #287 from timothymiller/dependabot/cargo/tokio-1.53.1
Bump tokio from 1.52.4 to 1.53.1
2026-07-22 14:30:57 -04:00
Timothy Miller
70a562b734 Merge pull request #286 from timothymiller/dependabot/cargo/serde_json-1.0.151
Bump serde_json from 1.0.150 to 1.0.151
2026-07-22 14:30:48 -04:00
Timothy Miller
4c4a5e544a Merge pull request #285 from timothymiller/dependabot/github_actions/azure/setup-helm-5
Bump azure/setup-helm from 4 to 5
2026-07-22 14:30:39 -04:00
Timothy Miller
9a3c86c9bc Merge pull request #284 from timothymiller/dependabot/github_actions/actions/checkout-7
Bump actions/checkout from 4 to 7
2026-07-22 14:30:29 -04:00
dependabot[bot]
7ea89cd973 Bump serde from 1.0.228 to 1.0.229
Bumps [serde](https://github.com/serde-rs/serde) from 1.0.228 to 1.0.229.
- [Release notes](https://github.com/serde-rs/serde/releases)
- [Commits](https://github.com/serde-rs/serde/compare/v1.0.228...v1.0.229)

---
updated-dependencies:
- dependency-name: serde
  dependency-version: 1.0.229
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 07:33:59 +00:00
dependabot[bot]
a59d787e89 Bump tokio from 1.52.4 to 1.53.1
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.52.4 to 1.53.1.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.4...tokio-1.53.1)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.53.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 07:33:51 +00:00
dependabot[bot]
f0be440d00 Bump serde_json from 1.0.150 to 1.0.151
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.150 to 1.0.151.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.150...v1.0.151)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.151
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 07:33:42 +00:00
dependabot[bot]
e3678c6e24 Bump azure/setup-helm from 4 to 5
Bumps [azure/setup-helm](https://github.com/azure/setup-helm) from 4 to 5.
- [Release notes](https://github.com/azure/setup-helm/releases)
- [Changelog](https://github.com/Azure/setup-helm/blob/main/CHANGELOG.md)
- [Commits](https://github.com/azure/setup-helm/compare/v4...v5)

---
updated-dependencies:
- dependency-name: azure/setup-helm
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 07:32:50 +00:00
dependabot[bot]
8e79fc8798 Bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 07:32:48 +00:00
Timothy Miller
6cb1e0c874 Release v2.2.0
- Add native Zulip notification support via zulip:// shoutrrr URLs (#271)
- Add ?messagekey= option for generic webhooks to rename the JSON payload
  field (#271)
- Change DELETE_ON_FAILURE default to false: preserve existing DNS records
  and skip WAF list updates when IP detection fails (#277)
- Document the local.iface.stable IPv6 provider and Helm chart added since
  v2.1.2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:33:17 -04:00
Timothy Miller
d697c28aa1 Merge pull request #283 from timothymiller/dependabot/cargo/tokio-1.52.4
Bump tokio from 1.52.3 to 1.52.4
2026-07-20 23:01:17 -04:00
Timothy Miller
e67cc4fbca Merge pull request #282 from timothymiller/dependabot/cargo/rustls-0.23.42
Bump rustls from 0.23.40 to 0.23.42
2026-07-20 23:01:09 -04:00
Timothy Miller
b1840ffdf2 Merge pull request #281 from timothymiller/dependabot/cargo/rand-0.10.2
Bump rand from 0.10.1 to 0.10.2
2026-07-20 23:01:01 -04:00
Timothy Miller
385f77688e Merge pull request #279 from timothymiller/dependabot/github_actions/actions/checkout-7
Bump actions/checkout from 6 to 7
2026-07-20 23:00:53 -04:00
Timothy Miller
166c45873b Merge pull request #278 from quniv/master
feat: add support Helm chart with GHCR OCI
2026-07-20 23:00:39 -04:00
Timothy Miller
a00a9d66f9 Merge pull request #275 from timothymiller/dependabot/cargo/reqwest-0.13.4
Bump reqwest from 0.13.3 to 0.13.4
2026-07-20 23:00:15 -04:00
Timothy Miller
344c96e8d9 Merge pull request #274 from timothymiller/dependabot/cargo/serde_json-1.0.150
Bump serde_json from 1.0.149 to 1.0.150
2026-07-20 23:00:07 -04:00
dependabot[bot]
f4100bfe76 Bump tokio from 1.52.3 to 1.52.4
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.52.3 to 1.52.4.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.3...tokio-1.52.4)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-17 07:33:47 +00:00
dependabot[bot]
23bea452ee Bump rustls from 0.23.40 to 0.23.42
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.40 to 0.23.42.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.40...v/0.23.42)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.42
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 07:33:31 +00:00
dependabot[bot]
bc69fc42a6 Bump rand from 0.10.1 to 0.10.2
Bumps [rand](https://github.com/rust-random/rand) from 0.10.1 to 0.10.2.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/0.10.1...0.10.2)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-03 07:33:24 +00:00
dependabot[bot]
ea85b0eadb Bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 07:32:27 +00:00
Van Quyet
bc14e8b0ca Merge pull request #2 from quniv/feat/k8s-helm-chart-09062026
ci: rename workflow to match existing naming convention
2026-06-09 17:14:49 +07:00
qitpydev
d607204884 ci: rename workflow to match existing naming convention 2026-06-09 17:01:39 +07:00
Van Quyet
45522f4ceb Merge pull request #1 from quniv/feat/k8s-helm-chart-09062026
feat: add support Helm chart with GHCR OCI
2026-06-09 17:00:11 +07:00
qitpydev
a0ce9812b3 ci: use github.repository_owner in helm push destination 2026-06-09 16:57:19 +07:00
qitpydev
7564cb14f0 feat: add Helm chart with GHCR OCI publish workflow
- Add charts/cloudflare-ddns/ Helm chart (env-var config, hostNetwork by default)
- Add .github/workflows/helm.yml — lint → package → push to oci://ghcr.io/timothymiller
- Update README Kubernetes section with Helm install guide (quick install, values.yaml, upgrade/uninstall)
- Keep k8s/cloudflare-ddns.yml as legacy raw-manifest fallback
2026-06-09 16:22:54 +07:00
dependabot[bot]
20dbb9495f Bump reqwest from 0.13.3 to 0.13.4
Bumps [reqwest](https://github.com/seanmonstar/reqwest) from 0.13.3 to 0.13.4.
- [Release notes](https://github.com/seanmonstar/reqwest/releases)
- [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md)
- [Commits](https://github.com/seanmonstar/reqwest/compare/v0.13.3...v0.13.4)

---
updated-dependencies:
- dependency-name: reqwest
  dependency-version: 0.13.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-26 13:39:32 +00:00
dependabot[bot]
64ff319af5 Bump serde_json from 1.0.149 to 1.0.150
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.149 to 1.0.150.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.149...v1.0.150)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.150
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 07:39:45 +00:00
Timothy Miller
bbe2ae4543 Merge pull request #270 from timothymiller/dependabot/cargo/tokio-1.52.3
Bump tokio from 1.52.1 to 1.52.3
2026-05-18 14:47:38 -04:00
Timothy Miller
572f94b9cf Merge pull request #273 from Mygod/codex/stable-local-ipv6-provider-redacted
[codex] Add stable local IPv6 provider
2026-05-18 14:47:07 -04:00
Mygod
9574f67b98 Add stable local IPv6 provider 2026-05-18 13:36:16 -04:00
dependabot[bot]
ac11623127 Bump tokio from 1.52.1 to 1.52.3
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.52.1 to 1.52.3.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.1...tokio-1.52.3)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 09:50:53 +00:00
Timothy Miller
fddabc7a3d Release v2.1.2
Patch release: case-insensitive Cloudflare DNS record matching (#255),
Pushover URL parsing fix for canonical shoutrrr format (#258), and
Gotify URL parsing fix for ?token= query and ?disabletls=yes (#262).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:04:28 -04:00
Timothy Miller
548d89dacf Make Cloudflare lookups case-insensitive
Improve shoutrrr URL parsing for Gotify and Pushover

- Add parse_gotify_url to handle gotify://, gotify+http(s)://, token in
  final path segment or ?token=, and ?disabletls=yes to force http
- Accept canonical pushover URLs by stripping an optional 'shoutrrr:'
  user
  prefix and ignoring query params
- Add tests for Gotify, Pushover, and Cloudflare parsing/lookup behavior
2026-04-29 20:03:30 -04:00
24 changed files with 3054 additions and 566 deletions

54
.github/workflows/helm.yml vendored Normal file
View 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 }}

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v4
@@ -24,7 +24,7 @@ jobs:
- name: Login to DockerHub - name: Login to DockerHub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v4 uses: docker/login-action@v4.6.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}

3
.gitignore vendored
View File

@@ -8,3 +8,6 @@ debug/
# Git History # Git History
**/.history/* **/.history/*
# JetBrains IDE
.idea/

77
Cargo.lock generated
View File

@@ -92,7 +92,7 @@ dependencies = [
[[package]] [[package]]
name = "cloudflare-ddns" name = "cloudflare-ddns"
version = "2.1.1" version = "2.2.0"
dependencies = [ dependencies = [
"if-addrs", "if-addrs",
"rand", "rand",
@@ -168,7 +168,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -276,7 +276,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -679,7 +679,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [ dependencies = [
"quote", "quote",
"syn", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -801,7 +801,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"syn", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -830,9 +830,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.10.1" version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [ dependencies = [
"chacha20", "chacha20",
"getrandom 0.4.2", "getrandom 0.4.2",
@@ -882,9 +882,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.3" version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
@@ -946,9 +946,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.40" version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"ring", "ring",
@@ -1008,9 +1008,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.13" version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -1078,9 +1078,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_derive", "serde_derive",
@@ -1088,29 +1088,29 @@ dependencies = [
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.2",
] ]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.149" version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@@ -1192,6 +1192,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "syn"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "sync_wrapper" name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
@@ -1209,7 +1220,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -1242,7 +1253,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -1257,9 +1268,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.52.1" version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -1279,7 +1290,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.117",
] ]
[[package]] [[package]]
@@ -1500,7 +1511,7 @@ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.117",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -1782,7 +1793,7 @@ dependencies = [
"heck", "heck",
"indexmap", "indexmap",
"prettyplease", "prettyplease",
"syn", "syn 2.0.117",
"wasm-metadata", "wasm-metadata",
"wit-bindgen-core", "wit-bindgen-core",
"wit-component", "wit-component",
@@ -1798,7 +1809,7 @@ dependencies = [
"prettyplease", "prettyplease",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.117",
"wit-bindgen-core", "wit-bindgen-core",
"wit-bindgen-rust", "wit-bindgen-rust",
] ]
@@ -1865,7 +1876,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.117",
"synstructure", "synstructure",
] ]
@@ -1886,7 +1897,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.117",
"synstructure", "synstructure",
] ]
@@ -1926,7 +1937,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.117",
] ]
[[package]] [[package]]

View File

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

View File

@@ -18,7 +18,7 @@ Configure everything with environment variables. Supports notifications, heartbe
- 🃏 **Wildcard domains** — Support for `*.example.com` records - 🃏 **Wildcard domains** — Support for `*.example.com` records
- 🌍 **Internationalized domain names** — Full IDN/punycode support (e.g. `münchen.de`) - 🌍 **Internationalized domain names** — Full IDN/punycode support (e.g. `münchen.de`)
- 🛡️ **WAF list management** — Automatically update Cloudflare WAF IP lists - 🛡️ **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 - 💓 **Heartbeat monitoring** — Healthchecks.io and Uptime Kuma integration
- ⏱️ **Cron scheduling** — Flexible update intervals via cron expressions - ⏱️ **Cron scheduling** — Flexible update intervals via cron expressions
- 🧪 **Dry-run mode** — Preview changes without modifying DNS records - 🧪 **Dry-run mode** — Preview changes without modifying DNS records
@@ -29,6 +29,7 @@ Configure everything with environment variables. Supports notifications, heartbe
- 🔒 **Zero-log IP detection** — Uses Cloudflare's [cdn-cgi/trace](https://www.cloudflare.com/cdn-cgi/trace) by default - 🔒 **Zero-log IP detection** — Uses Cloudflare's [cdn-cgi/trace](https://www.cloudflare.com/cdn-cgi/trace) by default
- 🏠 **CGNAT-aware local detection** — Filters out shared address space (100.64.0.0/10) and private ranges - 🏠 **CGNAT-aware local detection** — Filters out shared address space (100.64.0.0/10) and private ranges
- 🚫 **Cloudflare IP rejection** — Automatically rejects Cloudflare anycast IPs to prevent incorrect DNS updates - 🚫 **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 - 🤏 **Tiny static binary** — ~1.1 MB Docker image built from scratch, zero runtime dependencies
## 🚀 Quick Start ## 🚀 Quick Start
@@ -84,6 +85,7 @@ Available providers:
| `ipify` | 🌎 ipify.org API | | `ipify` | 🌎 ipify.org API |
| `local` | 🏠 Local IP via system routing table (no network traffic, CGNAT-aware) | | `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:<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 | | `url:<url>` | 🔗 Custom HTTP(S) endpoint that returns an IP address |
| `literal:<ips>` | 📌 Static IP addresses (comma-separated) | | `literal:<ips>` | 📌 Static IP addresses (comma-separated) |
| `none` | 🚫 Disable this IP type | | `none` | 🚫 Disable this IP type |
@@ -107,7 +109,7 @@ To disable this protection, set `REJECT_CLOUDFLARE_IPS=false`.
| `UPDATE_CRON` | `@every 5m` | Update schedule | | `UPDATE_CRON` | `@every 5m` | Update schedule |
| `UPDATE_ON_START` | `true` | Run an update immediately on startup | | `UPDATE_ON_START` | `true` | Run an update immediately on startup |
| `DELETE_ON_STOP` | `false` | Delete managed DNS records on shutdown | | `DELETE_ON_STOP` | `false` | Delete managed DNS records on shutdown |
| `DELETE_ON_FAILURE` | `true` | Delete managed DNS records when failed to obtain IP from provider | | `DELETE_ON_FAILURE` | `false` | Delete managed DNS records when a provider definitively reports no address of that family (see below) |
Schedule formats: Schedule formats:
@@ -118,6 +120,13 @@ Schedule formats:
When `UPDATE_CRON=@once`, `UPDATE_ON_START` must be `true` and `DELETE_ON_STOP` must be `false`. 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 ## 📝 DNS Record Settings
| Variable | Default | Description | | Variable | Default | Description |
@@ -166,8 +175,13 @@ Supported services:
| ✈️ Telegram | `telegram://bot-token@telegram?chats=chat-id` | | ✈️ Telegram | `telegram://bot-token@telegram?chats=chat-id` |
| 📡 Gotify | `gotify://host/path?token=app-token` | | 📡 Gotify | `gotify://host/path?token=app-token` |
| 📲 Pushover | `pushover://user-key@api-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` | | 🌐 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. Notifications are sent when DNS records are updated, created, deleted, or when errors occur.
## 💓 Heartbeat Monitoring ## 💓 Heartbeat Monitoring
@@ -214,7 +228,7 @@ Heartbeats are sent after each update cycle. On failure, a fail signal is sent.
| `UPDATE_CRON` | `@every 5m` | ⏱️ Update schedule | | `UPDATE_CRON` | `@every 5m` | ⏱️ Update schedule |
| `UPDATE_ON_START` | `true` | 🚀 Update on startup | | `UPDATE_ON_START` | `true` | 🚀 Update on startup |
| `DELETE_ON_STOP` | `false` | 🧹 Delete records on shutdown | | `DELETE_ON_STOP` | `false` | 🧹 Delete records on shutdown |
| `DELETE_ON_FAILURE` | `true` | 🧹 Delete records if failed to obtain new records | | `DELETE_ON_FAILURE` | `false` | 🧹 Delete records when provider definitively reports no IP |
| `TTL` | `1` | ⏳ DNS record TTL | | `TTL` | `1` | ⏳ DNS record TTL |
| `PROXIED` | `false` | ☁️ Proxied expression | | `PROXIED` | `false` | ☁️ Proxied expression |
| `RECORD_COMMENT` | — | 💬 DNS record comment | | `RECORD_COMMENT` | — | 💬 DNS record comment |
@@ -260,7 +274,77 @@ services:
### ☸️ Kubernetes ### ☸️ 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 ```bash
kubectl create secret generic config-cloudflare-ddns --from-file=config.json -n ddns kubectl create secret generic config-cloudflare-ddns --from-file=config.json -n ddns
@@ -315,7 +399,7 @@ The binary is at `target/release/cloudflare-ddns`.
- 🐳 [Docker](https://docs.docker.com/get-docker/) (amd64, arm64, ppc64le) - 🐳 [Docker](https://docs.docker.com/get-docker/) (amd64, arm64, ppc64le)
- 🐙 [Docker Compose](https://docs.docker.com/compose/install/) - 🐙 [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/) - 🐧 [Systemd](https://www.freedesktop.org/wiki/Software/systemd/)
- 🍎 macOS, 🪟 Windows, 🐧 Linux — anywhere Rust compiles - 🍎 macOS, 🪟 Windows, 🐧 Linux — anywhere Rust compiles
@@ -411,7 +495,7 @@ volumes:
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. Legacy mode now uses the same shared provider abstraction as environment variable mode. By default it uses the `cloudflare.trace` provider, which builds an IP-family-bound HTTP client (`0.0.0.0` for IPv4, `[::]` for IPv6) to guarantee the correct address family on dual-stack hosts.
You can override the detection method per address family with `ip4_provider` and `ip6_provider` in your `config.json`. Supported values are the same as the `IP4_PROVIDER` / `IP6_PROVIDER` environment variables: `cloudflare.trace`, `cloudflare.doh`, `ipify`, `local`, `local.iface:<name>`, `url:<https://...>`, `none`. 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`): Set a provider to `"none"` to disable detection for that address family (overrides `a`/`aaaa`):

48
RELEASE_NOTES_2.1.2.md Normal file
View 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
View 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`.

View File

@@ -0,0 +1,4 @@
.DS_Store
.git
.gitignore
*.orig

View 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

View 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 }}

View 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 }}

View 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 }}

View 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: {}

View File

@@ -19,10 +19,12 @@ DOMAINS=example.com,www.example.com
# Provider for IPv4 detection (default: cloudflare.trace) # Provider for IPv4 detection (default: cloudflare.trace)
# Options: cloudflare.trace, cloudflare.doh, ipify, local, local.iface:<name>, # 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 # IP4_PROVIDER=cloudflare.trace
# Provider for IPv6 detection (default: 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 # IP6_PROVIDER=cloudflare.trace
# === Scheduling === # === Scheduling ===
@@ -37,6 +39,12 @@ DOMAINS=example.com,www.example.com
# Delete managed DNS records on shutdown (default: false) # Delete managed DNS records on shutdown (default: false)
# DELETE_ON_STOP=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 === # === DNS Records ===
# TTL in seconds: 1=auto, or 30-86400 (default: 1) # TTL in seconds: 1=auto, or 30-86400 (default: 1)

View File

@@ -78,9 +78,7 @@ impl CloudflareIpFilter {
None => { None => {
ppfmt.warningf( ppfmt.warningf(
pp::EMOJI_WARNING, pp::EMOJI_WARNING,
&format!( &format!("Failed to parse Cloudflare IP range '{line}'"),
"Failed to parse Cloudflare IP range '{line}'"
),
); );
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
use crate::cloudflare::{Auth, TTL, WAFList}; use crate::cloudflare::{Auth, WAFList, TTL};
use crate::domain; use crate::domain;
use crate::notifier::{ use crate::notifier::{
CompositeNotifier, Heartbeat, HeartbeatMonitor, HealthchecksMonitor, NotifierDyn, CompositeNotifier, HealthchecksMonitor, Heartbeat, HeartbeatMonitor, NotifierDyn,
ShoutrrrNotifier, UptimeKumaMonitor, ShoutrrrNotifier, UptimeKumaMonitor,
}; };
use crate::pp::{self, PP}; use crate::pp::{self, PP};
@@ -31,6 +31,8 @@ pub struct LegacyConfig {
pub ip4_provider: Option<String>, pub ip4_provider: Option<String>,
#[serde(default)] #[serde(default)]
pub ip6_provider: Option<String>, pub ip6_provider: Option<String>,
#[serde(rename = "recordComment", alias = "record_comment", default)]
pub record_comment: Option<String>,
} }
fn default_true() -> bool { fn default_true() -> bool {
@@ -130,9 +132,15 @@ impl CronSchedule {
fn parse_duration_string(s: &str) -> Option<Duration> { fn parse_duration_string(s: &str) -> Option<Duration> {
let s = s.trim(); let s = s.trim();
if let Some(minutes) = s.strip_suffix('m') { 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') { } 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') { } else if let Some(secs) = s.strip_suffix('s') {
secs.parse::<u64>().ok().map(Duration::from_secs) secs.parse::<u64>().ok().map(Duration::from_secs)
} else { } else {
@@ -146,7 +154,10 @@ fn parse_duration_string(s: &str) -> Option<Duration> {
// ============================================================ // ============================================================
fn getenv(key: &str) -> Option<String> { 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 { fn getenv_bool(key: &str, default: bool) -> bool {
@@ -187,7 +198,10 @@ fn read_auth_from_env(ppfmt: &PP) -> Option<Auth> {
val val
}) { }) {
if token == "YOUR-CLOUDFLARE-API-TOKEN" { 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 None;
} }
return Some(Auth::Token(token)); return Some(Auth::Token(token));
@@ -212,7 +226,10 @@ fn read_auth_from_env(ppfmt: &PP) -> Option<Auth> {
} }
} }
Err(e) => { 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}"),
);
} }
} }
} }
@@ -234,27 +251,31 @@ fn read_providers_from_env(ppfmt: &PP) -> Result<HashMap<IpType, ProviderType>,
let ip4_str = getenv("IP4_PROVIDER").or_else(|| { let ip4_str = getenv("IP4_PROVIDER").or_else(|| {
let val = getenv("IP4_POLICY"); let val = getenv("IP4_POLICY");
if val.is_some() { 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 val
}); });
let ip6_str = getenv("IP6_PROVIDER").or_else(|| { let ip6_str = getenv("IP6_PROVIDER").or_else(|| {
let val = getenv("IP6_POLICY"); let val = getenv("IP6_POLICY");
if val.is_some() { 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 val
}); });
let ip4_provider = match ip4_str { let ip4_provider = match ip4_str {
Some(s) => ProviderType::parse(&s) Some(s) => ProviderType::parse(&s).map_err(|e| format!("Invalid IP4_PROVIDER: {e}"))?,
.map_err(|e| format!("Invalid IP4_PROVIDER: {e}"))?,
None => ProviderType::CloudflareTrace { url: None }, None => ProviderType::CloudflareTrace { url: None },
}; };
let ip6_provider = match ip6_str { let ip6_provider = match ip6_str {
Some(s) => ProviderType::parse(&s) Some(s) => ProviderType::parse(&s).map_err(|e| format!("Invalid IP6_PROVIDER: {e}"))?,
.map_err(|e| format!("Invalid IP6_PROVIDER: {e}"))?,
None => ProviderType::CloudflareTrace { url: None }, None => ProviderType::CloudflareTrace { url: None },
}; };
@@ -392,7 +413,11 @@ pub fn parse_legacy_config(content: &str) -> Result<LegacyConfig, String> {
} }
/// Convert a legacy config into a unified AppConfig /// Convert a legacy config into a unified AppConfig
fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Result<AppConfig, String> { fn legacy_to_app_config(
legacy: LegacyConfig,
dry_run: bool,
repeat: bool,
) -> Result<AppConfig, String> {
// Extract auth from first entry // Extract auth from first entry
let auth = if let Some(entry) = legacy.cloudflare.first() { let auth = if let Some(entry) = legacy.cloudflare.first() {
if !entry.authentication.api_token.is_empty() if !entry.authentication.api_token.is_empty()
@@ -450,10 +475,10 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Re
update_cron: schedule, update_cron: schedule,
update_on_start: true, update_on_start: true,
delete_on_stop: false, delete_on_stop: false,
delete_on_failure: true, delete_on_failure: false,
ttl, ttl,
proxied_expression: None, proxied_expression: None,
record_comment: None, record_comment: legacy.record_comment.clone(),
managed_comment_regex: None, managed_comment_regex: None,
waf_list_description: None, waf_list_description: None,
waf_list_item_comment: None, waf_list_item_comment: None,
@@ -505,7 +530,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
let update_cron = read_cron_from_env(ppfmt)?; let update_cron = read_cron_from_env(ppfmt)?;
let update_on_start = getenv_bool("UPDATE_ON_START", true); let update_on_start = getenv_bool("UPDATE_ON_START", true);
let delete_on_stop = getenv_bool("DELETE_ON_STOP", false); let delete_on_stop = getenv_bool("DELETE_ON_STOP", false);
let delete_on_failure = getenv_bool("DELETE_ON_FAILURE", true); let delete_on_failure = getenv_bool("DELETE_ON_FAILURE", false);
let ttl_val = getenv("TTL") let ttl_val = getenv("TTL")
.and_then(|s| s.parse::<i64>().ok()) .and_then(|s| s.parse::<i64>().ok())
@@ -620,11 +645,17 @@ pub fn setup_notifiers(ppfmt: &PP) -> CompositeNotifier {
if !shoutrrr_urls.is_empty() { if !shoutrrr_urls.is_empty() {
match ShoutrrrNotifier::new(&shoutrrr_urls) { match ShoutrrrNotifier::new(&shoutrrr_urls) {
Ok(n) => { 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)); notifiers.push(Box::new(n));
} }
Err(e) => { Err(e) => {
ppfmt.errorf(pp::EMOJI_ERROR, &format!("Failed to setup notifications: {e}")); ppfmt.errorf(
pp::EMOJI_ERROR,
&format!("Failed to setup notifications: {e}"),
);
} }
} }
} }
@@ -663,7 +694,10 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) {
if !config.domains.is_empty() { if !config.domains.is_empty() {
ppfmt.noticef(pp::EMOJI_CONFIG, "Domains to update:"); ppfmt.noticef(pp::EMOJI_CONFIG, "Domains to update:");
for (ip_type, domains) in &config.domains { for (ip_type, domains) in &config.domains {
inner.noticef("", &format!("{}: {}", ip_type.describe(), domains.join(", "))); inner.noticef(
"",
&format!("{}: {}", ip_type.describe(), domains.join(", ")),
);
} }
} }
@@ -675,7 +709,10 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) {
} }
for (ip_type, provider) in &config.providers { 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())); inner.infof("", &format!("TTL: {}", config.ttl.describe()));
@@ -686,7 +723,10 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) {
} }
if !config.reject_cloudflare_ips { if !config.reject_cloudflare_ips {
inner.warningf("", "Cloudflare IP rejection: DISABLED (REJECT_CLOUDFLARE_IPS=false)"); inner.warningf(
"",
"Cloudflare IP rejection: DISABLED (REJECT_CLOUDFLARE_IPS=false)",
);
} }
if let Some(ref comment) = config.record_comment { if let Some(ref comment) = config.record_comment {
@@ -766,7 +806,10 @@ mod tests {
#[test] #[test]
fn test_parse_duration_string_whitespace() { 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] #[test]
@@ -962,7 +1005,10 @@ mod tests {
std::env::remove_var("IP6_DOMAINS"); std::env::remove_var("IP6_DOMAINS");
let pp = PP::new(false, false); let pp = PP::new(false, false);
let domains = read_domains_from_env(&pp); 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()); assert!(domains.get(&IpType::V6).is_none());
std::env::remove_var("IP4_DOMAINS"); std::env::remove_var("IP4_DOMAINS");
} }
@@ -1019,6 +1065,7 @@ mod tests {
ttl: 300, ttl: 300,
ip4_provider: None, ip4_provider: None,
ip6_provider: None, ip6_provider: None,
record_comment: None,
}; };
let config = legacy_to_app_config(legacy, false, false).unwrap(); let config = legacy_to_app_config(legacy, false, false).unwrap();
assert!(config.legacy_mode); assert!(config.legacy_mode);
@@ -1047,9 +1094,12 @@ mod tests {
ttl: 120, ttl: 120,
ip4_provider: None, ip4_provider: None,
ip6_provider: None, ip6_provider: None,
record_comment: None,
}; };
let config = legacy_to_app_config(legacy, true, true).unwrap(); let config = legacy_to_app_config(legacy, true, true).unwrap();
assert!(matches!(config.update_cron, CronSchedule::Every(d) if d == Duration::from_secs(120))); assert!(
matches!(config.update_cron, CronSchedule::Every(d) if d == Duration::from_secs(120))
);
assert!(config.repeat); assert!(config.repeat);
assert!(config.dry_run); assert!(config.dry_run);
} }
@@ -1075,6 +1125,7 @@ mod tests {
ttl: 300, ttl: 300,
ip4_provider: None, ip4_provider: None,
ip6_provider: None, ip6_provider: None,
record_comment: None,
}; };
let config = legacy_to_app_config(legacy, false, false).unwrap(); let config = legacy_to_app_config(legacy, false, false).unwrap();
assert!(matches!(config.auth, Auth::Key { ref api_key, ref email } assert!(matches!(config.auth, Auth::Key { ref api_key, ref email }
@@ -1099,10 +1150,14 @@ mod tests {
ttl: 300, ttl: 300,
ip4_provider: Some("ipify".to_string()), ip4_provider: Some("ipify".to_string()),
ip6_provider: Some("cloudflare.doh".to_string()), ip6_provider: Some("cloudflare.doh".to_string()),
record_comment: None,
}; };
let config = legacy_to_app_config(legacy, false, false).unwrap(); let config = legacy_to_app_config(legacy, false, false).unwrap();
assert!(matches!(config.providers[&IpType::V4], ProviderType::Ipify)); assert!(matches!(config.providers[&IpType::V4], ProviderType::Ipify));
assert!(matches!(config.providers[&IpType::V6], ProviderType::CloudflareDOH)); assert!(matches!(
config.providers[&IpType::V6],
ProviderType::CloudflareDOH
));
} }
#[test] #[test]
@@ -1123,6 +1178,7 @@ mod tests {
ttl: 300, ttl: 300,
ip4_provider: Some("none".to_string()), ip4_provider: Some("none".to_string()),
ip6_provider: None, ip6_provider: None,
record_comment: None,
}; };
let config = legacy_to_app_config(legacy, false, false).unwrap(); let config = legacy_to_app_config(legacy, false, false).unwrap();
// ip4_provider=none should exclude V4 even though a=true // ip4_provider=none should exclude V4 even though a=true
@@ -1148,6 +1204,7 @@ mod tests {
ttl: 300, ttl: 300,
ip4_provider: Some("totally_invalid".to_string()), ip4_provider: Some("totally_invalid".to_string()),
ip6_provider: None, ip6_provider: None,
record_comment: None,
}; };
let result = legacy_to_app_config(legacy, false, false); let result = legacy_to_app_config(legacy, false, false);
assert!(result.is_err()); assert!(result.is_err());
@@ -1155,6 +1212,30 @@ mod tests {
assert!(err.contains("ip4_provider")); 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] #[test]
fn test_legacy_config_deserializes_providers() { fn test_legacy_config_deserializes_providers() {
let json = r#"{ let json = r#"{
@@ -1171,6 +1252,20 @@ mod tests {
assert_eq!(config.ip6_provider, Some("none".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] #[test]
fn test_legacy_config_deserializes_without_providers() { fn test_legacy_config_deserializes_without_providers() {
let json = r#"{ let json = r#"{
@@ -1395,7 +1490,10 @@ mod tests {
fn set(key: &str, value: &str) -> Self { fn set(key: &str, value: &str) -> Self {
let lock = ENV_MUTEX.lock().unwrap(); let lock = ENV_MUTEX.lock().unwrap();
std::env::set_var(key, value); 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) { fn add(&mut self, key: &str, value: &str) {

View File

@@ -13,7 +13,9 @@ pub fn make_fqdn(subdomain: &str, base_domain: &str) -> String {
// Supports: true, false, is(domain,...), sub(domain,...), !, &&, ||, () // Supports: true, false, is(domain,...), sub(domain,...), !, &&, ||, ()
/// Parse and evaluate a domain expression to determine if a domain should be proxied. /// 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(); let expr = expr.trim();
if expr.is_empty() || expr == "false" { if expr.is_empty() || expr == "false" {
return Ok(Box::new(|_: &str| false)); return Ok(Box::new(|_: &str| false));
@@ -25,7 +27,10 @@ pub fn parse_proxied_expression(expr: &str) -> Result<Box<dyn Fn(&str) -> bool +
let tokens = tokenize_expr(expr)?; let tokens = tokenize_expr(expr)?;
let (predicate, rest) = parse_or_expr(&tokens)?; let (predicate, rest) = parse_or_expr(&tokens)?;
if !rest.is_empty() { 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) Ok(predicate)
} }
@@ -63,7 +68,13 @@ fn tokenize_expr(input: &str) -> Result<Vec<String>, String> {
_ => { _ => {
let mut word = String::new(); let mut word = String::new();
while let Some(&c) = chars.peek() { 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); word.push(c);
chars.next(); chars.next();
} else { } else {
@@ -144,9 +155,9 @@ fn parse_atom(tokens: &[String]) -> Result<(Predicate, &[String]), String> {
let (domains, rest) = parse_domain_args(&tokens[1..])?; let (domains, rest) = parse_domain_args(&tokens[1..])?;
let pred: Predicate = Box::new(move |d: &str| { let pred: Predicate = Box::new(move |d: &str| {
let d_lower = d.to_lowercase(); let d_lower = d.to_lowercase();
domains.iter().any(|dom| { domains
d_lower == *dom || d_lower.ends_with(&format!(".{dom}")) .iter()
}) .any(|dom| d_lower == *dom || d_lower.ends_with(&format!(".{dom}")))
}); });
Ok((pred, rest)) Ok((pred, rest))
} }
@@ -260,7 +271,8 @@ mod tests {
assert!(!pred("a.com")); assert!(!pred("a.com"));
assert!(!pred("b.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("www.example.com"));
assert!(!pred2("internal.example.com")); assert!(!pred2("internal.example.com"));
} }
@@ -278,7 +290,10 @@ mod tests {
let result = parse_proxied_expression("(is(a.com)"); let result = parse_proxied_expression("(is(a.com)");
assert!(result.is_err()); assert!(result.is_err());
let err = result.err().unwrap(); let err = result.err().unwrap();
assert!(err.contains("parenthesis") || err.contains(")"), "error was: {err}"); assert!(
err.contains("parenthesis") || err.contains(")"),
"error was: {err}"
);
} }
#[test] #[test]

View File

@@ -11,11 +11,11 @@ use crate::cloudflare::{Auth, CloudflareHandle};
use crate::config::{AppConfig, CronSchedule}; use crate::config::{AppConfig, CronSchedule};
use crate::notifier::{CompositeNotifier, Heartbeat, Message}; use crate::notifier::{CompositeNotifier, Heartbeat, Message};
use crate::pp::PP; use crate::pp::PP;
use rand::RngExt;
use reqwest::Client;
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use rand::RngExt;
use reqwest::Client;
use tokio::signal; use tokio::signal;
use tokio::time::{sleep, Duration}; use tokio::time::{sleep, Duration};
@@ -131,10 +131,30 @@ async fn main() {
if app_config.legacy_mode { if app_config.legacy_mode {
// --- Legacy mode (original cloudflare-ddns behavior) --- // --- Legacy mode (original cloudflare-ddns behavior) ---
run_legacy_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running, &mut cf_cache, &detection_client).await; run_legacy_mode(
&app_config,
&handle,
&notifier,
&heartbeat,
&ppfmt,
running,
&mut cf_cache,
&detection_client,
)
.await;
} else { } else {
// --- Env var mode (cf-ddns behavior) --- // --- Env var mode (cf-ddns behavior) ---
run_env_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running, &mut cf_cache, &detection_client).await; run_env_mode(
&app_config,
&handle,
&notifier,
&heartbeat,
&ppfmt,
running,
&mut cf_cache,
&detection_client,
)
.await;
} }
// On shutdown: delete records if configured // On shutdown: delete records if configured
@@ -144,9 +164,7 @@ async fn main() {
} }
// Exit heartbeat // Exit heartbeat
heartbeat heartbeat.exit(&Message::new_ok("Shutting down")).await;
.exit(&Message::new_ok("Shutting down"))
.await;
} }
async fn run_legacy_mode( async fn run_legacy_mode(
@@ -182,7 +200,17 @@ async fn run_legacy_mode(
} }
while running.load(Ordering::SeqCst) { while running.load(Ordering::SeqCst) {
updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await; updater::update_once(
config,
handle,
notifier,
heartbeat,
cf_cache,
ppfmt,
&mut noop_reported,
detection_client,
)
.await;
for _ in 0..legacy.ttl { for _ in 0..legacy.ttl {
if !running.load(Ordering::SeqCst) { if !running.load(Ordering::SeqCst) {
@@ -192,7 +220,17 @@ async fn run_legacy_mode(
} }
} }
} else { } else {
updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await; updater::update_once(
config,
handle,
notifier,
heartbeat,
cf_cache,
ppfmt,
&mut noop_reported,
detection_client,
)
.await;
} }
} }
@@ -211,7 +249,17 @@ async fn run_env_mode(
match &config.update_cron { match &config.update_cron {
CronSchedule::Once => { CronSchedule::Once => {
if config.update_on_start { if config.update_on_start {
updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await; updater::update_once(
config,
handle,
notifier,
heartbeat,
cf_cache,
ppfmt,
&mut noop_reported,
detection_client,
)
.await;
} }
} }
schedule => { schedule => {
@@ -227,7 +275,17 @@ async fn run_env_mode(
// Update on start if configured // Update on start if configured
if config.update_on_start { if config.update_on_start {
updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await; updater::update_once(
config,
handle,
notifier,
heartbeat,
cf_cache,
ppfmt,
&mut noop_reported,
detection_client,
)
.await;
} }
// Main loop // Main loop
@@ -260,7 +318,17 @@ async fn run_env_mode(
sleep(std::time::Duration::from_secs(jitter_secs)).await; 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; updater::update_once(
config,
handle,
notifier,
heartbeat,
cf_cache,
ppfmt,
&mut noop_reported,
detection_client,
)
.await;
} }
} }
} }
@@ -319,8 +387,8 @@ pub(crate) fn test_client() -> reqwest::Client {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::config::{ use crate::config::{
LegacyAuthentication, LegacyCloudflareEntry, LegacyConfig, LegacySubdomainEntry, parse_legacy_config, LegacyAuthentication, LegacyCloudflareEntry, LegacyConfig,
parse_legacy_config, LegacySubdomainEntry,
}; };
use crate::provider::parse_trace_ip; use crate::provider::parse_trace_ip;
use reqwest::Client; use reqwest::Client;
@@ -353,6 +421,7 @@ mod tests {
ttl: 300, ttl: 300,
ip4_provider: None, ip4_provider: None,
ip6_provider: None, ip6_provider: None,
record_comment: None,
} }
} }
@@ -566,8 +635,7 @@ mod tests {
println!("[DRY RUN] Would add new record {fqdn} -> {ip}"); println!("[DRY RUN] Would add new record {fqdn} -> {ip}");
} else { } else {
println!("Adding new record {fqdn} -> {ip}"); println!("Adding new record {fqdn} -> {ip}");
let create_endpoint = let create_endpoint = format!("zones/{}/dns_records", entry.zone_id);
format!("zones/{}/dns_records", entry.zone_id);
let _: Option<serde_json::Value> = self let _: Option<serde_json::Value> = self
.cf_api( .cf_api(
&create_endpoint, &create_endpoint,
@@ -696,8 +764,15 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri()); let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id); let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new()) ddns.commit_record(
.await; "198.51.100.7",
"A",
&config.cloudflare,
300,
false,
&mut std::collections::HashSet::new(),
)
.await;
} }
#[tokio::test] #[tokio::test]
@@ -745,8 +820,15 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri()); let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id); let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new()) ddns.commit_record(
.await; "198.51.100.7",
"A",
&config.cloudflare,
300,
false,
&mut std::collections::HashSet::new(),
)
.await;
} }
#[tokio::test] #[tokio::test]
@@ -788,8 +870,15 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri()); let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id); let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new()) ddns.commit_record(
.await; "198.51.100.7",
"A",
&config.cloudflare,
300,
false,
&mut std::collections::HashSet::new(),
)
.await;
} }
#[tokio::test] #[tokio::test]
@@ -822,8 +911,15 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri()).dry_run(); let ddns = TestDdnsClient::new(&mock_server.uri()).dry_run();
let config = test_config(zone_id); let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new()) ddns.commit_record(
.await; "198.51.100.7",
"A",
&config.cloudflare,
300,
false,
&mut std::collections::HashSet::new(),
)
.await;
} }
#[tokio::test] #[tokio::test]
@@ -878,9 +974,17 @@ mod tests {
ttl: 300, ttl: 300,
ip4_provider: None, ip4_provider: None,
ip6_provider: None, ip6_provider: None,
record_comment: None,
}; };
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, true, &mut std::collections::HashSet::new()) ddns.commit_record(
.await; "198.51.100.7",
"A",
&config.cloudflare,
300,
true,
&mut std::collections::HashSet::new(),
)
.await;
} }
// --- jitter_duration tests --- // --- jitter_duration tests ---
@@ -1002,9 +1106,17 @@ mod tests {
ttl: 300, ttl: 300,
ip4_provider: None, ip4_provider: None,
ip6_provider: None, ip6_provider: None,
record_comment: None,
}; };
ddns.commit_record("203.0.113.99", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new()) ddns.commit_record(
.await; "203.0.113.99",
"A",
&config.cloudflare,
300,
false,
&mut std::collections::HashSet::new(),
)
.await;
} }
} }

View File

@@ -89,12 +89,22 @@ struct ShoutrrrService {
} }
enum ShoutrrrServiceType { enum ShoutrrrServiceType {
Generic, Generic {
// JSON field name for the message body ("message" unless overridden
// via ?messagekey=..., e.g. "text" for slack-compatible endpoints).
message_key: String,
},
Discord, Discord,
Slack, Slack,
Telegram, Telegram,
Gotify, Gotify,
Pushover, Pushover,
Zulip {
email: String,
api_key: String,
stream: String,
topic: String,
},
Other(String), Other(String),
} }
@@ -126,12 +136,13 @@ impl ShoutrrrNotifier {
.urls .urls
.iter() .iter()
.map(|s| match &s.service_type { .map(|s| match &s.service_type {
ShoutrrrServiceType::Generic => "generic webhook".to_string(), ShoutrrrServiceType::Generic { .. } => "generic webhook".to_string(),
ShoutrrrServiceType::Discord => "Discord".to_string(), ShoutrrrServiceType::Discord => "Discord".to_string(),
ShoutrrrServiceType::Slack => "Slack".to_string(), ShoutrrrServiceType::Slack => "Slack".to_string(),
ShoutrrrServiceType::Telegram => "Telegram".to_string(), ShoutrrrServiceType::Telegram => "Telegram".to_string(),
ShoutrrrServiceType::Gotify => "Gotify".to_string(), ShoutrrrServiceType::Gotify => "Gotify".to_string(),
ShoutrrrServiceType::Pushover => "Pushover".to_string(), ShoutrrrServiceType::Pushover => "Pushover".to_string(),
ShoutrrrServiceType::Zulip { .. } => "Zulip".to_string(),
ShoutrrrServiceType::Other(name) => name.clone(), ShoutrrrServiceType::Other(name) => name.clone(),
}) })
.collect(); .collect();
@@ -147,8 +158,13 @@ impl ShoutrrrNotifier {
let mut all_ok = true; let mut all_ok = true;
for service in &self.urls { for service in &self.urls {
let ok = match &service.service_type { let ok = match &service.service_type {
ShoutrrrServiceType::Generic => self.send_generic(&service.webhook_url, &text).await, ShoutrrrServiceType::Generic { message_key } => {
ShoutrrrServiceType::Discord => self.send_discord(&service.webhook_url, &text).await, self.send_generic(&service.webhook_url, message_key, &text)
.await
}
ShoutrrrServiceType::Discord => {
self.send_discord(&service.webhook_url, &text).await
}
ShoutrrrServiceType::Slack => self.send_slack(&service.webhook_url, &text).await, ShoutrrrServiceType::Slack => self.send_slack(&service.webhook_url, &text).await,
ShoutrrrServiceType::Telegram => { ShoutrrrServiceType::Telegram => {
self.send_telegram(&service.webhook_url, &text).await self.send_telegram(&service.webhook_url, &text).await
@@ -157,7 +173,19 @@ impl ShoutrrrNotifier {
ShoutrrrServiceType::Pushover => { ShoutrrrServiceType::Pushover => {
self.send_pushover(&service.webhook_url, &text).await self.send_pushover(&service.webhook_url, &text).await
} }
ShoutrrrServiceType::Other(_) => self.send_generic(&service.webhook_url, &text).await, ShoutrrrServiceType::Zulip {
email,
api_key,
stream,
topic,
} => {
self.send_zulip(&service.webhook_url, email, api_key, stream, topic, &text)
.await
}
ShoutrrrServiceType::Other(_) => {
self.send_generic(&service.webhook_url, "message", &text)
.await
}
}; };
if !ok { if !ok {
ppfmt.warningf( ppfmt.warningf(
@@ -170,11 +198,39 @@ impl ShoutrrrNotifier {
all_ok all_ok
} }
async fn send_generic(&self, url: &str, text: &str) -> bool { async fn send_generic(&self, url: &str, message_key: &str, text: &str) -> bool {
let body = serde_json::json!({ "message": text }); let mut body = serde_json::Map::new();
body.insert(message_key.to_string(), serde_json::Value::from(text));
self.client self.client
.post(url) .post(url)
.json(&body) .json(&serde_json::Value::Object(body))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
async fn send_zulip(
&self,
api_url: &str,
email: &str,
api_key: &str,
stream: &str,
topic: &str,
text: &str,
) -> bool {
// Zulip API: POST /api/v1/messages with Basic auth (bot email + API key)
// and form-encoded fields. https://zulip.com/api/send-message
let params = [
("type", "stream"),
("to", stream),
("topic", topic),
("content", text),
];
self.client
.post(api_url)
.basic_auth(email, Some(api_key))
.form(&params)
.send() .send()
.await .await
.map(|r| r.status().is_success()) .map(|r| r.status().is_success())
@@ -274,12 +330,217 @@ impl NotifierDyn for ShoutrrrNotifier {
} }
} }
/// Build a Gotify webhook URL from a shoutrrr-style URL.
///
/// Accepted forms:
/// gotify://host[:port]/TOKEN[?disabletls=yes]
/// gotify://host[:port]/path/?token=TOKEN[&disabletls=yes]
/// gotify+http://host[:port]/TOKEN
/// gotify+https://host[:port]/TOKEN
///
/// `disabletls=yes` switches the resulting webhook to plain HTTP, which is
/// required for typical home-LAN deployments where Gotify is reachable on a
/// private IP without TLS.
fn parse_gotify_url(
original: &str,
rest: &str,
default_scheme: &str,
) -> Result<ShoutrrrService, String> {
// Split off the query string (if any) before path manipulation.
let (path_part, query_part) = match rest.split_once('?') {
Some((p, q)) => (p, q),
None => (rest, ""),
};
let mut token: Option<String> = None;
let mut scheme = default_scheme;
if !query_part.is_empty() {
for pair in query_part.split('&') {
let (k, v) = match pair.split_once('=') {
Some(kv) => kv,
None => continue,
};
match k {
"token" => token = Some(v.to_string()),
"disabletls" if v.eq_ignore_ascii_case("yes") => scheme = "http",
_ => {}
}
}
}
// host[:port][/extra/path]/TOKEN -- token is the last non-empty path segment.
let trimmed = path_part.trim_end_matches('/');
let (host_path, last_segment) = match trimmed.rsplit_once('/') {
Some((h, t)) => (h, t),
None => (trimmed, ""),
};
if token.is_none() && !last_segment.is_empty() {
token = Some(last_segment.to_string());
}
let token = match token {
Some(t) if !t.is_empty() => t,
_ => {
return Err(format!(
"Invalid Gotify shoutrrr URL (missing token): {original}"
));
}
};
// host_path is either "host[:port]" or "host[:port]/extra/path" if user
// had additional path segments before the token.
let host_and_path = if host_path.is_empty() {
// No slash before token -> token *was* the only segment, host is path_part minus token.
path_part
.trim_end_matches('/')
.trim_end_matches(&token[..])
.trim_end_matches('/')
.to_string()
} else {
host_path.to_string()
};
if host_and_path.is_empty() {
return Err(format!(
"Invalid Gotify shoutrrr URL (missing host): {original}"
));
}
Ok(ShoutrrrService {
original_url: original.to_string(),
service_type: ShoutrrrServiceType::Gotify,
webhook_url: format!("{scheme}://{host_and_path}/message?token={token}"),
})
}
/// Decode %XX percent-escapes. Unlike form decoding, '+' stays literal so
/// bot emails like "ddns+bot@example.com" survive; use %20 for spaces.
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (
(bytes[i + 1] as char).to_digit(16),
(bytes[i + 2] as char).to_digit(16),
) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// Pull the shoutrrr generic `messagekey` prop out of the query string,
/// returning the URL remainder (with that pair removed) and the JSON field
/// name to use for the message body.
fn extract_messagekey(rest: &str) -> (String, String) {
let (path, query) = match rest.split_once('?') {
Some((p, q)) => (p, q),
None => return (rest.to_string(), "message".to_string()),
};
let mut message_key = "message".to_string();
let mut kept = Vec::new();
for pair in query.split('&').filter(|p| !p.is_empty()) {
match pair.split_once('=') {
Some(("messagekey", v)) if !v.is_empty() => message_key = percent_decode(v),
_ => kept.push(pair),
}
}
let rest = if kept.is_empty() {
path.to_string()
} else {
format!("{path}?{}", kept.join("&"))
};
(rest, message_key)
}
/// Build a Zulip service from a shoutrrr-style URL.
///
/// Format: zulip://botmail:botkey@host/?stream=STREAM[&topic=TOPIC]
///
/// The '@' in the bot email may be given literally or percent-encoded (%40);
/// the LAST '@' separates credentials from the host. Messages are sent to
/// https://host/api/v1/messages with Basic auth (issue #271).
fn parse_zulip_url(original: &str, rest: &str) -> Result<ShoutrrrService, String> {
let (creds, host_part) = rest.rsplit_once('@').ok_or_else(|| {
format!(
"Invalid Zulip shoutrrr URL (expected zulip://botmail:botkey@host/?stream=...): {original}"
)
})?;
let (email, api_key) = creds.rsplit_once(':').ok_or_else(|| {
format!("Invalid Zulip shoutrrr URL (missing botkey after ':'): {original}")
})?;
let email = percent_decode(email);
let api_key = percent_decode(api_key);
if email.is_empty() || api_key.is_empty() {
return Err(format!(
"Invalid Zulip shoutrrr URL (empty botmail or botkey): {original}"
));
}
let (host, query) = match host_part.split_once('?') {
Some((h, q)) => (h, q),
None => (host_part, ""),
};
let host = host.trim_end_matches('/');
if host.is_empty() {
return Err(format!(
"Invalid Zulip shoutrrr URL (missing host): {original}"
));
}
let mut stream = None;
let mut topic = None;
for pair in query.split('&') {
if let Some((k, v)) = pair.split_once('=') {
match k {
"stream" => stream = Some(percent_decode(v)),
"topic" => topic = Some(percent_decode(v)),
_ => {}
}
}
}
let stream = match stream {
Some(s) if !s.is_empty() => s,
_ => {
return Err(format!(
"Invalid Zulip shoutrrr URL (missing ?stream=...): {original}"
));
}
};
let topic = topic
.filter(|t| !t.is_empty())
.unwrap_or_else(|| "Cloudflare DDNS".to_string());
Ok(ShoutrrrService {
original_url: original.to_string(),
service_type: ShoutrrrServiceType::Zulip {
email,
api_key,
stream,
topic,
},
webhook_url: format!("https://{host}/api/v1/messages"),
})
}
fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> { fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
// Shoutrrr URL formats: // Shoutrrr URL formats:
// discord://token@id -> https://discord.com/api/webhooks/id/token // discord://token@id -> https://discord.com/api/webhooks/id/token
// slack://token-a/token-b/token-c -> https://hooks.slack.com/services/token-a/token-b/token-c // slack://token-a/token-b/token-c -> https://hooks.slack.com/services/token-a/token-b/token-c
// telegram://token@telegram?chats=chatid -> https://api.telegram.org/bot{token}/sendMessage?chat_id={chatid} // telegram://token@telegram?chats=chatid -> https://api.telegram.org/bot{token}/sendMessage?chat_id={chatid}
// gotify://host/path?token=TOKEN -> https://host/path/message?token=TOKEN // gotify://host/path?token=TOKEN -> https://host/path/message?token=TOKEN
// zulip://botmail:botkey@host/?stream=STREAM&topic=TOPIC -> https://host/api/v1/messages
// generic://host/path -> https://host/path // generic://host/path -> https://host/path
// generic+https://host/path -> https://host/path // generic+https://host/path -> https://host/path
@@ -334,45 +595,63 @@ fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
return Err(format!("Invalid Telegram shoutrrr URL: {url_str}")); return Err(format!("Invalid Telegram shoutrrr URL: {url_str}"));
} }
if let Some(rest) = url_str if let Some((rest, default_scheme)) = url_str
.strip_prefix("gotify://") .strip_prefix("gotify+https://")
.or_else(|| url_str.strip_prefix("gotify+https://")) .map(|r| (r, "https"))
.or_else(|| url_str.strip_prefix("gotify+http://").map(|r| (r, "http")))
.or_else(|| url_str.strip_prefix("gotify://").map(|r| (r, "https")))
{ {
return Ok(ShoutrrrService { return parse_gotify_url(url_str, rest, default_scheme);
original_url: url_str.to_string(), }
service_type: ShoutrrrServiceType::Gotify,
webhook_url: format!("https://{rest}/message"), if let Some(rest) = url_str.strip_prefix("zulip://") {
}); return parse_zulip_url(url_str, rest);
} }
if let Some(rest) = url_str if let Some(rest) = url_str
.strip_prefix("generic://") .strip_prefix("generic://")
.or_else(|| url_str.strip_prefix("generic+https://")) .or_else(|| url_str.strip_prefix("generic+https://"))
{ {
let (rest, message_key) = extract_messagekey(rest);
return Ok(ShoutrrrService { return Ok(ShoutrrrService {
original_url: url_str.to_string(), original_url: url_str.to_string(),
service_type: ShoutrrrServiceType::Generic, service_type: ShoutrrrServiceType::Generic { message_key },
webhook_url: format!("https://{rest}"), webhook_url: format!("https://{rest}"),
}); });
} }
if let Some(rest) = url_str.strip_prefix("generic+http://") { if let Some(rest) = url_str.strip_prefix("generic+http://") {
let (rest, message_key) = extract_messagekey(rest);
return Ok(ShoutrrrService { return Ok(ShoutrrrService {
original_url: url_str.to_string(), original_url: url_str.to_string(),
service_type: ShoutrrrServiceType::Generic, service_type: ShoutrrrServiceType::Generic { message_key },
webhook_url: format!("http://{rest}"), webhook_url: format!("http://{rest}"),
}); });
} }
if let Some(rest) = url_str.strip_prefix("pushover://") { if let Some(rest) = url_str.strip_prefix("pushover://") {
let parts: Vec<&str> = rest.splitn(2, '@').collect(); // Strip query string (devices, priority, title) — not yet supported.
let body = rest.split('?').next().unwrap_or(rest).trim_end_matches('/');
let parts: Vec<&str> = body.splitn(2, '@').collect();
if parts.len() == 2 { if parts.len() == 2 {
// Shoutrrr's canonical pushover URL is
// pushover://shoutrrr:APIToken@UserKey
// where the literal "shoutrrr:" username is required. Strip an
// optional "<user>:" prefix from the token portion so both the
// canonical form and the bare "pushover://TOKEN@USER" form work.
let token = parts[0]
.rsplit_once(':')
.map(|(_, t)| t)
.unwrap_or(parts[0]);
let user = parts[1];
if token.is_empty() || user.is_empty() {
return Err(format!("Invalid Pushover shoutrrr URL: {url_str}"));
}
return Ok(ShoutrrrService { return Ok(ShoutrrrService {
original_url: url_str.to_string(), original_url: url_str.to_string(),
service_type: ShoutrrrServiceType::Pushover, service_type: ShoutrrrServiceType::Pushover,
webhook_url: format!( webhook_url: format!(
"https://api.pushover.net/1/messages.json?token={}&user={}", "https://api.pushover.net/1/messages.json?token={token}&user={user}"
parts[0], parts[1]
), ),
}); });
} }
@@ -383,7 +662,9 @@ fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
if url_str.starts_with("http://") || url_str.starts_with("https://") { if url_str.starts_with("http://") || url_str.starts_with("https://") {
return Ok(ShoutrrrService { return Ok(ShoutrrrService {
original_url: url_str.to_string(), original_url: url_str.to_string(),
service_type: ShoutrrrServiceType::Generic, service_type: ShoutrrrServiceType::Generic {
message_key: "message".to_string(),
},
webhook_url: url_str.to_string(), webhook_url: url_str.to_string(),
}); });
} }
@@ -412,9 +693,7 @@ pub trait HeartbeatMonitor: Send + Sync {
&'a self, &'a self,
msg: &'a Message, msg: &'a Message,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + 'a>>; ) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + 'a>>;
fn start( fn start(&self) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + '_>>;
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + '_>>;
fn exit<'a>( fn exit<'a>(
&'a self, &'a self,
msg: &'a Message, msg: &'a Message,
@@ -498,9 +777,7 @@ impl HeartbeatMonitor for HealthchecksMonitor {
}) })
} }
fn start( fn start(&self) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + '_>> {
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + '_>> {
Box::pin(async move { self.send_ping("start", None).await }) Box::pin(async move { self.send_ping("start", None).await })
} }
@@ -560,9 +837,7 @@ impl HeartbeatMonitor for UptimeKumaMonitor {
}) })
} }
fn start( fn start(&self) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + '_>> {
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + '_>> {
Box::pin(async move { Box::pin(async move {
let url = format!("{}?status=up&msg=Starting", self.base_url); let url = format!("{}?status=up&msg=Starting", self.base_url);
self.client self.client
@@ -716,16 +991,12 @@ mod tests {
#[test] #[test]
fn test_parse_telegram() { fn test_parse_telegram() {
let result = let result = parse_shoutrrr_url("telegram://bottoken123@telegram?chats=12345").unwrap();
parse_shoutrrr_url("telegram://bottoken123@telegram?chats=12345").unwrap();
assert_eq!( assert_eq!(
result.webhook_url, result.webhook_url,
"https://api.telegram.org/botbottoken123/sendMessage?chat_id=12345" "https://api.telegram.org/botbottoken123/sendMessage?chat_id=12345"
); );
assert!(matches!( assert!(matches!(result.service_type, ShoutrrrServiceType::Telegram));
result.service_type,
ShoutrrrServiceType::Telegram
));
} }
#[test] #[test]
@@ -735,36 +1006,81 @@ mod tests {
} }
#[test] #[test]
fn test_parse_gotify() { fn test_parse_gotify_token_as_path_segment() {
let result = parse_shoutrrr_url("gotify://myhost.com/somepath").unwrap(); // Shoutrrr canonical format: token is the final path segment.
let result = parse_shoutrrr_url("gotify://myhost.com/MYTOKEN").unwrap();
assert_eq!( assert_eq!(
result.webhook_url, result.webhook_url,
"https://myhost.com/somepath/message" "https://myhost.com/message?token=MYTOKEN"
); );
assert!(matches!(result.service_type, ShoutrrrServiceType::Gotify)); assert!(matches!(result.service_type, ShoutrrrServiceType::Gotify));
} }
#[test]
fn test_parse_gotify_token_query_param() {
// Older "gotify://host?token=..." form (issue #262).
let result = parse_shoutrrr_url(
"gotify://192.168.178.222:9090?token=AtE2tUGQig67b0J&disabletls=yes",
)
.unwrap();
assert_eq!(
result.webhook_url,
"http://192.168.178.222:9090/message?token=AtE2tUGQig67b0J"
);
}
#[test]
fn test_parse_gotify_disabletls_switches_to_http() {
let result = parse_shoutrrr_url("gotify://10.0.0.1:8080/TOKEN123?disabletls=yes").unwrap();
assert_eq!(
result.webhook_url,
"http://10.0.0.1:8080/message?token=TOKEN123"
);
}
#[test]
fn test_parse_gotify_plus_http_scheme() {
let result = parse_shoutrrr_url("gotify+http://10.0.0.1:8080/TOKEN").unwrap();
assert_eq!(
result.webhook_url,
"http://10.0.0.1:8080/message?token=TOKEN"
);
}
#[test]
fn test_parse_gotify_missing_token_errors() {
assert!(parse_shoutrrr_url("gotify://myhost.com/").is_err());
assert!(parse_shoutrrr_url("gotify://myhost.com").is_err());
}
#[test] #[test]
fn test_parse_generic() { fn test_parse_generic() {
let result = parse_shoutrrr_url("generic://example.com/webhook").unwrap(); let result = parse_shoutrrr_url("generic://example.com/webhook").unwrap();
assert_eq!(result.webhook_url, "https://example.com/webhook"); assert_eq!(result.webhook_url, "https://example.com/webhook");
assert!(matches!(result.service_type, ShoutrrrServiceType::Generic)); assert!(matches!(
result.service_type,
ShoutrrrServiceType::Generic { .. }
));
} }
#[test] #[test]
fn test_parse_generic_plus_https() { fn test_parse_generic_plus_https() {
let result = let result = parse_shoutrrr_url("generic+https://example.com/webhook").unwrap();
parse_shoutrrr_url("generic+https://example.com/webhook").unwrap();
assert_eq!(result.webhook_url, "https://example.com/webhook"); assert_eq!(result.webhook_url, "https://example.com/webhook");
assert!(matches!(result.service_type, ShoutrrrServiceType::Generic)); assert!(matches!(
result.service_type,
ShoutrrrServiceType::Generic { .. }
));
} }
#[test] #[test]
fn test_parse_generic_plus_http() { fn test_parse_generic_plus_http() {
let result = let result = parse_shoutrrr_url("generic+http://example.com/webhook").unwrap();
parse_shoutrrr_url("generic+http://example.com/webhook").unwrap();
assert_eq!(result.webhook_url, "http://example.com/webhook"); assert_eq!(result.webhook_url, "http://example.com/webhook");
assert!(matches!(result.service_type, ShoutrrrServiceType::Generic)); assert!(matches!(
result.service_type,
ShoutrrrServiceType::Generic { .. }
));
} }
#[test] #[test]
@@ -774,10 +1090,29 @@ mod tests {
result.webhook_url, result.webhook_url,
"https://api.pushover.net/1/messages.json?token=apitoken&user=userkey" "https://api.pushover.net/1/messages.json?token=apitoken&user=userkey"
); );
assert!(matches!( assert!(matches!(result.service_type, ShoutrrrServiceType::Pushover));
result.service_type, }
ShoutrrrServiceType::Pushover
)); #[test]
fn test_parse_pushover_shoutrrr_canonical_form() {
// Shoutrrr's canonical URL has a literal "shoutrrr:" username.
// Issue #258: parser must strip this prefix or Pushover rejects the token.
let result = parse_shoutrrr_url("pushover://shoutrrr:apitoken@userkey").unwrap();
assert_eq!(
result.webhook_url,
"https://api.pushover.net/1/messages.json?token=apitoken&user=userkey"
);
}
#[test]
fn test_parse_pushover_strips_query_params() {
// Optional shoutrrr query params (devices, priority) should not break parsing.
let result =
parse_shoutrrr_url("pushover://shoutrrr:tok@user/?devices=phone&priority=1").unwrap();
assert_eq!(
result.webhook_url,
"https://api.pushover.net/1/messages.json?token=tok&user=user"
);
} }
#[test] #[test]
@@ -787,19 +1122,149 @@ mod tests {
} }
#[test] #[test]
fn test_parse_plain_https_url() { fn test_parse_pushover_empty_token_errors() {
assert!(parse_shoutrrr_url("pushover://shoutrrr:@user").is_err());
assert!(parse_shoutrrr_url("pushover://tok@").is_err());
}
#[test]
fn test_parse_zulip_basic() {
// Shoutrrr canonical format: zulip://botmail:botkey@host/?stream=...&topic=...
let result = parse_shoutrrr_url(
"zulip://bot%40example.com:APIKEY123@zulip.example.com/?stream=alerts&topic=ddns",
)
.unwrap();
assert_eq!(
result.webhook_url,
"https://zulip.example.com/api/v1/messages"
);
match &result.service_type {
ShoutrrrServiceType::Zulip {
email,
api_key,
stream,
topic,
} => {
assert_eq!(email, "bot@example.com");
assert_eq!(api_key, "APIKEY123");
assert_eq!(stream, "alerts");
assert_eq!(topic, "ddns");
}
_ => panic!("expected Zulip service type"),
}
}
#[test]
fn test_parse_zulip_unencoded_bot_email() {
// A literal '@' in the bot email must not break host detection:
// the LAST '@' separates credentials from host.
let result = let result =
parse_shoutrrr_url("https://hooks.example.com/notify").unwrap(); parse_shoutrrr_url("zulip://ddns-bot@example.com:secret@chat.example.com/?stream=ops")
.unwrap();
match &result.service_type {
ShoutrrrServiceType::Zulip { email, api_key, .. } => {
assert_eq!(email, "ddns-bot@example.com");
assert_eq!(api_key, "secret");
}
_ => panic!("expected Zulip service type"),
}
assert_eq!(
result.webhook_url,
"https://chat.example.com/api/v1/messages"
);
}
#[test]
fn test_parse_zulip_default_topic() {
let result =
parse_shoutrrr_url("zulip://bot%40x.com:key@zulip.x.com/?stream=general").unwrap();
match &result.service_type {
ShoutrrrServiceType::Zulip { topic, .. } => assert_eq!(topic, "Cloudflare DDNS"),
_ => panic!("expected Zulip service type"),
}
}
#[test]
fn test_parse_zulip_percent_encoded_stream_and_topic() {
let result = parse_shoutrrr_url(
"zulip://bot%40x.com:key@zulip.x.com/?stream=home%20lab&topic=dns%20updates",
)
.unwrap();
match &result.service_type {
ShoutrrrServiceType::Zulip { stream, topic, .. } => {
assert_eq!(stream, "home lab");
assert_eq!(topic, "dns updates");
}
_ => panic!("expected Zulip service type"),
}
}
#[test]
fn test_parse_zulip_missing_stream_errors() {
assert!(parse_shoutrrr_url("zulip://bot%40x.com:key@zulip.x.com/").is_err());
assert!(parse_shoutrrr_url("zulip://bot%40x.com:key@zulip.x.com/?topic=t").is_err());
}
#[test]
fn test_parse_zulip_missing_credentials_errors() {
// No credentials at all
assert!(parse_shoutrrr_url("zulip://zulip.x.com/?stream=s").is_err());
// Email but no key
assert!(parse_shoutrrr_url("zulip://bot%40x.com@zulip.x.com/?stream=s").is_err());
// Empty key
assert!(parse_shoutrrr_url("zulip://bot%40x.com:@zulip.x.com/?stream=s").is_err());
}
#[test]
fn test_parse_generic_custom_messagekey() {
// Shoutrrr generic "messagekey" prop renames the JSON payload field
// (issue #271: Zulip's slack-compatible endpoints expect "text").
let result = parse_shoutrrr_url("generic://example.com/hook?messagekey=text").unwrap();
assert_eq!(result.webhook_url, "https://example.com/hook");
match &result.service_type {
ShoutrrrServiceType::Generic { message_key } => assert_eq!(message_key, "text"),
_ => panic!("expected Generic service type"),
}
}
#[test]
fn test_parse_generic_messagekey_keeps_other_query_params() {
let result =
parse_shoutrrr_url("generic://example.com/hook?messagekey=text&foo=bar").unwrap();
assert_eq!(result.webhook_url, "https://example.com/hook?foo=bar");
match &result.service_type {
ShoutrrrServiceType::Generic { message_key } => assert_eq!(message_key, "text"),
_ => panic!("expected Generic service type"),
}
}
#[test]
fn test_parse_generic_default_messagekey() {
let result = parse_shoutrrr_url("generic://example.com/hook").unwrap();
match &result.service_type {
ShoutrrrServiceType::Generic { message_key } => assert_eq!(message_key, "message"),
_ => panic!("expected Generic service type"),
}
}
#[test]
fn test_parse_plain_https_url() {
let result = parse_shoutrrr_url("https://hooks.example.com/notify").unwrap();
assert_eq!(result.webhook_url, "https://hooks.example.com/notify"); assert_eq!(result.webhook_url, "https://hooks.example.com/notify");
assert!(matches!(result.service_type, ShoutrrrServiceType::Generic)); assert!(matches!(
result.service_type,
ShoutrrrServiceType::Generic { .. }
));
} }
#[test] #[test]
fn test_parse_plain_http_url() { fn test_parse_plain_http_url() {
let result = let result = parse_shoutrrr_url("http://hooks.example.com/notify").unwrap();
parse_shoutrrr_url("http://hooks.example.com/notify").unwrap();
assert_eq!(result.webhook_url, "http://hooks.example.com/notify"); assert_eq!(result.webhook_url, "http://hooks.example.com/notify");
assert!(matches!(result.service_type, ShoutrrrServiceType::Generic)); assert!(matches!(
result.service_type,
ShoutrrrServiceType::Generic { .. }
));
} }
#[test] #[test]
@@ -1063,7 +1528,9 @@ mod tests {
client: crate::test_client(), client: crate::test_client(),
urls: vec![ShoutrrrService { urls: vec![ShoutrrrService {
original_url: "generic://example.com/hook".to_string(), original_url: "generic://example.com/hook".to_string(),
service_type: ShoutrrrServiceType::Generic, service_type: ShoutrrrServiceType::Generic {
message_key: "message".to_string(),
},
webhook_url: format!("{}/hook", server.uri()), webhook_url: format!("{}/hook", server.uri()),
}], }],
}; };
@@ -1079,7 +1546,10 @@ mod tests {
client: crate::test_client(), client: crate::test_client(),
urls: vec![], urls: vec![],
}; };
let msg = Message { lines: Vec::new(), ok: true }; let msg = Message {
lines: Vec::new(),
ok: true,
};
let pp = PP::default_pp(); let pp = PP::default_pp();
// Empty message should return true immediately // Empty message should return true immediately
let result = notifier.send(&msg, &pp).await; let result = notifier.send(&msg, &pp).await;
@@ -1090,14 +1560,21 @@ mod tests {
#[test] #[test]
fn test_shoutrrr_notifier_new_valid() { fn test_shoutrrr_notifier_new_valid() {
let urls = vec!["discord://token@id".to_string(), "slack://a/b/c".to_string()]; let urls = vec![
"discord://token@id".to_string(),
"slack://a/b/c".to_string(),
];
let notifier = ShoutrrrNotifier::new(&urls).unwrap(); let notifier = ShoutrrrNotifier::new(&urls).unwrap();
assert_eq!(notifier.urls.len(), 2); assert_eq!(notifier.urls.len(), 2);
} }
#[test] #[test]
fn test_shoutrrr_notifier_new_skips_empty() { fn test_shoutrrr_notifier_new_skips_empty() {
let urls = vec!["".to_string(), " ".to_string(), "discord://token@id".to_string()]; let urls = vec![
"".to_string(),
" ".to_string(),
"discord://token@id".to_string(),
];
let notifier = ShoutrrrNotifier::new(&urls).unwrap(); let notifier = ShoutrrrNotifier::new(&urls).unwrap();
assert_eq!(notifier.urls.len(), 1); assert_eq!(notifier.urls.len(), 1);
} }
@@ -1139,9 +1616,21 @@ mod tests {
service_type: ShoutrrrServiceType::Pushover, service_type: ShoutrrrServiceType::Pushover,
webhook_url: "https://example.com".to_string(), webhook_url: "https://example.com".to_string(),
}, },
ShoutrrrService {
original_url: "zulip://b%40h:k@h/?stream=s".to_string(),
service_type: ShoutrrrServiceType::Zulip {
email: "b@h".to_string(),
api_key: "k".to_string(),
stream: "s".to_string(),
topic: "t".to_string(),
},
webhook_url: "https://example.com".to_string(),
},
ShoutrrrService { ShoutrrrService {
original_url: "generic://h/p".to_string(), original_url: "generic://h/p".to_string(),
service_type: ShoutrrrServiceType::Generic, service_type: ShoutrrrServiceType::Generic {
message_key: "message".to_string(),
},
webhook_url: "https://example.com".to_string(), webhook_url: "https://example.com".to_string(),
}, },
ShoutrrrService { ShoutrrrService {
@@ -1152,7 +1641,10 @@ mod tests {
], ],
}; };
let desc = notifier.describe(); let desc = notifier.describe();
assert_eq!(desc, "Discord, Slack, Telegram, Gotify, Pushover, generic webhook, custom"); assert_eq!(
desc,
"Discord, Slack, Telegram, Gotify, Pushover, Zulip, generic webhook, custom"
);
} }
// ---- send_telegram, send_gotify, send_pushover with wiremock ---- // ---- send_telegram, send_gotify, send_pushover with wiremock ----
@@ -1240,6 +1732,71 @@ mod tests {
assert!(result); assert!(result);
} }
#[tokio::test]
async fn test_shoutrrr_send_zulip() {
use wiremock::matchers::{body_string_contains, header_exists};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/messages"))
.and(header_exists("authorization"))
.and(body_string_contains("type=stream"))
.and(body_string_contains("to=alerts"))
.and(body_string_contains("content=zulip+test"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
let notifier = ShoutrrrNotifier {
client: crate::test_client(),
urls: vec![ShoutrrrService {
original_url: "zulip://bot%40x.com:key@host/?stream=alerts".to_string(),
service_type: ShoutrrrServiceType::Zulip {
email: "bot@x.com".to_string(),
api_key: "key".to_string(),
stream: "alerts".to_string(),
topic: "ddns".to_string(),
},
webhook_url: format!("{}/api/v1/messages", server.uri()),
}],
};
let msg = Message::new_ok("zulip test");
let pp = PP::new(false, true);
let result = notifier.send(&msg, &pp).await;
assert!(result);
}
#[tokio::test]
async fn test_shoutrrr_send_generic_custom_messagekey() {
use wiremock::matchers::body_partial_json;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(body_partial_json(
serde_json::json!({ "text": "generic test" }),
))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
let notifier = ShoutrrrNotifier {
client: crate::test_client(),
urls: vec![ShoutrrrService {
original_url: "generic://example.com/hook?messagekey=text".to_string(),
service_type: ShoutrrrServiceType::Generic {
message_key: "text".to_string(),
},
webhook_url: format!("{}/hook", server.uri()),
}],
};
let msg = Message::new_ok("generic test");
let pp = PP::new(false, true);
let result = notifier.send(&msg, &pp).await;
assert!(result);
}
#[tokio::test] #[tokio::test]
async fn test_shoutrrr_send_failure_logs_warning() { async fn test_shoutrrr_send_failure_logs_warning() {
let server = MockServer::start().await; let server = MockServer::start().await;
@@ -1299,7 +1856,9 @@ mod tests {
client: crate::test_client(), client: crate::test_client(),
urls: vec![ShoutrrrService { urls: vec![ShoutrrrService {
original_url: "generic://example.com/hook".to_string(), original_url: "generic://example.com/hook".to_string(),
service_type: ShoutrrrServiceType::Generic, service_type: ShoutrrrServiceType::Generic {
message_key: "message".to_string(),
},
webhook_url: format!("{}/hook", server.uri()), webhook_url: format!("{}/hook", server.uri()),
}], }],
}; };

View File

@@ -33,7 +33,11 @@ pub struct PP {
impl PP { impl PP {
pub fn new(emoji: bool, quiet: bool) -> Self { pub fn new(emoji: bool, quiet: bool) -> Self {
Self { Self {
verbosity: if quiet { Verbosity::Quiet } else { Verbosity::Verbose }, verbosity: if quiet {
Verbosity::Quiet
} else {
Verbosity::Verbose
},
emoji, emoji,
indent: 0, indent: 0,
} }

View File

@@ -1,6 +1,7 @@
use crate::pp::{self, PP}; use crate::pp::{self, PP};
use reqwest::dns::{Addrs, Name, Resolve, Resolving}; use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use reqwest::Client; use reqwest::Client;
use std::fs;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket};
use std::time::Duration; use std::time::Duration;
@@ -25,7 +26,6 @@ impl IpType {
IpType::V6 => "AAAA", IpType::V6 => "AAAA",
} }
} }
} }
/// All supported provider types /// All supported provider types
@@ -36,6 +36,7 @@ pub enum ProviderType {
Ipify, Ipify,
Local, Local,
LocalIface { interface: String }, LocalIface { interface: String },
StableLocalIface { interface: String },
CustomURL { url: String }, CustomURL { url: String },
Literal { ips: Vec<IpAddr> }, Literal { ips: Vec<IpAddr> },
None, None,
@@ -49,6 +50,7 @@ impl ProviderType {
ProviderType::Ipify => "ipify", ProviderType::Ipify => "ipify",
ProviderType::Local => "local", ProviderType::Local => "local",
ProviderType::LocalIface { .. } => "local.iface", ProviderType::LocalIface { .. } => "local.iface",
ProviderType::StableLocalIface { .. } => "local.iface.stable",
ProviderType::CustomURL { .. } => "url:", ProviderType::CustomURL { .. } => "url:",
ProviderType::Literal { .. } => "literal:", ProviderType::Literal { .. } => "literal:",
ProviderType::None => "none", ProviderType::None => "none",
@@ -78,6 +80,11 @@ impl ProviderType {
if input == "local" { if input == "local" {
return Ok(ProviderType::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:") { if let Some(iface) = input.strip_prefix("local.iface:") {
return Ok(ProviderType::LocalIface { return Ok(ProviderType::LocalIface {
interface: iface.to_string(), interface: iface.to_string(),
@@ -111,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. /// Detect IPs using this provider.
pub async fn detect_ips( pub async fn detect_ips(
&self, &self,
@@ -128,8 +167,9 @@ impl ProviderType {
} }
ProviderType::Ipify => detect_ipify(client, ip_type, timeout, ppfmt).await, ProviderType::Ipify => detect_ipify(client, ip_type, timeout, ppfmt).await,
ProviderType::Local => detect_local(ip_type, ppfmt), ProviderType::Local => detect_local(ip_type, ppfmt),
ProviderType::LocalIface { interface } => { ProviderType::LocalIface { interface } => detect_local_iface(interface, ip_type, ppfmt),
detect_local_iface(interface, ip_type, ppfmt) ProviderType::StableLocalIface { interface } => {
detect_stable_local_iface(interface, ip_type, ppfmt)
} }
ProviderType::CustomURL { url } => { ProviderType::CustomURL { url } => {
detect_custom_url(client, url, ip_type, timeout, ppfmt).await detect_custom_url(client, url, ip_type, timeout, ppfmt).await
@@ -140,6 +180,18 @@ 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 --- // --- Cloudflare Trace ---
/// Primary trace URL uses cloudflare.com (the CDN endpoint, not the DNS /// Primary trace URL uses cloudflare.com (the CDN endpoint, not the DNS
@@ -202,7 +254,8 @@ impl Resolve for FilteredResolver {
return Err(Box::new(std::io::Error::new( return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::AddrNotAvailable, std::io::ErrorKind::AddrNotAvailable,
format!("no {} addresses found", ip_type.describe()), format!("no {} addresses found", ip_type.describe()),
)) as Box<dyn std::error::Error + Send + Sync>); ))
as Box<dyn std::error::Error + Send + Sync>);
} }
Ok(Box::new(addrs.into_iter()) as Addrs) Ok(Box::new(addrs.into_iter()) as Addrs)
}) })
@@ -239,7 +292,10 @@ async fn detect_cloudflare_trace(
} }
ppfmt.warningf( ppfmt.warningf(
pp::EMOJI_WARNING, 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(); return Vec::new();
} }
@@ -252,7 +308,10 @@ async fn detect_cloudflare_trace(
} }
ppfmt.warningf( ppfmt.warningf(
pp::EMOJI_WARNING, 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 (hostname-based — works when literal IPs are intercepted by WARP/Zero Trust) // Try fallback (hostname-based — works when literal IPs are intercepted by WARP/Zero Trust)
@@ -307,7 +366,10 @@ async fn detect_cloudflare_doh(
Err(e) => { Err(e) => {
ppfmt.warningf( ppfmt.warningf(
pp::EMOJI_WARNING, pp::EMOJI_WARNING,
&format!("{} not detected via Cloudflare DoH: {e}", ip_type.describe()), &format!(
"{} not detected via Cloudflare DoH: {e}",
ip_type.describe()
),
); );
} }
} }
@@ -324,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]); // Answer RRs: 0
buf.extend_from_slice(&[0x00, 0x00]); // Authority RRs: 0 buf.extend_from_slice(&[0x00, 0x00]); // Authority RRs: 0
buf.extend_from_slice(&[0x00, 0x00]); // Additional RRs: 0 buf.extend_from_slice(&[0x00, 0x00]); // Additional RRs: 0
// Question section // Question section
buf.extend_from_slice(name); buf.extend_from_slice(name);
buf.extend_from_slice(&qtype.to_be_bytes()); buf.extend_from_slice(&qtype.to_be_bytes());
buf.extend_from_slice(&qclass.to_be_bytes()); buf.extend_from_slice(&qclass.to_be_bytes());
@@ -484,7 +546,10 @@ fn detect_local(ip_type: IpType, ppfmt: &PP) -> Vec<IpAddr> {
Err(e) => { Err(e) => {
ppfmt.warningf( ppfmt.warningf(
pp::EMOJI_WARNING, 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() Vec::new()
} }
@@ -525,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 --- // --- Custom URL ---
async fn detect_custom_url( async fn detect_custom_url(
@@ -574,7 +738,8 @@ fn validate_detected_ip(ip: &IpAddr, ip_type: IpType, ppfmt: &PP) -> bool {
pp::EMOJI_WARNING, pp::EMOJI_WARNING,
&format!( &format!(
"Detected IP {} does not match expected type {}", "Detected IP {} does not match expected type {}",
ip, ip_type.describe() ip,
ip_type.describe()
), ),
); );
return false; return false;
@@ -584,7 +749,8 @@ fn validate_detected_ip(ip: &IpAddr, ip_type: IpType, ppfmt: &PP) -> bool {
pp::EMOJI_WARNING, pp::EMOJI_WARNING,
&format!( &format!(
"Detected {} address {} is not a global unicast address", "Detected {} address {} is not a global unicast address",
ip_type.describe(), ip ip_type.describe(),
ip
), ),
); );
return false; return false;
@@ -695,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] #[test]
fn test_provider_parse_custom_url() { fn test_provider_parse_custom_url() {
match ProviderType::parse("url:https://example.com/ip").unwrap() { match ProviderType::parse("url:https://example.com/ip").unwrap() {
@@ -754,11 +930,11 @@ mod tests {
data.extend_from_slice(&[0x00, 0x01]); // ANCOUNT=1 data.extend_from_slice(&[0x00, 0x01]); // ANCOUNT=1
data.extend_from_slice(&[0x00, 0x00]); // NSCOUNT=0 data.extend_from_slice(&[0x00, 0x00]); // NSCOUNT=0
data.extend_from_slice(&[0x00, 0x00]); // ARCOUNT=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(b"\x04test\x00");
data.extend_from_slice(&[0x00, 0x10]); // QTYPE=TXT data.extend_from_slice(&[0x00, 0x10]); // QTYPE=TXT
data.extend_from_slice(&[0x00, 0x01]); // QCLASS=IN 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(&[0xC0, 0x0C]); // pointer to question name
data.extend_from_slice(&[0x00, 0x10]); // TYPE=TXT data.extend_from_slice(&[0x00, 0x10]); // TYPE=TXT
data.extend_from_slice(&[0x00, 0x01]); // CLASS=IN data.extend_from_slice(&[0x00, 0x01]); // CLASS=IN
@@ -861,8 +1037,11 @@ mod tests {
// ---- detect_cloudflare_trace with wiremock ---- // ---- detect_cloudflare_trace with wiremock ----
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::{method, path}};
use crate::pp::PP; use crate::pp::PP;
use wiremock::{
matchers::{method, path},
Mock, MockServer, ResponseTemplate,
};
#[tokio::test] #[tokio::test]
async fn test_detect_cloudflare_trace_primary_succeeds() { async fn test_detect_cloudflare_trace_primary_succeeds() {
@@ -880,14 +1059,8 @@ mod tests {
let url = format!("{}/cdn-cgi/trace", server.uri()); let url = format!("{}/cdn-cgi/trace", server.uri());
let timeout = Duration::from_secs(5); let timeout = Duration::from_secs(5);
let result = detect_cloudflare_trace( let result =
&client, detect_cloudflare_trace(&client, IpType::V4, timeout, Some(&url), &ppfmt).await;
IpType::V4,
timeout,
Some(&url),
&ppfmt,
)
.await;
assert_eq!(result.len(), 1); assert_eq!(result.len(), 1);
assert_eq!(result[0], "93.184.216.34".parse::<IpAddr>().unwrap()); assert_eq!(result[0], "93.184.216.34".parse::<IpAddr>().unwrap());
@@ -950,14 +1123,19 @@ mod tests {
// Primary uses cloudflare.com CDN endpoint (not DNS resolver IPs). // Primary uses cloudflare.com CDN endpoint (not DNS resolver IPs).
assert_eq!(CF_TRACE_PRIMARY, "https://cloudflare.com/cdn-cgi/trace"); 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). // 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"); assert_eq!(
CF_TRACE_FALLBACK,
"https://api.cloudflare.com/cdn-cgi/trace"
);
} }
// ---- FilteredResolver + build_split_client ---- // ---- FilteredResolver + build_split_client ----
#[tokio::test] #[tokio::test]
async fn test_filtered_resolver_v4() { async fn test_filtered_resolver_v4() {
let resolver = FilteredResolver { ip_type: IpType::V4 }; let resolver = FilteredResolver {
ip_type: IpType::V4,
};
let name: Name = "cloudflare.com".parse().unwrap(); let name: Name = "cloudflare.com".parse().unwrap();
let addrs: Vec<SocketAddr> = resolver let addrs: Vec<SocketAddr> = resolver
.resolve(name) .resolve(name)
@@ -972,7 +1150,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_filtered_resolver_v6() { async fn test_filtered_resolver_v6() {
let resolver = FilteredResolver { ip_type: IpType::V6 }; let resolver = FilteredResolver {
ip_type: IpType::V6,
};
let name: Name = "cloudflare.com".parse().unwrap(); let name: Name = "cloudflare.com".parse().unwrap();
// IPv6 may not be available in all test environments, so we just // IPv6 may not be available in all test environments, so we just
// verify the resolver doesn't panic and returns only v6 if any. // verify the resolver doesn't panic and returns only v6 if any.
@@ -1025,9 +1205,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/")) .and(path("/"))
.respond_with( .respond_with(ResponseTemplate::new(200).set_body_string("2606:4700:4700::1111\n"))
ResponseTemplate::new(200).set_body_string("2606:4700:4700::1111\n"),
)
.mount(&server) .mount(&server)
.await; .await;
@@ -1087,43 +1265,91 @@ mod tests {
#[test] #[test]
fn test_validate_detected_ip_accepts_global() { fn test_validate_detected_ip_accepts_global() {
let ppfmt = PP::default_pp(); let ppfmt = PP::default_pp();
assert!(validate_detected_ip(&"93.184.216.34".parse().unwrap(), IpType::V4, &ppfmt)); assert!(validate_detected_ip(
assert!(validate_detected_ip(&"2606:4700:4700::1111".parse().unwrap(), IpType::V6, &ppfmt)); &"93.184.216.34".parse().unwrap(),
IpType::V4,
&ppfmt
));
assert!(validate_detected_ip(
&"2606:4700:4700::1111".parse().unwrap(),
IpType::V6,
&ppfmt
));
} }
#[test] #[test]
fn test_validate_detected_ip_rejects_wrong_family() { fn test_validate_detected_ip_rejects_wrong_family() {
let ppfmt = PP::default_pp(); let ppfmt = PP::default_pp();
assert!(!validate_detected_ip(&"93.184.216.34".parse().unwrap(), IpType::V6, &ppfmt)); assert!(!validate_detected_ip(
assert!(!validate_detected_ip(&"2606:4700:4700::1111".parse().unwrap(), IpType::V4, &ppfmt)); &"93.184.216.34".parse().unwrap(),
IpType::V6,
&ppfmt
));
assert!(!validate_detected_ip(
&"2606:4700:4700::1111".parse().unwrap(),
IpType::V4,
&ppfmt
));
} }
#[test] #[test]
fn test_validate_detected_ip_rejects_private() { fn test_validate_detected_ip_rejects_private() {
let ppfmt = PP::default_pp(); let ppfmt = PP::default_pp();
assert!(!validate_detected_ip(&"10.0.0.1".parse().unwrap(), IpType::V4, &ppfmt)); assert!(!validate_detected_ip(
assert!(!validate_detected_ip(&"192.168.1.1".parse().unwrap(), IpType::V4, &ppfmt)); &"10.0.0.1".parse().unwrap(),
assert!(!validate_detected_ip(&"172.16.0.1".parse().unwrap(), IpType::V4, &ppfmt)); 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] #[test]
fn test_validate_detected_ip_rejects_loopback() { fn test_validate_detected_ip_rejects_loopback() {
let ppfmt = PP::default_pp(); let ppfmt = PP::default_pp();
assert!(!validate_detected_ip(&"127.0.0.1".parse().unwrap(), IpType::V4, &ppfmt)); assert!(!validate_detected_ip(
assert!(!validate_detected_ip(&"::1".parse().unwrap(), IpType::V6, &ppfmt)); &"127.0.0.1".parse().unwrap(),
IpType::V4,
&ppfmt
));
assert!(!validate_detected_ip(
&"::1".parse().unwrap(),
IpType::V6,
&ppfmt
));
} }
#[test] #[test]
fn test_validate_detected_ip_rejects_link_local() { fn test_validate_detected_ip_rejects_link_local() {
let ppfmt = PP::default_pp(); 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] #[test]
fn test_validate_detected_ip_rejects_documentation() { fn test_validate_detected_ip_rejects_documentation() {
let ppfmt = PP::default_pp(); let ppfmt = PP::default_pp();
assert!(!validate_detected_ip(&"198.51.100.1".parse().unwrap(), IpType::V4, &ppfmt)); assert!(!validate_detected_ip(
assert!(!validate_detected_ip(&"203.0.113.1".parse().unwrap(), IpType::V4, &ppfmt)); &"198.51.100.1".parse().unwrap(),
IpType::V4,
&ppfmt
));
assert!(!validate_detected_ip(
&"203.0.113.1".parse().unwrap(),
IpType::V4,
&ppfmt
));
} }
#[tokio::test] #[tokio::test]
@@ -1230,9 +1456,9 @@ mod tests {
#[test] #[test]
fn test_is_global_v4_documentation() { 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(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] #[test]
@@ -1280,36 +1506,97 @@ mod tests {
#[test] #[test]
fn test_is_global_v6_global() { fn test_is_global_v6_global() {
// 2606:4700:4700::1111 (Cloudflare DNS) // 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 // 2001:db8::1 is documentation, but our impl doesn't explicitly exclude it
// so it should be considered global by our function // 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 ---- // ---- ProviderType::name ----
#[test] #[test]
fn test_provider_type_name() { fn test_provider_type_name() {
assert_eq!(ProviderType::CloudflareTrace { url: None }.name(), "cloudflare.trace");
assert_eq!( 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" "cloudflare.trace"
); );
assert_eq!(ProviderType::CloudflareDOH.name(), "cloudflare.doh"); assert_eq!(ProviderType::CloudflareDOH.name(), "cloudflare.doh");
assert_eq!(ProviderType::Ipify.name(), "ipify"); assert_eq!(ProviderType::Ipify.name(), "ipify");
assert_eq!(ProviderType::Local.name(), "local"); assert_eq!(ProviderType::Local.name(), "local");
assert_eq!( assert_eq!(
ProviderType::LocalIface { interface: "eth0".into() }.name(), ProviderType::LocalIface {
interface: "eth0".into()
}
.name(),
"local.iface" "local.iface"
); );
assert_eq!( assert_eq!(
ProviderType::CustomURL { url: "https://x".into() }.name(), ProviderType::StableLocalIface {
"url:" interface: "eth0".into()
}
.name(),
"local.iface.stable"
); );
assert_eq!( assert_eq!(
ProviderType::Literal { ips: vec![] }.name(), ProviderType::CustomURL {
"literal:" url: "https://x".into()
}
.name(),
"url:"
); );
assert_eq!(ProviderType::Literal { ips: vec![] }.name(), "literal:");
assert_eq!(ProviderType::None.name(), "none"); assert_eq!(ProviderType::None.name(), "none");
} }
@@ -1351,7 +1638,9 @@ mod tests {
let ppfmt = PP::default_pp(); let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5); 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_eq!(result.len(), 2);
assert!(result.iter().all(|ip| ip.is_ipv4())); assert!(result.iter().all(|ip| ip.is_ipv4()));
} }
@@ -1369,7 +1658,9 @@ mod tests {
let ppfmt = PP::default_pp(); let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5); 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_eq!(result.len(), 2);
assert!(result.iter().all(|ip| ip.is_ipv6())); assert!(result.iter().all(|ip| ip.is_ipv6()));
} }
@@ -1383,10 +1674,14 @@ mod tests {
let ppfmt = PP::default_pp(); let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5); 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()); 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()); assert!(result_v6.is_empty());
} }
} }

File diff suppressed because it is too large Load Diff