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>
This commit is contained in:
Timothy Miller
2026-07-20 23:33:17 -04:00
parent d697c28aa1
commit 6cb1e0c874
15 changed files with 2080 additions and 530 deletions

2
Cargo.lock generated
View File

@@ -92,7 +92,7 @@ dependencies = [
[[package]]
name = "cloudflare-ddns"
version = "2.1.2"
version = "2.2.0"
dependencies = [
"if-addrs",
"rand",

View File

@@ -1,6 +1,6 @@
[package]
name = "cloudflare-ddns"
version = "2.1.2"
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"

View File

@@ -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,6 +29,7 @@ Configure everything with environment variables. Supports notifications, heartbe
- 🔒 **Zero-log IP detection** — Uses Cloudflare's [cdn-cgi/trace](https://www.cloudflare.com/cdn-cgi/trace) by default
- 🏠 **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
- 🛟 **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
@@ -108,7 +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` | `true` | Delete managed DNS records when failed to obtain IP from provider |
| `DELETE_ON_FAILURE` | `false` | Delete managed DNS records when a provider definitively reports no address of that family (see below) |
Schedule formats:
@@ -119,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 |
@@ -167,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
@@ -215,7 +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` | `true` | 🧹 Delete records if failed to obtain new records |
| `DELETE_ON_FAILURE` | `false` | 🧹 Delete records when provider definitively reports no IP |
| `TTL` | `1` | ⏳ DNS record TTL |
| `PROXIED` | `false` | ☁️ Proxied expression |
| `RECORD_COMMENT` | — | 💬 DNS record comment |

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

@@ -48,7 +48,8 @@ managedRecordsCommentRegex: ""
wafLists: ""
# --- Notifications (Shoutrrr) ---
# Newline-separated URLs: discord://, slack://, telegram://, generic+https://...
# Newline-separated URLs: discord://, slack://, telegram://, gotify://,
# pushover://, zulip://, generic+https://...
shoutrrr: ""
# --- Heartbeat Monitoring ---

View File

@@ -39,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}'"),
);
}
}

View File

@@ -64,12 +64,17 @@ impl WAFList {
pub fn parse(input: &str) -> Result<Self, String> {
let parts: Vec<&str> = input.splitn(2, '/').collect();
if parts.len() != 2 {
return Err(format!("WAF list must be in format 'account-id/list-name': {input}"));
return Err(format!(
"WAF list must be in format 'account-id/list-name': {input}"
));
}
let account_id = parts[0].trim().to_string();
let list_name = parts[1].trim().to_string();
if !list_name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
if !list_name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
return Err(format!("WAF list name must match [a-z0-9_]+: {list_name}"));
}
@@ -178,10 +183,7 @@ impl CloudflareHandle {
}
#[cfg(test)]
pub fn with_base_url(
base_url: &str,
auth: Auth,
) -> Self {
pub fn with_base_url(base_url: &str, auth: Auth) -> Self {
crate::init_crypto();
let client = Client::builder()
.timeout(Duration::from_secs(10))
@@ -220,12 +222,18 @@ impl CloudflareHandle {
} else {
let url_str = resp.url().to_string();
let text = resp.text().await.unwrap_or_default();
ppfmt.errorf(pp::EMOJI_ERROR, &format!("API {method} '{url_str}' failed: {text}"));
ppfmt.errorf(
pp::EMOJI_ERROR,
&format!("API {method} '{url_str}' failed: {text}"),
);
None
}
}
Err(e) => {
ppfmt.errorf(pp::EMOJI_ERROR, &format!("API {method} '{path}' error: {e}"));
ppfmt.errorf(
pp::EMOJI_ERROR,
&format!("API {method} '{path}' error: {e}"),
);
None
}
}
@@ -238,7 +246,12 @@ impl CloudflareHandle {
let mut current = domain.to_string();
loop {
let resp: Option<CfListResponse<ZoneResult>> = self
.api_request(reqwest::Method::GET, &format!("zones?name={current}"), None::<&()>, ppfmt)
.api_request(
reqwest::Method::GET,
&format!("zones?name={current}"),
None::<&()>,
ppfmt,
)
.await;
if let Some(r) = resp {
if let Some(zones) = r.result {
@@ -269,7 +282,9 @@ impl CloudflareHandle {
ppfmt: &PP,
) -> Vec<DnsRecord> {
let path = format!("zones/{zone_id}/dns_records?per_page=100&type={record_type}");
let resp: Option<CfListResponse<DnsRecord>> = self.api_request(reqwest::Method::GET, &path, None::<&()>, ppfmt).await;
let resp: Option<CfListResponse<DnsRecord>> = self
.api_request(reqwest::Method::GET, &path, None::<&()>, ppfmt)
.await;
resp.and_then(|r| r.result).unwrap_or_default()
}
@@ -309,7 +324,9 @@ impl CloudflareHandle {
ppfmt: &PP,
) -> Option<DnsRecord> {
let path = format!("zones/{zone_id}/dns_records");
let resp: Option<CfResponse<DnsRecord>> = self.api_request(reqwest::Method::POST, &path, Some(payload), ppfmt).await;
let resp: Option<CfResponse<DnsRecord>> = self
.api_request(reqwest::Method::POST, &path, Some(payload), ppfmt)
.await;
resp.and_then(|r| r.result)
}
@@ -321,18 +338,17 @@ impl CloudflareHandle {
ppfmt: &PP,
) -> Option<DnsRecord> {
let path = format!("zones/{zone_id}/dns_records/{record_id}");
let resp: Option<CfResponse<DnsRecord>> = self.api_request(reqwest::Method::PUT, &path, Some(payload), ppfmt).await;
let resp: Option<CfResponse<DnsRecord>> = self
.api_request(reqwest::Method::PUT, &path, Some(payload), ppfmt)
.await;
resp.and_then(|r| r.result)
}
pub async fn delete_record(
&self,
zone_id: &str,
record_id: &str,
ppfmt: &PP,
) -> bool {
pub async fn delete_record(&self, zone_id: &str, record_id: &str, ppfmt: &PP) -> bool {
let path = format!("zones/{zone_id}/dns_records/{record_id}");
let resp: Option<CfResponse<serde_json::Value>> = self.api_request(reqwest::Method::DELETE, &path, None::<&()>, ppfmt).await;
let resp: Option<CfResponse<serde_json::Value>> = self
.api_request(reqwest::Method::DELETE, &path, None::<&()>, ppfmt)
.await;
resp.is_some()
}
@@ -349,8 +365,13 @@ impl CloudflareHandle {
dry_run: bool,
ppfmt: &PP,
) -> SetResult {
let existing = self.list_records_by_name(zone_id, record_type, fqdn, ppfmt).await;
let managed: Vec<&DnsRecord> = existing.iter().filter(|r| self.is_managed_record(r)).collect();
let existing = self
.list_records_by_name(zone_id, record_type, fqdn, ppfmt)
.await;
let managed: Vec<&DnsRecord> = existing
.iter()
.filter(|r| self.is_managed_record(r))
.collect();
if ips.is_empty() {
// Delete all managed records
@@ -359,9 +380,15 @@ impl CloudflareHandle {
}
for record in &managed {
if dry_run {
ppfmt.noticef(pp::EMOJI_DELETE, &format!("[DRY RUN] Would delete record {fqdn} ({})", record.content));
ppfmt.noticef(
pp::EMOJI_DELETE,
&format!("[DRY RUN] Would delete record {fqdn} ({})", record.content),
);
} else {
ppfmt.noticef(pp::EMOJI_DELETE, &format!("Deleting record {fqdn} ({})", record.content));
ppfmt.noticef(
pp::EMOJI_DELETE,
&format!("Deleting record {fqdn} ({})", record.content),
);
self.delete_record(zone_id, &record.id, ppfmt).await;
}
}
@@ -376,9 +403,9 @@ impl CloudflareHandle {
let ip_str = ip.to_string();
// Find existing record with this IP
let matching = managed.iter().find(|r| {
r.content == ip_str && !used_record_ids.contains(&&r.id)
});
let matching = managed
.iter()
.find(|r| r.content == ip_str && !used_record_ids.contains(&&r.id));
if let Some(record) = matching {
used_record_ids.push(&record.id);
@@ -398,19 +425,24 @@ impl CloudflareHandle {
comment: comment.map(|s| s.to_string()),
};
if dry_run {
ppfmt.noticef(pp::EMOJI_UPDATE, &format!("[DRY RUN] Would update record {fqdn} -> {ip_str}"));
ppfmt.noticef(
pp::EMOJI_UPDATE,
&format!("[DRY RUN] Would update record {fqdn} -> {ip_str}"),
);
} else {
ppfmt.noticef(pp::EMOJI_UPDATE, &format!("Updating record {fqdn} -> {ip_str}"));
self.update_record(zone_id, &record.id, &payload, ppfmt).await;
ppfmt.noticef(
pp::EMOJI_UPDATE,
&format!("Updating record {fqdn} -> {ip_str}"),
);
self.update_record(zone_id, &record.id, &payload, ppfmt)
.await;
}
} else {
// Caller handles "up to date" logging based on SetResult::Noop
}
} else {
// Find an existing managed record to update, or create new
let reusable = managed.iter().find(|r| {
!used_record_ids.contains(&&r.id)
});
let reusable = managed.iter().find(|r| !used_record_ids.contains(&&r.id));
let payload = DnsRecordPayload {
record_type: record_type.to_string(),
@@ -425,17 +457,30 @@ impl CloudflareHandle {
used_record_ids.push(&record.id);
any_change = true;
if dry_run {
ppfmt.noticef(pp::EMOJI_UPDATE, &format!("[DRY RUN] Would update record {fqdn} -> {ip_str}"));
ppfmt.noticef(
pp::EMOJI_UPDATE,
&format!("[DRY RUN] Would update record {fqdn} -> {ip_str}"),
);
} else {
ppfmt.noticef(pp::EMOJI_UPDATE, &format!("Updating record {fqdn} -> {ip_str}"));
self.update_record(zone_id, &record.id, &payload, ppfmt).await;
ppfmt.noticef(
pp::EMOJI_UPDATE,
&format!("Updating record {fqdn} -> {ip_str}"),
);
self.update_record(zone_id, &record.id, &payload, ppfmt)
.await;
}
} else {
any_change = true;
if dry_run {
ppfmt.noticef(pp::EMOJI_CREATE, &format!("[DRY RUN] Would add new record {fqdn} -> {ip_str}"));
ppfmt.noticef(
pp::EMOJI_CREATE,
&format!("[DRY RUN] Would add new record {fqdn} -> {ip_str}"),
);
} else {
ppfmt.noticef(pp::EMOJI_CREATE, &format!("Adding new record {fqdn} -> {ip_str}"));
ppfmt.noticef(
pp::EMOJI_CREATE,
&format!("Adding new record {fqdn} -> {ip_str}"),
);
self.create_record(zone_id, &payload, ppfmt).await;
}
}
@@ -447,9 +492,18 @@ impl CloudflareHandle {
if !used_record_ids.contains(&&record.id) {
any_change = true;
if dry_run {
ppfmt.noticef(pp::EMOJI_DELETE, &format!("[DRY RUN] Would delete stale record {} ({})", fqdn, record.content));
ppfmt.noticef(
pp::EMOJI_DELETE,
&format!(
"[DRY RUN] Would delete stale record {} ({})",
fqdn, record.content
),
);
} else {
ppfmt.noticef(pp::EMOJI_DELETE, &format!("Deleting stale record {} ({})", fqdn, record.content));
ppfmt.noticef(
pp::EMOJI_DELETE,
&format!("Deleting stale record {} ({})", fqdn, record.content),
);
self.delete_record(zone_id, &record.id, ppfmt).await;
}
}
@@ -463,17 +517,16 @@ impl CloudflareHandle {
}
/// Delete all managed records for a specific domain/record type.
pub async fn final_delete(
&self,
zone_id: &str,
fqdn: &str,
record_type: &str,
ppfmt: &PP,
) {
let existing = self.list_records_by_name(zone_id, record_type, fqdn, ppfmt).await;
pub async fn final_delete(&self, zone_id: &str, fqdn: &str, record_type: &str, ppfmt: &PP) {
let existing = self
.list_records_by_name(zone_id, record_type, fqdn, ppfmt)
.await;
for record in &existing {
if self.is_managed_record(record) {
ppfmt.noticef(pp::EMOJI_DELETE, &format!("Deleting record {fqdn} ({})", record.content));
ppfmt.noticef(
pp::EMOJI_DELETE,
&format!("Deleting record {fqdn} ({})", record.content),
);
self.delete_record(zone_id, &record.id, ppfmt).await;
}
}
@@ -481,13 +534,11 @@ impl CloudflareHandle {
// --- WAF List Operations ---
pub async fn find_waf_list(
&self,
waf_list: &WAFList,
ppfmt: &PP,
) -> Option<WAFListMeta> {
pub async fn find_waf_list(&self, waf_list: &WAFList, ppfmt: &PP) -> Option<WAFListMeta> {
let path = format!("accounts/{}/rules/lists", waf_list.account_id);
let resp: Option<CfListResponse<WAFListMeta>> = self.api_request(reqwest::Method::GET, &path, None::<&()>, ppfmt).await;
let resp: Option<CfListResponse<WAFListMeta>> = self
.api_request(reqwest::Method::GET, &path, None::<&()>, ppfmt)
.await;
resp.and_then(|r| r.result)
.and_then(|lists| lists.into_iter().find(|l| l.name == waf_list.list_name))
}
@@ -499,7 +550,9 @@ impl CloudflareHandle {
ppfmt: &PP,
) -> Vec<WAFListItem> {
let path = format!("accounts/{account_id}/rules/lists/{list_id}/items");
let resp: Option<CfListResponse<WAFListItem>> = self.api_request(reqwest::Method::GET, &path, None::<&()>, ppfmt).await;
let resp: Option<CfListResponse<WAFListItem>> = self
.api_request(reqwest::Method::GET, &path, None::<&()>, ppfmt)
.await;
resp.and_then(|r| r.result).unwrap_or_default()
}
@@ -511,7 +564,9 @@ impl CloudflareHandle {
ppfmt: &PP,
) -> bool {
let path = format!("accounts/{account_id}/rules/lists/{list_id}/items");
let resp: Option<CfResponse<serde_json::Value>> = self.api_request(reqwest::Method::POST, &path, Some(&items), ppfmt).await;
let resp: Option<CfResponse<serde_json::Value>> = self
.api_request(reqwest::Method::POST, &path, Some(&items), ppfmt)
.await;
resp.is_some()
}
@@ -528,11 +583,17 @@ impl CloudflareHandle {
.map(|id| serde_json::json!({ "id": id }))
.collect();
let url = self.api_url(&path);
let req = self.auth.apply(self.client.delete(&url)).json(&serde_json::json!({ "items": body }));
let req = self
.auth
.apply(self.client.delete(&url))
.json(&serde_json::json!({ "items": body }));
match req.send().await {
Ok(resp) => resp.status().is_success(),
Err(e) => {
ppfmt.errorf(pp::EMOJI_ERROR, &format!("WAF list items DELETE error: {e}"));
ppfmt.errorf(
pp::EMOJI_ERROR,
&format!("WAF list items DELETE error: {e}"),
);
false
}
}
@@ -566,14 +627,12 @@ impl CloudflareHandle {
// Filter to managed items
let managed_items: Vec<&WAFListItem> = existing_items
.iter()
.filter(|item| {
match &self.managed_waf_comment_regex {
Some(regex) => {
let c = item.comment.as_deref().unwrap_or("");
regex.is_match(c)
}
None => true,
.filter(|item| match &self.managed_waf_comment_regex {
Some(regex) => {
let c = item.comment.as_deref().unwrap_or("");
regex.is_match(c)
}
None => true,
})
.collect();
@@ -599,7 +658,9 @@ impl CloudflareHandle {
let ids_to_delete: Vec<String> = managed_items
.iter()
.filter(|item| {
item.ip.as_ref().map_or(false, |ip| ips_to_remove.contains(ip))
item.ip
.as_ref()
.map_or(false, |ip| ips_to_remove.contains(ip))
})
.map(|item| item.id.clone())
.collect();
@@ -613,13 +674,21 @@ impl CloudflareHandle {
for item in &to_add {
ppfmt.noticef(
pp::EMOJI_CREATE,
&format!("[DRY RUN] Would add {} to WAF list {}", item.ip, waf_list.describe()),
&format!(
"[DRY RUN] Would add {} to WAF list {}",
item.ip,
waf_list.describe()
),
);
}
for ip in &ips_to_remove {
ppfmt.noticef(
pp::EMOJI_DELETE,
&format!("[DRY RUN] Would remove {} from WAF list {}", ip, waf_list.describe()),
&format!(
"[DRY RUN] Would remove {} from WAF list {}",
ip,
waf_list.describe()
),
);
}
return SetResult::Updated;
@@ -665,11 +734,7 @@ impl CloudflareHandle {
}
/// Clear all managed items from a WAF list (for shutdown).
pub async fn final_clear_waf_list(
&self,
waf_list: &WAFList,
ppfmt: &PP,
) {
pub async fn final_clear_waf_list(&self, waf_list: &WAFList, ppfmt: &PP) {
let list_meta = match self.find_waf_list(waf_list, ppfmt).await {
Some(meta) => meta,
None => return,
@@ -681,14 +746,12 @@ impl CloudflareHandle {
let managed_ids: Vec<String> = items
.iter()
.filter(|item| {
match &self.managed_waf_comment_regex {
Some(regex) => {
let c = item.comment.as_deref().unwrap_or("");
regex.is_match(c)
}
None => true,
.filter(|item| match &self.managed_waf_comment_regex {
Some(regex) => {
let c = item.comment.as_deref().unwrap_or("");
regex.is_match(c)
}
None => true,
})
.map(|item| item.id.clone())
.collect();
@@ -696,7 +759,11 @@ impl CloudflareHandle {
if !managed_ids.is_empty() {
ppfmt.noticef(
pp::EMOJI_DELETE,
&format!("Clearing {} items from WAF list {}", managed_ids.len(), waf_list.describe()),
&format!(
"Clearing {} items from WAF list {}",
managed_ids.len(),
waf_list.describe()
),
);
self.delete_waf_list_items(&waf_list.account_id, &list_meta.id, &managed_ids, ppfmt)
.await;
@@ -716,7 +783,10 @@ mod tests {
use super::*;
use crate::pp::PP;
use std::net::IpAddr;
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::{method, path, query_param}};
use wiremock::{
matchers::{method, path, query_param},
Mock, MockServer, ResponseTemplate,
};
fn pp() -> PP {
PP::new(false, false)
@@ -855,7 +925,12 @@ mod tests {
serde_json::json!({ "result": [] })
}
fn dns_record_json(id: &str, name: &str, content: &str, comment: Option<&str>) -> serde_json::Value {
fn dns_record_json(
id: &str,
name: &str,
content: &str,
comment: Option<&str>,
) -> serde_json::Value {
serde_json::json!({
"id": id,
"name": name,
@@ -888,7 +963,9 @@ mod tests {
Mock::given(method("GET"))
.and(path("/zones"))
.and(query_param("name", "example.com"))
.respond_with(ResponseTemplate::new(200).set_body_json(zone_response("zone-1", "example.com")))
.respond_with(
ResponseTemplate::new(200).set_body_json(zone_response("zone-1", "example.com")),
)
.mount(&server)
.await;
@@ -940,9 +1017,7 @@ mod tests {
#[tokio::test]
async fn list_records_by_name_case_insensitive() {
let server = MockServer::start().await;
let body = dns_list_response(vec![
dns_record_json("r1", "example.com", "1.2.3.4", None),
]);
let body = dns_list_response(vec![dns_record_json("r1", "example.com", "1.2.3.4", None)]);
Mock::given(method("GET"))
.and(path("/zones/z1/dns_records"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
@@ -971,7 +1046,9 @@ mod tests {
.await;
let h = handle(&server.uri());
let records = h.list_records_by_name("z1", "A", "a.example.com", &pp()).await;
let records = h
.list_records_by_name("z1", "A", "a.example.com", &pp())
.await;
assert_eq!(records.len(), 1);
assert_eq!(records[0].content, "1.2.3.4");
}
@@ -1035,7 +1112,10 @@ mod tests {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/zones/z1/dns_records/r1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "result": { "id": "r1" } })))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "result": { "id": "r1" } })),
)
.mount(&server)
.await;
@@ -1057,16 +1137,31 @@ mod tests {
// create
Mock::given(method("POST"))
.and(path("/zones/z1/dns_records"))
.respond_with(ResponseTemplate::new(200).set_body_json(
dns_single_response(dns_record_json("new1", "a.example.com", "1.2.3.4", None)),
))
.respond_with(
ResponseTemplate::new(200).set_body_json(dns_single_response(dns_record_json(
"new1",
"a.example.com",
"1.2.3.4",
None,
))),
)
.mount(&server)
.await;
let h = handle(&server.uri());
let ips: Vec<IpAddr> = vec!["1.2.3.4".parse().unwrap()];
let result = h
.set_ips("z1", "a.example.com", "A", &ips, false, TTL::AUTO, None, false, &pp())
.set_ips(
"z1",
"a.example.com",
"A",
&ips,
false,
TTL::AUTO,
None,
false,
&pp(),
)
.await;
assert_eq!(result, SetResult::Updated);
}
@@ -1078,16 +1173,31 @@ mod tests {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/zones/z1/dns_records"))
.respond_with(ResponseTemplate::new(200).set_body_json(dns_list_response(vec![
dns_record_json("r1", "a.example.com", "1.2.3.4", None),
])))
.respond_with(
ResponseTemplate::new(200).set_body_json(dns_list_response(vec![dns_record_json(
"r1",
"a.example.com",
"1.2.3.4",
None,
)])),
)
.mount(&server)
.await;
let h = handle(&server.uri());
let ips: Vec<IpAddr> = vec!["1.2.3.4".parse().unwrap()];
let result = h
.set_ips("z1", "a.example.com", "A", &ips, false, TTL::AUTO, None, false, &pp())
.set_ips(
"z1",
"a.example.com",
"A",
&ips,
false,
TTL::AUTO,
None,
false,
&pp(),
)
.await;
assert_eq!(result, SetResult::Noop);
}
@@ -1099,23 +1209,43 @@ mod tests {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/zones/z1/dns_records"))
.respond_with(ResponseTemplate::new(200).set_body_json(dns_list_response(vec![
dns_record_json("r1", "a.example.com", "9.9.9.9", None),
])))
.respond_with(
ResponseTemplate::new(200).set_body_json(dns_list_response(vec![dns_record_json(
"r1",
"a.example.com",
"9.9.9.9",
None,
)])),
)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/zones/z1/dns_records/r1"))
.respond_with(ResponseTemplate::new(200).set_body_json(
dns_single_response(dns_record_json("r1", "a.example.com", "1.2.3.4", None)),
))
.respond_with(
ResponseTemplate::new(200).set_body_json(dns_single_response(dns_record_json(
"r1",
"a.example.com",
"1.2.3.4",
None,
))),
)
.mount(&server)
.await;
let h = handle(&server.uri());
let ips: Vec<IpAddr> = vec!["1.2.3.4".parse().unwrap()];
let result = h
.set_ips("z1", "a.example.com", "A", &ips, false, TTL::AUTO, None, false, &pp())
.set_ips(
"z1",
"a.example.com",
"A",
&ips,
false,
TTL::AUTO,
None,
false,
&pp(),
)
.await;
assert_eq!(result, SetResult::Updated);
}
@@ -1127,22 +1257,37 @@ mod tests {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/zones/z1/dns_records"))
.respond_with(ResponseTemplate::new(200).set_body_json(dns_list_response(vec![
dns_record_json("r1", "a.example.com", "1.2.3.4", None),
dns_record_json("r2", "a.example.com", "5.5.5.5", None),
])))
.respond_with(
ResponseTemplate::new(200).set_body_json(dns_list_response(vec![
dns_record_json("r1", "a.example.com", "1.2.3.4", None),
dns_record_json("r2", "a.example.com", "5.5.5.5", None),
])),
)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/zones/z1/dns_records/r2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "result": { "id": "r2" } })))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "result": { "id": "r2" } })),
)
.mount(&server)
.await;
let h = handle(&server.uri());
let ips: Vec<IpAddr> = vec!["1.2.3.4".parse().unwrap()];
let result = h
.set_ips("z1", "a.example.com", "A", &ips, false, TTL::AUTO, None, false, &pp())
.set_ips(
"z1",
"a.example.com",
"A",
&ips,
false,
TTL::AUTO,
None,
false,
&pp(),
)
.await;
assert_eq!(result, SetResult::Updated);
}
@@ -1154,21 +1299,39 @@ mod tests {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/zones/z1/dns_records"))
.respond_with(ResponseTemplate::new(200).set_body_json(dns_list_response(vec![
dns_record_json("r1", "a.example.com", "1.2.3.4", None),
])))
.respond_with(
ResponseTemplate::new(200).set_body_json(dns_list_response(vec![dns_record_json(
"r1",
"a.example.com",
"1.2.3.4",
None,
)])),
)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/zones/z1/dns_records/r1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "result": { "id": "r1" } })))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "result": { "id": "r1" } })),
)
.mount(&server)
.await;
let h = handle(&server.uri());
let ips: Vec<IpAddr> = vec![];
let result = h
.set_ips("z1", "a.example.com", "A", &ips, false, TTL::AUTO, None, false, &pp())
.set_ips(
"z1",
"a.example.com",
"A",
&ips,
false,
TTL::AUTO,
None,
false,
&pp(),
)
.await;
assert_eq!(result, SetResult::Updated);
}
@@ -1188,7 +1351,17 @@ mod tests {
let h = handle(&server.uri());
let ips: Vec<IpAddr> = vec!["1.2.3.4".parse().unwrap()];
let result = h
.set_ips("z1", "a.example.com", "A", &ips, false, TTL::AUTO, None, true, &pp())
.set_ips(
"z1",
"a.example.com",
"A",
&ips,
false,
TTL::AUTO,
None,
true,
&pp(),
)
.await;
assert_eq!(result, SetResult::Updated);
}
@@ -1258,21 +1431,29 @@ mod tests {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/zones/z1/dns_records"))
.respond_with(ResponseTemplate::new(200).set_body_json(dns_list_response(vec![
dns_record_json("r1", "a.example.com", "1.2.3.4", None),
dns_record_json("r2", "a.example.com", "5.6.7.8", None),
])))
.respond_with(
ResponseTemplate::new(200).set_body_json(dns_list_response(vec![
dns_record_json("r1", "a.example.com", "1.2.3.4", None),
dns_record_json("r2", "a.example.com", "5.6.7.8", None),
])),
)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/zones/z1/dns_records/r1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "result": { "id": "r1" } })))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "result": { "id": "r1" } })),
)
.expect(1)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/zones/z1/dns_records/r2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "result": { "id": "r2" } })))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "result": { "id": "r2" } })),
)
.expect(1)
.mount(&server)
.await;
@@ -1344,13 +1525,17 @@ mod tests {
// list items - empty
Mock::given(method("GET"))
.and(path("/accounts/acct1/rules/lists/wl-1/items"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "result": [] })))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "result": [] })),
)
.mount(&server)
.await;
// create items
Mock::given(method("POST"))
.and(path("/accounts/acct1/rules/lists/wl-1/items"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "result": {} })))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "result": {} })),
)
.mount(&server)
.await;
@@ -1360,7 +1545,9 @@ mod tests {
list_name: "mylist".to_string(),
};
let ips: Vec<IpAddr> = vec!["10.0.0.1".parse().unwrap()];
let result = h.set_waf_list(&wl, &ips, Some("ddns"), None, false, &pp()).await;
let result = h
.set_waf_list(&wl, &ips, Some("ddns"), None, false, &pp())
.await;
assert_eq!(result, SetResult::Updated);
}
@@ -1404,7 +1591,9 @@ mod tests {
let h = handle(&server.uri());
let pp = PP::new(false, true); // quiet
let result: Option<CfListResponse<ZoneResult>> = h.api_request(reqwest::Method::GET, "zones", None::<&()>, &pp).await;
let result: Option<CfListResponse<ZoneResult>> = h
.api_request(reqwest::Method::GET, "zones", None::<&()>, &pp)
.await;
assert!(result.is_none());
}
@@ -1419,7 +1608,9 @@ mod tests {
let h = handle(&server.uri());
let pp = PP::new(false, true);
let body = serde_json::json!({"test": true});
let result: Option<CfResponse<serde_json::Value>> = h.api_request(reqwest::Method::POST, "endpoint", Some(&body), &pp).await;
let result: Option<CfResponse<serde_json::Value>> = h
.api_request(reqwest::Method::POST, "endpoint", Some(&body), &pp)
.await;
assert!(result.is_none());
}
@@ -1434,7 +1625,9 @@ mod tests {
let h = handle(&server.uri());
let pp = PP::new(false, true);
let body = serde_json::json!({"test": true});
let result: Option<CfResponse<serde_json::Value>> = h.api_request(reqwest::Method::PUT, "endpoint", Some(&body), &pp).await;
let result: Option<CfResponse<serde_json::Value>> = h
.api_request(reqwest::Method::PUT, "endpoint", Some(&body), &pp)
.await;
assert!(result.is_none());
}
@@ -1458,23 +1651,30 @@ mod tests {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/zones/z1/dns_records"))
.respond_with(ResponseTemplate::new(200).set_body_json(dns_list_response(vec![
serde_json::json!({
"id": "r1",
"name": "a.example.com",
"content": "1.2.3.4",
"proxied": false,
"ttl": 1,
"comment": null
}),
])))
.respond_with(
ResponseTemplate::new(200).set_body_json(dns_list_response(vec![
serde_json::json!({
"id": "r1",
"name": "a.example.com",
"content": "1.2.3.4",
"proxied": false,
"ttl": 1,
"comment": null
}),
])),
)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/zones/z1/dns_records/r1"))
.respond_with(ResponseTemplate::new(200).set_body_json(
dns_single_response(dns_record_json("r1", "a.example.com", "1.2.3.4", None)),
))
.respond_with(
ResponseTemplate::new(200).set_body_json(dns_single_response(dns_record_json(
"r1",
"a.example.com",
"1.2.3.4",
None,
))),
)
.expect(1)
.mount(&server)
.await;
@@ -1483,7 +1683,17 @@ mod tests {
let ips: Vec<IpAddr> = vec!["1.2.3.4".parse().unwrap()];
// proxied=true but record has proxied=false -> should update
let result = h
.set_ips("z1", "a.example.com", "A", &ips, true, TTL::AUTO, None, false, &pp())
.set_ips(
"z1",
"a.example.com",
"A",
&ips,
true,
TTL::AUTO,
None,
false,
&pp(),
)
.await;
assert_eq!(result, SetResult::Updated);
}
@@ -1495,16 +1705,31 @@ mod tests {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/zones/z1/dns_records"))
.respond_with(ResponseTemplate::new(200).set_body_json(dns_list_response(vec![
dns_record_json("r1", "a.example.com", "9.9.9.9", None),
])))
.respond_with(
ResponseTemplate::new(200).set_body_json(dns_list_response(vec![dns_record_json(
"r1",
"a.example.com",
"9.9.9.9",
None,
)])),
)
.mount(&server)
.await;
let h = handle(&server.uri());
let ips: Vec<IpAddr> = vec!["1.2.3.4".parse().unwrap()];
let result = h
.set_ips("z1", "a.example.com", "A", &ips, false, TTL::AUTO, None, true, &pp())
.set_ips(
"z1",
"a.example.com",
"A",
&ips,
false,
TTL::AUTO,
None,
true,
&pp(),
)
.await;
assert_eq!(result, SetResult::Updated);
}
@@ -1523,7 +1748,17 @@ mod tests {
let h = handle(&server.uri());
let ips: Vec<IpAddr> = vec![];
let result = h
.set_ips("z1", "a.example.com", "A", &ips, false, TTL::AUTO, None, false, &pp())
.set_ips(
"z1",
"a.example.com",
"A",
&ips,
false,
TTL::AUTO,
None,
false,
&pp(),
)
.await;
assert_eq!(result, SetResult::Noop);
}
@@ -1535,16 +1770,31 @@ mod tests {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/zones/z1/dns_records"))
.respond_with(ResponseTemplate::new(200).set_body_json(dns_list_response(vec![
dns_record_json("r1", "a.example.com", "1.2.3.4", None),
])))
.respond_with(
ResponseTemplate::new(200).set_body_json(dns_list_response(vec![dns_record_json(
"r1",
"a.example.com",
"1.2.3.4",
None,
)])),
)
.mount(&server)
.await;
let h = handle(&server.uri());
let ips: Vec<IpAddr> = vec![];
let result = h
.set_ips("z1", "a.example.com", "A", &ips, false, TTL::AUTO, None, true, &pp())
.set_ips(
"z1",
"a.example.com",
"A",
&ips,
false,
TTL::AUTO,
None,
true,
&pp(),
)
.await;
assert_eq!(result, SetResult::Updated);
}
@@ -1659,7 +1909,9 @@ mod tests {
.await;
Mock::given(method("DELETE"))
.and(path("/accounts/acct1/rules/lists/wl-1/items"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "result": {} })))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "result": {} })),
)
.expect(1)
.mount(&server)
.await;
@@ -1716,7 +1968,9 @@ mod tests {
// delete items
Mock::given(method("DELETE"))
.and(path("/accounts/acct1/rules/lists/wl-1/items"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "result": {} })))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "result": {} })),
)
.mount(&server)
.await;

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};
@@ -130,9 +130,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 {
@@ -146,7 +152,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 {
@@ -187,7 +196,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));
@@ -212,7 +224,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}"),
);
}
}
}
@@ -234,27 +249,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 },
};
@@ -392,7 +411,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()
@@ -450,7 +473,7 @@ 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: true,
delete_on_failure: false,
ttl,
proxied_expression: None,
record_comment: None,
@@ -505,7 +528,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", true);
let delete_on_failure = getenv_bool("DELETE_ON_FAILURE", false);
let ttl_val = getenv("TTL")
.and_then(|s| s.parse::<i64>().ok())
@@ -620,11 +643,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}"),
);
}
}
}
@@ -663,7 +692,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(", ")),
);
}
}
@@ -675,7 +707,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()));
@@ -686,7 +721,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 {
@@ -766,7 +804,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]
@@ -962,7 +1003,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");
}
@@ -1049,7 +1093,9 @@ mod tests {
ip6_provider: 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);
}
@@ -1102,7 +1148,10 @@ mod tests {
};
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]
@@ -1395,7 +1444,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) {

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,11 +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 rand::RngExt;
use reqwest::Client;
use tokio::signal;
use tokio::time::{sleep, Duration};
@@ -131,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
@@ -144,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(
@@ -182,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) {
@@ -192,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;
}
}
@@ -211,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 => {
@@ -227,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
@@ -260,7 +318,17 @@ async fn run_env_mode(
sleep(std::time::Duration::from_secs(jitter_secs)).await;
}
updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
updater::update_once(
config,
handle,
notifier,
heartbeat,
cf_cache,
ppfmt,
&mut noop_reported,
detection_client,
)
.await;
}
}
}
@@ -319,8 +387,8 @@ pub(crate) fn test_client() -> reqwest::Client {
#[cfg(test)]
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;
@@ -566,8 +634,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,
@@ -696,8 +763,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]
@@ -745,8 +819,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]
@@ -788,8 +869,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]
@@ -822,8 +910,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]
@@ -879,8 +974,15 @@ mod tests {
ip4_provider: None,
ip6_provider: 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 ---
@@ -1004,7 +1106,14 @@ mod tests {
ip6_provider: 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())
@@ -358,12 +414,133 @@ fn parse_gotify_url(
})
}
/// 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
@@ -427,21 +604,27 @@ fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
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}"),
});
}
@@ -479,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(),
});
}
@@ -508,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,
@@ -594,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 })
}
@@ -656,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
@@ -812,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]
@@ -844,9 +1019,10 @@ mod tests {
#[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();
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"
@@ -855,8 +1031,7 @@ mod tests {
#[test]
fn test_parse_gotify_disabletls_switches_to_http() {
let result =
parse_shoutrrr_url("gotify://10.0.0.1:8080/TOKEN123?disabletls=yes").unwrap();
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"
@@ -882,23 +1057,30 @@ mod tests {
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]
@@ -908,18 +1090,14 @@ 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();
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"
@@ -930,8 +1108,7 @@ mod tests {
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();
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"
@@ -951,19 +1128,143 @@ mod tests {
}
#[test]
fn test_parse_plain_https_url() {
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]
@@ -1227,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()),
}],
};
@@ -1243,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;
@@ -1254,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);
}
@@ -1303,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 {
@@ -1316,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 ----
@@ -1404,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;
@@ -1463,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

@@ -26,7 +26,6 @@ impl IpType {
IpType::V6 => "AAAA",
}
}
}
/// All supported provider types
@@ -119,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,
@@ -136,9 +167,7 @@ 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)
}
@@ -151,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
@@ -213,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)
})
@@ -250,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();
}
@@ -263,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)
@@ -318,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()
),
);
}
}
@@ -335,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());
@@ -495,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()
}
@@ -684,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;
@@ -694,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;
@@ -874,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
@@ -981,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() {
@@ -1000,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());
@@ -1070,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)
@@ -1092,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.
@@ -1145,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;
@@ -1207,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]
@@ -1350,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]
@@ -1400,18 +1506,20 @@ 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();
let addr =
parse_if_inet6_line("20010db8000000011111222233334444 03 40 00 00 eth0").unwrap();
assert_eq!(
addr.ip,
@@ -1453,30 +1561,42 @@ fdaa149d3b9900000000000000000001 0a 40 00 82 br-990e55930a86
#[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::StableLocalIface { interface: "eth0".into() }.name(),
ProviderType::StableLocalIface {
interface: "eth0".into()
}
.name(),
"local.iface.stable"
);
assert_eq!(
ProviderType::CustomURL { url: "https://x".into() }.name(),
ProviderType::CustomURL {
url: "https://x".into()
}
.name(),
"url:"
);
assert_eq!(
ProviderType::Literal { ips: vec![] }.name(),
"literal:"
);
assert_eq!(ProviderType::Literal { ips: vec![] }.name(), "literal:");
assert_eq!(ProviderType::None.name(), "none");
}
@@ -1518,7 +1638,9 @@ fdaa149d3b9900000000000000000001 0a 40 00 82 br-990e55930a86
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()));
}
@@ -1536,7 +1658,9 @@ fdaa149d3b9900000000000000000001 0a 40 00 82 br-990e55930a86
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()));
}
@@ -1550,10 +1674,14 @@ fdaa149d3b9900000000000000000001 0a 40 00 82 br-990e55930a86
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