scroll-chat 0.1.1

A secure terminal chat over SSH - host or join chatrooms with end-to-end encryption
//! bore.pub tunnel integration for free public access
//! bore is a free, open-source TCP tunnel - no signup required

use anyhow::{Context, Result};
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tracing::{debug, info, warn};

/// bore tunnel manager
pub struct BoreTunnel {
    /// The bore child process
    process: Child,
    /// The public host
    pub host: String,
    /// The public port
    pub port: u16,
}

impl BoreTunnel {
    /// Start a bore tunnel for the given local port
    /// Uses bore.pub as the public server (free, no signup)
    pub async fn start(local_port: u16) -> Result<Self> {
        // Check if bore is available
        if !is_bore_available().await {
            anyhow::bail!(
                "bore not found in PATH. Install it with:\n\
                 cargo install bore-cli\n\
                 \n\
                 Or via Homebrew:\n\
                 brew install bore-cli"
            );
        }

        info!("Starting bore tunnel for port {}...", local_port);

        // Start bore with the public bore.pub server
        // bore outputs to STDOUT, not stderr
        let mut process = Command::new("bore")
            .arg("local")
            .arg(local_port.to_string())
            .arg("--to")
            .arg("bore.pub")
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .context("Failed to start bore")?;

        // bore outputs to STDOUT
        let stdout = process.stdout.take().context("Failed to get stdout")?;
        let mut reader = BufReader::new(stdout).lines();

        // Wait for the tunnel URL (with timeout)
        let (host, port) = tokio::time::timeout(std::time::Duration::from_secs(30), async {
            while let Ok(Some(line)) = reader.next_line().await {
                debug!("bore: {}", line);
                
                // Look for the listening message
                // Format: "... listening at bore.pub:XXXXX"
                if line.contains("listening at") {
                    if let Some((host, port)) = extract_bore_address(&line) {
                        return Ok((host, port));
                    }
                }
                
                // Check for errors
                if line.to_lowercase().contains("error") {
                    warn!("bore: {}", line);
                }
            }
            anyhow::bail!("Could not find tunnel address in bore output")
        })
        .await
        .context("Timeout waiting for bore tunnel")??;

        info!("Tunnel established: {}:{}", host, port);

        Ok(Self { process, host, port })
    }

    /// Get connection string for display
    pub fn connection_string(&self) -> String {
        format!("{}:{}", self.host, self.port)
    }

    /// Stop the tunnel
    pub async fn stop(mut self) -> Result<()> {
        info!("Stopping bore tunnel...");
        self.process.kill().await.ok();
        Ok(())
    }
}

impl Drop for BoreTunnel {
    fn drop(&mut self) {
        #[cfg(unix)]
        {
            if let Some(id) = self.process.id() {
                unsafe {
                    libc::kill(id as i32, libc::SIGTERM);
                }
            }
        }
    }
}

/// Check if bore is available in PATH
async fn is_bore_available() -> bool {
    Command::new("bore")
        .arg("--version")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .await
        .map(|s| s.success())
        .unwrap_or(false)
}

/// Extract host and port from bore output
/// Format: "... listening at bore.pub:XXXXX"
fn extract_bore_address(line: &str) -> Option<(String, u16)> {
    // Look for "listening at" and extract what follows
    if let Some(pos) = line.find("listening at") {
        let after = &line[pos + "listening at".len()..].trim();
        // The address should be like "bore.pub:12345"
        if let Some(colon_pos) = after.rfind(':') {
            let host = after[..colon_pos].trim().to_string();
            if let Ok(port) = after[colon_pos + 1..].trim().parse::<u16>() {
                if !host.is_empty() {
                    return Some((host, port));
                }
            }
        }
    }
    None
}

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

    #[test]
    fn test_extract_bore_address() {
        // Actual bore output format
        assert_eq!(
            extract_bore_address("2026-01-13T13:48:35.804069Z  INFO bore_cli::client: listening at bore.pub:5819"),
            Some(("bore.pub".to_string(), 5819))
        );
        
        assert_eq!(
            extract_bore_address("listening at bore.pub:12345"),
            Some(("bore.pub".to_string(), 12345))
        );
        
        assert_eq!(
            extract_bore_address("Starting tunnel..."),
            None
        );
    }
}