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`.
74struct InterfaceInfo {
75    name: String,
76    ip: Ipv4Addr,
77    is_loopback: bool,
78}
79
80// -- Unix: interface enumeration via nix::ifaddrs ----------------------------
81
82#[cfg(unix)]
83fn find_interface_for_ip(target: &Ipv4Addr) -> Option<InterfaceInfo> {
84    let addrs = nix::ifaddrs::getifaddrs().ok()?;
85    for iface in addrs {
86        let flags = iface.flags;
87        let is_loopback = flags.contains(nix::net::if_::InterfaceFlags::IFF_LOOPBACK);
88        let ip = match iface
89            .address
90            .as_ref()
91            .and_then(|a| a.as_sockaddr_in().map(|sa| sa.ip()))
92        {
93            Some(ip) => ip,
94            None => continue,
95        };
96        if &ip == target {
97            return Some(InterfaceInfo {
98                name: iface.interface_name.clone(),
99                ip,
100                is_loopback,
101            });
102        }
103    }
104    None
105}
106
107#[cfg(unix)]
108fn is_virtual_interface(iface: &InterfaceInfo) -> bool {
109    if iface.is_loopback {
110        return true;
111    }
112    if iface.ip.is_link_local() {
113        return true;
114    }
115    let name = iface.name.to_lowercase();
116    // Docker virtual ethernet pairs
117    if name.starts_with("veth") || name.starts_with("br-") || name.starts_with("docker") {
118        return true;
119    }
120    // Libvirt bridges
121    if name.starts_with("virbr") {
122        return true;
123    }
124    // Windows Hyper-V (seen in WSL2)
125    if name.starts_with("vethernet") || name.starts_with("bridge") {
126        return true;
127    }
128    false
129}
130
131#[cfg(unix)]
132fn fallback_interface_ip() -> Option<Ipv4Addr> {
133    let addrs = nix::ifaddrs::getifaddrs().ok()?;
134    for iface in addrs {
135        let flags = iface.flags;
136        if !flags.contains(nix::net::if_::InterfaceFlags::IFF_UP) {
137            continue;
138        }
139        if flags.contains(nix::net::if_::InterfaceFlags::IFF_LOOPBACK) {
140            continue;
141        }
142        let ip = match iface
143            .address
144            .as_ref()
145            .and_then(|a| a.as_sockaddr_in().map(|sa| sa.ip()))
146        {
147            Some(ip) => ip,
148            None => continue,
149        };
150        if ip.is_loopback() || ip.is_link_local() {
151            continue;
152        }
153        let info = InterfaceInfo {
154            name: iface.interface_name.clone(),
155            ip,
156            is_loopback: false,
157        };
158        if !is_virtual_interface(&info) {
159            return Some(ip);
160        }
161    }
162    None
163}
164
165// -- Windows: no nix::ifaddrs, skip interface enumeration --------------------
166
167#[cfg(not(unix))]
168fn find_interface_for_ip(_target: &Ipv4Addr) -> Option<InterfaceInfo> {
169    None
170}
171
172#[cfg(not(unix))]
173fn is_virtual_interface(_iface: &InterfaceInfo) -> bool {
174    false
175}
176
177#[cfg(not(unix))]
178fn fallback_interface_ip() -> Option<Ipv4Addr> {
179    None
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[cfg(unix)]
187    #[test]
188    fn test_is_virtual_interface_loopback() {
189        let iface = InterfaceInfo {
190            name: "lo".to_string(),
191            ip: Ipv4Addr::new(127, 0, 0, 1),
192            is_loopback: true,
193        };
194        assert!(is_virtual_interface(&iface));
195    }
196
197    #[cfg(unix)]
198    #[test]
199    fn test_is_virtual_interface_docker() {
200        for name in &["veth1234", "br-abc", "docker0"] {
201            let iface = InterfaceInfo {
202                name: name.to_string(),
203                ip: Ipv4Addr::new(172, 17, 0, 1),
204                is_loopback: false,
205            };
206            assert!(
207                is_virtual_interface(&iface),
208                "expected {name} to be virtual"
209            );
210        }
211    }
212
213    #[cfg(unix)]
214    #[test]
215    fn test_is_virtual_interface_normal() {
216        let iface = InterfaceInfo {
217            name: "eth0".to_string(),
218            ip: Ipv4Addr::new(192, 168, 1, 42),
219            is_loopback: false,
220        };
221        assert!(!is_virtual_interface(&iface));
222    }
223
224    #[cfg(unix)]
225    #[test]
226    fn test_is_virtual_interface_link_local() {
227        let iface = InterfaceInfo {
228            name: "en0".to_string(),
229            ip: Ipv4Addr::new(169, 254, 1, 1),
230            is_loopback: false,
231        };
232        assert!(is_virtual_interface(&iface));
233    }
234}