mirror of
https://github.com/timothymiller/cloudflare-ddns.git
synced 2026-05-06 09:53:40 -03:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fddabc7a3d | ||
|
|
548d89dacf |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -92,7 +92,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cloudflare-ddns"
|
name = "cloudflare-ddns"
|
||||||
version = "2.1.1"
|
version = "2.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"if-addrs",
|
"if-addrs",
|
||||||
"rand",
|
"rand",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cloudflare-ddns"
|
name = "cloudflare-ddns"
|
||||||
version = "2.1.1"
|
version = "2.1.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Access your home network remotely via a custom domain name without a static IP"
|
description = "Access your home network remotely via a custom domain name without a static IP"
|
||||||
license = "GPL-3.0"
|
license = "GPL-3.0"
|
||||||
|
|||||||
48
RELEASE_NOTES_2.1.2.md
Normal file
48
RELEASE_NOTES_2.1.2.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# cloudflare-ddns v2.1.2 — Notification & Domain Casing Fixes
|
||||||
|
|
||||||
|
This patch release fixes three bugs reported on GitHub.
|
||||||
|
|
||||||
|
## Bug fixes
|
||||||
|
|
||||||
|
- **Mixed-case domains now match existing DNS records (#255).**
|
||||||
|
In env-var mode, configuring a domain with mixed casing (for example
|
||||||
|
`ExaMple.com`) caused every update cycle to attempt a duplicate record
|
||||||
|
create and fail with Cloudflare error `81058: An identical record already
|
||||||
|
exists.` Cloudflare normalizes record names to lowercase server-side, so
|
||||||
|
the lookup is now case-insensitive.
|
||||||
|
|
||||||
|
- **Pushover notifications work again (#258).**
|
||||||
|
The shoutrrr-style URL `pushover://shoutrrr:TOKEN@USER` (the canonical form
|
||||||
|
from `containrrr/shoutrrr`) was being parsed with the literal `shoutrrr:`
|
||||||
|
username included in the API token, which Pushover rejected. The parser
|
||||||
|
now strips the optional `<user>:` prefix from the token segment, restoring
|
||||||
|
the v2.0.7 behavior. Optional shoutrrr query parameters (`?devices=...`,
|
||||||
|
`?priority=...`) are tolerated.
|
||||||
|
|
||||||
|
- **Gotify notifications now produce a valid request URL (#262).**
|
||||||
|
The Gotify URL parser blindly appended `/message` after any query string,
|
||||||
|
producing malformed webhook URLs like
|
||||||
|
`https://host:9090?token=XYZ/message`. The parser now follows shoutrrr's
|
||||||
|
canonical layout — token as the final path segment or `?token=` query —
|
||||||
|
and supports `?disabletls=yes` to switch the resulting webhook from HTTPS
|
||||||
|
to HTTP for typical home-LAN setups, plus the `gotify+http://` /
|
||||||
|
`gotify+https://` aliases.
|
||||||
|
|
||||||
|
## Already addressed (closing #257)
|
||||||
|
|
||||||
|
The robust public-IP discovery enhancements requested in #257 (multi-endpoint
|
||||||
|
trace fallback, strict address-family validation, API request timeouts,
|
||||||
|
duplicate record cleanup) were already folded into the Rust port shipped in
|
||||||
|
v2.0.8 — see `src/provider.rs` (`CF_TRACE_PRIMARY` / `CF_TRACE_FALLBACK`,
|
||||||
|
`validate_detected_ip`, `build_split_client`) and `src/cloudflare.rs`
|
||||||
|
(`set_ips` dedup behavior, per-request `timeout`).
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull timothyjmiller/cloudflare-ddns:2.1.2
|
||||||
|
# or
|
||||||
|
docker pull timothyjmiller/cloudflare-ddns:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
No configuration changes are required.
|
||||||
@@ -280,8 +280,16 @@ impl CloudflareHandle {
|
|||||||
name: &str,
|
name: &str,
|
||||||
ppfmt: &PP,
|
ppfmt: &PP,
|
||||||
) -> Vec<DnsRecord> {
|
) -> Vec<DnsRecord> {
|
||||||
|
// Cloudflare normalizes DNS record names to lowercase server-side, so a
|
||||||
|
// case-sensitive match against the user-supplied name (e.g. ExaMple.com)
|
||||||
|
// would never find existing records and trigger 81058 duplicate-create
|
||||||
|
// errors on every cycle. Match case-insensitively to mirror Cloudflare's
|
||||||
|
// own comparison rules.
|
||||||
let records = self.list_records(zone_id, record_type, ppfmt).await;
|
let records = self.list_records(zone_id, record_type, ppfmt).await;
|
||||||
records.into_iter().filter(|r| r.name == name).collect()
|
records
|
||||||
|
.into_iter()
|
||||||
|
.filter(|r| r.name.eq_ignore_ascii_case(name))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_managed_record(&self, record: &DnsRecord) -> bool {
|
fn is_managed_record(&self, record: &DnsRecord) -> bool {
|
||||||
@@ -926,6 +934,29 @@ mod tests {
|
|||||||
assert_eq!(records[1].id, "r2");
|
assert_eq!(records[1].id, "r2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue #255: Cloudflare normalizes record names to lowercase, so a
|
||||||
|
// case-sensitive match against the user-supplied name (e.g. ExaMple.com)
|
||||||
|
// would loop forever creating duplicates. Verify match is case-insensitive.
|
||||||
|
#[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),
|
||||||
|
]);
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/zones/z1/dns_records"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(body))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let h = handle(&server.uri());
|
||||||
|
let records = h
|
||||||
|
.list_records_by_name("z1", "A", "ExaMple.com", &pp())
|
||||||
|
.await;
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(records[0].id, "r1");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_records_by_name_filters() {
|
async fn list_records_by_name_filters() {
|
||||||
let server = MockServer::start().await;
|
let server = MockServer::start().await;
|
||||||
|
|||||||
192
src/notifier.rs
192
src/notifier.rs
@@ -274,6 +274,90 @@ impl NotifierDyn for ShoutrrrNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a Gotify webhook URL from a shoutrrr-style URL.
|
||||||
|
///
|
||||||
|
/// Accepted forms:
|
||||||
|
/// gotify://host[:port]/TOKEN[?disabletls=yes]
|
||||||
|
/// gotify://host[:port]/path/?token=TOKEN[&disabletls=yes]
|
||||||
|
/// gotify+http://host[:port]/TOKEN
|
||||||
|
/// gotify+https://host[:port]/TOKEN
|
||||||
|
///
|
||||||
|
/// `disabletls=yes` switches the resulting webhook to plain HTTP, which is
|
||||||
|
/// required for typical home-LAN deployments where Gotify is reachable on a
|
||||||
|
/// private IP without TLS.
|
||||||
|
fn parse_gotify_url(
|
||||||
|
original: &str,
|
||||||
|
rest: &str,
|
||||||
|
default_scheme: &str,
|
||||||
|
) -> Result<ShoutrrrService, String> {
|
||||||
|
// Split off the query string (if any) before path manipulation.
|
||||||
|
let (path_part, query_part) = match rest.split_once('?') {
|
||||||
|
Some((p, q)) => (p, q),
|
||||||
|
None => (rest, ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut token: Option<String> = None;
|
||||||
|
let mut scheme = default_scheme;
|
||||||
|
if !query_part.is_empty() {
|
||||||
|
for pair in query_part.split('&') {
|
||||||
|
let (k, v) = match pair.split_once('=') {
|
||||||
|
Some(kv) => kv,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
match k {
|
||||||
|
"token" => token = Some(v.to_string()),
|
||||||
|
"disabletls" if v.eq_ignore_ascii_case("yes") => scheme = "http",
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// host[:port][/extra/path]/TOKEN -- token is the last non-empty path segment.
|
||||||
|
let trimmed = path_part.trim_end_matches('/');
|
||||||
|
let (host_path, last_segment) = match trimmed.rsplit_once('/') {
|
||||||
|
Some((h, t)) => (h, t),
|
||||||
|
None => (trimmed, ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
if token.is_none() && !last_segment.is_empty() {
|
||||||
|
token = Some(last_segment.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = match token {
|
||||||
|
Some(t) if !t.is_empty() => t,
|
||||||
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid Gotify shoutrrr URL (missing token): {original}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// host_path is either "host[:port]" or "host[:port]/extra/path" if user
|
||||||
|
// had additional path segments before the token.
|
||||||
|
let host_and_path = if host_path.is_empty() {
|
||||||
|
// No slash before token -> token *was* the only segment, host is path_part minus token.
|
||||||
|
path_part
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.trim_end_matches(&token[..])
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
host_path.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
if host_and_path.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid Gotify shoutrrr URL (missing host): {original}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ShoutrrrService {
|
||||||
|
original_url: original.to_string(),
|
||||||
|
service_type: ShoutrrrServiceType::Gotify,
|
||||||
|
webhook_url: format!("{scheme}://{host_and_path}/message?token={token}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
|
fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
|
||||||
// Shoutrrr URL formats:
|
// Shoutrrr URL formats:
|
||||||
// discord://token@id -> https://discord.com/api/webhooks/id/token
|
// discord://token@id -> https://discord.com/api/webhooks/id/token
|
||||||
@@ -334,15 +418,13 @@ fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
|
|||||||
return Err(format!("Invalid Telegram shoutrrr URL: {url_str}"));
|
return Err(format!("Invalid Telegram shoutrrr URL: {url_str}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(rest) = url_str
|
if let Some((rest, default_scheme)) = url_str
|
||||||
.strip_prefix("gotify://")
|
.strip_prefix("gotify+https://")
|
||||||
.or_else(|| url_str.strip_prefix("gotify+https://"))
|
.map(|r| (r, "https"))
|
||||||
|
.or_else(|| url_str.strip_prefix("gotify+http://").map(|r| (r, "http")))
|
||||||
|
.or_else(|| url_str.strip_prefix("gotify://").map(|r| (r, "https")))
|
||||||
{
|
{
|
||||||
return Ok(ShoutrrrService {
|
return parse_gotify_url(url_str, rest, default_scheme);
|
||||||
original_url: url_str.to_string(),
|
|
||||||
service_type: ShoutrrrServiceType::Gotify,
|
|
||||||
webhook_url: format!("https://{rest}/message"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(rest) = url_str
|
if let Some(rest) = url_str
|
||||||
@@ -365,14 +447,28 @@ fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(rest) = url_str.strip_prefix("pushover://") {
|
if let Some(rest) = url_str.strip_prefix("pushover://") {
|
||||||
let parts: Vec<&str> = rest.splitn(2, '@').collect();
|
// Strip query string (devices, priority, title) — not yet supported.
|
||||||
|
let body = rest.split('?').next().unwrap_or(rest).trim_end_matches('/');
|
||||||
|
let parts: Vec<&str> = body.splitn(2, '@').collect();
|
||||||
if parts.len() == 2 {
|
if parts.len() == 2 {
|
||||||
|
// Shoutrrr's canonical pushover URL is
|
||||||
|
// pushover://shoutrrr:APIToken@UserKey
|
||||||
|
// where the literal "shoutrrr:" username is required. Strip an
|
||||||
|
// optional "<user>:" prefix from the token portion so both the
|
||||||
|
// canonical form and the bare "pushover://TOKEN@USER" form work.
|
||||||
|
let token = parts[0]
|
||||||
|
.rsplit_once(':')
|
||||||
|
.map(|(_, t)| t)
|
||||||
|
.unwrap_or(parts[0]);
|
||||||
|
let user = parts[1];
|
||||||
|
if token.is_empty() || user.is_empty() {
|
||||||
|
return Err(format!("Invalid Pushover shoutrrr URL: {url_str}"));
|
||||||
|
}
|
||||||
return Ok(ShoutrrrService {
|
return Ok(ShoutrrrService {
|
||||||
original_url: url_str.to_string(),
|
original_url: url_str.to_string(),
|
||||||
service_type: ShoutrrrServiceType::Pushover,
|
service_type: ShoutrrrServiceType::Pushover,
|
||||||
webhook_url: format!(
|
webhook_url: format!(
|
||||||
"https://api.pushover.net/1/messages.json?token={}&user={}",
|
"https://api.pushover.net/1/messages.json?token={token}&user={user}"
|
||||||
parts[0], parts[1]
|
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -735,15 +831,53 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_gotify() {
|
fn test_parse_gotify_token_as_path_segment() {
|
||||||
let result = parse_shoutrrr_url("gotify://myhost.com/somepath").unwrap();
|
// Shoutrrr canonical format: token is the final path segment.
|
||||||
|
let result = parse_shoutrrr_url("gotify://myhost.com/MYTOKEN").unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.webhook_url,
|
result.webhook_url,
|
||||||
"https://myhost.com/somepath/message"
|
"https://myhost.com/message?token=MYTOKEN"
|
||||||
);
|
);
|
||||||
assert!(matches!(result.service_type, ShoutrrrServiceType::Gotify));
|
assert!(matches!(result.service_type, ShoutrrrServiceType::Gotify));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_gotify_token_query_param() {
|
||||||
|
// Older "gotify://host?token=..." form (issue #262).
|
||||||
|
let result =
|
||||||
|
parse_shoutrrr_url("gotify://192.168.178.222:9090?token=AtE2tUGQig67b0J&disabletls=yes")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result.webhook_url,
|
||||||
|
"http://192.168.178.222:9090/message?token=AtE2tUGQig67b0J"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_gotify_disabletls_switches_to_http() {
|
||||||
|
let result =
|
||||||
|
parse_shoutrrr_url("gotify://10.0.0.1:8080/TOKEN123?disabletls=yes").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result.webhook_url,
|
||||||
|
"http://10.0.0.1:8080/message?token=TOKEN123"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_gotify_plus_http_scheme() {
|
||||||
|
let result = parse_shoutrrr_url("gotify+http://10.0.0.1:8080/TOKEN").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result.webhook_url,
|
||||||
|
"http://10.0.0.1:8080/message?token=TOKEN"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_gotify_missing_token_errors() {
|
||||||
|
assert!(parse_shoutrrr_url("gotify://myhost.com/").is_err());
|
||||||
|
assert!(parse_shoutrrr_url("gotify://myhost.com").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_generic() {
|
fn test_parse_generic() {
|
||||||
let result = parse_shoutrrr_url("generic://example.com/webhook").unwrap();
|
let result = parse_shoutrrr_url("generic://example.com/webhook").unwrap();
|
||||||
@@ -780,12 +914,42 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_pushover_shoutrrr_canonical_form() {
|
||||||
|
// Shoutrrr's canonical URL has a literal "shoutrrr:" username.
|
||||||
|
// Issue #258: parser must strip this prefix or Pushover rejects the token.
|
||||||
|
let result =
|
||||||
|
parse_shoutrrr_url("pushover://shoutrrr:apitoken@userkey").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result.webhook_url,
|
||||||
|
"https://api.pushover.net/1/messages.json?token=apitoken&user=userkey"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_pushover_strips_query_params() {
|
||||||
|
// Optional shoutrrr query params (devices, priority) should not break parsing.
|
||||||
|
let result =
|
||||||
|
parse_shoutrrr_url("pushover://shoutrrr:tok@user/?devices=phone&priority=1")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result.webhook_url,
|
||||||
|
"https://api.pushover.net/1/messages.json?token=tok&user=user"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_pushover_invalid() {
|
fn test_parse_pushover_invalid() {
|
||||||
let result = parse_shoutrrr_url("pushover://noatsign");
|
let result = parse_shoutrrr_url("pushover://noatsign");
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_pushover_empty_token_errors() {
|
||||||
|
assert!(parse_shoutrrr_url("pushover://shoutrrr:@user").is_err());
|
||||||
|
assert!(parse_shoutrrr_url("pushover://tok@").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_plain_https_url() {
|
fn test_parse_plain_https_url() {
|
||||||
let result =
|
let result =
|
||||||
|
|||||||
Reference in New Issue
Block a user