use anyhow::{Context, Result};
use crossterm::{
event::{Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
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};
pub struct ClientConfig {
pub address: String,
pub password: String,
pub username: String,
}
struct ClientHandler;
#[async_trait::async_trait]
impl client::Handler for ClientHandler {
type Error = anyhow::Error;
async fn check_server_key(
self,
server_public_key: &PublicKey,
) -> Result<(Self, bool), Self::Error> {
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))
}
}
pub async fn connect(config: ClientConfig) -> Result<()> {
let (host, port) = parse_address(&config.address)?;
eprintln!("📡 Connecting to {}:{}...", host, port);
let ssh_config = client::Config::default();
let ssh_config = Arc::new(ssh_config);
let mut session = client::connect(ssh_config, (host.as_str(), port), ClientHandler)
.await
.context("Failed to connect to server")?;
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);
let channel = session
.channel_open_session()
.await
.context("Failed to open channel")?;
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
channel
.request_pty(
false,
"xterm-256color",
cols as u32,
rows as u32,
0,
0,
&[],
)
.await
.context("Failed to request PTY")?;
channel
.request_shell(false)
.await
.context("Failed to request shell")?;
run_session(channel, session).await
}
fn parse_address(addr: &str) -> Result<(String, u16)> {
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));
}
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));
}
Ok((addr.to_string(), 22))
}
async fn run_session(
mut channel: Channel<client::Msg>,
session: Handle<ClientHandler>,
) -> Result<()> {
enable_raw_mode().context("Failed to enable raw mode")?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen).ok();
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;
let (input_tx, mut input_rx) = mpsc::channel::<Vec<u8>>(1024);
let (resize_tx, mut resize_rx) = mpsc::channel::<(u16, u16)>(8);
let input_handle = tokio::task::spawn_blocking(move || {
loop {
if let Ok(true) = crossterm::event::poll(std::time::Duration::from_millis(1)) {
match crossterm::event::read() {
Ok(Event::Key(key)) => {
let bytes = key_to_bytes(key);
if !bytes.is_empty() && input_tx.blocking_send(bytes).is_err() {
break;
}
}
Ok(Event::Resize(cols, rows)) => {
if resize_tx.blocking_send((cols, rows)).is_err() {
break;
}
}
Err(_) => break,
_ => {}
}
}
}
});
loop {
tokio::select! {
msg = channel.wait() => {
match msg {
Some(ChannelMsg::Data { data }) => {
stdout.write_all(&data)?;
stdout.flush()?;
}
Some(ChannelMsg::ExtendedData { data, ext: _ }) => {
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;
}
_ => {}
}
}
Some(bytes) = input_rx.recv() => {
channel.data(&bytes[..]).await?;
}
Some((cols, rows)) = resize_rx.recv() => {
channel.window_change(cols as u32, rows as u32, 0, 0).await?;
debug!("Terminal resized to {}x{}", cols, rows);
}
}
}
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(())
}
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![],
}
}