Add stable local IPv6 provider

This commit is contained in:
Mygod
2026-05-18 13:24:14 -04:00
parent fddabc7a3d
commit 9574f67b98
3 changed files with 172 additions and 2 deletions

View File

@@ -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`):

View File

@@ -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 ===

View File

@@ -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:"