loopflow 0.9.12

Run steps and flows with coding agents
Documentation
use serde::Deserialize;
use std::net::{IpAddr, SocketAddr, UdpSocket};
use tokio::process::Command;
use tokio::time::{timeout, Duration};

const TAILSCALE_STATUS_TIMEOUT: Duration = Duration::from_millis(800);

#[derive(Debug, Deserialize)]
struct TailscaleStatus {
    #[serde(rename = "TailscaleIPs", default)]
    tailscale_ips: Vec<String>,
}

pub async fn detect_lfd_url(bind_addr: SocketAddr) -> String {
    let tailscale_ip = detect_tailscale_ip().await;
    let interface_ip = detect_primary_interface_ip();
    let host_ip = select_preferred_ip(tailscale_ip, interface_ip, bind_addr.ip());
    format_url(host_ip, bind_addr.port())
}

fn select_preferred_ip(
    tailscale_ip: Option<IpAddr>,
    interface_ip: Option<IpAddr>,
    bind_ip: IpAddr,
) -> IpAddr {
    tailscale_ip.or(interface_ip).unwrap_or(bind_ip)
}

async fn detect_tailscale_ip() -> Option<IpAddr> {
    let output = timeout(
        TAILSCALE_STATUS_TIMEOUT,
        Command::new("tailscale")
            .args(["status", "--json"])
            .output(),
    )
    .await
    .ok()?
    .ok()?;

    if !output.status.success() {
        return None;
    }

    let status_json = String::from_utf8(output.stdout).ok()?;
    parse_tailscale_ip(&status_json)
}

fn parse_tailscale_ip(status_json: &str) -> Option<IpAddr> {
    let status: TailscaleStatus = serde_json::from_str(status_json).ok()?;
    status
        .tailscale_ips
        .into_iter()
        .find_map(|ip| ip.parse::<IpAddr>().ok())
}

fn detect_primary_interface_ip() -> Option<IpAddr> {
    for target in ["1.1.1.1:80", "8.8.8.8:80", "[2606:4700:4700::1111]:80"] {
        let Some(ip) = detect_routable_ip(target) else {
            continue;
        };
        if !ip.is_loopback() && !ip.is_unspecified() {
            return Some(ip);
        }
    }
    None
}

fn detect_routable_ip(target: &str) -> Option<IpAddr> {
    let bind_addr = if target.starts_with('[') {
        "[::]:0"
    } else {
        "0.0.0.0:0"
    };

    let socket = UdpSocket::bind(bind_addr).ok()?;
    socket.connect(target).ok()?;
    Some(socket.local_addr().ok()?.ip())
}

fn format_url(ip: IpAddr, port: u16) -> String {
    match ip {
        IpAddr::V4(ipv4) => format!("http://{ipv4}:{port}"),
        IpAddr::V6(ipv6) => format!("http://[{ipv6}]:{port}"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::net::{Ipv4Addr, Ipv6Addr};

    #[test]
    fn parse_tailscale_ip_returns_first_ip() {
        let status = r#"{"TailscaleIPs":["100.64.1.5","fd7a:115c:a1e0::1"]}"#;
        let ip = parse_tailscale_ip(status);
        assert_eq!(ip, Some(IpAddr::V4(Ipv4Addr::new(100, 64, 1, 5))));
    }

    #[test]
    fn parse_tailscale_ip_returns_none_when_missing() {
        let status = r#"{"Self":{"HostName":"devbox"}}"#;
        assert_eq!(parse_tailscale_ip(status), None);
    }

    #[test]
    fn select_preferred_ip_uses_fallback_chain() {
        let bind = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
        let iface_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10));
        let tailscale_ip = IpAddr::V4(Ipv4Addr::new(100, 101, 102, 103));
        let iface = Some(iface_ip);
        let tailscale = Some(tailscale_ip);

        assert_eq!(select_preferred_ip(tailscale, iface, bind), tailscale_ip);
        assert_eq!(select_preferred_ip(None, iface, bind), iface_ip);
        assert_eq!(select_preferred_ip(None, None, bind), bind);
    }

    #[test]
    fn format_url_wraps_ipv6() {
        let ip = IpAddr::V6(Ipv6Addr::LOCALHOST);
        assert_eq!(format_url(ip, 2486), "http://[::1]:2486");
    }
}