scroll-chat 0.1.0

A secure terminal chat over SSH - host or join chatrooms with end-to-end encryption
//! SSH Client implementation for joining chat rooms

use anyhow::{Context, Result};
use crossterm::{
    event::{Event, KeyCode, KeyModifiers},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures::StreamExt;
use russh::client::{self, Handle};
use russh::{Channel, ChannelMsg, Disconnect};
use russh_keys::key::PublicKey;
use std::io::{stdout, Write};
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::{debug, info};

/// Client configuration
pub struct ClientConfig {
    pub address: String,
    pub password: String,
    pub username: String,
}

/// SSH Client handler
struct ClientHandler;

#[async_trait::async_trait]
impl client::Handler for ClientHandler {
    type Error = anyhow::Error;

    /// Called when the server sends its host key
    async fn check_server_key(
        self,
        server_public_key: &PublicKey,
    ) -> Result<(Self, bool), Self::Error> {
        // In production, you'd verify this against known hosts
        // For now, we accept all keys and display the fingerprint
        let fingerprint = server_public_key.fingerprint();
        eprintln!("\n🔐 Server fingerprint: {}", fingerprint);
        eprintln!("   (In production, verify this matches the host's displayed fingerprint)\n");
        Ok((self, true))
    }
}

/// Connect to a chat room and run the client session
pub async fn connect(config: ClientConfig) -> Result<()> {
    // Parse address
    let (host, port) = parse_address(&config.address)?;
    
    eprintln!("📡 Connecting to {}:{}...", host, port);
    
    // Create client config
    let ssh_config = client::Config::default();
    let ssh_config = Arc::new(ssh_config);
    
    // Connect
    let mut session = client::connect(ssh_config, (host.as_str(), port), ClientHandler)
        .await
        .context("Failed to connect to server")?;
    
    // Authenticate
    let auth_result = session
        .authenticate_password(&config.username, &config.password)
        .await
        .context("Authentication failed")?;
    
    if !auth_result {
        anyhow::bail!("Authentication rejected - wrong password?");
    }
    
    eprintln!("✅ Connected as {}", config.username);
    
    // Open a session channel
    let channel = session
        .channel_open_session()
        .await
        .context("Failed to open channel")?;
    
    // Get terminal size
    let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
    
    // Request PTY
    channel
        .request_pty(
            false,
            "xterm-256color",
            cols as u32,
            rows as u32,
            0,
            0,
            &[],
        )
        .await
        .context("Failed to request PTY")?;
    
    // Request shell
    channel
        .request_shell(false)
        .await
        .context("Failed to request shell")?;
    
    // Run the interactive session
    run_session(channel, session).await
}

/// Parse address into host and port
fn parse_address(addr: &str) -> Result<(String, u16)> {
    // Handle trycloudflare.com URLs
    if addr.contains("trycloudflare.com") {
        let host = addr
            .trim_start_matches("https://")
            .trim_start_matches("http://")
            .trim_end_matches('/');
        return Ok((host.to_string(), 443));
    }
    
    // Handle IP:PORT format
    if let Some(pos) = addr.rfind(':') {
        let host = addr[..pos].to_string();
        let port: u16 = addr[pos + 1..]
            .parse()
            .context("Invalid port number")?;
        return Ok((host, port));
    }
    
    // Default to port 22
    Ok((addr.to_string(), 22))
}

/// Run the interactive terminal session
async fn run_session(
    mut channel: Channel<client::Msg>,
    session: Handle<ClientHandler>,
) -> Result<()> {
    // Enter raw mode
    enable_raw_mode().context("Failed to enable raw mode")?;
    let mut stdout = stdout();
    execute!(stdout, EnterAlternateScreen).ok();
    
    // Cleanup guard
    struct Cleanup;
    impl Drop for Cleanup {
        fn drop(&mut self) {
            let _ = disable_raw_mode();
            let mut out = std::io::stdout();
            let _ = execute!(out, LeaveAlternateScreen);
        }
    }
    let _cleanup = Cleanup;
    
    // Channel for terminal input
    let (input_tx, mut input_rx) = mpsc::channel::<Vec<u8>>(32);
    
    // Spawn input reader
    let input_handle = tokio::spawn(async move {
        let mut reader = crossterm::event::EventStream::new();
        while let Some(event) = reader.next().await {
            match event {
                Ok(Event::Key(key)) => {
                    let bytes = key_to_bytes(key);
                    if !bytes.is_empty() {
                        if input_tx.send(bytes).await.is_err() {
                            break;
                        }
                    }
                }
                Ok(Event::Resize(cols, rows)) => {
                    // Send window change request
                    // Note: We'd need to handle this through the session
                }
                Err(_) => break,
                _ => {}
            }
        }
    });
    
    loop {
        tokio::select! {
            // Handle channel messages (data from server)
            msg = channel.wait() => {
                match msg {
                    Some(ChannelMsg::Data { data }) => {
                        stdout.write_all(&data)?;
                        stdout.flush()?;
                    }
                    Some(ChannelMsg::ExtendedData { data, ext }) => {
                        // stderr
                        stdout.write_all(&data)?;
                        stdout.flush()?;
                    }
                    Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => {
                        info!("Channel closed by server");
                        break;
                    }
                    Some(ChannelMsg::ExitStatus { exit_status }) => {
                        debug!("Exit status: {}", exit_status);
                        break;
                    }
                    _ => {}
                }
            }
            
            // Handle input from terminal
            Some(bytes) = input_rx.recv() => {
                channel.data(&bytes[..]).await?;
            }
        }
    }
    
    // Cleanup
    input_handle.abort();
    let _ = channel.eof().await;
    let _ = channel.close().await;
    let _ = session.disconnect(Disconnect::ByApplication, "", "en").await;
    
    eprintln!("\n👋 Disconnected from chat room");
    Ok(())
}

/// Convert a key event to raw bytes
fn key_to_bytes(key: crossterm::event::KeyEvent) -> Vec<u8> {
    match (key.modifiers, key.code) {
        (KeyModifiers::CONTROL, KeyCode::Char('c')) => vec![3],
        (KeyModifiers::CONTROL, KeyCode::Char('d')) => vec![4],
        (_, KeyCode::Enter) => vec![13],
        (_, KeyCode::Backspace) => vec![127],
        (_, KeyCode::Tab) => vec![9],
        (_, KeyCode::Esc) => vec![27],
        (_, KeyCode::Up) => vec![27, b'[', b'A'],
        (_, KeyCode::Down) => vec![27, b'[', b'B'],
        (_, KeyCode::Right) => vec![27, b'[', b'C'],
        (_, KeyCode::Left) => vec![27, b'[', b'D'],
        (_, KeyCode::Home) => vec![27, b'[', b'H'],
        (_, KeyCode::End) => vec![27, b'[', b'F'],
        (_, KeyCode::PageUp) => vec![27, b'[', b'5', b'~'],
        (_, KeyCode::PageDown) => vec![27, b'[', b'6', b'~'],
        (_, KeyCode::Delete) => vec![27, b'[', b'3', b'~'],
        (_, KeyCode::Char(c)) => {
            let mut buf = [0u8; 4];
            c.encode_utf8(&mut buf).as_bytes().to_vec()
        }
        _ => vec![],
    }
}