Skip to main content

pitchfork_cli/proxy/
lan_ip.rs

1//! LAN IP address detection for the reverse proxy.
2//!
3//! Detects the local IPv4 address used for the default route by opening a UDP
4//! "connection" to a public address (1.1.1.1:53).  No data is sent; the OS
5//! routing table determines which local address to use, and we read it back
6//! via `socket.local_addr()`.
7//!
8//! On Unix, falls back to interface enumeration via `nix::ifaddrs` when the UDP
9//! probe fails (e.g. no route to the internet).  On Windows, only the UDP
10//! probe is available.
11
12use std::net::{Ipv4Addr, SocketAddrV4};
13
14/// Probe target: Cloudflare DNS on a well-known anycast address.
15const PROBE_HOST: Ipv4Addr = Ipv4Addr::new(1, 1, 1, 1);
16const PROBE_PORT: u16 = 53;
17
18/// Detect the LAN IPv4 address of the default outbound route.
19///
20/// Uses a UDP connect probe to determine which local address the OS would use
21/// to reach the public internet, then validates it against the interface list
22/// to exclude virtual/internal interfaces.
23///
24/// Returns `None` if:
25/// - Only loopback addresses are available
26/// - The detected interface is virtual (Docker, Hyper-V, bridges)
27/// - No route to the internet exists
28pub async fn detect_lan_ip() -> Option<Ipv4Addr> {
29    // Try the UDP probe first (most reliable for default route).
30    if let Some(ip) = probe_default_route().await {
31        if let Some(iface) = find_interface_for_ip(&ip) {
32            if !is_virtual_interface(&iface) {
33                return Some(ip);
34            }
35        } else {
36            // IP was found but no matching interface — could still be valid
37            // (e.g. the interface disappeared between probe and enumeration).
38            // Accept it as long as it's not loopback.
39            if !ip.is_loopback() && !ip.is_link_local() {
40                return Some(ip);
41            }
42        }
43    }
44
45    // Fallback: pick the first non-loopback, non-virtual IPv4 from ifaddrs.
46    fallback_interface_ip()
47}
48
49/// Detect LAN IP, returning `None` if it hasn't changed since `last`.
50pub async fn detect_lan_ip_if_changed(last: Ipv4Addr) -> Option<Ipv4Addr> {
51    let current = detect_lan_ip().await?;
52    (current != last).then_some(current)
53}
54
55/// Probe the default route by opening a UDP "connection" to a public address.
56///
57/// The OS picks the source address based on its routing table, which is exactly
58/// the LAN IP we want.  No packets are actually sent.
59async fn probe_default_route() -> Option<Ipv4Addr> {
60    let sock = tokio::net::UdpSocket::bind("0.0.0.0:0").await.ok()?;
61    let dest = SocketAddrV4::new(PROBE_HOST, PROBE_PORT);
62    // connect() on a UDP socket doesn't send anything — it just sets the
63    // default destination and lets the OS pick the source address.
64    sock.connect(dest).await.ok()?;
65    let local = sock.local_addr().ok()?;
66    let ip = local.ip();
67    match ip {
68        std::net::IpAddr::V4(v4) if !v4.is_unspecified() && !v4.is_loopback() => Some(v4),
69        _ => None,
70    }
71}
72
73/// A network interface matched from `getifaddrs`.
74#[allow(dead_code)] // fields only read on Unix; struct used as type signature on Windows
75struct InterfaceInfo {
76    name: String,
77    ip: Ipv4Addr,
78    is_loopback: bool,
79}
80
81// -- Unix: interface enumeration via nix::ifaddrs ----------------------------
82
83#[cfg(unix)]
84fn find_interface_for_ip(target: &Ipv4Addr) -> Option<InterfaceInfo> {
85    let addrs = nix::ifaddrs::getifaddrs().ok()?;
86    for iface in addrs {
87        let flags = iface.flags;
88        let is_loopback = flags.contains(nix::net::if_::InterfaceFlags::IFF_LOOPBACK);
89        let ip = match iface
90            .address
91            .as_ref()
92            .and_then(|a| a.as_sockaddr_in().map(|sa| sa.ip()))
93        {
94            Some(ip) => ip,
95            None => continue,
96        };
97        if &ip == target {
98            return Some(InterfaceInfo {
99                name: iface.interface_name.clone(),
100                ip,
101                is_loopback,
102            });
103        }
104    }
105    None
106}
107
108#[cfg(unix)]
109fn is_virtual_interface(iface: &InterfaceInfo) -> bool {
110    if iface.is_loopback {
111        return true;
112    }
113    if iface.ip.is_link_local() {
114        return true;
115    }
116    let name = iface.name.to_lowercase();
117    // Docker virtual ethernet pairs
118    if name.starts_with("veth") || name.starts_with("br-") || name.starts_with("docker") {
119        return true;
120    }
121    // Libvirt bridges
122    if name.starts_with("virbr") {
123        return true;
124    }
125    // Windows Hyper-V (seen in WSL2)
126    if name.starts_with("vethernet") || name.starts_with("bridge") {
127        return true;
128    }
129    false
130}
131
132#[cfg(unix)]
133fn fallback_interface_ip() -> Option<Ipv4Addr> {
134    let addrs = nix::ifaddrs::getifaddrs().ok()?;
135    for iface in addrs {
136        let flags = iface.flags;
137        if !flags.contains(nix::net::if_::InterfaceFlags::IFF_UP) {
138            continue;
139        }
140        if flags.contains(nix::net::if_::InterfaceFlags::IFF_LOOPBACK) {
141            continue;
142        }
143        let ip = match iface
144            .address
145            .as_ref()
146            .and_then(|a| a.as_sockaddr_in().map(|sa| sa.ip()))
147        {
148            Some(ip) => ip,
149            None => continue,
150        };
151        if ip.is_loopback() || ip.is_link_local() {
152            continue;
153        }
154        let info = InterfaceInfo {
155            name: iface.interface_name.clone(),
156            ip,
157            is_loopback: false,
158        };
159        if !is_virtual_interface(&info) {
160            return Some(ip);
161        }
162    }
163    None
164}
165
166// -- Windows: no nix::ifaddrs, skip interface enumeration --------------------
167
168#[cfg(not(unix))]
169fn find_interface_for_ip(_target: &Ipv4Addr) -> Option<InterfaceInfo> {
170    None
171}
172
173#[cfg(not(unix))]
174fn is_virtual_interface(_iface: &InterfaceInfo) -> bool {
175    false
176}
177
178#[cfg(not(unix))]
179fn fallback_interface_ip() -> Option<Ipv4Addr> {
180    None
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[cfg(unix)]
188    #[test]
189    fn test_is_virtual_interface_loopback() {
190        let iface = InterfaceInfo {
191            name: "lo".to_string(),
192            ip: Ipv4Addr::new(127, 0, 0, 1),
193            is_loopback: true,
194        };
195        assert!(is_virtual_interface(&iface));
196    }
197
198    #[cfg(unix)]
199    #[test]
200    fn test_is_virtual_interface_docker() {
201        for name in &["veth1234", "br-abc", "docker0"] {
202            let iface = InterfaceInfo {
203                name: name.to_string(),
204                ip: Ipv4Addr::new(172, 17, 0, 1),
205                is_loopback: false,
206            };
207            assert!(
208                is_virtual_interface(&iface),
209                "expected {name} to be virtual"
210            );
211        }
212    }
213
214    #[cfg(unix)]
215    #[test]
216    fn test_is_virtual_interface_normal() {
217        let iface = InterfaceInfo {
218            name: "eth0".to_string(),
219            ip: Ipv4Addr::new(192, 168, 1, 42),
220            is_loopback: false,
221        };
222        assert!(!is_virtual_interface(&iface));
223    }
224
225    #[cfg(unix)]
226    #[test]
227    fn test_is_virtual_interface_link_local() {
228        let iface = InterfaceInfo {
229            name: "en0".to_string(),
230            ip: Ipv4Addr::new(169, 254, 1, 1),
231            is_loopback: false,
232        };
233        assert!(is_virtual_interface(&iface));
234    }
235}