67 Commits

Author SHA1 Message Date
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
Timothy Miller
22320bea79 Release v2.1.1
Fix rand 0.10 compile error (RngExt trait import) and ship version
bump alongside DELETE_ON_FAILURE, proportional jitter, and dependency
refresh changes already merged on master.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:57:53 -04:00
Timothy Miller
1bb347bea7 Merge pull request #263 from DMaxter/master
Allow not deleting domains if the IP list is empty
2026-04-29 18:51:59 -04:00
Timothy Miller
1d5ad2738c Merge pull request #265 from timothymiller/dependabot/cargo/reqwest-0.13.3
Bump reqwest from 0.13.2 to 0.13.3
2026-04-29 18:51:16 -04:00
Timothy Miller
08ff76f443 Merge pull request #266 from timothymiller/dependabot/cargo/rand-0.10.1
Bump rand from 0.9.3 to 0.10.1
2026-04-29 18:51:08 -04:00
Timothy Miller
199bbae2bd Merge pull request #267 from timothymiller/dependabot/cargo/rustls-0.23.40
Bump rustls from 0.23.39 to 0.23.40
2026-04-29 18:50:56 -04:00
dependabot[bot]
591f3e4905 Bump rustls from 0.23.39 to 0.23.40
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.39 to 0.23.40.
- [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.39...v/0.23.40)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-29 07:36:47 +00:00
DMaxter
687d299bda docs: document the variable in the README 2026-04-28 23:56:25 +01:00
dependabot[bot]
25122d2ce3 Bump rand from 0.9.3 to 0.10.1
Bumps [rand](https://github.com/rust-random/rand) from 0.9.3 to 0.10.1.
- [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.9.3...0.10.1)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-28 07:37:29 +00:00
dependabot[bot]
64c971b198 Bump reqwest from 0.13.2 to 0.13.3
Bumps [reqwest](https://github.com/seanmonstar/reqwest) from 0.13.2 to 0.13.3.
- [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.2...v0.13.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-28 07:37:21 +00:00
Timothy Miller
b1d8721e8d Merge pull request #264 from timothymiller/dependabot/cargo/rand-0.9.3
Bump rand from 0.9.2 to 0.9.3
2026-04-27 16:18:25 -04:00
dependabot[bot]
278f8ae629 Bump rand from 0.9.2 to 0.9.3
Bumps [rand](https://github.com/rust-random/rand) from 0.9.2 to 0.9.3.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/0.9.3/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/rand_core-0.9.2...0.9.3)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.9.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 19:40:54 +00:00
Timothy Miller
896e08e38e Merge pull request #261 from timothymiller/dependabot/cargo/rustls-webpki-0.103.13
Bump rustls-webpki from 0.103.10 to 0.103.13
2026-04-27 15:40:32 -04:00
Timothy Miller
85d060678d Merge pull request #260 from timothymiller/dependabot/cargo/rustls-0.23.39
Bump rustls from 0.23.37 to 0.23.39
2026-04-27 15:40:19 -04:00
Timothy Miller
8501a35c82 Merge pull request #259 from timothymiller/dependabot/cargo/tokio-1.52.1
Bump tokio from 1.50.0 to 1.52.1
2026-04-27 15:40:09 -04:00
Timothy Miller
0f2b772ecb Merge pull request #253 from jhutchings1/fix/proportional-jitter
fix: add proportional jitter to reduce synchronized API calls
2026-04-27 15:39:59 -04:00
DMaxter
b748e80592 tests: added tests for delete_on_failure 2026-04-26 01:06:49 +01:00
DMaxter
714ec4f11f feat: prevent deletion on failure 2026-04-26 00:46:34 +01:00
dependabot[bot]
d344ae0174 Bump rustls-webpki from 0.103.10 to 0.103.13
Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.10 to 0.103.13.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.10...v/0.103.13)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-24 17:00:46 +00:00
dependabot[bot]
c76a141f58 Bump rustls from 0.23.37 to 0.23.39
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.37 to 0.23.39.
- [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.37...v/0.23.39)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 07:36:56 +00:00
dependabot[bot]
5eb93b45d1 Bump tokio from 1.50.0 to 1.52.1
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.50.0 to 1.52.1.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.50.0...tokio-1.52.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 07:37:04 +00:00
Justin Hutchings
e816cce5a8 fix: add proportional jitter to reduce synchronized API calls 2026-04-10 10:18:59 -07:00
Timothy Miller
7b20b7a477 Update Docker image size in README 2026-03-27 13:39:38 -04:00
Timothy Miller
38d7023987 Correct Docker image size in README
Updated the image size from ~1.9 MB to ~1.1 MB in the README.
2026-03-25 15:09:39 -04:00
25 changed files with 3462 additions and 554 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
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
@@ -24,7 +24,7 @@ jobs:
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
uses: docker/login-action@v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

3
.gitignore vendored
View File

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

120
Cargo.lock generated
View File

@@ -79,11 +79,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core",
]
[[package]]
name = "cloudflare-ddns"
version = "2.1.0"
version = "2.2.0"
dependencies = [
"if-addrs",
"rand",
"regex-lite",
"reqwest",
"rustls",
@@ -121,6 +133,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "deadpool"
version = "0.12.3"
@@ -147,7 +168,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@@ -255,7 +276,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@@ -307,6 +328,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core",
"wasip2",
"wasip3",
]
@@ -657,7 +679,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@@ -714,9 +736,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mio"
version = "1.1.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
@@ -779,7 +801,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
"syn 2.0.117",
]
[[package]]
@@ -806,6 +828,23 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.2",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "regex"
version = "1.12.3"
@@ -843,9 +882,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.13.2"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64",
"bytes",
@@ -907,9 +946,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.37"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"once_cell",
"ring",
@@ -969,9 +1008,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.10"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
@@ -1039,9 +1078,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
@@ -1049,29 +1088,29 @@ dependencies = [
[[package]]
name = "serde_core"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.2",
]
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
@@ -1153,6 +1192,17 @@ dependencies = [
"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]]
name = "sync_wrapper"
version = "1.0.2"
@@ -1170,7 +1220,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@@ -1203,7 +1253,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@@ -1218,9 +1268,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.50.0"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
@@ -1234,13 +1284,13 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.6.1"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@@ -1461,7 +1511,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
"wasm-bindgen-shared",
]
@@ -1743,7 +1793,7 @@ dependencies = [
"heck",
"indexmap",
"prettyplease",
"syn",
"syn 2.0.117",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
@@ -1759,7 +1809,7 @@ dependencies = [
"prettyplease",
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
"wit-bindgen-core",
"wit-bindgen-rust",
]
@@ -1826,7 +1876,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
"synstructure",
]
@@ -1847,7 +1897,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
"synstructure",
]
@@ -1887,7 +1937,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]

View File

@@ -1,6 +1,6 @@
[package]
name = "cloudflare-ddns"
version = "2.1.0"
version = "2.2.0"
edition = "2021"
description = "Access your home network remotely via a custom domain name without a static IP"
license = "GPL-3.0"
@@ -14,6 +14,7 @@ tokio = { version = "1", features = ["rt", "macros", "time", "signal", "net"] }
regex-lite = "0.1"
url = "2"
if-addrs = "0.15"
rand = "0.10"
[profile.release]
opt-level = "z"

View File

@@ -4,7 +4,7 @@
Access your home network remotely via a custom domain name without a static IP!
A feature-complete dynamic DNS client for Cloudflare, written in Rust. The **smallest and most memory-efficient** open-source Cloudflare DDNS Docker image available — **~1.9 MB image size** and **~3.5 MB RAM** at runtime, smaller and leaner than Go-based alternatives. Built as a fully static binary from scratch with zero runtime dependencies.
A feature-complete dynamic DNS client for Cloudflare, written in Rust. The **smallest and most memory-efficient** open-source Cloudflare DDNS Docker image available — **~1.1 MB image size** and **~3.5 MB RAM** at runtime, smaller and leaner than Go-based alternatives. Built as a fully static binary from scratch with zero runtime dependencies.
Configure everything with environment variables. Supports notifications, heartbeat monitoring, WAF list management, flexible scheduling, and more.
@@ -18,7 +18,7 @@ Configure everything with environment variables. Supports notifications, heartbe
- 🃏 **Wildcard domains** — Support for `*.example.com` records
- 🌍 **Internationalized domain names** — Full IDN/punycode support (e.g. `münchen.de`)
- 🛡️ **WAF list management** — Automatically update Cloudflare WAF IP lists
- 🔔 **Notifications** — Shoutrrr-compatible notifications (Discord, Slack, Telegram, Gotify, Pushover, generic webhooks)
- 🔔 **Notifications** — Shoutrrr-compatible notifications (Discord, Slack, Telegram, Gotify, Pushover, Zulip, generic webhooks)
- 💓 **Heartbeat monitoring** — Healthchecks.io and Uptime Kuma integration
- ⏱️ **Cron scheduling** — Flexible update intervals via cron expressions
- 🧪 **Dry-run mode** — Preview changes without modifying DNS records
@@ -29,7 +29,8 @@ 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
- 🏠 **CGNAT-aware local detection** — Filters out shared address space (100.64.0.0/10) and private ranges
- 🚫 **Cloudflare IP rejection** — Automatically rejects Cloudflare anycast IPs to prevent incorrect DNS updates
- 🤏 **Tiny static binary** — ~1.9 MB Docker image built from scratch, zero runtime dependencies
- 🛟 **Outage-proof updates** — Transient IP detection failures never delete or overwrite existing DNS records
- 🤏 **Tiny static binary** — ~1.1 MB Docker image built from scratch, zero runtime dependencies
## 🚀 Quick Start
@@ -84,6 +85,7 @@ Available providers:
| `ipify` | 🌎 ipify.org API |
| `local` | 🏠 Local IP via system routing table (no network traffic, CGNAT-aware) |
| `local.iface:<name>` | 🔌 IP from a specific network interface (e.g., `local.iface:eth0`) |
| `local.iface.stable:<name>` | 🔌 Preferred stable IPv6 address from a Linux network interface, excluding temporary/deprecated addresses |
| `url:<url>` | 🔗 Custom HTTP(S) endpoint that returns an IP address |
| `literal:<ips>` | 📌 Static IP addresses (comma-separated) |
| `none` | 🚫 Disable this IP type |
@@ -107,6 +109,7 @@ To disable this protection, set `REJECT_CLOUDFLARE_IPS=false`.
| `UPDATE_CRON` | `@every 5m` | Update schedule |
| `UPDATE_ON_START` | `true` | Run an update immediately on startup |
| `DELETE_ON_STOP` | `false` | Delete managed DNS records on shutdown |
| `DELETE_ON_FAILURE` | `false` | Delete managed DNS records when a provider definitively reports no address of that family (see below) |
Schedule formats:
@@ -117,6 +120,13 @@ Schedule formats:
When `UPDATE_CRON=@once`, `UPDATE_ON_START` must be `true` and `DELETE_ON_STOP` must be `false`.
### 🛟 Detection Failure Behavior
A failed IP detection never breaks your DNS. Two cases are distinguished:
- **Transient failure** — a network-based provider (`cloudflare.trace`, `cloudflare.doh`, `ipify`, `url:`) errored, or all detected IPs were rejected as Cloudflare IPs. The real IP is unknown, so the update is skipped and existing DNS records and WAF list items are always preserved, regardless of `DELETE_ON_FAILURE`. If detection fails for one address family, WAF list updates are skipped entirely so the failed family's IPs aren't stripped from the list.
- **Definitive absence** — a deterministic provider (`none`, `literal:`, `local`, `local.iface:`) reports that the host has no address of that family. With `DELETE_ON_FAILURE=true` the managed records for that family are deleted; with the default `false` the update is skipped and existing records are preserved.
## 📝 DNS Record Settings
| Variable | Default | Description |
@@ -165,8 +175,13 @@ Supported services:
| ✈️ Telegram | `telegram://bot-token@telegram?chats=chat-id` |
| 📡 Gotify | `gotify://host/path?token=app-token` |
| 📲 Pushover | `pushover://user-key@api-token` |
| 💬 Zulip | `zulip://bot-mail:bot-key@host/?stream=stream-name&topic=topic-name` |
| 🌐 Generic webhook | `generic://host/path` or `generic+https://host/path` |
For Zulip, the `@` in the bot email may be written literally or percent-encoded (`%40`), and `topic` is optional (defaults to `Cloudflare DDNS`).
Generic webhooks send a JSON payload of `{"message": "..."}`. Use `?messagekey=<field>` to rename the field, e.g. `generic://host/path?messagekey=text` for services expecting Slack-style payloads.
Notifications are sent when DNS records are updated, created, deleted, or when errors occur.
## 💓 Heartbeat Monitoring
@@ -213,6 +228,7 @@ Heartbeats are sent after each update cycle. On failure, a fail signal is sent.
| `UPDATE_CRON` | `@every 5m` | ⏱️ Update schedule |
| `UPDATE_ON_START` | `true` | 🚀 Update on startup |
| `DELETE_ON_STOP` | `false` | 🧹 Delete records on shutdown |
| `DELETE_ON_FAILURE` | `false` | 🧹 Delete records when provider definitively reports no IP |
| `TTL` | `1` | ⏳ DNS record TTL |
| `PROXIED` | `false` | ☁️ Proxied expression |
| `RECORD_COMMENT` | — | 💬 DNS record comment |
@@ -258,7 +274,77 @@ services:
### ☸️ Kubernetes
The included manifest uses the legacy JSON config mode. Create a secret containing your `config.json` and apply:
#### Helm (recommended)
The chart is published to GitHub Container Registry as an OCI artifact.
**1. Quick install (single domain):**
```bash
helm install cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \
--namespace ddns --create-namespace \
--set cloudflare.apiToken=your-api-token \
--set domains=example.com
```
> For multiple domains, use a `values.yaml` file — Helm's `--set` treats commas as value-list separators.
**2. Or use a `values.yaml` for a full configuration:**
```yaml
cloudflare:
apiToken: your-api-token # or use existingSecret
domains: example.com,www.example.com
ip4Provider: cloudflare.trace
ip6Provider: cloudflare.trace # set to none if IPv6 is not needed
proxied: "true"
updateCron: "@every 5m"
healthchecks: https://hc-ping.com/your-uuid # optional
```
```bash
helm install cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \
--namespace ddns --create-namespace \
-f values.yaml
```
**Upgrade:**
```bash
helm upgrade cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \
--namespace ddns -f values.yaml
```
**Uninstall:**
```bash
helm uninstall cloudflare-ddns --namespace ddns
```
> ⚠️ `hostNetwork: true` is set by default so the pod can detect IPv6 addresses. Disable it with `--set hostNetwork=false` if you only need IPv4.
**Key values:**
| Value | Default | Description |
|---|---|---|
| `cloudflare.apiToken` | `""` | API token (required unless `existingSecret` is set) |
| `cloudflare.existingSecret` | `""` | Use a pre-existing Secret instead |
| `domains` | `""` | Comma-separated domains for A+AAAA records |
| `ip4Domains` / `ip6Domains` | `""` | IPv4-only or IPv6-only domains |
| `ip4Provider` / `ip6Provider` | `cloudflare.trace` | IP detection provider |
| `proxied` | `"false"` | Proxy through Cloudflare (boolean expression) |
| `updateCron` | `@every 5m` | Update schedule |
| `hostNetwork` | `true` | Required for local IPv6 detection |
| `extraEnv` | `[]` | Additional env vars for advanced settings |
See [`charts/cloudflare-ddns/values.yaml`](charts/cloudflare-ddns/values.yaml) for all options.
#### Raw manifest (legacy)
The `k8s/cloudflare-ddns.yml` manifest uses the legacy JSON config mode. Create a secret containing your `config.json` and apply:
```bash
kubectl create secret generic config-cloudflare-ddns --from-file=config.json -n ddns
@@ -313,7 +399,7 @@ The binary is at `target/release/cloudflare-ddns`.
- 🐳 [Docker](https://docs.docker.com/get-docker/) (amd64, arm64, ppc64le)
- 🐙 [Docker Compose](https://docs.docker.com/compose/install/)
- ☸️ [Kubernetes](https://kubernetes.io/docs/tasks/tools/)
- ☸️ [Kubernetes](https://kubernetes.io/docs/tasks/tools/) + [Helm](https://helm.sh) (OCI chart at `ghcr.io/timothymiller/cloudflare-ddns`)
- 🐧 [Systemd](https://www.freedesktop.org/wiki/Software/systemd/)
- 🍎 macOS, 🪟 Windows, 🐧 Linux — anywhere Rust compiles
@@ -409,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.
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`):

49
RELEASE_NOTES_2.1.1.md Normal file
View File

@@ -0,0 +1,49 @@
# cloudflare-ddns v2.1.1
Maintenance release. Bug fix for `rand` 0.10 API change, plus opt-in failure-safe deletion behavior contributed in the v2.1.0 → v2.1.1 window, dependency refresh, and proportional jitter for IP detection.
## Highlights
- **Fix:** Restore the build under `rand` 0.10 — `random_range` moved to the `RngExt` trait, and the unconditional jitter sleep in `--repeat` mode no longer fails to compile.
- **New:** `DELETE_ON_FAILURE` (env-var mode) controls whether DNS records are removed when an IP detection or update fails. Defaults to `true` to preserve existing behavior; set `DELETE_ON_FAILURE=false` to keep stale records on transient failures instead of yanking them.
- **Improvement:** Proportional jitter (up to 20% of the update interval) is added before each scheduled update to spread requests across clients and reduce synchronized spikes against the Cloudflare API.
## Changes since v2.1.0
### Features
- `DELETE_ON_FAILURE` env var to prevent DNS record deletion on failed updates (#263, thanks @DMaxter)
- Proportional jitter on update intervals to desynchronize API traffic (#253, thanks @jhutchings1)
### Fixes
- Compile fix for `rand` 0.10: import `RngExt` so `random_range` resolves
- `delete_on_failure` regression test coverage added
### Dependencies
- `rustls` 0.23.37 → 0.23.40
- `rustls-webpki` 0.103.10 → 0.103.13
- `tokio` 1.50.0 → 1.52.1
- `reqwest` 0.13.2 → 0.13.3
- `rand` 0.9.2 → 0.10.1
### Docs
- Document `DELETE_ON_FAILURE` in the README
## Upgrade notes
- **Default behavior unchanged.** `DELETE_ON_FAILURE` defaults to `true`, matching pre-2.1.1 behavior. Set it to `false` if you want stale records preserved during outages.
- No config file schema changes. Existing `config.json` deployments continue to work without edits.
## Docker
```sh
docker pull timothyjmiller/cloudflare-ddns:2.1.1
docker pull timothyjmiller/cloudflare-ddns:latest
```
Multi-arch: `linux/amd64`, `linux/arm64`, `linux/ppc64le`.
## Verification
- `cargo test` — 352 tests pass
- Release build succeeds, binary size ~1.7 MiB (pre-UPX)
- Smoke tested in both legacy `config.json` mode and env-var mode against the live Cloudflare API

48
RELEASE_NOTES_2.1.2.md Normal file
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)
# Options: cloudflare.trace, cloudflare.doh, ipify, local, local.iface:<name>,
# url:<custom-url>, literal:<ip1>,<ip2>, none
# local.iface.stable:<name>, url:<custom-url>, literal:<ip1>,<ip2>, none
# IP4_PROVIDER=cloudflare.trace
# Provider for IPv6 detection (default: cloudflare.trace)
# Use local.iface.stable:<name> on Linux to publish a stable address instead
# of temporary privacy addresses from the selected interface.
# IP6_PROVIDER=cloudflare.trace
# === Scheduling ===
@@ -37,6 +39,12 @@ DOMAINS=example.com,www.example.com
# Delete managed DNS records on shutdown (default: false)
# DELETE_ON_STOP=false
# Delete managed DNS records when a provider definitively reports no address
# of that family, e.g. "none" or an interface without one (default: false).
# Transient detection errors (network failures) always preserve existing
# records, regardless of this setting.
# DELETE_ON_FAILURE=false
# === DNS Records ===
# TTL in seconds: 1=auto, or 30-86400 (default: 1)

View File

@@ -78,9 +78,7 @@ impl CloudflareIpFilter {
None => {
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!(
"Failed to parse Cloudflare IP range '{line}'"
),
&format!("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::notifier::{
CompositeNotifier, Heartbeat, HeartbeatMonitor, HealthchecksMonitor, NotifierDyn,
CompositeNotifier, HealthchecksMonitor, Heartbeat, HeartbeatMonitor, NotifierDyn,
ShoutrrrNotifier, UptimeKumaMonitor,
};
use crate::pp::{self, PP};
@@ -31,6 +31,8 @@ pub struct LegacyConfig {
pub ip4_provider: Option<String>,
#[serde(default)]
pub ip6_provider: Option<String>,
#[serde(rename = "recordComment", alias = "record_comment", default)]
pub record_comment: Option<String>,
}
fn default_true() -> bool {
@@ -84,6 +86,7 @@ pub struct AppConfig {
pub update_cron: CronSchedule,
pub update_on_start: bool,
pub delete_on_stop: bool,
pub delete_on_failure: bool,
pub ttl: TTL,
pub proxied_expression: Option<Box<dyn Fn(&str) -> bool + Send + Sync>>,
pub record_comment: Option<String>,
@@ -129,9 +132,15 @@ impl CronSchedule {
fn parse_duration_string(s: &str) -> Option<Duration> {
let s = s.trim();
if let Some(minutes) = s.strip_suffix('m') {
minutes.parse::<u64>().ok().map(|m| Duration::from_secs(m * 60))
minutes
.parse::<u64>()
.ok()
.map(|m| Duration::from_secs(m * 60))
} else if let Some(hours) = s.strip_suffix('h') {
hours.parse::<u64>().ok().map(|h| Duration::from_secs(h * 3600))
hours
.parse::<u64>()
.ok()
.map(|h| Duration::from_secs(h * 3600))
} else if let Some(secs) = s.strip_suffix('s') {
secs.parse::<u64>().ok().map(Duration::from_secs)
} else {
@@ -145,7 +154,10 @@ fn parse_duration_string(s: &str) -> Option<Duration> {
// ============================================================
fn getenv(key: &str) -> Option<String> {
env::var(key).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
env::var(key)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
fn getenv_bool(key: &str, default: bool) -> bool {
@@ -186,7 +198,10 @@ fn read_auth_from_env(ppfmt: &PP) -> Option<Auth> {
val
}) {
if token == "YOUR-CLOUDFLARE-API-TOKEN" {
ppfmt.errorf(pp::EMOJI_ERROR, "Please set CLOUDFLARE_API_TOKEN to your actual API token");
ppfmt.errorf(
pp::EMOJI_ERROR,
"Please set CLOUDFLARE_API_TOKEN to your actual API token",
);
return None;
}
return Some(Auth::Token(token));
@@ -211,7 +226,10 @@ fn read_auth_from_env(ppfmt: &PP) -> Option<Auth> {
}
}
Err(e) => {
ppfmt.errorf(pp::EMOJI_ERROR, &format!("Failed to read API token file '{path}': {e}"));
ppfmt.errorf(
pp::EMOJI_ERROR,
&format!("Failed to read API token file '{path}': {e}"),
);
}
}
}
@@ -233,27 +251,31 @@ fn read_providers_from_env(ppfmt: &PP) -> Result<HashMap<IpType, ProviderType>,
let ip4_str = getenv("IP4_PROVIDER").or_else(|| {
let val = getenv("IP4_POLICY");
if val.is_some() {
ppfmt.warningf(pp::EMOJI_WARNING, "IP4_POLICY is deprecated; use IP4_PROVIDER instead");
ppfmt.warningf(
pp::EMOJI_WARNING,
"IP4_POLICY is deprecated; use IP4_PROVIDER instead",
);
}
val
});
let ip6_str = getenv("IP6_PROVIDER").or_else(|| {
let val = getenv("IP6_POLICY");
if val.is_some() {
ppfmt.warningf(pp::EMOJI_WARNING, "IP6_POLICY is deprecated; use IP6_PROVIDER instead");
ppfmt.warningf(
pp::EMOJI_WARNING,
"IP6_POLICY is deprecated; use IP6_PROVIDER instead",
);
}
val
});
let ip4_provider = match ip4_str {
Some(s) => ProviderType::parse(&s)
.map_err(|e| format!("Invalid IP4_PROVIDER: {e}"))?,
Some(s) => ProviderType::parse(&s).map_err(|e| format!("Invalid IP4_PROVIDER: {e}"))?,
None => ProviderType::CloudflareTrace { url: None },
};
let ip6_provider = match ip6_str {
Some(s) => ProviderType::parse(&s)
.map_err(|e| format!("Invalid IP6_PROVIDER: {e}"))?,
Some(s) => ProviderType::parse(&s).map_err(|e| format!("Invalid IP6_PROVIDER: {e}"))?,
None => ProviderType::CloudflareTrace { url: None },
};
@@ -391,7 +413,11 @@ pub fn parse_legacy_config(content: &str) -> Result<LegacyConfig, String> {
}
/// Convert a legacy config into a unified AppConfig
fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Result<AppConfig, String> {
fn legacy_to_app_config(
legacy: LegacyConfig,
dry_run: bool,
repeat: bool,
) -> Result<AppConfig, String> {
// Extract auth from first entry
let auth = if let Some(entry) = legacy.cloudflare.first() {
if !entry.authentication.api_token.is_empty()
@@ -449,9 +475,10 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Re
update_cron: schedule,
update_on_start: true,
delete_on_stop: false,
delete_on_failure: false,
ttl,
proxied_expression: None,
record_comment: None,
record_comment: legacy.record_comment.clone(),
managed_comment_regex: None,
waf_list_description: None,
waf_list_item_comment: None,
@@ -503,6 +530,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
let update_cron = read_cron_from_env(ppfmt)?;
let update_on_start = getenv_bool("UPDATE_ON_START", true);
let delete_on_stop = getenv_bool("DELETE_ON_STOP", false);
let delete_on_failure = getenv_bool("DELETE_ON_FAILURE", false);
let ttl_val = getenv("TTL")
.and_then(|s| s.parse::<i64>().ok())
@@ -571,6 +599,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
update_cron,
update_on_start,
delete_on_stop,
delete_on_failure,
ttl,
proxied_expression,
record_comment,
@@ -616,11 +645,17 @@ pub fn setup_notifiers(ppfmt: &PP) -> CompositeNotifier {
if !shoutrrr_urls.is_empty() {
match ShoutrrrNotifier::new(&shoutrrr_urls) {
Ok(n) => {
ppfmt.infof(pp::EMOJI_NOTIFY, &format!("Notifications: {}", n.describe()));
ppfmt.infof(
pp::EMOJI_NOTIFY,
&format!("Notifications: {}", n.describe()),
);
notifiers.push(Box::new(n));
}
Err(e) => {
ppfmt.errorf(pp::EMOJI_ERROR, &format!("Failed to setup notifications: {e}"));
ppfmt.errorf(
pp::EMOJI_ERROR,
&format!("Failed to setup notifications: {e}"),
);
}
}
}
@@ -659,7 +694,10 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) {
if !config.domains.is_empty() {
ppfmt.noticef(pp::EMOJI_CONFIG, "Domains to update:");
for (ip_type, domains) in &config.domains {
inner.noticef("", &format!("{}: {}", ip_type.describe(), domains.join(", ")));
inner.noticef(
"",
&format!("{}: {}", ip_type.describe(), domains.join(", ")),
);
}
}
@@ -671,7 +709,10 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) {
}
for (ip_type, provider) in &config.providers {
inner.infof("", &format!("{} provider: {}", ip_type.describe(), provider.name()));
inner.infof(
"",
&format!("{} provider: {}", ip_type.describe(), provider.name()),
);
}
inner.infof("", &format!("TTL: {}", config.ttl.describe()));
@@ -682,7 +723,10 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) {
}
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 {
@@ -762,7 +806,10 @@ mod tests {
#[test]
fn test_parse_duration_string_whitespace() {
assert_eq!(parse_duration_string(" 5m "), Some(Duration::from_secs(300)));
assert_eq!(
parse_duration_string(" 5m "),
Some(Duration::from_secs(300))
);
}
#[test]
@@ -958,7 +1005,10 @@ mod tests {
std::env::remove_var("IP6_DOMAINS");
let pp = PP::new(false, false);
let domains = read_domains_from_env(&pp);
assert_eq!(domains.get(&IpType::V4).unwrap(), &vec!["v4.example.com".to_string()]);
assert_eq!(
domains.get(&IpType::V4).unwrap(),
&vec!["v4.example.com".to_string()]
);
assert!(domains.get(&IpType::V6).is_none());
std::env::remove_var("IP4_DOMAINS");
}
@@ -1015,6 +1065,7 @@ mod tests {
ttl: 300,
ip4_provider: None,
ip6_provider: None,
record_comment: None,
};
let config = legacy_to_app_config(legacy, false, false).unwrap();
assert!(config.legacy_mode);
@@ -1043,9 +1094,12 @@ mod tests {
ttl: 120,
ip4_provider: None,
ip6_provider: None,
record_comment: None,
};
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.dry_run);
}
@@ -1071,6 +1125,7 @@ mod tests {
ttl: 300,
ip4_provider: None,
ip6_provider: None,
record_comment: None,
};
let config = legacy_to_app_config(legacy, false, false).unwrap();
assert!(matches!(config.auth, Auth::Key { ref api_key, ref email }
@@ -1095,10 +1150,14 @@ mod tests {
ttl: 300,
ip4_provider: Some("ipify".to_string()),
ip6_provider: Some("cloudflare.doh".to_string()),
record_comment: None,
};
let config = legacy_to_app_config(legacy, false, false).unwrap();
assert!(matches!(config.providers[&IpType::V4], ProviderType::Ipify));
assert!(matches!(config.providers[&IpType::V6], ProviderType::CloudflareDOH));
assert!(matches!(
config.providers[&IpType::V6],
ProviderType::CloudflareDOH
));
}
#[test]
@@ -1119,6 +1178,7 @@ mod tests {
ttl: 300,
ip4_provider: Some("none".to_string()),
ip6_provider: None,
record_comment: None,
};
let config = legacy_to_app_config(legacy, false, false).unwrap();
// ip4_provider=none should exclude V4 even though a=true
@@ -1144,6 +1204,7 @@ mod tests {
ttl: 300,
ip4_provider: Some("totally_invalid".to_string()),
ip6_provider: None,
record_comment: None,
};
let result = legacy_to_app_config(legacy, false, false);
assert!(result.is_err());
@@ -1151,6 +1212,30 @@ mod tests {
assert!(err.contains("ip4_provider"));
}
#[test]
fn test_legacy_to_app_config_with_record_comment() {
let legacy = LegacyConfig {
cloudflare: vec![LegacyCloudflareEntry {
authentication: LegacyAuthentication {
api_token: "tok".to_string(),
api_key: None,
},
zone_id: "z".to_string(),
subdomains: vec![],
proxied: false,
}],
a: true,
aaaa: false,
purge_unknown_records: false,
ttl: 300,
ip4_provider: None,
ip6_provider: None,
record_comment: Some("managed by cloudflare-ddns".to_string()),
};
let config = legacy_to_app_config(legacy, false, false).unwrap();
assert_eq!(config.record_comment, Some("managed by cloudflare-ddns".to_string()));
}
#[test]
fn test_legacy_config_deserializes_providers() {
let json = r#"{
@@ -1167,6 +1252,20 @@ mod tests {
assert_eq!(config.ip6_provider, Some("none".to_string()));
}
#[test]
fn test_legacy_config_deserializes_record_comment() {
let json = r#"{
"cloudflare": [{
"authentication": { "api_token": "tok" },
"zone_id": "z",
"subdomains": ["@"]
}],
"recordComment": "managed by cloudflare-ddns"
}"#;
let config = parse_legacy_config(json).unwrap();
assert_eq!(config.record_comment, Some("managed by cloudflare-ddns".to_string()));
}
#[test]
fn test_legacy_config_deserializes_without_providers() {
let json = r#"{
@@ -1317,6 +1416,7 @@ mod tests {
update_cron: CronSchedule::Once,
update_on_start: true,
delete_on_stop: false,
delete_on_failure: true,
ttl: TTL::AUTO,
proxied_expression: None,
record_comment: None,
@@ -1351,6 +1451,7 @@ mod tests {
update_cron: CronSchedule::Every(Duration::from_secs(300)),
update_on_start: true,
delete_on_stop: true,
delete_on_failure: true,
ttl: TTL::new(60),
proxied_expression: None,
record_comment: Some("managed".to_string()),
@@ -1389,7 +1490,10 @@ mod tests {
fn set(key: &str, value: &str) -> Self {
let lock = ENV_MUTEX.lock().unwrap();
std::env::set_var(key, value);
Self { keys: vec![key.to_string()], _lock: lock }
Self {
keys: vec![key.to_string()],
_lock: lock,
}
}
fn add(&mut self, key: &str, value: &str) {
@@ -2003,6 +2107,7 @@ mod tests {
update_cron: CronSchedule::Every(Duration::from_secs(300)),
update_on_start: true,
delete_on_stop: false,
delete_on_failure: true,
ttl: TTL::AUTO,
proxied_expression: None,
record_comment: None,
@@ -2039,6 +2144,7 @@ mod tests {
update_cron: CronSchedule::Every(Duration::from_secs(600)),
update_on_start: true,
delete_on_stop: true,
delete_on_failure: true,
ttl: TTL::new(120),
proxied_expression: None,
record_comment: Some("cf-ddns".to_string()),
@@ -2072,6 +2178,7 @@ mod tests {
update_cron: CronSchedule::Once,
update_on_start: true,
delete_on_stop: false,
delete_on_failure: true,
ttl: TTL::AUTO,
proxied_expression: None,
record_comment: None,

View File

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

View File

@@ -11,10 +11,11 @@ use crate::cloudflare::{Auth, CloudflareHandle};
use crate::config::{AppConfig, CronSchedule};
use crate::notifier::{CompositeNotifier, Heartbeat, Message};
use crate::pp::PP;
use rand::RngExt;
use reqwest::Client;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use reqwest::Client;
use tokio::signal;
use tokio::time::{sleep, Duration};
@@ -130,10 +131,30 @@ async fn main() {
if app_config.legacy_mode {
// --- 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 {
// --- 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
@@ -143,9 +164,7 @@ async fn main() {
}
// Exit heartbeat
heartbeat
.exit(&Message::new_ok("Shutting down"))
.await;
heartbeat.exit(&Message::new_ok("Shutting down")).await;
}
async fn run_legacy_mode(
@@ -181,7 +200,17 @@ async fn run_legacy_mode(
}
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 {
if !running.load(Ordering::SeqCst) {
@@ -191,7 +220,17 @@ async fn run_legacy_mode(
}
}
} 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;
}
}
@@ -210,7 +249,17 @@ async fn run_env_mode(
match &config.update_cron {
CronSchedule::Once => {
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 => {
@@ -226,7 +275,17 @@ async fn run_env_mode(
// Update on start if configured
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
@@ -251,12 +310,38 @@ async fn run_env_mode(
return;
}
updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
// Apply proportional jitter before each update to spread API calls
// across clients and reduce synchronized traffic spikes at Cloudflare.
let max_jitter = interval.as_secs() / 5;
if max_jitter > 0 {
let jitter_secs = rand::rng().random_range(0..=max_jitter);
sleep(std::time::Duration::from_secs(jitter_secs)).await;
}
updater::update_once(
config,
handle,
notifier,
heartbeat,
cf_cache,
ppfmt,
&mut noop_reported,
detection_client,
)
.await;
}
}
}
}
fn jitter_duration(interval_secs: u64, rand_val: u64) -> std::time::Duration {
let max_jitter = interval_secs / 5;
if max_jitter == 0 {
return std::time::Duration::ZERO;
}
std::time::Duration::from_secs(rand_val % (max_jitter + 1))
}
fn describe_duration(d: Duration) -> String {
let secs = d.as_secs();
if secs >= 3600 {
@@ -302,8 +387,8 @@ pub(crate) fn test_client() -> reqwest::Client {
#[cfg(test)]
mod tests {
use crate::config::{
LegacyAuthentication, LegacyCloudflareEntry, LegacyConfig, LegacySubdomainEntry,
parse_legacy_config,
parse_legacy_config, LegacyAuthentication, LegacyCloudflareEntry, LegacyConfig,
LegacySubdomainEntry,
};
use crate::provider::parse_trace_ip;
use reqwest::Client;
@@ -336,6 +421,7 @@ mod tests {
ttl: 300,
ip4_provider: None,
ip6_provider: None,
record_comment: None,
}
}
@@ -549,8 +635,7 @@ mod tests {
println!("[DRY RUN] Would add new record {fqdn} -> {ip}");
} else {
println!("Adding new record {fqdn} -> {ip}");
let create_endpoint =
format!("zones/{}/dns_records", entry.zone_id);
let create_endpoint = format!("zones/{}/dns_records", entry.zone_id);
let _: Option<serde_json::Value> = self
.cf_api(
&create_endpoint,
@@ -679,8 +764,15 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await;
ddns.commit_record(
"198.51.100.7",
"A",
&config.cloudflare,
300,
false,
&mut std::collections::HashSet::new(),
)
.await;
}
#[tokio::test]
@@ -728,8 +820,15 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await;
ddns.commit_record(
"198.51.100.7",
"A",
&config.cloudflare,
300,
false,
&mut std::collections::HashSet::new(),
)
.await;
}
#[tokio::test]
@@ -771,8 +870,15 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await;
ddns.commit_record(
"198.51.100.7",
"A",
&config.cloudflare,
300,
false,
&mut std::collections::HashSet::new(),
)
.await;
}
#[tokio::test]
@@ -805,8 +911,15 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri()).dry_run();
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await;
ddns.commit_record(
"198.51.100.7",
"A",
&config.cloudflare,
300,
false,
&mut std::collections::HashSet::new(),
)
.await;
}
#[tokio::test]
@@ -861,9 +974,40 @@ mod tests {
ttl: 300,
ip4_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())
.await;
ddns.commit_record(
"198.51.100.7",
"A",
&config.cloudflare,
300,
true,
&mut std::collections::HashSet::new(),
)
.await;
}
// --- jitter_duration tests ---
#[test]
fn test_jitter_duration_standard() {
// 5-minute interval: max jitter = 60s
let d = super::jitter_duration(300, 30);
assert_eq!(d, std::time::Duration::from_secs(30));
let d = super::jitter_duration(300, 61);
assert_eq!(d, std::time::Duration::from_secs(61 % 61)); // wraps within [0, 60]
}
#[test]
fn test_jitter_duration_short_interval() {
// interval < 5s: must return zero
assert_eq!(super::jitter_duration(4, 99), std::time::Duration::ZERO);
assert_eq!(super::jitter_duration(0, 99), std::time::Duration::ZERO);
}
#[test]
fn test_jitter_duration_deterministic() {
// rand_val=0 always returns zero duration
assert_eq!(super::jitter_duration(300, 0), std::time::Duration::ZERO);
}
// --- describe_duration tests ---
@@ -962,9 +1106,17 @@ mod tests {
ttl: 300,
ip4_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())
.await;
ddns.commit_record(
"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 {
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,
Slack,
Telegram,
Gotify,
Pushover,
Zulip {
email: String,
api_key: String,
stream: String,
topic: String,
},
Other(String),
}
@@ -126,12 +136,13 @@ impl ShoutrrrNotifier {
.urls
.iter()
.map(|s| match &s.service_type {
ShoutrrrServiceType::Generic => "generic webhook".to_string(),
ShoutrrrServiceType::Generic { .. } => "generic webhook".to_string(),
ShoutrrrServiceType::Discord => "Discord".to_string(),
ShoutrrrServiceType::Slack => "Slack".to_string(),
ShoutrrrServiceType::Telegram => "Telegram".to_string(),
ShoutrrrServiceType::Gotify => "Gotify".to_string(),
ShoutrrrServiceType::Pushover => "Pushover".to_string(),
ShoutrrrServiceType::Zulip { .. } => "Zulip".to_string(),
ShoutrrrServiceType::Other(name) => name.clone(),
})
.collect();
@@ -147,8 +158,13 @@ impl ShoutrrrNotifier {
let mut all_ok = true;
for service in &self.urls {
let ok = match &service.service_type {
ShoutrrrServiceType::Generic => self.send_generic(&service.webhook_url, &text).await,
ShoutrrrServiceType::Discord => self.send_discord(&service.webhook_url, &text).await,
ShoutrrrServiceType::Generic { message_key } => {
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::Telegram => {
self.send_telegram(&service.webhook_url, &text).await
@@ -157,7 +173,19 @@ impl ShoutrrrNotifier {
ShoutrrrServiceType::Pushover => {
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 {
ppfmt.warningf(
@@ -170,11 +198,39 @@ impl ShoutrrrNotifier {
all_ok
}
async fn send_generic(&self, url: &str, text: &str) -> bool {
let body = serde_json::json!({ "message": text });
async fn send_generic(&self, url: &str, message_key: &str, text: &str) -> bool {
let mut body = serde_json::Map::new();
body.insert(message_key.to_string(), serde_json::Value::from(text));
self.client
.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()
.await
.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> {
// Shoutrrr URL formats:
// 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
// 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
// zulip://botmail:botkey@host/?stream=STREAM&topic=TOPIC -> https://host/api/v1/messages
// generic://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}"));
}
if let Some(rest) = url_str
.strip_prefix("gotify://")
.or_else(|| url_str.strip_prefix("gotify+https://"))
if let Some((rest, default_scheme)) = 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 {
original_url: url_str.to_string(),
service_type: ShoutrrrServiceType::Gotify,
webhook_url: format!("https://{rest}/message"),
});
return parse_gotify_url(url_str, rest, default_scheme);
}
if let Some(rest) = url_str.strip_prefix("zulip://") {
return parse_zulip_url(url_str, rest);
}
if let Some(rest) = url_str
.strip_prefix("generic://")
.or_else(|| url_str.strip_prefix("generic+https://"))
{
let (rest, message_key) = extract_messagekey(rest);
return Ok(ShoutrrrService {
original_url: url_str.to_string(),
service_type: ShoutrrrServiceType::Generic,
service_type: ShoutrrrServiceType::Generic { message_key },
webhook_url: format!("https://{rest}"),
});
}
if let Some(rest) = url_str.strip_prefix("generic+http://") {
let (rest, message_key) = extract_messagekey(rest);
return Ok(ShoutrrrService {
original_url: url_str.to_string(),
service_type: ShoutrrrServiceType::Generic,
service_type: ShoutrrrServiceType::Generic { message_key },
webhook_url: format!("http://{rest}"),
});
}
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 {
// 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 {
original_url: url_str.to_string(),
service_type: ShoutrrrServiceType::Pushover,
webhook_url: format!(
"https://api.pushover.net/1/messages.json?token={}&user={}",
parts[0], parts[1]
"https://api.pushover.net/1/messages.json?token={token}&user={user}"
),
});
}
@@ -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://") {
return Ok(ShoutrrrService {
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(),
});
}
@@ -412,9 +693,7 @@ pub trait HeartbeatMonitor: Send + Sync {
&'a self,
msg: &'a Message,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + 'a>>;
fn start(
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + '_>>;
fn start(&self) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + '_>>;
fn exit<'a>(
&'a self,
msg: &'a Message,
@@ -498,9 +777,7 @@ impl HeartbeatMonitor for HealthchecksMonitor {
})
}
fn start(
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + '_>> {
fn start(&self) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + '_>> {
Box::pin(async move { self.send_ping("start", None).await })
}
@@ -560,9 +837,7 @@ impl HeartbeatMonitor for UptimeKumaMonitor {
})
}
fn start(
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + '_>> {
fn start(&self) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + '_>> {
Box::pin(async move {
let url = format!("{}?status=up&msg=Starting", self.base_url);
self.client
@@ -716,16 +991,12 @@ mod tests {
#[test]
fn test_parse_telegram() {
let result =
parse_shoutrrr_url("telegram://bottoken123@telegram?chats=12345").unwrap();
let result = parse_shoutrrr_url("telegram://bottoken123@telegram?chats=12345").unwrap();
assert_eq!(
result.webhook_url,
"https://api.telegram.org/botbottoken123/sendMessage?chat_id=12345"
);
assert!(matches!(
result.service_type,
ShoutrrrServiceType::Telegram
));
assert!(matches!(result.service_type, ShoutrrrServiceType::Telegram));
}
#[test]
@@ -735,36 +1006,81 @@ mod tests {
}
#[test]
fn test_parse_gotify() {
let result = parse_shoutrrr_url("gotify://myhost.com/somepath").unwrap();
fn test_parse_gotify_token_as_path_segment() {
// Shoutrrr canonical format: token is the final path segment.
let result = parse_shoutrrr_url("gotify://myhost.com/MYTOKEN").unwrap();
assert_eq!(
result.webhook_url,
"https://myhost.com/somepath/message"
"https://myhost.com/message?token=MYTOKEN"
);
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]
fn test_parse_generic() {
let result = parse_shoutrrr_url("generic://example.com/webhook").unwrap();
assert_eq!(result.webhook_url, "https://example.com/webhook");
assert!(matches!(result.service_type, ShoutrrrServiceType::Generic));
assert!(matches!(
result.service_type,
ShoutrrrServiceType::Generic { .. }
));
}
#[test]
fn test_parse_generic_plus_https() {
let result =
parse_shoutrrr_url("generic+https://example.com/webhook").unwrap();
let result = parse_shoutrrr_url("generic+https://example.com/webhook").unwrap();
assert_eq!(result.webhook_url, "https://example.com/webhook");
assert!(matches!(result.service_type, ShoutrrrServiceType::Generic));
assert!(matches!(
result.service_type,
ShoutrrrServiceType::Generic { .. }
));
}
#[test]
fn test_parse_generic_plus_http() {
let result =
parse_shoutrrr_url("generic+http://example.com/webhook").unwrap();
let result = parse_shoutrrr_url("generic+http://example.com/webhook").unwrap();
assert_eq!(result.webhook_url, "http://example.com/webhook");
assert!(matches!(result.service_type, ShoutrrrServiceType::Generic));
assert!(matches!(
result.service_type,
ShoutrrrServiceType::Generic { .. }
));
}
#[test]
@@ -774,10 +1090,29 @@ mod tests {
result.webhook_url,
"https://api.pushover.net/1/messages.json?token=apitoken&user=userkey"
);
assert!(matches!(
result.service_type,
ShoutrrrServiceType::Pushover
));
assert!(matches!(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]
@@ -787,19 +1122,149 @@ mod tests {
}
#[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 =
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!(matches!(result.service_type, ShoutrrrServiceType::Generic));
assert!(matches!(
result.service_type,
ShoutrrrServiceType::Generic { .. }
));
}
#[test]
fn test_parse_plain_http_url() {
let result =
parse_shoutrrr_url("http://hooks.example.com/notify").unwrap();
let result = parse_shoutrrr_url("http://hooks.example.com/notify").unwrap();
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]
@@ -1063,7 +1528,9 @@ mod tests {
client: crate::test_client(),
urls: vec![ShoutrrrService {
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()),
}],
};
@@ -1079,7 +1546,10 @@ mod tests {
client: crate::test_client(),
urls: vec![],
};
let msg = Message { lines: Vec::new(), ok: true };
let msg = Message {
lines: Vec::new(),
ok: true,
};
let pp = PP::default_pp();
// Empty message should return true immediately
let result = notifier.send(&msg, &pp).await;
@@ -1090,14 +1560,21 @@ mod tests {
#[test]
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();
assert_eq!(notifier.urls.len(), 2);
}
#[test]
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();
assert_eq!(notifier.urls.len(), 1);
}
@@ -1139,9 +1616,21 @@ mod tests {
service_type: ShoutrrrServiceType::Pushover,
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 {
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(),
},
ShoutrrrService {
@@ -1152,7 +1641,10 @@ mod tests {
],
};
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 ----
@@ -1240,6 +1732,71 @@ mod tests {
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]
async fn test_shoutrrr_send_failure_logs_warning() {
let server = MockServer::start().await;
@@ -1299,7 +1856,9 @@ mod tests {
client: crate::test_client(),
urls: vec![ShoutrrrService {
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()),
}],
};

View File

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

View File

@@ -1,6 +1,7 @@
use crate::pp::{self, PP};
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use reqwest::Client;
use std::fs;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket};
use std::time::Duration;
@@ -25,7 +26,6 @@ impl IpType {
IpType::V6 => "AAAA",
}
}
}
/// All supported provider types
@@ -36,6 +36,7 @@ pub enum ProviderType {
Ipify,
Local,
LocalIface { interface: String },
StableLocalIface { interface: String },
CustomURL { url: String },
Literal { ips: Vec<IpAddr> },
None,
@@ -49,6 +50,7 @@ impl ProviderType {
ProviderType::Ipify => "ipify",
ProviderType::Local => "local",
ProviderType::LocalIface { .. } => "local.iface",
ProviderType::StableLocalIface { .. } => "local.iface.stable",
ProviderType::CustomURL { .. } => "url:",
ProviderType::Literal { .. } => "literal:",
ProviderType::None => "none",
@@ -78,6 +80,11 @@ impl ProviderType {
if input == "local" {
return Ok(ProviderType::Local);
}
if let Some(iface) = input.strip_prefix("local.iface.stable:") {
return Ok(ProviderType::StableLocalIface {
interface: iface.to_string(),
});
}
if let Some(iface) = input.strip_prefix("local.iface:") {
return Ok(ProviderType::LocalIface {
interface: iface.to_string(),
@@ -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.
pub async fn detect_ips(
&self,
@@ -128,8 +167,9 @@ impl ProviderType {
}
ProviderType::Ipify => detect_ipify(client, ip_type, timeout, ppfmt).await,
ProviderType::Local => detect_local(ip_type, ppfmt),
ProviderType::LocalIface { interface } => {
detect_local_iface(interface, ip_type, ppfmt)
ProviderType::LocalIface { interface } => detect_local_iface(interface, ip_type, ppfmt),
ProviderType::StableLocalIface { interface } => {
detect_stable_local_iface(interface, ip_type, ppfmt)
}
ProviderType::CustomURL { url } => {
detect_custom_url(client, url, ip_type, timeout, ppfmt).await
@@ -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 ---
/// 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(
std::io::ErrorKind::AddrNotAvailable,
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)
})
@@ -239,7 +292,10 @@ async fn detect_cloudflare_trace(
}
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!("{} not detected via custom Cloudflare trace URL", ip_type.describe()),
&format!(
"{} not detected via custom Cloudflare trace URL",
ip_type.describe()
),
);
return Vec::new();
}
@@ -252,7 +308,10 @@ async fn detect_cloudflare_trace(
}
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!("{} not detected via primary, trying fallback", ip_type.describe()),
&format!(
"{} not detected via primary, trying fallback",
ip_type.describe()
),
);
// Try fallback (hostname-based — works when literal IPs are intercepted by WARP/Zero Trust)
@@ -307,7 +366,10 @@ async fn detect_cloudflare_doh(
Err(e) => {
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!("{} not detected via Cloudflare DoH: {e}", ip_type.describe()),
&format!(
"{} not detected via Cloudflare DoH: {e}",
ip_type.describe()
),
);
}
}
@@ -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]); // Authority RRs: 0
buf.extend_from_slice(&[0x00, 0x00]); // Additional RRs: 0
// Question section
// Question section
buf.extend_from_slice(name);
buf.extend_from_slice(&qtype.to_be_bytes());
buf.extend_from_slice(&qclass.to_be_bytes());
@@ -484,7 +546,10 @@ fn detect_local(ip_type: IpType, ppfmt: &PP) -> Vec<IpAddr> {
Err(e) => {
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!("Failed to bind socket for {} detection: {e}", ip_type.describe()),
&format!(
"Failed to bind socket for {} detection: {e}",
ip_type.describe()
),
);
Vec::new()
}
@@ -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 ---
async fn detect_custom_url(
@@ -574,7 +738,8 @@ fn validate_detected_ip(ip: &IpAddr, ip_type: IpType, ppfmt: &PP) -> bool {
pp::EMOJI_WARNING,
&format!(
"Detected IP {} does not match expected type {}",
ip, ip_type.describe()
ip,
ip_type.describe()
),
);
return false;
@@ -584,7 +749,8 @@ fn validate_detected_ip(ip: &IpAddr, ip_type: IpType, ppfmt: &PP) -> bool {
pp::EMOJI_WARNING,
&format!(
"Detected {} address {} is not a global unicast address",
ip_type.describe(), ip
ip_type.describe(),
ip
),
);
return false;
@@ -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]
fn test_provider_parse_custom_url() {
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, 0x00]); // NSCOUNT=0
data.extend_from_slice(&[0x00, 0x00]); // ARCOUNT=0
// Question section: name = \x04test\x00
// Question section: name = \x04test\x00
data.extend_from_slice(b"\x04test\x00");
data.extend_from_slice(&[0x00, 0x10]); // QTYPE=TXT
data.extend_from_slice(&[0x00, 0x01]); // QCLASS=IN
// Answer section: name pointer to offset 12
// Answer section: name pointer to offset 12
data.extend_from_slice(&[0xC0, 0x0C]); // pointer to question name
data.extend_from_slice(&[0x00, 0x10]); // TYPE=TXT
data.extend_from_slice(&[0x00, 0x01]); // CLASS=IN
@@ -861,8 +1037,11 @@ mod tests {
// ---- detect_cloudflare_trace with wiremock ----
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::{method, path}};
use crate::pp::PP;
use wiremock::{
matchers::{method, path},
Mock, MockServer, ResponseTemplate,
};
#[tokio::test]
async fn test_detect_cloudflare_trace_primary_succeeds() {
@@ -880,14 +1059,8 @@ mod tests {
let url = format!("{}/cdn-cgi/trace", server.uri());
let timeout = Duration::from_secs(5);
let result = detect_cloudflare_trace(
&client,
IpType::V4,
timeout,
Some(&url),
&ppfmt,
)
.await;
let result =
detect_cloudflare_trace(&client, IpType::V4, timeout, Some(&url), &ppfmt).await;
assert_eq!(result.len(), 1);
assert_eq!(result[0], "93.184.216.34".parse::<IpAddr>().unwrap());
@@ -950,14 +1123,19 @@ mod tests {
// Primary uses cloudflare.com CDN endpoint (not DNS resolver IPs).
assert_eq!(CF_TRACE_PRIMARY, "https://cloudflare.com/cdn-cgi/trace");
// Fallback uses api.cloudflare.com for when cloudflare.com is intercepted (WARP/Zero Trust).
assert_eq!(CF_TRACE_FALLBACK, "https://api.cloudflare.com/cdn-cgi/trace");
assert_eq!(
CF_TRACE_FALLBACK,
"https://api.cloudflare.com/cdn-cgi/trace"
);
}
// ---- FilteredResolver + build_split_client ----
#[tokio::test]
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 addrs: Vec<SocketAddr> = resolver
.resolve(name)
@@ -972,7 +1150,9 @@ mod tests {
#[tokio::test]
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();
// IPv6 may not be available in all test environments, so we just
// verify the resolver doesn't panic and returns only v6 if any.
@@ -1025,9 +1205,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(200).set_body_string("2606:4700:4700::1111\n"),
)
.respond_with(ResponseTemplate::new(200).set_body_string("2606:4700:4700::1111\n"))
.mount(&server)
.await;
@@ -1087,43 +1265,91 @@ mod tests {
#[test]
fn test_validate_detected_ip_accepts_global() {
let ppfmt = PP::default_pp();
assert!(validate_detected_ip(&"93.184.216.34".parse().unwrap(), IpType::V4, &ppfmt));
assert!(validate_detected_ip(&"2606:4700:4700::1111".parse().unwrap(), IpType::V6, &ppfmt));
assert!(validate_detected_ip(
&"93.184.216.34".parse().unwrap(),
IpType::V4,
&ppfmt
));
assert!(validate_detected_ip(
&"2606:4700:4700::1111".parse().unwrap(),
IpType::V6,
&ppfmt
));
}
#[test]
fn test_validate_detected_ip_rejects_wrong_family() {
let ppfmt = PP::default_pp();
assert!(!validate_detected_ip(&"93.184.216.34".parse().unwrap(), IpType::V6, &ppfmt));
assert!(!validate_detected_ip(&"2606:4700:4700::1111".parse().unwrap(), IpType::V4, &ppfmt));
assert!(!validate_detected_ip(
&"93.184.216.34".parse().unwrap(),
IpType::V6,
&ppfmt
));
assert!(!validate_detected_ip(
&"2606:4700:4700::1111".parse().unwrap(),
IpType::V4,
&ppfmt
));
}
#[test]
fn test_validate_detected_ip_rejects_private() {
let ppfmt = PP::default_pp();
assert!(!validate_detected_ip(&"10.0.0.1".parse().unwrap(), IpType::V4, &ppfmt));
assert!(!validate_detected_ip(&"192.168.1.1".parse().unwrap(), IpType::V4, &ppfmt));
assert!(!validate_detected_ip(&"172.16.0.1".parse().unwrap(), IpType::V4, &ppfmt));
assert!(!validate_detected_ip(
&"10.0.0.1".parse().unwrap(),
IpType::V4,
&ppfmt
));
assert!(!validate_detected_ip(
&"192.168.1.1".parse().unwrap(),
IpType::V4,
&ppfmt
));
assert!(!validate_detected_ip(
&"172.16.0.1".parse().unwrap(),
IpType::V4,
&ppfmt
));
}
#[test]
fn test_validate_detected_ip_rejects_loopback() {
let ppfmt = PP::default_pp();
assert!(!validate_detected_ip(&"127.0.0.1".parse().unwrap(), IpType::V4, &ppfmt));
assert!(!validate_detected_ip(&"::1".parse().unwrap(), IpType::V6, &ppfmt));
assert!(!validate_detected_ip(
&"127.0.0.1".parse().unwrap(),
IpType::V4,
&ppfmt
));
assert!(!validate_detected_ip(
&"::1".parse().unwrap(),
IpType::V6,
&ppfmt
));
}
#[test]
fn test_validate_detected_ip_rejects_link_local() {
let ppfmt = PP::default_pp();
assert!(!validate_detected_ip(&"169.254.0.1".parse().unwrap(), IpType::V4, &ppfmt));
assert!(!validate_detected_ip(
&"169.254.0.1".parse().unwrap(),
IpType::V4,
&ppfmt
));
}
#[test]
fn test_validate_detected_ip_rejects_documentation() {
let ppfmt = PP::default_pp();
assert!(!validate_detected_ip(&"198.51.100.1".parse().unwrap(), IpType::V4, &ppfmt));
assert!(!validate_detected_ip(&"203.0.113.1".parse().unwrap(), IpType::V4, &ppfmt));
assert!(!validate_detected_ip(
&"198.51.100.1".parse().unwrap(),
IpType::V4,
&ppfmt
));
assert!(!validate_detected_ip(
&"203.0.113.1".parse().unwrap(),
IpType::V4,
&ppfmt
));
}
#[tokio::test]
@@ -1230,9 +1456,9 @@ mod tests {
#[test]
fn test_is_global_v4_documentation() {
assert!(!is_global_v4(&Ipv4Addr::new(192, 0, 2, 1))); // 192.0.2.0/24
assert!(!is_global_v4(&Ipv4Addr::new(192, 0, 2, 1))); // 192.0.2.0/24
assert!(!is_global_v4(&Ipv4Addr::new(198, 51, 100, 1))); // 198.51.100.0/24
assert!(!is_global_v4(&Ipv4Addr::new(203, 0, 113, 1))); // 203.0.113.0/24
assert!(!is_global_v4(&Ipv4Addr::new(203, 0, 113, 1))); // 203.0.113.0/24
}
#[test]
@@ -1280,36 +1506,97 @@ mod tests {
#[test]
fn test_is_global_v6_global() {
// 2606:4700:4700::1111 (Cloudflare DNS)
assert!(is_global_v6(&Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111)));
assert!(is_global_v6(&Ipv6Addr::new(
0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111
)));
// 2001:db8::1 is documentation, but our impl doesn't explicitly exclude it
// so it should be considered global by our function
assert!(is_global_v6(&Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1)));
assert!(is_global_v6(&Ipv6Addr::new(
0x2001, 0x0db8, 0, 0, 0, 0, 0, 1
)));
}
#[test]
fn test_parse_if_inet6_line() {
let addr =
parse_if_inet6_line("20010db8000000011111222233334444 03 40 00 00 eth0").unwrap();
assert_eq!(
addr.ip,
"2001:db8:0:1:1111:2222:3333:4444"
.parse::<Ipv6Addr>()
.unwrap()
);
assert_eq!(addr.prefix_len, 64);
assert_eq!(addr.scope, IPV6_SCOPE_GLOBAL);
assert_eq!(addr.flags, 0);
assert_eq!(addr.interface, "eth0");
}
#[test]
fn test_stable_ipv6_addresses_from_if_inet6_filters_privacy_addresses() {
let contents = "\
20010db8000000015555666677778888 03 40 00 01 eth0
20010db8000000010000000000003486 03 80 00 00 eth0
20010db8000000011111222233334444 03 40 00 00 eth0
20010db8000000019999aaaabbbbcccc 03 40 00 21 eth0
fe80000000000000d399115858c872af 03 40 20 80 eth0
fdaa149d3b9900000000000000000001 0a 40 00 82 br-990e55930a86
";
let ips = stable_ipv6_addresses_from_if_inet6(contents, "eth0");
assert_eq!(
ips,
vec![
"2001:db8:0:1:1111:2222:3333:4444"
.parse::<Ipv6Addr>()
.unwrap(),
"2001:db8:0:1::3486".parse::<Ipv6Addr>().unwrap(),
]
);
}
// ---- ProviderType::name ----
#[test]
fn test_provider_type_name() {
assert_eq!(ProviderType::CloudflareTrace { url: None }.name(), "cloudflare.trace");
assert_eq!(
ProviderType::CloudflareTrace { url: Some("https://x".into()) }.name(),
ProviderType::CloudflareTrace { url: None }.name(),
"cloudflare.trace"
);
assert_eq!(
ProviderType::CloudflareTrace {
url: Some("https://x".into())
}
.name(),
"cloudflare.trace"
);
assert_eq!(ProviderType::CloudflareDOH.name(), "cloudflare.doh");
assert_eq!(ProviderType::Ipify.name(), "ipify");
assert_eq!(ProviderType::Local.name(), "local");
assert_eq!(
ProviderType::LocalIface { interface: "eth0".into() }.name(),
ProviderType::LocalIface {
interface: "eth0".into()
}
.name(),
"local.iface"
);
assert_eq!(
ProviderType::CustomURL { url: "https://x".into() }.name(),
"url:"
ProviderType::StableLocalIface {
interface: "eth0".into()
}
.name(),
"local.iface.stable"
);
assert_eq!(
ProviderType::Literal { ips: vec![] }.name(),
"literal:"
ProviderType::CustomURL {
url: "https://x".into()
}
.name(),
"url:"
);
assert_eq!(ProviderType::Literal { ips: vec![] }.name(), "literal:");
assert_eq!(ProviderType::None.name(), "none");
}
@@ -1351,7 +1638,9 @@ mod tests {
let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5);
let result = provider.detect_ips(&client, IpType::V4, timeout, &ppfmt).await;
let result = provider
.detect_ips(&client, IpType::V4, timeout, &ppfmt)
.await;
assert_eq!(result.len(), 2);
assert!(result.iter().all(|ip| ip.is_ipv4()));
}
@@ -1369,7 +1658,9 @@ mod tests {
let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5);
let result = provider.detect_ips(&client, IpType::V6, timeout, &ppfmt).await;
let result = provider
.detect_ips(&client, IpType::V6, timeout, &ppfmt)
.await;
assert_eq!(result.len(), 2);
assert!(result.iter().all(|ip| ip.is_ipv6()));
}
@@ -1383,10 +1674,14 @@ mod tests {
let ppfmt = PP::default_pp();
let timeout = Duration::from_secs(5);
let result_v4 = provider.detect_ips(&client, IpType::V4, timeout, &ppfmt).await;
let result_v4 = provider
.detect_ips(&client, IpType::V4, timeout, &ppfmt)
.await;
assert!(result_v4.is_empty());
let result_v6 = provider.detect_ips(&client, IpType::V6, timeout, &ppfmt).await;
let result_v6 = provider
.detect_ips(&client, IpType::V6, timeout, &ppfmt)
.await;
assert!(result_v6.is_empty());
}
}

File diff suppressed because it is too large Load Diff