mirror of
https://github.com/timothymiller/cloudflare-ddns.git
synced 2026-06-20 20:35:38 -03:00
Compare commits
7 Commits
v2.1.1
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20dbb9495f | ||
|
|
bbe2ae4543 | ||
|
|
572f94b9cf | ||
|
|
9574f67b98 | ||
|
|
ac11623127 | ||
|
|
fddabc7a3d | ||
|
|
548d89dacf |
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -92,7 +92,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cloudflare-ddns"
|
||||
version = "2.1.1"
|
||||
version = "2.1.2"
|
||||
dependencies = [
|
||||
"if-addrs",
|
||||
"rand",
|
||||
@@ -882,9 +882,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.3"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
@@ -1257,9 +1257,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.1"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cloudflare-ddns"
|
||||
version = "2.1.1"
|
||||
version = "2.1.2"
|
||||
edition = "2021"
|
||||
description = "Access your home network remotely via a custom domain name without a static IP"
|
||||
license = "GPL-3.0"
|
||||
|
||||
@@ -84,6 +84,7 @@ Available providers:
|
||||
| `ipify` | 🌎 ipify.org API |
|
||||
| `local` | 🏠 Local IP via system routing table (no network traffic, CGNAT-aware) |
|
||||
| `local.iface:<name>` | 🔌 IP from a specific network interface (e.g., `local.iface:eth0`) |
|
||||
| `local.iface.stable:<name>` | 🔌 Preferred stable IPv6 address from a Linux network interface, excluding temporary/deprecated addresses |
|
||||
| `url:<url>` | 🔗 Custom HTTP(S) endpoint that returns an IP address |
|
||||
| `literal:<ips>` | 📌 Static IP addresses (comma-separated) |
|
||||
| `none` | 🚫 Disable this IP type |
|
||||
@@ -411,7 +412,7 @@ volumes:
|
||||
|
||||
Legacy mode now uses the same shared provider abstraction as environment variable mode. By default it uses the `cloudflare.trace` provider, which builds an IP-family-bound HTTP client (`0.0.0.0` for IPv4, `[::]` for IPv6) to guarantee the correct address family on dual-stack hosts.
|
||||
|
||||
You can override the detection method per address family with `ip4_provider` and `ip6_provider` in your `config.json`. Supported values are the same as the `IP4_PROVIDER` / `IP6_PROVIDER` environment variables: `cloudflare.trace`, `cloudflare.doh`, `ipify`, `local`, `local.iface:<name>`, `url:<https://...>`, `none`.
|
||||
You can override the detection method per address family with `ip4_provider` and `ip6_provider` in your `config.json`. Supported values are the same as the `IP4_PROVIDER` / `IP6_PROVIDER` environment variables: `cloudflare.trace`, `cloudflare.doh`, `ipify`, `local`, `local.iface:<name>`, `local.iface.stable:<name>`, `url:<https://...>`, `none`.
|
||||
|
||||
Set a provider to `"none"` to disable detection for that address family (overrides `a`/`aaaa`):
|
||||
|
||||
|
||||
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.
|
||||
@@ -19,10 +19,12 @@ DOMAINS=example.com,www.example.com
|
||||
|
||||
# Provider for IPv4 detection (default: cloudflare.trace)
|
||||
# Options: cloudflare.trace, cloudflare.doh, ipify, local, local.iface:<name>,
|
||||
# url:<custom-url>, literal:<ip1>,<ip2>, none
|
||||
# local.iface.stable:<name>, url:<custom-url>, literal:<ip1>,<ip2>, none
|
||||
# IP4_PROVIDER=cloudflare.trace
|
||||
|
||||
# Provider for IPv6 detection (default: cloudflare.trace)
|
||||
# Use local.iface.stable:<name> on Linux to publish a stable address instead
|
||||
# of temporary privacy addresses from the selected interface.
|
||||
# IP6_PROVIDER=cloudflare.trace
|
||||
|
||||
# === Scheduling ===
|
||||
|
||||
@@ -280,8 +280,16 @@ impl CloudflareHandle {
|
||||
name: &str,
|
||||
ppfmt: &PP,
|
||||
) -> 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;
|
||||
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 {
|
||||
@@ -926,6 +934,29 @@ mod tests {
|
||||
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]
|
||||
async fn list_records_by_name_filters() {
|
||||
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> {
|
||||
// Shoutrrr URL formats:
|
||||
// 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}"));
|
||||
}
|
||||
|
||||
if let Some(rest) = url_str
|
||||
.strip_prefix("gotify://")
|
||||
.or_else(|| url_str.strip_prefix("gotify+https://"))
|
||||
if let Some((rest, default_scheme)) = url_str
|
||||
.strip_prefix("gotify+https://")
|
||||
.map(|r| (r, "https"))
|
||||
.or_else(|| url_str.strip_prefix("gotify+http://").map(|r| (r, "http")))
|
||||
.or_else(|| url_str.strip_prefix("gotify://").map(|r| (r, "https")))
|
||||
{
|
||||
return Ok(ShoutrrrService {
|
||||
original_url: url_str.to_string(),
|
||||
service_type: ShoutrrrServiceType::Gotify,
|
||||
webhook_url: format!("https://{rest}/message"),
|
||||
});
|
||||
return parse_gotify_url(url_str, rest, default_scheme);
|
||||
}
|
||||
|
||||
if let Some(rest) = url_str
|
||||
@@ -365,14 +447,28 @@ fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
|
||||
}
|
||||
|
||||
if let Some(rest) = url_str.strip_prefix("pushover://") {
|
||||
let parts: Vec<&str> = rest.splitn(2, '@').collect();
|
||||
// Strip query string (devices, priority, title) — not yet supported.
|
||||
let body = rest.split('?').next().unwrap_or(rest).trim_end_matches('/');
|
||||
let parts: Vec<&str> = body.splitn(2, '@').collect();
|
||||
if parts.len() == 2 {
|
||||
// Shoutrrr's canonical pushover URL is
|
||||
// pushover://shoutrrr:APIToken@UserKey
|
||||
// where the literal "shoutrrr:" username is required. Strip an
|
||||
// optional "<user>:" prefix from the token portion so both the
|
||||
// canonical form and the bare "pushover://TOKEN@USER" form work.
|
||||
let token = parts[0]
|
||||
.rsplit_once(':')
|
||||
.map(|(_, t)| t)
|
||||
.unwrap_or(parts[0]);
|
||||
let user = parts[1];
|
||||
if token.is_empty() || user.is_empty() {
|
||||
return Err(format!("Invalid Pushover shoutrrr URL: {url_str}"));
|
||||
}
|
||||
return Ok(ShoutrrrService {
|
||||
original_url: url_str.to_string(),
|
||||
service_type: ShoutrrrServiceType::Pushover,
|
||||
webhook_url: format!(
|
||||
"https://api.pushover.net/1/messages.json?token={}&user={}",
|
||||
parts[0], parts[1]
|
||||
"https://api.pushover.net/1/messages.json?token={token}&user={user}"
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -735,15 +831,53 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gotify() {
|
||||
let result = parse_shoutrrr_url("gotify://myhost.com/somepath").unwrap();
|
||||
fn test_parse_gotify_token_as_path_segment() {
|
||||
// Shoutrrr canonical format: token is the final path segment.
|
||||
let result = parse_shoutrrr_url("gotify://myhost.com/MYTOKEN").unwrap();
|
||||
assert_eq!(
|
||||
result.webhook_url,
|
||||
"https://myhost.com/somepath/message"
|
||||
"https://myhost.com/message?token=MYTOKEN"
|
||||
);
|
||||
assert!(matches!(result.service_type, ShoutrrrServiceType::Gotify));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gotify_token_query_param() {
|
||||
// Older "gotify://host?token=..." form (issue #262).
|
||||
let result =
|
||||
parse_shoutrrr_url("gotify://192.168.178.222:9090?token=AtE2tUGQig67b0J&disabletls=yes")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.webhook_url,
|
||||
"http://192.168.178.222:9090/message?token=AtE2tUGQig67b0J"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gotify_disabletls_switches_to_http() {
|
||||
let result =
|
||||
parse_shoutrrr_url("gotify://10.0.0.1:8080/TOKEN123?disabletls=yes").unwrap();
|
||||
assert_eq!(
|
||||
result.webhook_url,
|
||||
"http://10.0.0.1:8080/message?token=TOKEN123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gotify_plus_http_scheme() {
|
||||
let result = parse_shoutrrr_url("gotify+http://10.0.0.1:8080/TOKEN").unwrap();
|
||||
assert_eq!(
|
||||
result.webhook_url,
|
||||
"http://10.0.0.1:8080/message?token=TOKEN"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gotify_missing_token_errors() {
|
||||
assert!(parse_shoutrrr_url("gotify://myhost.com/").is_err());
|
||||
assert!(parse_shoutrrr_url("gotify://myhost.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_generic() {
|
||||
let result = parse_shoutrrr_url("generic://example.com/webhook").unwrap();
|
||||
@@ -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]
|
||||
fn test_parse_pushover_invalid() {
|
||||
let result = parse_shoutrrr_url("pushover://noatsign");
|
||||
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]
|
||||
fn test_parse_plain_https_url() {
|
||||
let result =
|
||||
|
||||
167
src/provider.rs
167
src/provider.rs
@@ -1,6 +1,7 @@
|
||||
use crate::pp::{self, PP};
|
||||
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||
use reqwest::Client;
|
||||
use std::fs;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -36,6 +37,7 @@ pub enum ProviderType {
|
||||
Ipify,
|
||||
Local,
|
||||
LocalIface { interface: String },
|
||||
StableLocalIface { interface: String },
|
||||
CustomURL { url: String },
|
||||
Literal { ips: Vec<IpAddr> },
|
||||
None,
|
||||
@@ -49,6 +51,7 @@ impl ProviderType {
|
||||
ProviderType::Ipify => "ipify",
|
||||
ProviderType::Local => "local",
|
||||
ProviderType::LocalIface { .. } => "local.iface",
|
||||
ProviderType::StableLocalIface { .. } => "local.iface.stable",
|
||||
ProviderType::CustomURL { .. } => "url:",
|
||||
ProviderType::Literal { .. } => "literal:",
|
||||
ProviderType::None => "none",
|
||||
@@ -78,6 +81,11 @@ impl ProviderType {
|
||||
if input == "local" {
|
||||
return Ok(ProviderType::Local);
|
||||
}
|
||||
if let Some(iface) = input.strip_prefix("local.iface.stable:") {
|
||||
return Ok(ProviderType::StableLocalIface {
|
||||
interface: iface.to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(iface) = input.strip_prefix("local.iface:") {
|
||||
return Ok(ProviderType::LocalIface {
|
||||
interface: iface.to_string(),
|
||||
@@ -131,6 +139,9 @@ impl ProviderType {
|
||||
ProviderType::LocalIface { interface } => {
|
||||
detect_local_iface(interface, ip_type, ppfmt)
|
||||
}
|
||||
ProviderType::StableLocalIface { interface } => {
|
||||
detect_stable_local_iface(interface, ip_type, ppfmt)
|
||||
}
|
||||
ProviderType::CustomURL { url } => {
|
||||
detect_custom_url(client, url, ip_type, timeout, ppfmt).await
|
||||
}
|
||||
@@ -525,6 +536,105 @@ fn detect_local_iface(interface: &str, ip_type: IpType, ppfmt: &PP) -> Vec<IpAdd
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stable Local Interface ---
|
||||
|
||||
const IF_INET6_PATH: &str = "/proc/net/if_inet6";
|
||||
const IFA_F_TEMPORARY: u32 = 0x01;
|
||||
const IFA_F_DADFAILED: u32 = 0x08;
|
||||
const IFA_F_DEPRECATED: u32 = 0x20;
|
||||
const IFA_F_TENTATIVE: u32 = 0x40;
|
||||
const IPV6_SCOPE_GLOBAL: u8 = 0x00;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct IfInet6Address {
|
||||
ip: Ipv6Addr,
|
||||
prefix_len: u8,
|
||||
scope: u8,
|
||||
flags: u32,
|
||||
interface: String,
|
||||
}
|
||||
|
||||
fn detect_stable_local_iface(interface: &str, ip_type: IpType, ppfmt: &PP) -> Vec<IpAddr> {
|
||||
if ip_type == IpType::V4 {
|
||||
return detect_local_iface(interface, ip_type, ppfmt);
|
||||
}
|
||||
|
||||
let contents = match fs::read_to_string(IF_INET6_PATH) {
|
||||
Ok(contents) => contents,
|
||||
Err(e) => {
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
&format!("Failed to read {IF_INET6_PATH} for stable IPv6 detection: {e}"),
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let ip = stable_ipv6_addresses_from_if_inet6(&contents, interface)
|
||||
.into_iter()
|
||||
.next();
|
||||
if ip.is_none() {
|
||||
ppfmt.warningf(
|
||||
pp::EMOJI_WARNING,
|
||||
&format!("No stable global IPv6 address found on interface {interface}"),
|
||||
);
|
||||
}
|
||||
ip.into_iter().map(IpAddr::V6).collect()
|
||||
}
|
||||
|
||||
fn stable_ipv6_addresses_from_if_inet6(contents: &str, interface: &str) -> Vec<Ipv6Addr> {
|
||||
let mut entries: Vec<IfInet6Address> = contents
|
||||
.lines()
|
||||
.filter_map(parse_if_inet6_line)
|
||||
.filter(|addr| addr.interface == interface && is_stable_global_ipv6(addr))
|
||||
.collect();
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
a.prefix_len
|
||||
.cmp(&b.prefix_len)
|
||||
.then_with(|| a.ip.to_string().cmp(&b.ip.to_string()))
|
||||
});
|
||||
|
||||
let mut ips: Vec<Ipv6Addr> = entries.into_iter().map(|addr| addr.ip).collect();
|
||||
ips.dedup();
|
||||
ips
|
||||
}
|
||||
|
||||
fn is_stable_global_ipv6(addr: &IfInet6Address) -> bool {
|
||||
addr.scope == IPV6_SCOPE_GLOBAL
|
||||
&& IpAddr::V6(addr.ip).is_global_()
|
||||
&& addr.flags & (IFA_F_TEMPORARY | IFA_F_DADFAILED | IFA_F_DEPRECATED | IFA_F_TENTATIVE)
|
||||
== 0
|
||||
}
|
||||
|
||||
fn parse_if_inet6_line(line: &str) -> Option<IfInet6Address> {
|
||||
let mut fields = line.split_whitespace();
|
||||
let addr_hex = fields.next()?;
|
||||
let _ifindex = fields.next()?;
|
||||
let prefix_hex = fields.next()?;
|
||||
let scope_hex = fields.next()?;
|
||||
let flags_hex = fields.next()?;
|
||||
let interface = fields.next()?.to_string();
|
||||
|
||||
if addr_hex.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut octets = [0_u8; 16];
|
||||
for (index, octet) in octets.iter_mut().enumerate() {
|
||||
let start = index * 2;
|
||||
*octet = u8::from_str_radix(&addr_hex[start..start + 2], 16).ok()?;
|
||||
}
|
||||
|
||||
Some(IfInet6Address {
|
||||
ip: Ipv6Addr::from(octets),
|
||||
prefix_len: u8::from_str_radix(prefix_hex, 16).ok()?,
|
||||
scope: u8::from_str_radix(scope_hex, 16).ok()?,
|
||||
flags: u32::from_str_radix(flags_hex, 16).ok()?,
|
||||
interface,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Custom URL ---
|
||||
|
||||
async fn detect_custom_url(
|
||||
@@ -695,6 +805,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_parse_stable_local_iface() {
|
||||
match ProviderType::parse("local.iface.stable:eth0").unwrap() {
|
||||
ProviderType::StableLocalIface { interface } => {
|
||||
assert_eq!(interface, "eth0");
|
||||
}
|
||||
_ => panic!("Expected StableLocalIface provider"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_parse_custom_url() {
|
||||
match ProviderType::parse("url:https://example.com/ip").unwrap() {
|
||||
@@ -1286,6 +1406,49 @@ mod tests {
|
||||
assert!(is_global_v6(&Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_if_inet6_line() {
|
||||
let addr = parse_if_inet6_line(
|
||||
"20010db8000000011111222233334444 03 40 00 00 eth0",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
addr.ip,
|
||||
"2001:db8:0:1:1111:2222:3333:4444"
|
||||
.parse::<Ipv6Addr>()
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(addr.prefix_len, 64);
|
||||
assert_eq!(addr.scope, IPV6_SCOPE_GLOBAL);
|
||||
assert_eq!(addr.flags, 0);
|
||||
assert_eq!(addr.interface, "eth0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stable_ipv6_addresses_from_if_inet6_filters_privacy_addresses() {
|
||||
let contents = "\
|
||||
20010db8000000015555666677778888 03 40 00 01 eth0
|
||||
20010db8000000010000000000003486 03 80 00 00 eth0
|
||||
20010db8000000011111222233334444 03 40 00 00 eth0
|
||||
20010db8000000019999aaaabbbbcccc 03 40 00 21 eth0
|
||||
fe80000000000000d399115858c872af 03 40 20 80 eth0
|
||||
fdaa149d3b9900000000000000000001 0a 40 00 82 br-990e55930a86
|
||||
";
|
||||
|
||||
let ips = stable_ipv6_addresses_from_if_inet6(contents, "eth0");
|
||||
|
||||
assert_eq!(
|
||||
ips,
|
||||
vec![
|
||||
"2001:db8:0:1:1111:2222:3333:4444"
|
||||
.parse::<Ipv6Addr>()
|
||||
.unwrap(),
|
||||
"2001:db8:0:1::3486".parse::<Ipv6Addr>().unwrap(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- ProviderType::name ----
|
||||
|
||||
#[test]
|
||||
@@ -1302,6 +1465,10 @@ mod tests {
|
||||
ProviderType::LocalIface { interface: "eth0".into() }.name(),
|
||||
"local.iface"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::StableLocalIface { interface: "eth0".into() }.name(),
|
||||
"local.iface.stable"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::CustomURL { url: "https://x".into() }.name(),
|
||||
"url:"
|
||||
|
||||
Reference in New Issue
Block a user