use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use tokio::net::UdpSocket;
pub fn default_ipv4_gateway() -> Option<Ipv4Addr> {
#[cfg(target_os = "linux")]
{
parse_proc_net_route()
}
#[cfg(target_os = "macos")]
{
run_route_command()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
None
}
}
#[cfg(target_os = "linux")]
fn parse_proc_net_route() -> Option<Ipv4Addr> {
let content = std::fs::read_to_string("/proc/net/route").ok()?;
parse_proc_net_route_content(&content)
}
#[cfg(any(target_os = "linux", test))]
fn parse_proc_net_route_content(content: &str) -> Option<Ipv4Addr> {
for line in content.lines().skip(1) {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() < 3 {
continue;
}
if cols[1] != "00000000" {
continue;
}
let Some(raw) = u32::from_str_radix(cols[2], 16).ok() else {
continue;
};
if raw == 0 {
continue;
}
let bytes = raw.to_le_bytes();
return Some(Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3]));
}
None
}
#[cfg(target_os = "macos")]
fn run_route_command() -> Option<Ipv4Addr> {
let output = std::process::Command::new("/usr/sbin/route")
.args(["-n", "get", "default"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = std::str::from_utf8(&output.stdout).ok()?;
parse_route_get_default(text)
}
#[cfg(any(target_os = "macos", test))]
fn parse_route_get_default(text: &str) -> Option<Ipv4Addr> {
for line in text.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("gateway:") {
return rest.trim().parse().ok();
}
}
None
}
pub async fn local_ipv4_for_gateway(gateway: Ipv4Addr) -> Option<IpAddr> {
let sock = UdpSocket::bind("0.0.0.0:0").await.ok()?;
sock.connect(SocketAddr::new(IpAddr::V4(gateway), 5351))
.await
.ok()?;
let local = sock.local_addr().ok()?;
match local.ip() {
IpAddr::V4(v4) if !v4.is_unspecified() => Some(IpAddr::V4(v4)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn proc_net_route_parses_default_gateway() {
let fixture = "\
Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0
eth0\t0000A8C0\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0
";
assert_eq!(
parse_proc_net_route_content(fixture),
Some(Ipv4Addr::new(192, 168, 1, 1)),
);
}
#[test]
fn proc_net_route_skips_non_default_routes() {
let fixture = "\
Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
eth0\t0000A8C0\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0
eth0\t00000000\t010010AC\t0003\t0\t0\t100\t00000000\t0\t0\t0
";
assert_eq!(
parse_proc_net_route_content(fixture),
Some(Ipv4Addr::new(172, 16, 0, 1)),
);
}
#[test]
fn proc_net_route_returns_none_when_no_default() {
let fixture = "\
Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
eth0\t0000A8C0\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0
";
assert!(parse_proc_net_route_content(fixture).is_none());
}
#[test]
fn proc_net_route_handles_empty_file() {
assert!(parse_proc_net_route_content("").is_none());
assert!(parse_proc_net_route_content(
"Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n"
)
.is_none());
}
#[test]
fn proc_net_route_skips_unparseable_default_row_and_continues() {
let fixture = "\
Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
tun0\t00000000\tNOT_HEX!\t0003\t0\t0\t50\t00000000\t0\t0\t0
eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0
";
assert_eq!(
parse_proc_net_route_content(fixture),
Some(Ipv4Addr::new(192, 168, 1, 1)),
"unparseable default-route row must not abort the scan — \
the eth0 row below should still resolve",
);
}
#[test]
fn proc_net_route_skips_zero_gateway_row() {
let fixture = "\
Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
wg0\t00000000\t00000000\t0001\t0\t0\t0\t00000000\t0\t0\t0
eth0\t00000000\t010010AC\t0003\t0\t0\t100\t00000000\t0\t0\t0
";
assert_eq!(
parse_proc_net_route_content(fixture),
Some(Ipv4Addr::new(172, 16, 0, 1)),
"zero-gateway default row must not be returned — it's a \
point-to-point or transient state, not a routable gateway",
);
}
#[test]
fn proc_net_route_returns_none_when_only_zero_default_present() {
let fixture = "\
Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
wg0\t00000000\t00000000\t0001\t0\t0\t0\t00000000\t0\t0\t0
";
assert!(
parse_proc_net_route_content(fixture).is_none(),
"a single all-zero default-route row should resolve to None, not 0.0.0.0",
);
}
#[test]
fn route_get_default_parses_gateway() {
let fixture = " route to: default
destination: default
mask: default
gateway: 192.168.1.1
interface: en0
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>
";
assert_eq!(
parse_route_get_default(fixture),
Some(Ipv4Addr::new(192, 168, 1, 1)),
);
}
#[test]
fn route_get_default_handles_missing_gateway() {
let fixture = " route to: default
destination: default
interface: en0
";
assert!(parse_route_get_default(fixture).is_none());
}
#[test]
fn route_get_default_ignores_malformed_ip() {
let fixture = "gateway: not-an-ip\n";
assert!(parse_route_get_default(fixture).is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn local_ipv4_for_gateway_resolves_against_loopback() {
let ip = local_ipv4_for_gateway(Ipv4Addr::new(127, 0, 0, 1)).await;
assert!(ip.is_some(), "loopback should always resolve");
if let Some(IpAddr::V4(v4)) = ip {
assert!(v4.is_loopback());
} else {
panic!("expected IPv4, got {ip:?}");
}
}
}