From e561fe3810da23a8072c4f3402ff3d21608c8ff0 Mon Sep 17 00:00:00 2001 From: Timothy Miller Date: Sun, 11 Oct 2026 00:38:40 -0400 Subject: [PATCH 1/5] Add interface-bound providers (@) HTTP providers (cloudflare.trace, cloudflare.doh, ipify, url:) accept an @ suffix that binds the detection request to that interface: SO_BINDTODEVICE on Linux/Android, IP_BOUND_IF on Apple/Solaris/illumos, and a source-address bind elsewhere. Part of #248. --- src/config.rs | 2 +- src/provider.rs | 287 +++++++++++++++++++++++++++++++++++++++++------- src/updater.rs | 4 +- 3 files changed, 253 insertions(+), 40 deletions(-) diff --git a/src/config.rs b/src/config.rs index 901d8f4..04400d8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -806,7 +806,7 @@ 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()), + &format!("{} provider: {}", ip_type.describe(), provider.label()), ); } diff --git a/src/provider.rs b/src/provider.rs index 925b973..e48c442 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -40,6 +40,12 @@ pub enum ProviderType { CustomURL { url: String }, Literal { ips: Vec }, None, + /// An HTTP-based provider whose requests egress through a specific network + /// interface, written as `@` (e.g. `ipify@eth1`). + Bound { + inner: Box, + interface: String, + }, } impl ProviderType { @@ -54,11 +60,54 @@ impl ProviderType { ProviderType::CustomURL { .. } => "url:", ProviderType::Literal { .. } => "literal:", ProviderType::None => "none", + ProviderType::Bound { inner, .. } => inner.name(), } } - /// Parse a provider string like "cloudflare.trace", "url:https://...", "literal:1.2.3.4" + /// Name plus interface binding, for logs (e.g. `cloudflare.trace@ens5`). + pub fn label(&self) -> String { + match self { + ProviderType::Bound { inner, interface } => format!("{}@{interface}", inner.name()), + other => other.name().to_string(), + } + } + + /// Whether this provider queries a remote HTTP endpoint (and so can be + /// bound to an interface). + fn is_http(&self) -> bool { + matches!( + self, + ProviderType::CloudflareTrace { .. } + | ProviderType::CloudflareDOH + | ProviderType::Ipify + | ProviderType::CustomURL { .. } + ) + } + + /// Parse a provider string like "cloudflare.trace", "url:https://...", "literal:1.2.3.4", + /// optionally bound to an interface with an `@` suffix ("ipify@eth1"). pub fn parse(input: &str) -> Result { + let input = input.trim(); + // Split on the last '@' only when the suffix looks like an interface + // name, so URLs containing '@' elsewhere keep working. + if let Some((base, iface)) = input.rsplit_once('@') { + if is_interface_name(iface) { + let inner = Self::parse_unbound(base)?; + if !inner.is_http() { + return Err(format!( + "Interface binding (@{iface}) only applies to cloudflare.trace, cloudflare.doh, ipify and url: providers; for local addresses use local.iface:{iface}" + )); + } + return Ok(ProviderType::Bound { + inner: Box::new(inner), + interface: iface.to_string(), + }); + } + } + Self::parse_unbound(input) + } + + fn parse_unbound(input: &str) -> Result { let input = input.trim(); if input.is_empty() || input == "none" { return Ok(ProviderType::None); @@ -146,19 +195,37 @@ impl ProviderType { ProviderType::CloudflareTrace { .. } | ProviderType::CloudflareDOH | ProviderType::Ipify - | ProviderType::CustomURL { .. } => DetectionOutcome::Failed, + | ProviderType::CustomURL { .. } + | ProviderType::Bound { .. } => DetectionOutcome::Failed, } } /// Detect IPs using this provider. pub async fn detect_ips( &self, - client: &Client, + _client: &Client, ip_type: IpType, timeout: Duration, ppfmt: &PP, ) -> Vec { - match self { + let (provider, interface) = match self { + ProviderType::Bound { inner, interface } => (inner.as_ref(), Some(interface.as_str())), + other => (other, None), + }; + if !provider.is_http() { + return provider.detect_local_ips(ip_type, ppfmt); + } + // HTTP detectors always use an address-family-pinned client so the + // endpoint sees the requested family, optionally bound to an interface. + let client = match build_detection_client(ip_type, timeout, interface) { + Ok(c) => c, + Err(e) => { + ppfmt.warningf(pp::EMOJI_WARNING, &e); + return Vec::new(); + } + }; + let client = &client; + match provider { ProviderType::CloudflareTrace { url } => { detect_cloudflare_trace(client, ip_type, timeout, url.as_deref(), ppfmt).await } @@ -166,20 +233,35 @@ impl ProviderType { detect_cloudflare_doh(client, ip_type, timeout, ppfmt).await } ProviderType::Ipify => detect_ipify(client, ip_type, timeout, ppfmt).await, + ProviderType::CustomURL { url } => { + detect_custom_url(client, url, ip_type, timeout, ppfmt).await + } + _ => Vec::new(), + } + } + + /// Detection for providers that don't touch the network. + fn detect_local_ips(&self, ip_type: IpType, ppfmt: &PP) -> Vec { + match self { ProviderType::Local => detect_local(ip_type, ppfmt), 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 - } ProviderType::Literal { ips } => filter_ips_by_type(ips, ip_type), - ProviderType::None => Vec::new(), + _ => Vec::new(), } } } +/// Interface names: letters, digits, '.', '_' and '-' (e.g. eth0, ens5, wg0.1). +fn is_interface_name(s: &str) -> bool { + !s.is_empty() + && s.len() <= 64 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) +} + /// Result of a provider detection attempt (see [`ProviderType::detect`]). #[derive(Debug, Clone, PartialEq, Eq)] pub enum DetectionOutcome { @@ -195,7 +277,7 @@ pub enum DetectionOutcome { // --- Cloudflare Trace --- /// Primary trace URL uses cloudflare.com (the CDN endpoint, not the DNS -/// resolver). The `build_split_client` forces the correct address family by +/// resolver). The `build_detection_client` forces the correct address family by /// filtering DNS results, so a dual-stack hostname is safe. /// Using literal DNS-resolver IPs (1.0.0.1 / [2606:4700:4700::1001]) caused /// TLS SNI mismatches and returned Cloudflare proxy IPs for some users. @@ -262,27 +344,83 @@ impl Resolve for FilteredResolver { } } -/// Build an HTTP client that only connects via the given IP family. -/// Uses a DNS-level filter to strip addresses of the wrong family from -/// resolution results, ensuring the client never attempts a connection -/// over the wrong protocol. -pub fn build_split_client(ip_type: IpType, timeout: Duration) -> Client { - Client::builder() +/// Client for HTTP detection: pinned to `ip_type` and, if given, bound to a +/// network interface so the request egresses through it (multi-homed hosts). +fn build_detection_client( + ip_type: IpType, + timeout: Duration, + interface: Option<&str>, +) -> Result { + let builder = Client::builder() .dns_resolver(FilteredResolver { ip_type }) - .timeout(timeout) + .timeout(timeout); + let builder = match interface { + Some(iface) => bind_interface(builder, iface, ip_type)?, + None => builder, + }; + builder .build() - .unwrap_or_default() + .map_err(|e| format!("Failed to build detection client: {e}")) +} + +#[cfg(any( + target_os = "android", + target_os = "fuchsia", + target_os = "illumos", + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "solaris", + target_os = "tvos", + target_os = "visionos", + target_os = "watchos", +))] +fn bind_interface( + builder: reqwest::ClientBuilder, + iface: &str, + _ip_type: IpType, +) -> Result { + // SO_BINDTODEVICE on Linux/Android, IP_BOUND_IF/IPV6_BOUND_IF on Apple + // platforms, Solaris and illumos. + Ok(builder.interface(iface)) +} + +#[cfg(not(any( + target_os = "android", + target_os = "fuchsia", + target_os = "illumos", + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "solaris", + target_os = "tvos", + target_os = "visionos", + target_os = "watchos", +)))] +fn bind_interface( + builder: reqwest::ClientBuilder, + iface: &str, + ip_type: IpType, +) -> Result { + // No device binding on this platform (e.g. Windows): bind the source + // address instead. Egress only follows it with source-based routing. + let addr = if_addrs::get_if_addrs() + .map_err(|e| format!("Failed to list interfaces: {e}"))? + .into_iter() + .filter(|a| a.name == iface) + .map(|a| a.ip()) + .find(|ip| matches_ip_type(ip, ip_type)) + .ok_or_else(|| format!("Interface {iface} has no {} address to bind", ip_type.describe()))?; + Ok(builder.local_address(addr)) } async fn detect_cloudflare_trace( - _client: &Client, + client: &Client, ip_type: IpType, timeout: Duration, custom_url: Option<&str>, ppfmt: &PP, ) -> Vec { - // Use an IP-family-specific client so the trace endpoint sees the right address family. - let client = build_split_client(ip_type, timeout); if let Some(url) = custom_url { if let Some(ip) = fetch_trace_ip(&client, url, timeout, None).await { @@ -334,14 +472,11 @@ async fn detect_cloudflare_trace( // --- Cloudflare DNS over HTTPS --- async fn detect_cloudflare_doh( - _client: &Client, + client: &Client, ip_type: IpType, timeout: Duration, ppfmt: &PP, ) -> Vec { - // Pin the address family: a shared client may connect over the other - // family on dual-stack hosts and then report the wrong address type. - let client = build_split_client(ip_type, timeout); // Construct a DNS query for whoami.cloudflare. TXT CH let query = build_dns_query(b"\x06whoami\x0Acloudflare\x00", 16, 3); // TXT=16, CH=3 @@ -477,14 +612,11 @@ fn rand_u16() -> u16 { // --- Ipify --- async fn detect_ipify( - _client: &Client, + client: &Client, ip_type: IpType, timeout: Duration, ppfmt: &PP, ) -> Vec { - // Pin the address family: a shared client may connect over the other - // family on dual-stack hosts and then report the wrong address type. - let client = build_split_client(ip_type, timeout); let url = match ip_type { IpType::V4 => "https://api4.ipify.org", IpType::V6 => "https://api6.ipify.org", @@ -698,15 +830,12 @@ fn parse_if_inet6_line(line: &str) -> Option { // --- Custom URL --- async fn detect_custom_url( - _client: &Client, + client: &Client, url: &str, ip_type: IpType, timeout: Duration, ppfmt: &PP, ) -> Vec { - // Pin the address family: a shared client may connect over the other - // family on dual-stack hosts and then report the wrong address type. - let client = build_split_client(ip_type, timeout); match client.get(url).timeout(timeout).send().await { Ok(resp) => { if let Ok(body) = resp.text().await { @@ -1144,7 +1273,7 @@ mod tests { ); } - // ---- FilteredResolver + build_split_client ---- + // ---- FilteredResolver + build_detection_client ---- #[tokio::test] async fn test_filtered_resolver_v4() { @@ -1179,15 +1308,15 @@ mod tests { } #[test] - fn test_build_split_client_v4() { - let client = build_split_client(IpType::V4, Duration::from_secs(5)); + fn test_build_detection_client_v4() { + let client = build_detection_client(IpType::V4, Duration::from_secs(5), None).unwrap(); // Client should build successfully with filtered resolver. drop(client); } #[test] - fn test_build_split_client_v6() { - let client = build_split_client(IpType::V6, Duration::from_secs(5)); + fn test_build_detection_client_v6() { + let client = build_detection_client(IpType::V6, Duration::from_secs(5), None).unwrap(); drop(client); } @@ -1720,4 +1849,88 @@ fdaa149d3b9900000000000000000001 0a 40 00 82 br-990e55930a86 assert!(ip.is_global_(), "{ip} should be global"); } } + + // ---- Interface-bound providers (issue #248) ---- + + #[test] + fn test_parse_bound_providers() { + for (input, inner, iface) in [ + ("cloudflare.trace@ens5", "cloudflare.trace", "ens5"), + ("cloudflare.doh@eth0", "cloudflare.doh", "eth0"), + ("ipify@eth1", "ipify", "eth1"), + ("url:https://ip.example.com@wg0.1", "url:", "wg0.1"), + ] { + match ProviderType::parse(input).unwrap() { + ProviderType::Bound { inner: p, interface } => { + assert_eq!(p.name(), inner, "{input}"); + assert_eq!(interface, iface, "{input}"); + } + other => panic!("{input} parsed as {other:?}"), + } + } + assert_eq!(ProviderType::parse("ipify@eth1").unwrap().label(), "ipify@eth1"); + assert_eq!(ProviderType::parse("ipify").unwrap().label(), "ipify"); + } + + #[test] + fn test_parse_url_with_at_in_path_is_not_bound() { + // The suffix after the last '@' contains '/', so it is not an interface. + match ProviderType::parse("url:https://user@ip.example.com/raw").unwrap() { + ProviderType::CustomURL { url } => assert_eq!(url, "https://user@ip.example.com/raw"), + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn test_parse_bound_non_http_provider_is_error() { + for input in ["local@eth0", "local.iface:eth0@eth1", "literal:1.2.3.4@eth0", "none@eth0"] { + let err = ProviderType::parse(input).unwrap_err(); + assert!(err.contains("Interface binding"), "{input}: {err}"); + } + } + + #[test] + fn test_parse_bound_unknown_provider_is_error() { + assert!(ProviderType::parse("bogus@eth0").is_err()); + } + + #[tokio::test] + async fn test_bound_provider_failure_is_transient() { + // Unreachable endpoint through a missing interface: the real IP is + // unknown, so this must be Failed (records preserved), never NoIp. + crate::init_crypto(); + let p = ProviderType::parse("url:http://127.0.0.1:9/@nosuchif0").unwrap(); + let outcome = p + .detect(&Client::new(), IpType::V4, Duration::from_secs(2), &PP::default_pp()) + .await; + assert_eq!(outcome, DetectionOutcome::Failed); + } + + /// Binding to the loopback interface still reaches a loopback server; + /// binding to a missing interface fails. Proves the socket option is applied. + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[tokio::test] + async fn test_detection_client_binds_interface() { + use wiremock::matchers::method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + crate::init_crypto(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_string("ok")) + .mount(&server) + .await; + let lo = if cfg!(target_os = "macos") { "lo0" } else { "lo" }; + + let bound = build_detection_client(IpType::V4, Duration::from_secs(5), Some(lo)).unwrap(); + match bound.get(server.uri()).send().await { + Ok(r) => assert!(r.status().is_success()), + // Older Linux kernels require CAP_NET_RAW for SO_BINDTODEVICE. + Err(e) if format!("{e:?}").contains("PermissionDenied") => return, + Err(e) => panic!("bound request failed: {e:?}"), + } + + let missing = + build_detection_client(IpType::V4, Duration::from_secs(5), Some("nosuchif0")).unwrap(); + assert!(missing.get(server.uri()).send().await.is_err()); + } } diff --git a/src/updater.rs b/src/updater.rs index 560de3d..91cd2de 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -41,7 +41,7 @@ pub async fn update_once( for (ip_type, provider) in &config.providers { ppfmt.infof( pp::EMOJI_DETECT, - &format!("Detecting {} via {}", ip_type.describe(), provider.name()), + &format!("Detecting {} via {}", ip_type.describe(), provider.label()), ); match provider .detect(detection_client, *ip_type, config.detection_timeout, ppfmt) @@ -353,7 +353,7 @@ async fn update_legacy( for (ip_type, provider) in &config.providers { ppfmt.infof( pp::EMOJI_DETECT, - &format!("Detecting {} via {}", ip_type.describe(), provider.name()), + &format!("Detecting {} via {}", ip_type.describe(), provider.label()), ); match provider .detect(detection_client, *ip_type, config.detection_timeout, ppfmt) From edd72bd5c76f86a10ed68c781b15a0f625b1e12e Mon Sep 17 00:00:00 2001 From: Timothy Miller Date: Sun, 11 Oct 2026 00:41:35 -0400 Subject: [PATCH 2/5] Add detection groups via numbered env vars (#248) DOMAINS_N / IP4_DOMAINS_N / IP6_DOMAINS_N with IP4_PROVIDER_N / IP6_PROVIDER_N (N >= 2) define extra detection groups. Each group detects its own addresses and updates its own domains; a family without a group provider inherits the default group's. Identical provider/family pairs are detected once per cycle, WAF lists receive the union of all groups' addresses, and a record configured in two groups is a startup error. Without numbered variables behavior is unchanged. --- src/config.rs | 310 +++++++++++++++++++++++++++++-- src/provider.rs | 63 +++++-- src/updater.rs | 478 +++++++++++++++++++++++++++++++----------------- 3 files changed, 656 insertions(+), 195 deletions(-) diff --git a/src/config.rs b/src/config.rs index 04400d8..f14a54a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -77,11 +77,42 @@ pub struct LegacyApiKey { // Unified Config (supports both legacy JSON and env var modes) // ============================================================ +/// An additional detection group from numbered env vars (`DOMAINS_2`, +/// `IP4_PROVIDER_2`, ...). Each group detects its own addresses, e.g. through +/// a different network interface, and updates its own domains (issue #248). +#[derive(Debug, Clone)] +pub struct DetectionGroup { + /// The env var suffix, e.g. "2". + pub label: String, + pub providers: HashMap, + pub domains: HashMap>, +} + +/// A view of one detection group: the default (unsuffixed) one or an extra one. +pub struct GroupRef<'a> { + pub label: Option<&'a str>, + pub providers: &'a HashMap, + pub domains: &'a HashMap>, +} + +impl GroupRef<'_> { + /// Log/message suffix identifying the group; empty for the default group. + pub fn tag(&self) -> String { + match self.label { + Some(l) => format!(" (group {l})"), + None => String::new(), + } + } +} + /// The complete application configuration pub struct AppConfig { pub auth: Auth, + /// Providers and domains of the default detection group (unsuffixed vars). pub providers: HashMap, pub domains: HashMap>, // FQDN domains by IP type + /// Additional detection groups (`DOMAINS_2`, ...); empty for most setups. + pub extra_groups: Vec, pub waf_lists: Vec, pub update_cron: CronSchedule, pub update_on_start: bool, @@ -106,6 +137,23 @@ pub struct AppConfig { pub repeat: bool, } +impl AppConfig { + /// All detection groups, default first. + pub fn groups(&self) -> Vec> { + std::iter::once(GroupRef { + label: None, + providers: &self.providers, + domains: &self.domains, + }) + .chain(self.extra_groups.iter().map(|g| GroupRef { + label: Some(g.label.as_str()), + providers: &g.providers, + domains: &g.domains, + })) + .collect() + } +} + /// Shortest allowed `@every` interval; anything lower hammers the Cloudflare API. pub const MIN_UPDATE_INTERVAL: Duration = Duration::from_secs(30); @@ -347,11 +395,16 @@ fn read_providers_from_env(ppfmt: &PP) -> Result, } fn read_domains_from_env(_ppfmt: &PP) -> HashMap> { + read_domains_with_suffix("") +} + +/// Reads DOMAINS/IP4_DOMAINS/IP6_DOMAINS with a group suffix ("" or "_2"). +fn read_domains_with_suffix(suffix: &str) -> HashMap> { let mut domains: HashMap> = HashMap::new(); - let both = getenv_list("DOMAINS", ','); - let ip4_only = getenv_list("IP4_DOMAINS", ','); - let ip6_only = getenv_list("IP6_DOMAINS", ','); + let both = getenv_list(&format!("DOMAINS{suffix}"), ','); + let ip4_only = getenv_list(&format!("IP4_DOMAINS{suffix}"), ','); + let ip6_only = getenv_list(&format!("IP6_DOMAINS{suffix}"), ','); let mut v4_domains: Vec = both.clone(); v4_domains.extend(ip4_only); @@ -368,6 +421,115 @@ fn read_domains_from_env(_ppfmt: &PP) -> HashMap> { domains } +/// Env var prefixes that can carry a detection group suffix (`_2`, `_3`, ...). +const GROUP_VAR_PREFIXES: [&str; 5] = [ + "DOMAINS_", + "IP4_DOMAINS_", + "IP6_DOMAINS_", + "IP4_PROVIDER_", + "IP6_PROVIDER_", +]; + +/// Numeric suffixes of set group variables, sorted. `_1`/`_01` are rejected: +/// the unsuffixed variables are the first group. +fn group_numbers() -> Result, String> { + let mut numbers = std::collections::BTreeSet::new(); + for (key, value) in env::vars() { + if value.trim().is_empty() { + continue; + } + for prefix in GROUP_VAR_PREFIXES { + let Some(n) = key.strip_prefix(prefix) else { + continue; + }; + if n.is_empty() || !n.chars().all(|c| c.is_ascii_digit()) { + continue; + } + match n.parse::() { + Ok(num) if num >= 2 && !n.starts_with('0') => { + numbers.insert(num); + } + _ => { + return Err(format!( + "{key}: numbered groups start at _2; use the unsuffixed {} for the first group", + prefix.trim_end_matches('_') + )) + } + } + } + } + Ok(numbers.into_iter().collect()) +} + +/// Reads the numbered detection groups. A group without its own provider for a +/// family inherits the default group's provider for that family. +fn read_extra_groups( + default_providers: &HashMap, + ppfmt: &PP, +) -> Result, String> { + let mut groups = Vec::new(); + for n in group_numbers()? { + let suffix = format!("_{n}"); + let domains = read_domains_with_suffix(&suffix); + + let mut providers = HashMap::new(); + for (ip_type, var) in [(IpType::V4, "IP4_PROVIDER"), (IpType::V6, "IP6_PROVIDER")] { + let key = format!("{var}{suffix}"); + let provider = match getenv(&key) { + Some(s) => ProviderType::parse(&s).map_err(|e| format!("Invalid {key}: {e}"))?, + None => match default_providers.get(&ip_type) { + Some(p) => p.clone(), + None => ProviderType::None, + }, + }; + if !matches!(provider, ProviderType::None) { + providers.insert(ip_type, provider); + } + } + + if domains.is_empty() { + ppfmt.warningf( + pp::EMOJI_WARNING, + &format!("Detection group {n} has no DOMAINS{suffix}, IP4_DOMAINS{suffix} or IP6_DOMAINS{suffix}; ignoring it"), + ); + continue; + } + groups.push(DetectionGroup { + label: n.to_string(), + providers, + domains, + }); + } + Ok(groups) +} + +/// Each (domain, record type) may belong to only one group; otherwise two +/// groups would overwrite each other's records every cycle. +fn check_group_overlap( + default_domains: &HashMap>, + groups: &[DetectionGroup], +) -> Result<(), String> { + let mut owner: HashMap<(IpType, String), String> = HashMap::new(); + let all = std::iter::once(("default".to_string(), default_domains)) + .chain(groups.iter().map(|g| (format!("group {}", g.label), &g.domains))); + for (name, domains) in all { + for (ip_type, list) in domains { + for d in list { + let key = (*ip_type, d.to_ascii_lowercase()); + if let Some(prev) = owner.insert(key, name.clone()) { + if prev != name { + return Err(format!( + "{} record for {d} is configured in both the {prev} and {name} detection groups", + ip_type.record_type() + )); + } + } + } + } + } + Ok(()) +} + fn read_waf_lists_from_env() -> Result, String> { getenv_list("WAF_LISTS", ',') .iter() @@ -559,6 +721,7 @@ fn legacy_to_app_config( auth, providers, domains: HashMap::new(), + extra_groups: Vec::new(), waf_lists: Vec::new(), update_cron: schedule, update_on_start: true, @@ -597,6 +760,12 @@ pub fn is_env_config_mode() -> bool { || getenv("DOMAINS").is_some() || getenv("IP4_DOMAINS").is_some() || getenv("IP6_DOMAINS").is_some() + || env::vars().any(|(k, v)| { + !v.trim().is_empty() + && ["DOMAINS_", "IP4_DOMAINS_", "IP6_DOMAINS_"] + .iter() + .any(|p| k.strip_prefix(p).is_some_and(|n| n.chars().all(|c| c.is_ascii_digit()))) + }) } /// Load configuration from environment variables (cf-ddns mode). @@ -614,6 +783,8 @@ pub fn load_env_config(ppfmt: &PP) -> Result { let providers = read_providers_from_env(ppfmt)?; let domains = read_domains_from_env(ppfmt); + let extra_groups = read_extra_groups(&providers, ppfmt)?; + check_group_overlap(&domains, &extra_groups)?; let waf_lists = read_waf_lists_from_env()?; let update_cron = read_cron_from_env(ppfmt)?; let update_on_start = getenv_bool("UPDATE_ON_START", true); @@ -650,7 +821,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result { let reject_cloudflare_ips = getenv_bool("REJECT_CLOUDFLARE_IPS", true); // Validate: must have at least one update target - if domains.is_empty() && waf_lists.is_empty() { + if domains.is_empty() && extra_groups.is_empty() && waf_lists.is_empty() { return Err( "No update targets configured. Set DOMAINS, IP4_DOMAINS, IP6_DOMAINS, or WAF_LISTS." .to_string(), @@ -685,6 +856,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result { auth, providers, domains, + extra_groups, waf_lists, update_cron, update_on_start, @@ -786,12 +958,20 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) { let inner = ppfmt.indent(); - if !config.domains.is_empty() { - ppfmt.noticef(pp::EMOJI_CONFIG, "Domains to update:"); - for (ip_type, domains) in &config.domains { + for group in config.groups() { + if group.domains.is_empty() { + continue; + } + ppfmt.noticef(pp::EMOJI_CONFIG, &format!("Domains to update{}:", group.tag())); + for (ip_type, domains) in group.domains { + let provider = group + .providers + .get(ip_type) + .map(|p| p.label()) + .unwrap_or_else(|| "none".to_string()); inner.noticef( "", - &format!("{}: {}", ip_type.describe(), domains.join(", ")), + &format!("{} via {provider}: {}", ip_type.describe(), domains.join(", ")), ); } } @@ -803,11 +983,13 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) { } } - for (ip_type, provider) in &config.providers { - inner.infof( - "", - &format!("{} provider: {}", ip_type.describe(), provider.label()), - ); + if config.domains.is_empty() { + for (ip_type, provider) in &config.providers { + inner.infof( + "", + &format!("{} provider: {}", ip_type.describe(), provider.label()), + ); + } } inner.infof("", &format!("TTL: {}", config.ttl.describe())); @@ -1530,6 +1712,7 @@ mod tests { auth: Auth::Token(String::new()), providers: HashMap::new(), domains: HashMap::new(), + extra_groups: Vec::new(), waf_lists: Vec::new(), update_cron: CronSchedule::Once, update_on_start: true, @@ -1565,6 +1748,7 @@ mod tests { auth: Auth::Token("tok".to_string()), providers: HashMap::new(), domains, + extra_groups: Vec::new(), waf_lists: Vec::new(), update_cron: CronSchedule::Every(Duration::from_secs(300)), update_on_start: true, @@ -2221,6 +2405,7 @@ mod tests { auth: Auth::Token("tok".to_string()), providers: HashMap::new(), domains: HashMap::new(), + extra_groups: Vec::new(), waf_lists: vec![waf_list], update_cron: CronSchedule::Every(Duration::from_secs(300)), update_on_start: true, @@ -2258,6 +2443,7 @@ mod tests { auth: Auth::Token("tok".to_string()), providers, domains, + extra_groups: Vec::new(), waf_lists: Vec::new(), update_cron: CronSchedule::Every(Duration::from_secs(600)), update_on_start: true, @@ -2292,6 +2478,7 @@ mod tests { auth: Auth::Token("tok".to_string()), providers: HashMap::new(), domains, + extra_groups: Vec::new(), waf_lists: Vec::new(), update_cron: CronSchedule::Once, update_on_start: true, @@ -2455,4 +2642,101 @@ mod tests { drop(g); assert_eq!(out, "long short"); } + + // ============================================================ + // Detection groups (issue #248) + // ============================================================ + + fn group_guard() -> EnvGuard { + let mut g = base_env_guard(); + for k in [ + "DOMAINS_2", "IP4_DOMAINS_2", "IP6_DOMAINS_2", "IP4_PROVIDER_2", "IP6_PROVIDER_2", + "DOMAINS_3", "DOMAINS_1", "IP4_PROVIDER_3", + ] { + g.remove(k); + } + g + } + + #[test] + fn test_no_groups_by_default() { + let g = group_guard(); + let config = load_env_config(&PP::new(false, true)).unwrap_or_else(|e| panic!("{e}")); + drop(g); + assert!(config.extra_groups.is_empty()); + assert_eq!(config.groups().len(), 1); + } + + #[test] + fn test_group_parsing_and_inheritance() { + let mut g = group_guard(); + g.add("IP6_PROVIDER", "local.iface:eth0"); + g.add("DOMAINS_2", "b.example.com"); + g.add("IP4_PROVIDER_2", "cloudflare.trace@ens5"); + g.add("IP4_DOMAINS_3", "c.example.com"); + g.add("IP4_PROVIDER_3", "none"); + let config = load_env_config(&PP::new(false, true)).unwrap_or_else(|e| panic!("{e}")); + drop(g); + + assert_eq!(config.extra_groups.len(), 2); + let g2 = &config.extra_groups[0]; + assert_eq!(g2.label, "2"); + assert_eq!(g2.providers[&IpType::V4].label(), "cloudflare.trace@ens5"); + // IPv6 inherited from the default group. + assert_eq!(g2.providers[&IpType::V6].label(), "local.iface"); + assert_eq!(g2.domains[&IpType::V6], vec!["b.example.com".to_string()]); + + let g3 = &config.extra_groups[1]; + assert_eq!(g3.label, "3"); + assert!(!g3.providers.contains_key(&IpType::V4), "none disables the family"); + } + + #[test] + fn test_group_one_is_rejected() { + let mut g = group_guard(); + g.add("DOMAINS_1", "x.example.com"); + let err = load_env_config(&PP::new(false, true)).err(); + drop(g); + assert!(err.unwrap().contains("start at _2")); + } + + #[test] + fn test_group_overlap_is_rejected() { + let mut g = group_guard(); + g.add("DOMAINS_2", "Example.com"); + let err = load_env_config(&PP::new(false, true)).err(); + drop(g); + let err = err.expect("overlap must be rejected"); + assert!(err.contains("both the default and group 2"), "{err}"); + } + + #[test] + fn test_group_without_domains_is_ignored() { + let mut g = group_guard(); + g.add("IP4_PROVIDER_2", "ipify@eth1"); + let config = load_env_config(&PP::new(false, true)).unwrap_or_else(|e| panic!("{e}")); + drop(g); + assert!(config.extra_groups.is_empty()); + } + + #[test] + fn test_invalid_group_provider_is_error() { + let mut g = group_guard(); + g.add("DOMAINS_2", "b.example.com"); + g.add("IP4_PROVIDER_2", "local@eth0"); + let err = load_env_config(&PP::new(false, true)).err(); + drop(g); + assert!(err.unwrap().contains("IP4_PROVIDER_2")); + } + + #[test] + fn test_group_domains_alone_enable_env_mode() { + let mut g = group_guard(); + g.remove("CLOUDFLARE_API_TOKEN"); + g.remove("DOMAINS"); + g.add("DOMAINS_2", "b.example.com"); + let env_mode = is_env_config_mode(); + drop(g); + assert!(env_mode); + } } diff --git a/src/provider.rs b/src/provider.rs index e48c442..ef1d441 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -31,14 +31,24 @@ impl IpType { /// All supported provider types #[derive(Debug, Clone)] pub enum ProviderType { - CloudflareTrace { url: Option }, + CloudflareTrace { + url: Option, + }, CloudflareDOH, Ipify, Local, - LocalIface { interface: String }, - StableLocalIface { interface: String }, - CustomURL { url: String }, - Literal { ips: Vec }, + LocalIface { + interface: String, + }, + StableLocalIface { + interface: String, + }, + CustomURL { + url: String, + }, + Literal { + ips: Vec, + }, None, /// An HTTP-based provider whose requests egress through a specific network /// interface, written as `@` (e.g. `ipify@eth1`). @@ -64,6 +74,11 @@ impl ProviderType { } } + /// Identity for caching detection results within one update cycle. + pub fn cache_key(&self) -> String { + format!("{self:?}") + } + /// Name plus interface binding, for logs (e.g. `cloudflare.trace@ens5`). pub fn label(&self) -> String { match self { @@ -410,7 +425,12 @@ fn bind_interface( .filter(|a| a.name == iface) .map(|a| a.ip()) .find(|ip| matches_ip_type(ip, ip_type)) - .ok_or_else(|| format!("Interface {iface} has no {} address to bind", ip_type.describe()))?; + .ok_or_else(|| { + format!( + "Interface {iface} has no {} address to bind", + ip_type.describe() + ) + })?; Ok(builder.local_address(addr)) } @@ -421,7 +441,6 @@ async fn detect_cloudflare_trace( custom_url: Option<&str>, ppfmt: &PP, ) -> Vec { - if let Some(url) = custom_url { if let Some(ip) = fetch_trace_ip(&client, url, timeout, None).await { if validate_detected_ip(&ip, ip_type, ppfmt) { @@ -1861,14 +1880,20 @@ fdaa149d3b9900000000000000000001 0a 40 00 82 br-990e55930a86 ("url:https://ip.example.com@wg0.1", "url:", "wg0.1"), ] { match ProviderType::parse(input).unwrap() { - ProviderType::Bound { inner: p, interface } => { + ProviderType::Bound { + inner: p, + interface, + } => { assert_eq!(p.name(), inner, "{input}"); assert_eq!(interface, iface, "{input}"); } other => panic!("{input} parsed as {other:?}"), } } - assert_eq!(ProviderType::parse("ipify@eth1").unwrap().label(), "ipify@eth1"); + assert_eq!( + ProviderType::parse("ipify@eth1").unwrap().label(), + "ipify@eth1" + ); assert_eq!(ProviderType::parse("ipify").unwrap().label(), "ipify"); } @@ -1883,7 +1908,12 @@ fdaa149d3b9900000000000000000001 0a 40 00 82 br-990e55930a86 #[test] fn test_parse_bound_non_http_provider_is_error() { - for input in ["local@eth0", "local.iface:eth0@eth1", "literal:1.2.3.4@eth0", "none@eth0"] { + for input in [ + "local@eth0", + "local.iface:eth0@eth1", + "literal:1.2.3.4@eth0", + "none@eth0", + ] { let err = ProviderType::parse(input).unwrap_err(); assert!(err.contains("Interface binding"), "{input}: {err}"); } @@ -1901,7 +1931,12 @@ fdaa149d3b9900000000000000000001 0a 40 00 82 br-990e55930a86 crate::init_crypto(); let p = ProviderType::parse("url:http://127.0.0.1:9/@nosuchif0").unwrap(); let outcome = p - .detect(&Client::new(), IpType::V4, Duration::from_secs(2), &PP::default_pp()) + .detect( + &Client::new(), + IpType::V4, + Duration::from_secs(2), + &PP::default_pp(), + ) .await; assert_eq!(outcome, DetectionOutcome::Failed); } @@ -1919,7 +1954,11 @@ fdaa149d3b9900000000000000000001 0a 40 00 82 br-990e55930a86 .respond_with(ResponseTemplate::new(200).set_body_string("ok")) .mount(&server) .await; - let lo = if cfg!(target_os = "macos") { "lo0" } else { "lo" }; + let lo = if cfg!(target_os = "macos") { + "lo0" + } else { + "lo" + }; let bound = build_detection_client(IpType::V4, Duration::from_secs(5), Some(lo)).unwrap(); match bound.get(server.uri()).send().await { diff --git a/src/updater.rs b/src/updater.rs index 91cd2de..0dae5e1 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -31,120 +31,146 @@ pub async fn update_once( messages = legacy_msgs; notify = legacy_notify; } else { - // Detect IPs for each provider. Types in `detection_failed` had a - // transient detection error: the real IP is unknown, so their DNS - // records and WAF list entries are preserved this cycle regardless of - // delete_on_failure (issue #277). - let mut detected_ips: HashMap> = HashMap::new(); - let mut detection_failed: HashSet = HashSet::new(); - - for (ip_type, provider) in &config.providers { - ppfmt.infof( - pp::EMOJI_DETECT, - &format!("Detecting {} via {}", ip_type.describe(), provider.label()), - ); - match provider - .detect(detection_client, *ip_type, config.detection_timeout, ppfmt) - .await - { - DetectionOutcome::Ips(ips) => { - let ip_strs: Vec = ips.iter().map(|ip| ip.to_string()).collect(); - ppfmt.infof( - pp::EMOJI_DETECT, - &format!("Detected {}: {}", ip_type.describe(), ip_strs.join(", ")), - ); - messages.push(Message::new_ok(&format!( - "Detected {}: {}", - ip_type.describe(), - ip_strs.join(", ") - ))); - detected_ips.insert(*ip_type, ips); - } - DetectionOutcome::NoIp => { - ppfmt.warningf( - pp::EMOJI_WARNING, - &format!("No {} address detected", ip_type.describe()), - ); - messages.push(Message::new_fail(&format!( - "Failed to detect {} address", - ip_type.describe() - ))); - } - DetectionOutcome::Failed => { - ppfmt.warningf( + // Each detection group (the default one plus any DOMAINS_2, ... groups) + // detects its own addresses and updates its own domains. Identical + // provider/family pairs are detected once per cycle. + let mut detection_cache: HashMap<(String, IpType), DetectionOutcome> = HashMap::new(); + // WAF lists get the union of all groups' addresses, and are left alone + // if any group's detection failed transiently. + let mut waf_ips: Vec = Vec::new(); + let mut any_detection_failed = false; + + for group in config.groups() { + let tag = group.tag(); + // Types in `detection_failed` had a transient detection error: the real + // IP is unknown, so their DNS records and WAF list entries are preserved + // this cycle regardless of delete_on_failure (issue #277). + let mut detected_ips: HashMap> = HashMap::new(); + let mut detection_failed: HashSet = HashSet::new(); + + for (ip_type, provider) in group.providers { + let cache_key = (provider.cache_key(), *ip_type); + let outcome = match detection_cache.get(&cache_key) { + Some(cached) => cached.clone(), + None => { + ppfmt.infof( + pp::EMOJI_DETECT, + &format!( + "Detecting {}{tag} via {}", + ip_type.describe(), + provider.label() + ), + ); + let outcome = provider + .detect(detection_client, *ip_type, config.detection_timeout, ppfmt) + .await; + detection_cache.insert(cache_key, outcome.clone()); + outcome + } + }; + match outcome { + DetectionOutcome::Ips(ips) => { + let ip_strs: Vec = ips.iter().map(|ip| ip.to_string()).collect(); + ppfmt.infof( + pp::EMOJI_DETECT, + &format!( + "Detected {}{tag}: {}", + ip_type.describe(), + ip_strs.join(", ") + ), + ); + messages.push(Message::new_ok(&format!( + "Detected {}{tag}: {}", + ip_type.describe(), + ip_strs.join(", ") + ))); + detected_ips.insert(*ip_type, ips); + } + DetectionOutcome::NoIp => { + ppfmt.warningf( + pp::EMOJI_WARNING, + &format!("No {} address detected{tag}", ip_type.describe()), + ); + messages.push(Message::new_fail(&format!( + "Failed to detect {} address{tag}", + ip_type.describe() + ))); + } + DetectionOutcome::Failed => { + ppfmt.warningf( pp::EMOJI_WARNING, &format!( - "{} detection failed; skipping {} updates this cycle (existing records preserved)", + "{} detection failed{tag}; skipping {} updates this cycle (existing records preserved)", ip_type.describe(), ip_type.describe() ), ); - messages.push(Message::new_fail(&format!( - "Failed to detect {} address", - ip_type.describe() - ))); - detection_failed.insert(*ip_type); + messages.push(Message::new_fail(&format!( + "Failed to detect {} address{tag}", + ip_type.describe() + ))); + detection_failed.insert(*ip_type); + } } } - } - // Filter out Cloudflare IPs if enabled - if config.reject_cloudflare_ips { - if let Some(cf_filter) = cf_cache - .get(detection_client, config.detection_timeout, ppfmt) - .await - { - for (ip_type, ips) in detected_ips.iter_mut() { - let before_count = ips.len(); - ips.retain(|ip| { - if cf_filter.contains(ip) { + // Filter out Cloudflare IPs if enabled + if config.reject_cloudflare_ips { + if let Some(cf_filter) = cf_cache + .get(detection_client, config.detection_timeout, ppfmt) + .await + { + for (ip_type, ips) in detected_ips.iter_mut() { + let before_count = ips.len(); + ips.retain(|ip| { + if cf_filter.contains(ip) { + ppfmt.warningf( + pp::EMOJI_WARNING, + &format!( + "Rejected {ip}: matches Cloudflare IP range ({})", + ip_type.describe() + ), + ); + false + } else { + true + } + }); + if ips.is_empty() && before_count > 0 { ppfmt.warningf( - pp::EMOJI_WARNING, - &format!( - "Rejected {ip}: matches Cloudflare IP range ({})", - ip_type.describe() - ), - ); - false - } else { - true - } - }); - if ips.is_empty() && before_count > 0 { - ppfmt.warningf( pp::EMOJI_WARNING, &format!( "All detected {} addresses were Cloudflare IPs; skipping updates for this type", ip_type.describe() ), ); - messages.push(Message::new_fail(&format!( - "All {} addresses rejected (Cloudflare IPs)", - ip_type.describe() - ))); - // The real IP is unknown, not absent - preserve records. - detection_failed.insert(*ip_type); + messages.push(Message::new_fail(&format!( + "All {} addresses rejected (Cloudflare IPs)", + ip_type.describe() + ))); + // The real IP is unknown, not absent - preserve records. + detection_failed.insert(*ip_type); + } } - } - } else if !detected_ips.is_empty() { - ppfmt.warningf( + } else if !detected_ips.is_empty() { + ppfmt.warningf( pp::EMOJI_WARNING, "Could not fetch Cloudflare IP ranges; skipping update to avoid writing Cloudflare IPs", ); - detection_failed.extend(detected_ips.keys().copied()); - detected_ips.clear(); + detection_failed.extend(detected_ips.keys().copied()); + detected_ips.clear(); + } } - } - // Update DNS records (env var mode - domain-based) - for (ip_type, domains) in &config.domains { - let ips = detected_ips.get(ip_type).cloned().unwrap_or_default(); + // Update DNS records (env var mode - domain-based) + for (ip_type, domains) in group.domains { + let ips = detected_ips.get(ip_type).cloned().unwrap_or_default(); - if ips.is_empty() { - // Transient detection failure: the real IP is unknown, so never - // touch existing records - not even with delete_on_failure set. - if detection_failed.contains(ip_type) { - ppfmt.warningf( + if ips.is_empty() { + // Transient detection failure: the real IP is unknown, so never + // touch existing records - not even with delete_on_failure set. + if detection_failed.contains(ip_type) { + ppfmt.warningf( pp::EMOJI_WARNING, &format!( "Skipping {} update for {}: IP detection failed (existing records preserved)", @@ -152,12 +178,12 @@ pub async fn update_once( domains.join(", ") ), ); - continue; - } - // Definitive "no address of this family": deletion is the - // documented DELETE_ON_FAILURE=true behavior; otherwise skip. - if !config.delete_on_failure { - ppfmt.warningf( + continue; + } + // Definitive "no address of this family": deletion is the + // documented DELETE_ON_FAILURE=true behavior; otherwise skip. + if !config.delete_on_failure { + ppfmt.warningf( pp::EMOJI_WARNING, &format!( "Skipping {} update for {}: no {} address detected (existing records preserved)", @@ -166,84 +192,89 @@ pub async fn update_once( ip_type.describe() ), ); - continue; - } - } - - let record_type = ip_type.record_type(); - - for domain_str in domains { - // Find zone ID for this domain - let zone_id = match handle.zone_id_of_domain(domain_str, ppfmt).await { - Some(id) => id, - None => { - ppfmt.errorf( - pp::EMOJI_ERROR, - &format!("Could not find zone for domain {domain_str}"), - ); - all_ok = false; - messages.push(Message::new_fail(&format!( - "Failed to find zone for {domain_str}" - ))); continue; } - }; + } - let proxied = config - .proxied_expression - .as_ref() - .map(|f| f(domain_str)) - .unwrap_or(false); - - let result = handle - .set_ips( - &zone_id, - domain_str, - record_type, - &ips, - proxied, - config.ttl, - config.record_comment.as_deref(), - config.dry_run, - ppfmt, - ) - .await; - - let noop_key = format!("{domain_str}:{record_type}"); - match result { - SetResult::Updated => { - noop_reported.remove(&noop_key); - notify = true; - if ips.is_empty() { - messages.push(Message::new_ok(&format!( - "Deleted DNS records for {domain_str} (no {} address detected)", - ip_type.describe() - ))); - } else { - let ip_strs: Vec = - ips.iter().map(|ip| ip.to_string()).collect(); - messages.push(Message::new_ok(&format!( - "Updated {domain_str} -> {}", - ip_strs.join(", ") + let record_type = ip_type.record_type(); + + for domain_str in domains { + // Find zone ID for this domain + let zone_id = match handle.zone_id_of_domain(domain_str, ppfmt).await { + Some(id) => id, + None => { + ppfmt.errorf( + pp::EMOJI_ERROR, + &format!("Could not find zone for domain {domain_str}"), + ); + all_ok = false; + messages.push(Message::new_fail(&format!( + "Failed to find zone for {domain_str}" ))); + continue; } - } - SetResult::Failed => { - noop_reported.remove(&noop_key); - notify = true; - all_ok = false; - messages.push(Message::new_fail(&format!("Failed to update {domain_str}"))); - } - SetResult::Noop => { - if noop_reported.insert(noop_key) { - ppfmt.infof( - pp::EMOJI_SKIP, - &format!("Record {domain_str} is up to date"), - ); + }; + + let proxied = config + .proxied_expression + .as_ref() + .map(|f| f(domain_str)) + .unwrap_or(false); + + let result = handle + .set_ips( + &zone_id, + domain_str, + record_type, + &ips, + proxied, + config.ttl, + config.record_comment.as_deref(), + config.dry_run, + ppfmt, + ) + .await; + + let noop_key = format!("{domain_str}:{record_type}"); + match result { + SetResult::Updated => { + noop_reported.remove(&noop_key); + notify = true; + if ips.is_empty() { + messages.push(Message::new_ok(&format!( + "Deleted DNS records for {domain_str} (no {} address detected)", + ip_type.describe() + ))); + } else { + let ip_strs: Vec = + ips.iter().map(|ip| ip.to_string()).collect(); + messages.push(Message::new_ok(&format!( + "Updated {domain_str} -> {}", + ip_strs.join(", ") + ))); + } + } + SetResult::Failed => { + noop_reported.remove(&noop_key); + notify = true; + all_ok = false; + messages + .push(Message::new_fail(&format!("Failed to update {domain_str}"))); + } + SetResult::Noop => { + if noop_reported.insert(noop_key) { + ppfmt.infof( + pp::EMOJI_SKIP, + &format!("Record {domain_str} is up to date"), + ); + } } } } } + + waf_ips.extend(detected_ips.values().flatten().copied()); + any_detection_failed |= !detection_failed.is_empty(); } // Update WAF lists. @@ -251,7 +282,7 @@ pub async fn update_once( // detection failure for any family would silently strip that family's // IPs from the list. Preserve the list until detection recovers. for waf_list in &config.waf_lists { - if !detection_failed.is_empty() { + if any_detection_failed { ppfmt.warningf( pp::EMOJI_WARNING, &format!( @@ -262,8 +293,9 @@ pub async fn update_once( continue; } - // Collect all detected IPs for WAF lists - let all_ips: Vec = detected_ips.values().flatten().copied().collect(); + let mut all_ips = waf_ips.clone(); + all_ips.sort(); + all_ips.dedup(); let result = handle .set_waf_list( @@ -465,7 +497,8 @@ pub async fn final_delete( let mut messages = Vec::new(); // Delete DNS records - for (ip_type, domains) in &config.domains { + let groups = config.groups(); + for (ip_type, domains) in groups.iter().flat_map(|g| g.domains.iter()) { let record_type = ip_type.record_type(); for domain_str in domains { @@ -896,6 +929,7 @@ mod tests { auth: Auth::Token("test-token".to_string()), providers, domains, + extra_groups: Vec::new(), waf_lists, update_cron: CronSchedule::Once, update_on_start: true, @@ -3313,6 +3347,110 @@ mod tests { .await; assert!(ok, "Should succeed with record_comment set"); } + + // ---- Detection groups (issue #248) ---- + + /// Two groups with different providers write their own address to their + /// own domain; the WAF list receives both. + #[tokio::test] + async fn test_detection_groups_update_independently() { + use wiremock::matchers::body_partial_json; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/zones")) + .respond_with( + ResponseTemplate::new(200).set_body_json(zones_response("z1", "example.com")), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/zones/z1/dns_records")) + .respond_with(ResponseTemplate::new(200).set_body_json(dns_records_empty())) + .mount(&server) + .await; + for (name, ip) in [ + ("a.example.com", "198.51.100.1"), + ("b.example.com", "203.0.113.2"), + ] { + Mock::given(method("POST")) + .and(path("/zones/z1/dns_records")) + .and(body_partial_json( + serde_json::json!({ "name": name, "content": ip }), + )) + .respond_with( + ResponseTemplate::new(200).set_body_json(dns_record_created("r", name, ip)), + ) + .expect(1) + .mount(&server) + .await; + } + + let literal = |ip: &str| ProviderType::Literal { + ips: vec![ip.parse().unwrap()], + }; + let mut config = make_config( + HashMap::from([(IpType::V4, literal("198.51.100.1"))]), + HashMap::from([(IpType::V4, vec!["a.example.com".to_string()])]), + vec![], + false, + ); + config.extra_groups = vec![crate::config::DetectionGroup { + label: "2".to_string(), + providers: HashMap::from([(IpType::V4, literal("203.0.113.2"))]), + domains: HashMap::from([(IpType::V4, vec!["b.example.com".to_string()])]), + }]; + + let ok = update_once( + &config, + &handle(&server.uri()), + &empty_notifier(), + &empty_heartbeat(), + &mut CachedCloudflareFilter::new(), + &pp(), + &mut HashSet::new(), + &crate::test_client(), + ) + .await; + assert!(ok); + } + + /// Groups sharing a provider detect once per cycle. + #[tokio::test] + async fn test_detection_groups_share_identical_detection() { + let ip_server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_string("198.51.100.7")) + .expect(1) + .mount(&ip_server) + .await; + let provider = ProviderType::CustomURL { + url: ip_server.uri(), + }; + // No domains: only detection runs. + let mut config = make_config( + HashMap::from([(IpType::V4, provider.clone())]), + HashMap::new(), + vec![], + false, + ); + config.reject_cloudflare_ips = false; + config.extra_groups = vec![crate::config::DetectionGroup { + label: "2".to_string(), + providers: HashMap::from([(IpType::V4, provider)]), + domains: HashMap::new(), + }]; + update_once( + &config, + &handle("http://127.0.0.1:9"), + &empty_notifier(), + &empty_heartbeat(), + &mut CachedCloudflareFilter::new(), + &pp(), + &mut HashSet::new(), + &crate::test_client(), + ) + .await; + } } // Legacy types for backwards compatibility From 6101b69d59af7ba98212a627caab82af26af1415 Mon Sep 17 00:00:00 2001 From: Timothy Miller Date: Sun, 11 Oct 2026 00:41:42 -0400 Subject: [PATCH 3/5] Drop needless borrows in trace detection --- src/provider.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/provider.rs b/src/provider.rs index ef1d441..c59dbf1 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -442,7 +442,7 @@ async fn detect_cloudflare_trace( ppfmt: &PP, ) -> Vec { if let Some(url) = custom_url { - if let Some(ip) = fetch_trace_ip(&client, url, timeout, None).await { + if let Some(ip) = fetch_trace_ip(client, url, timeout, None).await { if validate_detected_ip(&ip, ip_type, ppfmt) { return vec![ip]; } @@ -458,7 +458,7 @@ async fn detect_cloudflare_trace( } // Try primary (cloudflare.com — the CDN trace endpoint) - if let Some(ip) = fetch_trace_ip(&client, CF_TRACE_PRIMARY, timeout, None).await { + if let Some(ip) = fetch_trace_ip(client, CF_TRACE_PRIMARY, timeout, None).await { if validate_detected_ip(&ip, ip_type, ppfmt) { return vec![ip]; } @@ -472,7 +472,7 @@ async fn detect_cloudflare_trace( ); // Try fallback (hostname-based — works when literal IPs are intercepted by WARP/Zero Trust) - if let Some(ip) = fetch_trace_ip(&client, CF_TRACE_FALLBACK, timeout, None).await { + if let Some(ip) = fetch_trace_ip(client, CF_TRACE_FALLBACK, timeout, None).await { if validate_detected_ip(&ip, ip_type, ppfmt) { return vec![ip]; } From 9963d1e1787e56b3b85385973915c6e9a4155728 Mon Sep 17 00:00:00 2001 From: Timothy Miller Date: Sun, 11 Oct 2026 00:42:21 -0400 Subject: [PATCH 4/5] Document interface-bound providers and detection groups --- docs/multi-interface.md | 105 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/multi-interface.md diff --git a/docs/multi-interface.md b/docs/multi-interface.md new file mode 100644 index 0000000..905021d --- /dev/null +++ b/docs/multi-interface.md @@ -0,0 +1,105 @@ +# Multiple interfaces and detection groups + +By default every domain shares one detected IPv4 and one detected IPv6 +address, and IP detection goes out through the default route. On multi-homed +hosts, such as Oracle Cloud instances with several VNICs or dual-WAN routers, +two features let each domain follow a different interface: + +- **Interface-bound providers** send the detection request out through a + specific interface. +- **Detection groups** give different domains their own providers. + +They work independently and can be combined. + +## Interface-bound providers + +Add `@` to any HTTP-based provider: `cloudflare.trace`, +`cloudflare.doh`, `ipify` or `url:`. + +```ini +IP4_PROVIDER=cloudflare.trace@ens5 +IP6_PROVIDER=ipify@eth1 +IP4_PROVIDER=url:https://ip.example.com@wg0 +``` + +The request leaves through that interface, so the endpoint sees, and reports +back, the public address of that interface. This works even when the +interface itself only holds a private, NATed address. + +| Platform | Mechanism | +|---|---| +| Linux, Android | `SO_BINDTODEVICE` | +| macOS, iOS, Solaris, illumos | `IP_BOUND_IF` / `IPV6_BOUND_IF` | +| Others (e.g. Windows) | Binds the interface's address as the source address. This only works with source-based routing. | + +Notes: + +- The binding picks the interface used for the detection request; it does + not configure routing. A secondary interface still needs policy routing so + that replies come back through it. +- On Linux kernels older than 5.7, `SO_BINDTODEVICE` needs `CAP_NET_RAW`. + Run the container with `--cap-add NET_RAW` there; root containers are + unaffected. +- The interface name must contain only letters, digits, `.`, `_` and `-`. + The suffix is split on the last `@`, so a `url:` provider with credentials + in it (`user:pass@host`) is not supported. +- Local providers already read a specific interface; use + `local.iface:` or `local.iface.stable:` for those, not `@`. +- If the interface doesn't exist, detection fails for that cycle and existing + records are left unchanged. + +## Detection groups + +The unsuffixed variables (`DOMAINS`, `IP4_PROVIDER`, …) make up the default +group. Adding a numeric suffix, starting at `_2`, defines another group: + +| Variable | Meaning | +|---|---| +| `DOMAINS_N` | Domains updated with both the IPv4 and IPv6 of group N | +| `IP4_DOMAINS_N` / `IP6_DOMAINS_N` | Domains updated with only one family | +| `IP4_PROVIDER_N` / `IP6_PROVIDER_N` | Providers for group N | + +Rules: + +- A group with no provider for a family uses the default group's provider + for that family. Set it to `none` to disable the family in that group. +- A group with providers but no domains is ignored, and a warning is logged. +- A domain and record type can belong to only one group. Defining it in two + groups is a startup error. +- `_1` is rejected: the unsuffixed variables are the first group. +- When several groups use the same provider for the same family, detection + runs once per update cycle and the result is shared. +- WAF lists (`WAF_LISTS`) receive the addresses detected by all groups. They + are left unchanged in any cycle where a group's detection fails. +- Detection groups exist only in environment-variable mode, not in legacy + `config.json` mode. + +### Example: Oracle Cloud with a secondary VNIC + +The secondary VNIC's IPv4 is NATed, so it has to be detected through the +interface. Its IPv6 is set directly on the interface and can be read locally. + +```ini +# Default group: primary interface, unchanged +DOMAINS=a.example.com +IP4_PROVIDER=cloudflare.trace + +# Group 2: secondary VNIC +DOMAINS_2=b.example.com +IP4_PROVIDER_2=cloudflare.trace@ens5 +IP6_PROVIDER_2=local.iface:ens5 +``` + +### Example: dual WAN + +```ini +IP4_DOMAINS=wan1.example.com +IP4_PROVIDER=cloudflare.trace@eth0 + +IP4_DOMAINS_2=wan2.example.com +IP4_PROVIDER_2=cloudflare.trace@eth1 +``` + +Each WAN keeps its own A record up to date. Putting both addresses on one +name (round-robin) is not supported yet, because a record can belong to only +one group. Automatic failover is not part of this feature either. From 76fbef1169cb4964099c07b04f4e43ab96f77c00 Mon Sep 17 00:00:00 2001 From: Timothy Miller Date: Sun, 11 Oct 2026 12:31:02 -0400 Subject: [PATCH 5/5] Link multi-interface docs from README and CHANGELOG --- CHANGELOG.md | 10 ++++++++++ README.md | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1aa561..0f36524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ Docker images are published as `timothyjmiller/cloudflare-ddns:` ## Unreleased +### New features + +- **Interface-bound providers (#248).** `cloudflare.trace`, `cloudflare.doh`, + `ipify` and `url:` accept an `@` suffix + (`IP4_PROVIDER=cloudflare.trace@ens5`) to send detection through a specific + network interface. +- **Detection groups (#248).** `DOMAINS_N`, `IP4_DOMAINS_N`, `IP6_DOMAINS_N`, + `IP4_PROVIDER_N` and `IP6_PROVIDER_N` (N ≥ 2) give groups of domains their + own providers. See [docs/multi-interface.md](docs/multi-interface.md). + ### Behavior changes - **Graceful shutdown on SIGTERM as well as SIGINT.** `docker stop`, diff --git a/README.md b/README.md index 758249d..dcf4b11 100755 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A dynamic DNS client for Cloudflare, written in Rust: a ~1.1 MB static Docker im ## ✨ Features - **IPv4 and IPv6** with independent detection providers: Cloudflare trace (default), Cloudflare DNS-over-HTTPS, ipify, local routing table or interface, custom URL, or static IPs +- **Multi-homed hosts**: bind detection to a network interface (`ipify@eth1`) and give groups of domains their own providers - **Many domains across many zones**, including wildcards (`*.example.com`); zones are found automatically - **Outage-safe**: a failed IP lookup never deletes or overwrites your records, and Cloudflare's own anycast IPs are rejected - **WAF IP lists** kept in sync with your addresses @@ -154,8 +155,23 @@ Use fully-qualified names (`example.com`, `home.example.com`, `*.example.com`). | `literal:` | Fixed addresses, separated by commas or spaces, e.g. `literal:203.0.113.10` | | `none` | Don't manage this address family | +Add `@` to `cloudflare.trace`, `cloudflare.doh`, `ipify` or `url:` to send the detection request through a specific network interface, e.g. `IP4_PROVIDER=cloudflare.trace@ens5`. + Detected addresses must be global unicast: private, CGNAT (`100.64.0.0/10`), loopback and link-local addresses are ignored. An empty `IP4_PROVIDER` / `IP6_PROVIDER` counts as unset (the default applies), so use `none` to disable a family. +### Multiple interfaces and detection groups + +On multi-homed hosts (cloud instances with several network interfaces, dual-WAN routers), numbered variables define extra detection groups, each with its own domains and providers: + +```ini +DOMAINS=a.example.com # default group +DOMAINS_2=b.example.com # group 2 +IP4_PROVIDER_2=cloudflare.trace@ens5 # IPv4 detected through ens5 +IP6_PROVIDER_2=local.iface:ens5 # IPv6 read from ens5 +``` + +Groups start at `_2`, and a family without its own provider uses the default group's. See [docs/multi-interface.md](docs/multi-interface.md) for the full rules and platform notes. + ### Scheduling | Variable | Default | Description | @@ -280,6 +296,7 @@ A heartbeat is sent at startup, after every update cycle (success or fail), and | `IP6_DOMAINS` | — | Domains for AAAA records only | | `IP4_PROVIDER` | `cloudflare.trace` | IPv4 detection provider | | `IP6_PROVIDER` | `cloudflare.trace` | IPv6 detection provider | +| `DOMAINS_N`, `IP4_DOMAINS_N`, `IP6_DOMAINS_N`, `IP4_PROVIDER_N`, `IP6_PROVIDER_N` | — | Detection group N (N ≥ 2); see [multiple interfaces](#multiple-interfaces-and-detection-groups) | | `REJECT_CLOUDFLARE_IPS` | `true` | Discard detected Cloudflare IPs | | `UPDATE_CRON` | `@every 5m` | `@every ` (min 30s) or `@once` | | `UPDATE_ON_START` | `true` | Update immediately at startup |