mirror of
https://github.com/timothymiller/cloudflare-ddns.git
synced 2026-09-20 14:39:04 -03:00
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:
@@ -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}'"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
104
src/config.rs
104
src/config.rs
@@ -1,7 +1,7 @@
|
||||
use crate::cloudflare::{Auth, TTL, WAFList};
|
||||
use crate::cloudflare::{Auth, WAFList, TTL};
|
||||
use crate::domain;
|
||||
use crate::notifier::{
|
||||
CompositeNotifier, Heartbeat, HeartbeatMonitor, HealthchecksMonitor, NotifierDyn,
|
||||
CompositeNotifier, HealthchecksMonitor, Heartbeat, HeartbeatMonitor, NotifierDyn,
|
||||
ShoutrrrNotifier, UptimeKumaMonitor,
|
||||
};
|
||||
use crate::pp::{self, PP};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
165
src/main.rs
165
src/main.rs
@@ -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, ¬ifier, &heartbeat, &ppfmt, running, &mut cf_cache, &detection_client).await;
|
||||
run_legacy_mode(
|
||||
&app_config,
|
||||
&handle,
|
||||
¬ifier,
|
||||
&heartbeat,
|
||||
&ppfmt,
|
||||
running,
|
||||
&mut cf_cache,
|
||||
&detection_client,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
// --- Env var mode (cf-ddns behavior) ---
|
||||
run_env_mode(&app_config, &handle, ¬ifier, &heartbeat, &ppfmt, running, &mut cf_cache, &detection_client).await;
|
||||
run_env_mode(
|
||||
&app_config,
|
||||
&handle,
|
||||
¬ifier,
|
||||
&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;
|
||||
}
|
||||
}
|
||||
|
||||
513
src/notifier.rs
513
src/notifier.rs
@@ -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(¶ms)
|
||||
.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()),
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
252
src/provider.rs
252
src/provider.rs
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
879
src/updater.rs
879
src/updater.rs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user