openlatch-client 0.1.6

The open-source security layer for AI agents — client forwarder
//! Host IP detection — runs once at daemon startup and caches the result on `AppState`.
//!
//! Four addresses are captured, all 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.
//! - `public_ipv4` / `public_ipv6`: from the `public-ip` crate using DNS queries to OpenDNS and
//!   Google DNS in parallel. Each lookup is bounded by a 3-second timeout so a hung resolver
//!   cannot stall daemon startup. DNS-only (no HTTP/HTTPS) to stay rustls-policy-compliant.
//!
//! All failures collapse to `None` — fail-open is the rule, per `.claude/rules/architecture.md`.

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

/// Bound for each public-IP resolver call. Picked to stay well inside any reasonable user
/// perception of "the daemon started" while still giving the DNS fan-out time to settle.
const PUBLIC_IP_TIMEOUT: Duration = Duration::from_secs(3);

/// 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, or a public v4 but no public v6.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HostIps {
    pub local_ipv4: Option<Ipv4Addr>,
    pub local_ipv6: Option<Ipv6Addr>,
    pub public_ipv4: Option<Ipv4Addr>,
    pub public_ipv6: Option<Ipv6Addr>,
}

impl HostIps {
    /// Detect all four addresses. Never fails: any individual lookup that errors or times out
    /// is recorded as `None`. Worst-case wall-clock cost is ~3 seconds (two public lookups in
    /// parallel, each capped by `PUBLIC_IP_TIMEOUT`).
    pub async fn detect() -> Self {
        let local_ipv4 = detect_local_ipv4();
        let local_ipv6 = detect_local_ipv6();

        let (public_ipv4, public_ipv6) = tokio::join!(detect_public_ipv4(), detect_public_ipv6());

        Self {
            local_ipv4,
            local_ipv6,
            public_ipv4,
            public_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
        }
    }
}

async fn detect_public_ipv4() -> Option<Ipv4Addr> {
    match tokio::time::timeout(PUBLIC_IP_TIMEOUT, public_ip::addr_v4()).await {
        Ok(Some(addr)) => Some(addr),
        Ok(None) => {
            tracing::debug!("public IPv4 resolvers returned no address");
            None
        }
        Err(_) => {
            tracing::debug!(
                timeout_ms = PUBLIC_IP_TIMEOUT.as_millis() as u64,
                "public IPv4 resolution timed out"
            );
            None
        }
    }
}

async fn detect_public_ipv6() -> Option<Ipv6Addr> {
    match tokio::time::timeout(PUBLIC_IP_TIMEOUT, public_ip::addr_v6()).await {
        Ok(Some(addr)) => Some(addr),
        Ok(None) => {
            tracing::debug!("public IPv6 resolvers returned no address");
            None
        }
        Err(_) => {
            tracing::debug!(
                timeout_ms = PUBLIC_IP_TIMEOUT.as_millis() as u64,
                "public IPv6 resolution timed out"
            );
            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
            );
        }
    }

    #[tokio::test]
    async 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 public v6).
        let ips = HostIps::detect().await;
        let _ = ips.local_ipv4;
        let _ = ips.local_ipv6;
        let _ = ips.public_ipv4;
        let _ = ips.public_ipv6;
    }
}