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

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