diff --git a/README.md b/README.md index 3489ef7..cbb65cc 100755 --- a/README.md +++ b/README.md @@ -368,6 +368,8 @@ Some ISP provided modems only allow port forwarding over IPv4 or IPv6. Disable t | `aaaa` | bool | `true` | Enable IPv6 (AAAA record) updates | | `purgeUnknownRecords` | bool | `false` | Delete stale/duplicate DNS records | | `ttl` | int | `300` | DNS record TTL in seconds (30-86400, values < 30 become auto) | +| `ip4_provider` | string | `"cloudflare.trace"` | IPv4 detection provider (same values as `IP4_PROVIDER` env var) | +| `ip6_provider` | string | `"cloudflare.trace"` | IPv6 detection provider (same values as `IP6_PROVIDER` env var) | ### 🚫 Cloudflare IP Rejection (Legacy Mode) @@ -388,12 +390,20 @@ volumes: ### 🔍 IP Detection (Legacy Mode) -Legacy mode uses [Cloudflare's `/cdn-cgi/trace`](https://www.cloudflare.com/cdn-cgi/trace) endpoint for IP detection. To ensure the correct address family is detected on dual-stack hosts: +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. -- **Primary:** Literal IP URLs (`1.0.0.1` for IPv4, `[2606:4700:4700::1001]` for IPv6) — guarantees the connection uses the correct address family -- **Fallback:** Hostname URL (`api.cloudflare.com`) — works when literal IPs are intercepted (e.g. Cloudflare WARP or Zero Trust) +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:`, `url:`, `none`. -Each address family uses a dedicated HTTP client bound to the correct local address (`0.0.0.0` for IPv4, `[::]` for IPv6), preventing the wrong address type from being returned on dual-stack networks. +Set a provider to `"none"` to disable detection for that address family (overrides `a`/`aaaa`): + +```json +{ + "a": true, + "aaaa": true, + "ip4_provider": "cloudflare.trace", + "ip6_provider": "none" +} +``` Each zone entry contains: diff --git a/config-example.json b/config-example.json index 38f1097..f239342 100755 --- a/config-example.json +++ b/config-example.json @@ -24,5 +24,7 @@ "a": true, "aaaa": true, "purgeUnknownRecords": false, - "ttl": 300 + "ttl": 300, + "ip4_provider": "cloudflare.trace", + "ip6_provider": "cloudflare.trace" } diff --git a/src/config.rs b/src/config.rs index 9eb285f..3eba8ba 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,6 +27,10 @@ pub struct LegacyConfig { pub purge_unknown_records: bool, #[serde(default = "default_ttl")] pub ttl: i64, + #[serde(default)] + pub ip4_provider: Option, + #[serde(default)] + pub ip6_provider: Option, } fn default_true() -> bool { @@ -387,7 +391,7 @@ pub fn parse_legacy_config(content: &str) -> Result { } /// Convert a legacy config into a unified AppConfig -fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> AppConfig { +fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Result { // Extract auth from first entry let auth = if let Some(entry) = legacy.cloudflare.first() { if !entry.authentication.api_token.is_empty() @@ -406,13 +410,27 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Ap Auth::Token(String::new()) }; - // Build providers + // Build providers — ip4_provider/ip6_provider override the default cloudflare.trace let mut providers = HashMap::new(); if legacy.a { - providers.insert(IpType::V4, ProviderType::CloudflareTrace { url: None }); + let provider = match &legacy.ip4_provider { + Some(s) => ProviderType::parse(s) + .map_err(|e| format!("Invalid ip4_provider in config.json: {e}"))?, + None => ProviderType::CloudflareTrace { url: None }, + }; + if !matches!(provider, ProviderType::None) { + providers.insert(IpType::V4, provider); + } } if legacy.aaaa { - providers.insert(IpType::V6, ProviderType::CloudflareTrace { url: None }); + let provider = match &legacy.ip6_provider { + Some(s) => ProviderType::parse(s) + .map_err(|e| format!("Invalid ip6_provider in config.json: {e}"))?, + None => ProviderType::CloudflareTrace { url: None }, + }; + if !matches!(provider, ProviderType::None) { + providers.insert(IpType::V6, provider); + } } let ttl = TTL::new(legacy.ttl); @@ -423,7 +441,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Ap CronSchedule::Once }; - AppConfig { + Ok(AppConfig { auth, providers, domains: HashMap::new(), @@ -447,7 +465,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Ap legacy_mode: true, legacy_config: Some(legacy), repeat, - } + }) } // ============================================================ @@ -583,7 +601,7 @@ pub fn load_config(dry_run: bool, repeat: bool, ppfmt: &PP) -> Result bool { let legacy = match &config.legacy_config { Some(l) => l, @@ -243,38 +248,56 @@ async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool { let ddns = LegacyDdnsClient { client, cf_api_base: "https://api.cloudflare.com/client/v4".to_string(), - // Literal IPs primary (guarantees correct address family on dual-stack hosts), - // hostname fallback (works when literal IPs are intercepted by WARP/Zero Trust). - ipv4_urls: vec![ - "https://1.0.0.1/cdn-cgi/trace".to_string(), - "https://api.cloudflare.com/cdn-cgi/trace".to_string(), - ], - ipv6_urls: vec![ - "https://[2606:4700:4700::1001]/cdn-cgi/trace".to_string(), - "https://api.cloudflare.com/cdn-cgi/trace".to_string(), - ], - detection_timeout: config.detection_timeout, dry_run: config.dry_run, }; - let mut warnings = LegacyWarningState::default(); + // Detect IPs using the shared provider abstraction + let detection_client = Client::builder() + .timeout(config.detection_timeout) + .build() + .unwrap_or_default(); - let mut ips = ddns - .get_ips( - legacy.a, - legacy.aaaa, - legacy.purge_unknown_records, - &legacy.cloudflare, - &mut warnings, - ) - .await; + let mut ips = HashMap::new(); + + for (ip_type, provider) in &config.providers { + ppfmt.infof( + pp::EMOJI_DETECT, + &format!("Detecting {} via {}", ip_type.describe(), provider.name()), + ); + let detected = provider + .detect_ips(&detection_client, *ip_type, config.detection_timeout, ppfmt) + .await; + + if detected.is_empty() { + ppfmt.warningf( + pp::EMOJI_WARNING, + &format!("No {} address detected", ip_type.describe()), + ); + if legacy.purge_unknown_records { + ddns.delete_entries(ip_type.record_type(), &legacy.cloudflare) + .await; + } + } else { + let key = match ip_type { + IpType::V4 => "ipv4", + IpType::V6 => "ipv6", + }; + ppfmt.infof( + pp::EMOJI_DETECT, + &format!("Detected {}: {}", ip_type.describe(), detected[0]), + ); + ips.insert( + key.to_string(), + LegacyIpInfo { + record_type: ip_type.record_type().to_string(), + ip: detected[0].to_string(), + }, + ); + } + } // Filter out Cloudflare IPs if enabled if config.reject_cloudflare_ips { - let detection_client = Client::builder() - .timeout(config.detection_timeout) - .build() - .unwrap_or_default(); if let Some(cf_filter) = CloudflareIpFilter::fetch(&detection_client, config.detection_timeout, ppfmt).await { @@ -358,162 +381,13 @@ pub struct LegacyIpInfo { pub ip: String, } -struct LegacyWarningState { - shown_ipv4: bool, - shown_ipv4_secondary: bool, - shown_ipv6: bool, - shown_ipv6_secondary: bool, -} - -impl Default for LegacyWarningState { - fn default() -> Self { - Self { - shown_ipv4: false, - shown_ipv4_secondary: false, - shown_ipv6: false, - shown_ipv6_secondary: false, - } - } -} - struct LegacyDdnsClient { client: Client, cf_api_base: String, - ipv4_urls: Vec, - ipv6_urls: Vec, - detection_timeout: Duration, dry_run: bool, } -/// Return a Host header override for literal-IP trace URLs so TLS SNI works. -fn legacy_host_override(url: &str) -> Option<&'static str> { - if url.contains("1.0.0.1") || url.contains("2606:4700:4700::1001") { - Some("one.one.one.one") - } else { - None - } -} - impl LegacyDdnsClient { - async fn get_ips( - &self, - ipv4_enabled: bool, - ipv6_enabled: bool, - purge_unknown_records: bool, - config: &[LegacyCloudflareEntry], - warnings: &mut LegacyWarningState, - ) -> HashMap { - let mut ips = HashMap::new(); - - if ipv4_enabled { - // Use an IPv4-bound client so the trace endpoint sees the correct address family. - let v4_client = crate::provider::build_split_client(IpType::V4, self.detection_timeout); - let a = self - .try_trace_urls( - &v4_client, - &self.ipv4_urls, - &mut warnings.shown_ipv4, - &mut warnings.shown_ipv4_secondary, - "IPv4", - true, - ) - .await; - if a.is_none() && purge_unknown_records { - self.delete_entries("A", config).await; - } - if let Some(ip) = a { - ips.insert( - "ipv4".to_string(), - LegacyIpInfo { - record_type: "A".to_string(), - ip, - }, - ); - } - } - - if ipv6_enabled { - // Use an IPv6-bound client so the trace endpoint sees the correct address family. - let v6_client = crate::provider::build_split_client(IpType::V6, self.detection_timeout); - let aaaa = self - .try_trace_urls( - &v6_client, - &self.ipv6_urls, - &mut warnings.shown_ipv6, - &mut warnings.shown_ipv6_secondary, - "IPv6", - false, - ) - .await; - if aaaa.is_none() && purge_unknown_records { - self.delete_entries("AAAA", config).await; - } - if let Some(ip) = aaaa { - ips.insert( - "ipv6".to_string(), - LegacyIpInfo { - record_type: "AAAA".to_string(), - ip, - }, - ); - } - } - - ips - } - - async fn try_trace_urls( - &self, - trace_client: &Client, - urls: &[String], - shown_primary: &mut bool, - shown_secondary: &mut bool, - label: &str, - expect_v4: bool, - ) -> Option { - for (i, url) in urls.iter().enumerate() { - let mut req = trace_client.get(url); - if let Some(host) = legacy_host_override(url) { - req = req.header("Host", host); - } - match req.send().await { - Ok(resp) => { - if let Some(ip) = - crate::provider::parse_trace_ip(&resp.text().await.unwrap_or_default()) - { - // Validate the IP matches the expected address family - if let Ok(addr) = ip.parse::() { - if expect_v4 && !addr.is_ipv4() { - eprintln!("{label} trace returned IPv6 address, skipping"); - continue; - } - if !expect_v4 && !addr.is_ipv6() { - eprintln!("{label} trace returned IPv4 address, skipping"); - continue; - } - } - return Some(ip); - } - } - Err(_) => { - if i == 0 && !*shown_primary { - *shown_primary = true; - let next = if urls.len() > 1 { - ", trying fallback" - } else { - "" - }; - eprintln!("{label} not detected via primary{next}"); - } else if i > 0 && !*shown_secondary { - *shown_secondary = true; - eprintln!("{label} not detected via fallback. Verify your ISP or DNS provider isn't blocking Cloudflare's IPs."); - } - } - } - } - None - } - async fn cf_api( &self, endpoint: &str, @@ -1757,89 +1631,6 @@ mod tests { // LegacyDdnsClient tests (internal/private struct) // ------------------------------------------------------- - #[tokio::test] - async fn test_legacy_try_trace_urls_primary_success() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/trace")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string("fl=1\nh=mock\nip=198.51.100.1\nts=0\n"), - ) - .mount(&server) - .await; - - let ddns = LegacyDdnsClient { - client: Client::new(), - cf_api_base: server.uri(), - ipv4_urls: vec![format!("{}/trace", server.uri())], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), - dry_run: false, - }; - let mut shown_primary = false; - let mut shown_secondary = false; - let result = ddns - .try_trace_urls(&ddns.client, &ddns.ipv4_urls, &mut shown_primary, &mut shown_secondary, "IPv4", true) - .await; - assert_eq!(result, Some("198.51.100.1".to_string())); - } - - #[tokio::test] - async fn test_legacy_try_trace_urls_primary_fails_fallback_succeeds() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/fallback")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string("fl=1\nh=mock\nip=198.51.100.2\nts=0\n"), - ) - .mount(&server) - .await; - - let ddns = LegacyDdnsClient { - client: Client::new(), - cf_api_base: server.uri(), - ipv4_urls: vec![ - "http://127.0.0.1:1/nonexistent".to_string(), // will fail - format!("{}/fallback", server.uri()), - ], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), - dry_run: false, - }; - let mut shown_primary = false; - let mut shown_secondary = false; - let result = ddns - .try_trace_urls(&ddns.client, &ddns.ipv4_urls, &mut shown_primary, &mut shown_secondary, "IPv4", true) - .await; - assert_eq!(result, Some("198.51.100.2".to_string())); - assert!(shown_primary); - } - - #[tokio::test] - async fn test_legacy_try_trace_urls_all_fail() { - let ddns = LegacyDdnsClient { - client: Client::builder().timeout(Duration::from_millis(100)).build().unwrap(), - cf_api_base: String::new(), - ipv4_urls: vec![ - "http://127.0.0.1:1/fail1".to_string(), - "http://127.0.0.1:1/fail2".to_string(), - ], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), - dry_run: false, - }; - let mut shown_primary = false; - let mut shown_secondary = false; - let result = ddns - .try_trace_urls(&ddns.client, &ddns.ipv4_urls, &mut shown_primary, &mut shown_secondary, "IPv4", true) - .await; - assert!(result.is_none()); - assert!(shown_primary); - assert!(shown_secondary); - } - #[tokio::test] async fn test_legacy_cf_api_get_success() { let server = MockServer::start().await; @@ -1854,9 +1645,6 @@ mod tests { let ddns = LegacyDdnsClient { client: Client::new(), cf_api_base: server.uri(), - ipv4_urls: vec![], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), dry_run: false, }; let entry = crate::config::LegacyCloudflareEntry { @@ -1889,9 +1677,6 @@ mod tests { let ddns = LegacyDdnsClient { client: Client::new(), cf_api_base: server.uri(), - ipv4_urls: vec![], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), dry_run: false, }; let entry = crate::config::LegacyCloudflareEntry { @@ -1921,9 +1706,6 @@ mod tests { let ddns = LegacyDdnsClient { client: Client::new(), cf_api_base: server.uri(), - ipv4_urls: vec![], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), dry_run: false, }; let entry = crate::config::LegacyCloudflareEntry { @@ -1946,9 +1728,6 @@ mod tests { let ddns = LegacyDdnsClient { client: Client::new(), cf_api_base: "http://localhost".to_string(), - ipv4_urls: vec![], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), dry_run: false, }; let entry = crate::config::LegacyCloudflareEntry { @@ -1979,9 +1758,6 @@ mod tests { let ddns = LegacyDdnsClient { client: Client::new(), cf_api_base: server.uri(), - ipv4_urls: vec![], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), dry_run: false, }; let entry = crate::config::LegacyCloudflareEntry { @@ -2002,80 +1778,6 @@ mod tests { assert!(result.is_some()); } - #[tokio::test] - async fn test_legacy_get_ips_ipv4_enabled() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/trace")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string("ip=198.51.100.42\n"), - ) - .mount(&server) - .await; - - let ddns = LegacyDdnsClient { - client: Client::new(), - cf_api_base: server.uri(), - ipv4_urls: vec![format!("{}/trace", server.uri())], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), - dry_run: false, - }; - let mut warnings = LegacyWarningState::default(); - let config: Vec = vec![]; - let ips = ddns.get_ips(true, false, false, &config, &mut warnings).await; - assert!(ips.contains_key("ipv4")); - assert_eq!(ips["ipv4"].ip, "198.51.100.42"); - assert_eq!(ips["ipv4"].record_type, "A"); - } - - #[tokio::test] - async fn test_legacy_get_ips_ipv6_enabled() { - // Test the IPv6 trace URL parsing via try_trace_urls directly, since - // get_ips creates an IPv6-bound client that can't reach IPv4-only mock servers. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/trace6")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string("ip=2001:db8::1\n"), - ) - .mount(&server) - .await; - - let ddns = LegacyDdnsClient { - client: Client::new(), - cf_api_base: server.uri(), - ipv4_urls: vec![], - ipv6_urls: vec![format!("{}/trace6", server.uri())], - detection_timeout: Duration::from_secs(5), - dry_run: false, - }; - let mut shown_primary = false; - let mut shown_secondary = false; - let result = ddns - .try_trace_urls(&ddns.client, &ddns.ipv6_urls, &mut shown_primary, &mut shown_secondary, "IPv6", false) - .await; - assert_eq!(result, Some("2001:db8::1".to_string())); - } - - #[tokio::test] - async fn test_legacy_get_ips_both_disabled() { - let ddns = LegacyDdnsClient { - client: Client::new(), - cf_api_base: String::new(), - ipv4_urls: vec![], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), - dry_run: false, - }; - let mut warnings = LegacyWarningState::default(); - let config: Vec = vec![]; - let ips = ddns.get_ips(false, false, false, &config, &mut warnings).await; - assert!(ips.is_empty()); - } - #[tokio::test] async fn test_legacy_commit_record_creates_new() { let server = MockServer::start().await; @@ -2112,9 +1814,6 @@ mod tests { let ddns = LegacyDdnsClient { client: Client::new(), cf_api_base: server.uri(), - ipv4_urls: vec![], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), dry_run: false, }; let ip = LegacyIpInfo { @@ -2171,9 +1870,6 @@ mod tests { let ddns = LegacyDdnsClient { client: Client::new(), cf_api_base: server.uri(), - ipv4_urls: vec![], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), dry_run: false, }; let ip = LegacyIpInfo { @@ -2216,9 +1912,6 @@ mod tests { let ddns = LegacyDdnsClient { client: Client::new(), cf_api_base: server.uri(), - ipv4_urls: vec![], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), dry_run: true, }; let ip = LegacyIpInfo { @@ -2270,9 +1963,6 @@ mod tests { let ddns = LegacyDdnsClient { client: Client::new(), cf_api_base: server.uri(), - ipv4_urls: vec![], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), dry_run: false, }; let ip = LegacyIpInfo { @@ -2328,9 +2018,6 @@ mod tests { let ddns = LegacyDdnsClient { client: Client::new(), cf_api_base: server.uri(), - ipv4_urls: vec![], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), dry_run: false, }; let ip = LegacyIpInfo { @@ -2380,9 +2067,6 @@ mod tests { let ddns = LegacyDdnsClient { client: Client::new(), cf_api_base: server.uri(), - ipv4_urls: vec![], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), dry_run: false, }; let mut ips = HashMap::new(); @@ -2427,9 +2111,6 @@ mod tests { let ddns = LegacyDdnsClient { client: Client::new(), cf_api_base: server.uri(), - ipv4_urls: vec![], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), dry_run: false, }; let config = vec![crate::config::LegacyCloudflareEntry { @@ -2462,9 +2143,6 @@ mod tests { let ddns = LegacyDdnsClient { client: Client::new(), cf_api_base: server.uri(), - ipv4_urls: vec![], - ipv6_urls: vec![], - detection_timeout: Duration::from_secs(5), dry_run: true, }; let config = vec![crate::config::LegacyCloudflareEntry { @@ -2480,14 +2158,6 @@ mod tests { ddns.delete_entries("A", &config).await; } - #[test] - fn test_legacy_warning_state_default() { - let w = LegacyWarningState::default(); - assert!(!w.shown_ipv4); - assert!(!w.shown_ipv4_secondary); - assert!(!w.shown_ipv6); - assert!(!w.shown_ipv6_secondary); - } } // Legacy types for backwards compatibility