From 9574f67b984d242f491c35d60f2dd9edfa6a6893 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 18 May 2026 13:24:14 -0400 Subject: [PATCH] Add stable local IPv6 provider --- README.md | 3 +- env-example | 4 +- src/provider.rs | 167 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 81e7ee6..2d9d2b6 100755 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Available providers: | `ipify` | 🌎 ipify.org API | | `local` | 🏠 Local IP via system routing table (no network traffic, CGNAT-aware) | | `local.iface:` | 🔌 IP from a specific network interface (e.g., `local.iface:eth0`) | +| `local.iface.stable:` | 🔌 Preferred stable IPv6 address from a Linux network interface, excluding temporary/deprecated addresses | | `url:` | 🔗 Custom HTTP(S) endpoint that returns an IP address | | `literal:` | 📌 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:`, `url:`, `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:`, `local.iface.stable:`, `url:`, `none`. Set a provider to `"none"` to disable detection for that address family (overrides `a`/`aaaa`): diff --git a/env-example b/env-example index 58bc3ad..e09c7c3 100644 --- a/env-example +++ b/env-example @@ -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:, -# url:, literal:,, none +# local.iface.stable:, url:, literal:,, none # IP4_PROVIDER=cloudflare.trace # Provider for IPv6 detection (default: cloudflare.trace) +# Use local.iface.stable: on Linux to publish a stable address instead +# of temporary privacy addresses from the selected interface. # IP6_PROVIDER=cloudflare.trace # === Scheduling === diff --git a/src/provider.rs b/src/provider.rs index ee7b891..fbdf0c3 100644 --- a/src/provider.rs +++ b/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 }, 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 Vec { + 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 { + let mut entries: Vec = 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 = 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 { + 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::() + .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::() + .unwrap(), + "2001:db8:0:1::3486".parse::().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:"