openlatch-client 0.6.19

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Host IP detection — runs once at daemon startup and caches the result on `AppState`.
//!
//! Two addresses are captured, both optional:
//! - `local_ipv4` / `local_ipv6`: from `getifaddrs` via the `local-ip-address` crate. Synchronous,
//!   microseconds, no network. Loopback and link-local results are filtered out.
//!
//! The public address is deliberately not detected here. The platform stamps it from the
//! ingest connection, so the client never opens a socket for it: the former DNS lookup bound
//! a wildcard UDP socket, which Windows Firewall reports as a listener, and it reached third-party
//! resolvers outside the corporate egress proxy.
//!
//! All failures collapse to `None` — enrichment must never block an event.

use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

/// Host IP addresses detected once at daemon startup. Every field is independent — it is normal
/// for a machine to have an IPv4 but no IPv6.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HostIps {
    pub local_ipv4: Option<Ipv4Addr>,
    pub local_ipv6: Option<Ipv6Addr>,
}

impl HostIps {
    /// Detect both addresses. Never fails: a lookup that errors is recorded as `None`.
    /// Synchronous and sub-millisecond — it reads the interface table and nothing else.
    pub fn detect() -> Self {
        Self {
            local_ipv4: detect_local_ipv4(),
            local_ipv6: detect_local_ipv6(),
        }
    }
}

fn detect_local_ipv4() -> Option<Ipv4Addr> {
    match local_ip_address::local_ip() {
        Ok(IpAddr::V4(addr)) if is_routable_ipv4(&addr) => Some(addr),
        Ok(addr) => {
            tracing::debug!(addr = %addr, "local_ip() returned non-routable or non-v4 address");
            None
        }
        Err(e) => {
            tracing::debug!(error = %e, "local IPv4 detection failed");
            None
        }
    }
}

fn detect_local_ipv6() -> Option<Ipv6Addr> {
    match local_ip_address::local_ipv6() {
        Ok(IpAddr::V6(addr)) if is_routable_ipv6(&addr) => Some(addr),
        Ok(addr) => {
            tracing::debug!(addr = %addr, "local_ipv6() returned non-routable or non-v6 address");
            None
        }
        Err(e) => {
            tracing::debug!(error = %e, "local IPv6 detection failed");
            None
        }
    }
}

fn is_routable_ipv4(addr: &Ipv4Addr) -> bool {
    !addr.is_loopback() && !addr.is_unspecified()
}

fn is_routable_ipv6(addr: &Ipv6Addr) -> bool {
    !addr.is_loopback() && !addr.is_unspecified() && !is_unicast_link_local(addr)
}

/// `Ipv6Addr::is_unicast_link_local` is still unstable on our MSRV, so we inline the check.
/// Link-local addresses have the prefix `fe80::/10`.
fn is_unicast_link_local(addr: &Ipv6Addr) -> bool {
    (addr.segments()[0] & 0xffc0) == 0xfe80
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn loopback_ipv4_is_not_routable() {
        assert!(!is_routable_ipv4(&Ipv4Addr::new(127, 0, 0, 1)));
    }

    #[test]
    fn unspecified_ipv4_is_not_routable() {
        assert!(!is_routable_ipv4(&Ipv4Addr::UNSPECIFIED));
    }

    #[test]
    fn private_ipv4_is_routable() {
        // Private (RFC 1918) addresses are still useful LAN metadata.
        assert!(is_routable_ipv4(&Ipv4Addr::new(192, 168, 1, 42)));
        assert!(is_routable_ipv4(&Ipv4Addr::new(10, 0, 0, 1)));
        assert!(is_routable_ipv4(&Ipv4Addr::new(172, 16, 0, 1)));
    }

    #[test]
    fn link_local_ipv6_is_not_routable() {
        let fe80 = "fe80::1".parse::<Ipv6Addr>().unwrap();
        assert!(!is_routable_ipv6(&fe80));
    }

    #[test]
    fn loopback_ipv6_is_not_routable() {
        assert!(!is_routable_ipv6(&Ipv6Addr::LOCALHOST));
    }

    #[test]
    fn global_ipv6_is_routable() {
        let global = "2001:db8::1".parse::<Ipv6Addr>().unwrap();
        assert!(is_routable_ipv6(&global));
    }

    #[test]
    fn unique_local_ipv6_is_routable() {
        // fc00::/7 (ULA) — still routable in the "worth recording" sense.
        let ula = "fc00::1".parse::<Ipv6Addr>().unwrap();
        assert!(is_routable_ipv6(&ula));
    }

    #[test]
    fn unicast_link_local_check_matches_rfc_prefix() {
        for seg in [0xfe80, 0xfe81, 0xfebf] {
            let addr = Ipv6Addr::new(seg, 0, 0, 0, 0, 0, 0, 1);
            assert!(
                is_unicast_link_local(&addr),
                "{} should be link-local",
                addr
            );
        }
        for seg in [0xfec0, 0xff00, 0x2001] {
            let addr = Ipv6Addr::new(seg, 0, 0, 0, 0, 0, 0, 1);
            assert!(
                !is_unicast_link_local(&addr),
                "{} should not be link-local",
                addr
            );
        }
    }

    #[test]
    fn detect_completes_and_returns_struct() {
        // Smoke test: `detect()` must always return a HostIps value, never panic or hang.
        // Individual fields may be None depending on the test environment (CI has no v6).
        let ips = HostIps::detect();
        let _ = ips.local_ipv4;
        let _ = ips.local_ipv6;
    }
}