kftray-portforward 0.27.30

KFtray library with port forwarding logic
Documentation
use std::net::IpAddr;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use std::process::Command;
use std::str::FromStr;

use anyhow::{
    Result,
    anyhow,
};
use tracing::{
    debug,
    error,
    info,
    warn,
};

#[cfg(any(target_os = "macos", target_os = "linux"))]
pub fn execute_command(cmd: &str, args: &[&str]) -> anyhow::Result<()> {
    let status = Command::new(cmd)
        .args(args)
        .status()
        .map_err(|e| anyhow!("failed to spawn `{}`: {}", cmd, e))?;
    if status.success() {
        Ok(())
    } else {
        Err(anyhow!("`{}` exited with {}", cmd, status))
    }
}

pub fn is_loopback_address(addr: &str) -> bool {
    if let Ok(ip) = IpAddr::from_str(addr) {
        return ip.is_loopback();
    }
    false
}

pub fn is_default_loopback_address(addr: &str) -> bool {
    addr == "127.0.0.1"
}

pub fn is_custom_loopback_address(addr: &str) -> bool {
    is_loopback_address(addr) && !is_default_loopback_address(addr)
}

pub async fn ensure_loopback_address(addr: &str) -> Result<()> {
    if !is_loopback_address(addr) {
        return Ok(());
    }

    if addr == "127.0.0.1" {
        return Ok(());
    }

    debug!("Ensuring loopback address {} is configured", addr);

    if is_address_accessible(addr).await {
        debug!("Loopback address {} is already accessible", addr);
        return Ok(());
    }

    info!("Configuring loopback address: {}", addr);

    let helper_result = configure_loopback_with_helper(addr).await;

    if helper_result.is_ok() {
        debug!(
            "Successfully configured loopback address via helper: {}",
            addr
        );

        if is_address_accessible(addr).await {
            debug!("Verified loopback address {} is now accessible", addr);
            return Ok(());
        } else {
            warn!(
                "Helper claimed success but address {} is still not accessible",
                addr
            );
        }
    } else if let Err(e) = helper_result {
        warn!("Failed to configure loopback address with helper: {}", e);
    }

    debug!("Falling back to traditional methods for configuring loopback address");

    #[cfg(target_os = "macos")]
    {
        info!("Using macOS-specific method for loopback configuration");
        configure_loopback_macos(addr)?;
    }

    #[cfg(target_os = "linux")]
    {
        info!("Using Linux-specific method for loopback configuration");
        configure_loopback_linux(addr)?;
    }

    #[cfg(target_os = "windows")]
    {
        info!("Using Windows-specific method for loopback configuration");
        configure_loopback_windows(addr)?;
    }

    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows",)))]
    {
        return Err(anyhow!(
            "Loopback address configuration not supported on this platform"
        ));
    }

    if is_address_accessible(addr).await {
        debug!("Successfully configured loopback address: {}", addr);
        Ok(())
    } else {
        Err(anyhow!(
            "Failed to configure loopback address {} after all attempts",
            addr
        ))
    }
}

async fn configure_loopback_with_helper(addr: &str) -> Result<()> {
    debug!("Checking if helper is available");

    let app_id = "com.kftray.app".to_string();

    let socket_path = kftray_helper::communication::get_default_socket_path()?;

    if !kftray_helper::client::socket_comm::is_socket_available(&socket_path) {
        return Err(anyhow!("Helper service is not available"));
    }

    debug!("Helper service is available, proceeding with configuration");

    let command = kftray_helper::messages::RequestCommand::Network(
        kftray_helper::messages::NetworkCommand::Add {
            address: addr.to_string(),
        },
    );

    match kftray_helper::client::socket_comm::send_request(&socket_path, &app_id, command) {
        Ok(_) => {
            debug!(
                "Successfully configured loopback address with helper: {}",
                addr
            );
            Ok(())
        }
        Err(e) => {
            error!("Helper failed to add loopback address: {}", e);
            Err(anyhow!(
                "Helper failed to configure loopback address: {}",
                e
            ))
        }
    }
}

async fn remove_loopback_with_helper(addr: &str) -> Result<()> {
    debug!("Checking if helper is available for address removal");

    let app_id = "com.kftray.app".to_string();

    let socket_path = kftray_helper::communication::get_default_socket_path()?;

    if !kftray_helper::client::socket_comm::is_socket_available(&socket_path) {
        return Err(anyhow!("Helper service is not available"));
    }

    debug!("Helper service is available, proceeding with address removal");

    let command = kftray_helper::messages::RequestCommand::Network(
        kftray_helper::messages::NetworkCommand::Remove {
            address: addr.to_string(),
        },
    );

    match kftray_helper::client::socket_comm::send_request(&socket_path, &app_id, command) {
        Ok(_) => {
            debug!(
                "Successfully removed loopback address with helper: {}",
                addr
            );
            Ok(())
        }
        Err(e) => {
            error!("Helper failed to remove loopback address: {}", e);
            Err(anyhow!("Helper failed to remove loopback address: {}", e))
        }
    }
}

/// Remove loopback address. Only uses helper service - skips osascript fallback
/// to avoid blocking on user interaction during stop operations.
/// Address cleanup is not critical - addresses will be freed on system restart.
pub async fn remove_loopback_address(addr: &str) -> Result<()> {
    if !is_loopback_address(addr) {
        return Ok(());
    }

    if addr == "127.0.0.1" {
        return Ok(());
    }

    debug!("Removing loopback address: {}", addr);

    // Try helper service first (non-blocking)
    let helper_result = remove_loopback_with_helper(addr).await;

    if helper_result.is_ok() {
        debug!("Successfully removed loopback address via helper: {}", addr);
        return Ok(());
    } else if let Err(e) = &helper_result {
        warn!("Failed to remove loopback address with helper: {}", e);
    }

    // Skip osascript fallback on macOS - it blocks waiting for user interaction
    // which can hang stop operations indefinitely. Address cleanup is not critical.
    #[cfg(target_os = "macos")]
    {
        warn!(
            "Could not remove loopback address {} via helper. Skipping osascript fallback to avoid blocking. Address will be freed on restart.",
            addr
        );
        Ok(())
    }

    #[cfg(target_os = "linux")]
    {
        info!("Using Linux-specific method for loopback removal");
        if unsafe { libc::geteuid() } == 0 {
            execute_command("ip", &["addr", "del", addr, "dev", "lo"])?;
        } else {
            execute_command("sudo", &["ip", "addr", "del", addr, "dev", "lo"])?;
        }
        Ok(())
    }

    #[cfg(target_os = "windows")]
    {
        info!("Using Windows-specific method for loopback removal");
        Ok(())
    }

    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows",)))]
    {
        Err(anyhow!(
            "Loopback address removal not supported on this platform"
        ))
    }
}

pub async fn is_address_accessible(addr: &str) -> bool {
    let socket_addr = format!("{addr}:0");
    tokio::net::TcpListener::bind(socket_addr).await.is_ok()
}

#[cfg(target_os = "macos")]
fn configure_loopback_macos(addr: &str) -> Result<()> {
    let check_output = Command::new("ifconfig")
        .args(["lo0"])
        .output()
        .map_err(|e| anyhow!("Failed to check loopback interface: {}", e))?;

    let output_str = String::from_utf8_lossy(&check_output.stdout);
    if output_str.contains(addr) {
        debug!("Loopback address {} is already configured on lo0", addr);
        return Ok(());
    }

    debug!("Trying to add loopback address alias with osascript");
    let script =
        format!(r#"do shell script "ifconfig lo0 alias {addr}" with administrator privileges"#);

    let result = Command::new("osascript").args(["-e", &script]).output();

    match result {
        Ok(output) if output.status.success() => {
            debug!("Successfully added loopback address using osascript with admin privileges");
            Ok(())
        }
        Ok(output) => {
            let stderr = String::from_utf8_lossy(&output.stderr);
            debug!(
                "osascript command failed with exit code {}: {}",
                output.status.code().unwrap_or(-1),
                stderr
            );

            if stderr.contains("User cancelled")
                || stderr.contains("user cancelled")
                || stderr.contains("cancelled")
                || stderr.contains("User canceled")
                || stderr.contains("canceled")
                || stderr.contains("(-128)")
                || output.status.code() == Some(1)
            {
                Err(anyhow!("User cancelled loopback address configuration"))
            } else {
                Err(anyhow!("Failed to configure loopback address: {}", stderr))
            }
        }
        Err(e) => Err(anyhow!("Failed to execute osascript: {}", e)),
    }
}

#[cfg(target_os = "linux")]
fn configure_loopback_linux(addr: &str) -> Result<()> {
    if unsafe { libc::geteuid() } == 0 {
        execute_command("ip", &["addr", "add", addr, "dev", "lo"])?;

        execute_command(
            "ip",
            &["route", "add", &format!("{}/32", addr), "dev", "lo"],
        )
        .or_else(|e| {
            debug!("Route might already exist: {}", e);
            Ok(())
        })
    } else {
        execute_command("/usr/bin/pkexec", &["ip", "addr", "add", addr, "dev", "lo"])
            .or_else(|_| execute_command("sudo", &["ip", "addr", "add", addr, "dev", "lo"]))?;

        execute_command(
            "/usr/bin/pkexec",
            &["ip", "route", "add", &format!("{}/32", addr), "dev", "lo"],
        )
        .or_else(|_| {
            execute_command(
                "sudo",
                &["ip", "route", "add", &format!("{}/32", addr), "dev", "lo"],
            )
        })
        .or_else(|e| {
            debug!("Route might already exist: {}", e);
            Ok(())
        })
    }
}

#[cfg(target_os = "windows")]
fn configure_loopback_windows(_addr: &str) -> Result<()> {
    Ok(())
}

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

    #[test]
    fn test_is_loopback_address() {
        assert!(is_loopback_address("127.0.0.1"));
        assert!(is_loopback_address("127.0.0.2"));
        assert!(is_loopback_address("127.255.255.255"));
        assert!(!is_loopback_address("192.168.1.1"));
        assert!(!is_loopback_address("10.0.0.1"));
        assert!(!is_loopback_address("invalid-ip"));
    }

    #[test]
    fn test_is_default_loopback_address() {
        assert!(is_default_loopback_address("127.0.0.1"));
        assert!(!is_default_loopback_address("127.0.0.2"));
        assert!(!is_default_loopback_address("127.255.255.255"));
        assert!(!is_default_loopback_address("192.168.1.1"));
        assert!(!is_default_loopback_address("10.0.0.1"));
        assert!(!is_default_loopback_address("invalid-ip"));
    }

    #[test]
    fn test_is_custom_loopback_address() {
        assert!(!is_custom_loopback_address("127.0.0.1"));
        assert!(is_custom_loopback_address("127.0.0.2"));
        assert!(is_custom_loopback_address("127.0.0.5"));
        assert!(is_custom_loopback_address("127.255.255.255"));
        assert!(!is_custom_loopback_address("192.168.1.1"));
        assert!(!is_custom_loopback_address("10.0.0.1"));
        assert!(!is_custom_loopback_address("invalid-ip"));
    }

    #[tokio::test]
    async fn test_ensure_loopback_address_non_loopback() {
        assert!(ensure_loopback_address("192.168.1.1").await.is_ok());
    }

    #[tokio::test]
    async fn test_ensure_loopback_address_default() {
        assert!(ensure_loopback_address("127.0.0.1").await.is_ok());
    }

    #[cfg(test)]
    #[cfg(target_os = "windows")]
    #[tokio::test]
    async fn test_configure_loopback_windows() {
        assert!(configure_loopback_windows("127.0.0.2").is_ok());
    }
}