use anyhow::{Context, Result, anyhow, bail}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use clap::{ArgAction, Parser}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, size}; use dosh::auth::{ BootstrapResponse, build_bootstrap, decode_bootstrap, load_or_create_server_secret, }; use dosh::config::{expand_tilde, load_client_config, load_hosts_config, load_server_config}; use dosh::crypto; use dosh::exec_service::{ EXEC_STREAM_SENTINEL, ExecRequest, ExecResponse, FrameDecoder as ExecFrameDecoder, decode_response as decode_exec_response, encode_request as encode_exec_request, }; use dosh::file_transfer::{ CHUNK_SIZE, CopyEndpoint, FILE_STREAM_SENTINEL, FileKind, FileRequest, FileResponse, FrameDecoder, decode_response, encode_request, ensure_relative_child, parse_copy_endpoint, remote_basename, remote_join, }; use dosh::native::{ EnvVar, ForwardingKind, ForwardingRequest, KnownHostStatus, TrustResult, derive_native_session_key, generate_native_ephemeral, host_fingerprint, load_native_identity, load_native_identity_with_passphrase, parse_host_public_key_line, remove_trusted_host, sign_user_auth_with_private_key, supported_user_key_algorithms, trust_host, verify_known_host, verify_server_hello, }; use dosh::protocol::{ self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, Rekey, Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose, StreamData, StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust, TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope, }; use dosh::ssh_agent; use dosh::transport::{ DoshTransport, SessionEvent, SessionRole, SessionTransportConfig, TransportConfig, TransportEvent, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; use std::fs; use std::io::{Read, Seek, Write}; use std::net::{ Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener as StdTcpListener, TcpStream as StdTcpStream, ToSocketAddrs, }; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[cfg(unix)] use tokio::net::UnixStream; use tokio::net::{TcpListener, TcpStream, UdpSocket}; use tokio::sync::mpsc; const STREAM_INITIAL_WINDOW: usize = 1024 * 1024; const MAX_PENDING_USER_INPUT_BYTES: usize = 1024 * 1024; const STARTUP_INPUT_HOLD: Duration = Duration::from_millis(750); const POST_SUBMIT_ALL_INPUT_HOLD: Duration = Duration::from_millis(120); /// Sentinel `target_host` the server uses on a server-initiated `StreamOpen` that /// represents an SSH-agent connection (rather than a TCP target). The client /// splices these into its local `SSH_AUTH_SOCK` instead of dialing TCP. The /// `:0` port and this reserved name are never valid TCP forward targets. const AGENT_STREAM_SENTINEL: &str = "@dosh-agent"; /// Current terminal size, with a sane fallback. /// /// `crossterm::size()` returns `Err` when stdout is not a TTY (piped), but it /// can also return `Ok((0, 0))` for a real PTY that has not been sized yet /// (e.g. launched under `script` with no controlling window). Sending `0` to /// the server is meaningless and used to crash older servers, so treat any /// zero dimension the same as a missing size and fall back to 80x24. fn terminal_size() -> (u16, u16) { match size() { Ok((cols, rows)) if cols > 0 && rows > 0 => (cols, rows), _ => (80, 24), } } #[derive(Debug, Clone, Parser)] #[command( name = "dosh-client", version = dosh::build_info::VERSION, long_version = dosh::build_info::LONG_VERSION )] struct Args { #[arg()] server: Option, #[arg(trailing_var_arg = true, allow_hyphen_values = true)] command: Vec, #[arg(long)] session: Option, #[arg(long)] new: bool, #[arg(long)] view_only: bool, #[arg(long)] local_auth: bool, #[arg(long)] auth: Option, #[arg(long)] no_cache: bool, #[arg(long)] remove: bool, #[arg(long)] replace: bool, #[arg(long)] attach_only: bool, #[arg(short = 'L', long = "local-forward")] local_forward: Vec, #[arg(short = 'R', long = "remote-forward")] remote_forward: Vec, #[arg(short = 'D', long = "dynamic-forward")] dynamic_forward: Vec, /// Forward the local ssh-agent (SSH_AUTH_SOCK) to the remote session. /// SECURITY: opt-in only; the server must also set allow_agent_forwarding. #[arg(short = 'A', long = "forward-agent")] forward_agent: bool, #[arg(short = 'N', long)] forward_only: bool, #[arg(short = 'f', long)] background: bool, #[arg(long)] predict: bool, #[arg(long)] predict_mode: Option, #[arg(short = 'a', long = "predict-always")] predict_always: bool, #[arg(short = 'n', long = "predict-never")] predict_never: bool, #[arg(long)] escape_key: Option, #[arg(long)] ssh_port: Option, #[arg(long)] ssh_auth_command: Option, #[arg(long)] ssh_key: Option, #[arg(long)] ssh_known_hosts: Option, #[arg(long)] ssh_control_path: Option, #[arg(long)] dosh_port: Option, #[arg(long)] dosh_host: Option, #[arg(short, long, action = ArgAction::Count)] verbose: u8, } #[derive(Debug, Clone, Serialize, Deserialize)] struct CachedCredential { server: String, session: String, mode: String, udp_host: String, udp_port: u16, client_id: [u8; 16], session_key: [u8; 32], session_key_id: [u8; 16], attach_ticket: Vec, attach_ticket_psk: [u8; 32], last_rendered_seq: u64, } #[derive(Debug, Clone, PartialEq, Eq)] struct LocalForward { bind_host: String, listen_port: u16, target_host: String, target_port: u16, } #[derive(Debug, Clone, PartialEq, Eq)] struct RemoteForward { bind_host: String, listen_port: u16, target_host: String, target_port: u16, } #[derive(Debug, Clone, PartialEq, Eq)] struct DynamicForward { bind_host: String, listen_port: u16, } enum ForwardEvent { Open { stream_id: u64, target_host: String, target_port: u16, writer: tokio::net::tcp::OwnedWriteHalf, socks5_reply: bool, }, Data { stream_id: u64, bytes: Vec, }, Close { stream_id: u64, }, } #[derive(Clone)] struct PendingStreamChunk { offset: u64, bytes: Vec, last_sent: Instant, attempts: u32, } #[derive(Clone)] struct PendingStreamOpen { target_host: String, target_port: u16, last_sent: Instant, attempts: u32, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum StartupGateMode { HoldAll, HoldControl, } #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { let mut args = Args::parse(); let config = load_client_config(None).unwrap_or_default(); if args.server.as_deref() == Some("cp") { return run_cp_command(&config, &args); } if args.server.as_deref() == Some("exec") { return run_exec_command(&args); } if args.server.as_deref() == Some("ls") { return run_file_ls_command(&args); } if args.server.as_deref() == Some("mkdir") { return run_file_mkdir_command(&args); } if args.server.as_deref() == Some("rm") { return run_file_rm_command(&args); } if args.server.as_deref() == Some("cat") { return run_file_cat_command(&args); } if args.server.as_deref() == Some("update") { return run_update(&config, update_check_requested(&args.command)?); } if args.server.as_deref() == Some("setup") { return run_setup_command(&config, &args).await; } if args.server.as_deref() == Some("selftest") { return run_selftest_command(&config, &args).await; } if args.server.as_deref() == Some("proxy-stdio") { return run_proxy_stdio_command(&config, &args).await; } if args.server.as_deref() == Some("vscode") { return run_vscode_command(&config, &args); } if matches!(args.server.as_deref(), Some("recover" | "repair")) { return run_recover_command(&config, &args).await; } if matches!(args.server.as_deref(), Some("status" | "sessions")) { return run_status_command(&config, &args); } if args.server.as_deref() == Some("restart") { return run_restart_command(&config, &args); } if args.server.as_deref() == Some("forward") { args = rewrite_forward_command(args)?; } if args.server.as_deref() == Some("import-ssh") { return run_import_ssh(&config, &args.command); } if args.server.as_deref() == Some("trust") { return run_trust_command(&config, &args); } if args.server.as_deref() == Some("doctor") { return run_doctor_command(&config, &args).await; } let requested_server = args.server.clone().unwrap_or_else(|| config.server.clone()); let hosts = load_hosts_config(None).unwrap_or_default(); let host = hosts .hosts .get(&requested_server) .cloned() .unwrap_or_default(); let raw_server = host.ssh.clone().unwrap_or_else(|| requested_server.clone()); let server = ssh_with_user(&raw_server, host.user.as_deref()); let startup_command = resolved_startup_command(&args.command, &config, &host); let local_forwards = parse_local_forwards(&args.local_forward)?; let remote_forwards = parse_remote_forwards(&args.remote_forward)?; let dynamic_forwards = parse_dynamic_forwards(&args.dynamic_forward)?; // SSH-agent forwarding (opt-in via -A / forward_agent). SECURITY: this exposes // your local agent to the remote host for the session's lifetime; only ever // active on explicit opt-in AND when the server's allow_agent_forwarding is // set. We capture the LOCAL agent socket path now so the run loop can splice // each server-initiated agent stream into it. let forward_agent = args.forward_agent || config.forward_agent; let agent_sock = if forward_agent { match std::env::var_os("SSH_AUTH_SOCK") { Some(path) if !path.is_empty() => Some(PathBuf::from(path)), _ => { return Err(anyhow!( "agent forwarding requested but SSH_AUTH_SOCK is not set" )); } } } else { None }; let forwarding_requested = !local_forwards.is_empty() || !remote_forwards.is_empty() || !dynamic_forwards.is_empty() || forward_agent; let predict = args.predict || host.predict.unwrap_or(config.predict); let predict_mode = selected_predict_mode(&args, &config)?; let escape_key = selected_escape_key(&args, &config)?; let session = select_session(args.session.as_deref(), args.new); let mode = if args.forward_only { "forward-only" } else if args.view_only || config.view_only { "view-only" } else { "read-write" } .to_string(); let ssh_port = args.ssh_port.or(host.ssh_port).or(config.ssh_port); let ssh_auth_command = args .ssh_auth_command .clone() .or_else(|| config.ssh_auth_command.clone()) .unwrap_or_else(|| "~/.local/bin/dosh-auth".to_string()); if args .command .first() .is_some_and(|command| command == "sessions") { return run_sessions_command( &server, ssh_port, args.ssh_key.as_deref(), args.ssh_known_hosts.as_deref(), ); } let dosh_port = args.dosh_port.or(host.port).unwrap_or(config.dosh_port); let cache_path = cache_path(&config.credential_cache, &requested_server, &session, &mode); let (cols, rows) = terminal_size(); let auth_preference = args .auth .clone() .unwrap_or_else(|| config.auth_preference.clone()); if forwarding_requested && args.local_auth { return Err(anyhow!( "port forwarding requires native auth; remove --local-auth" )); } if forwarding_requested && !auth_allows(&auth_preference, "native") { return Err(anyhow!("port forwarding requires --auth native or auto")); } if args.background && std::env::var_os("DOSH_BACKGROUND_CHILD").is_none() { return spawn_background_forwarder(&args, forwarding_requested); } let allow_cache = !args.no_cache && !forwarding_requested; let started = Instant::now(); let mut resolved_ssh_config = None; let requested_udp_host = args .dosh_host .clone() .or_else(|| host.dosh_host.clone()) .or_else(|| config.dosh_host.clone()); let target_udp_host = if args.local_auth { requested_udp_host.unwrap_or_else(|| "127.0.0.1".to_string()) } else { match ssh_config(&server, ssh_port) { Ok(parsed) => { let hostname = selected_udp_host(requested_udp_host.as_deref(), &server, &parsed)?; resolved_ssh_config = Some(parsed); hostname } Err(err) => { log_debug( args.verbose, 2, &format!("dosh could not resolve SSH config hostname, using {server}: {err:#}"), ); resolved_ssh_config = Some(SshConfig::default()); selected_udp_host( requested_udp_host.as_deref(), &server, &SshConfig::default(), )? } } }; let credential = if allow_cache { load_cache(&cache_path).ok() } else { None }; let cache_requested_env = requested_env(&config, &host, resolved_ssh_config.as_ref()); let socket = UdpSocket::bind("0.0.0.0:0").await?; if let Some(mut cached) = credential.clone() { cached.udp_host = target_udp_host.clone(); cached.udp_port = dosh_port; log_timing(args.verbose, "credential_lookup_end", started.elapsed()); if config.cache_attach_tickets && allow_cache { match try_ticket_attach(&socket, &cached, cols, rows, cache_requested_env.clone()).await { Ok((frame, cred)) => { log_timing(args.verbose, "udp_ticket_attach_ready", started.elapsed()); save_cache(&cache_path, &cred)?; if args.attach_only { render_frame(&frame)?; detach_once(&socket, &cred, 2).await?; return Ok(()); } return run_terminal( socket, cred, Some(frame), predict, predict_mode, escape_key, startup_command, config.reconnect_timeout_secs, Vec::new(), Vec::new(), false, None, ) .await; } Err(err) => { log_debug( args.verbose, 2, &format!( "dosh ticket attach failed, falling back to SSH bootstrap: {err:#}" ), ); } } } else { match try_resume_fresh_process(&socket, &cached, cols, rows).await { Ok((frame, cred)) => { log_timing(args.verbose, "udp_resume_ready", started.elapsed()); if allow_cache { save_cache(&cache_path, &cred)?; } if args.attach_only { render_frame(&frame)?; detach_once(&socket, &cred, 2).await?; return Ok(()); } return run_terminal( socket, cred, Some(frame), predict, predict_mode, escape_key, startup_command, config.reconnect_timeout_secs, Vec::new(), Vec::new(), false, None, ) .await; } Err(err) => { log_debug( args.verbose, 2, &format!("dosh resume failed, falling back to SSH bootstrap: {err:#}"), ); } } } } if !args.local_auth && auth_allows(&auth_preference, "native") { if resolved_ssh_config.is_none() { resolved_ssh_config = Some(ssh_config(&server, ssh_port).unwrap_or_default()); } let cold_requested_env = requested_env(&config, &host, resolved_ssh_config.as_ref()); let native_start = Instant::now(); match try_native_auth( &socket, &config, &requested_server, &server, ssh_port, &target_udp_host, dosh_port, args.ssh_key.as_deref(), &session, &mode, cols, rows, forwarding_requests( &local_forwards, &remote_forwards, &dynamic_forwards, forward_agent, ), resolved_ssh_config.as_ref(), cold_requested_env, true, ) .await { Ok((frame, cred)) => { log_timing(args.verbose, "native_auth", native_start.elapsed()); if allow_cache { save_cache(&cache_path, &cred)?; } log_timing(args.verbose, "terminal_ready", started.elapsed()); if args.attach_only { render_frame(&frame)?; detach_once(&socket, &cred, 2).await?; return Ok(()); } return run_terminal( socket, cred, Some(frame), predict, predict_mode, escape_key, startup_command, config.reconnect_timeout_secs, local_forwards, dynamic_forwards, args.forward_only, agent_sock, ) .await; } Err(err) if !forwarding_requested && auth_allows(&auth_preference, "ssh") => { log_debug( args.verbose, 2, &format!("dosh native auth failed, falling back to SSH bootstrap: {err:#}"), ); } Err(err) => return Err(err.context("native auth failed")), } } if !args.local_auth && !auth_allows(&auth_preference, "ssh") { return Err(anyhow!( "auth preference {auth_preference:?} does not allow SSH fallback" )); } let bootstrap_start = Instant::now(); if !args.local_auth && resolved_ssh_config.is_none() { resolved_ssh_config = Some(ssh_config(&server, ssh_port).unwrap_or_default()); } let cold_requested_env = requested_env(&config, &host, resolved_ssh_config.as_ref()); let bootstrap = if args.local_auth { local_bootstrap(&session, &mode, cols, rows, dosh_port, target_udp_host)? } else { let mut bootstrap = ssh_bootstrap( &server, ssh_port, &ssh_auth_command, args.ssh_key.as_deref(), args.ssh_known_hosts.as_deref(), args.ssh_control_path.as_deref(), &session, &mode, cols, rows, )?; bootstrap.udp_host = target_udp_host; bootstrap.udp_port = dosh_port; bootstrap }; log_timing(args.verbose, "ssh_bootstrap", bootstrap_start.elapsed()); let (ok, mut cred) = bootstrap_attach(&socket, &server, &bootstrap, cols, rows, cold_requested_env).await?; cred.last_rendered_seq = ok.initial_seq; if allow_cache { save_cache(&cache_path, &cred)?; } log_timing(args.verbose, "terminal_ready", started.elapsed()); let first = Frame { session: ok.session, output_seq: ok.initial_seq, bytes: ok.snapshot, snapshot: true, closed: false, }; if args.attach_only { render_frame(&first)?; detach_once(&socket, &cred, 2).await?; return Ok(()); } run_terminal( socket, cred, Some(first), predict, predict_mode, escape_key, startup_command, config.reconnect_timeout_secs, Vec::new(), Vec::new(), false, None, ) .await } fn run_trust_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { let host = args .command .first() .ok_or_else(|| anyhow!("usage: dosh trust [--remove] [--replace] "))?; let path = expand_tilde(&config.known_hosts); if args.remove { if remove_trusted_host(&path, host)? { eprintln!("dosh trust: removed {host} from {}", path.display()); } else { eprintln!("dosh trust: {host} was not trusted in {}", path.display()); } return Ok(()); } let hosts = load_hosts_config(None).unwrap_or_default(); let host_config = hosts.hosts.get(host).cloned().unwrap_or_default(); let raw_server = host_config.ssh.clone().unwrap_or_else(|| host.to_string()); let server = ssh_with_user(&raw_server, host_config.user.as_deref()); let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port); let ssh_auth_command = args .ssh_auth_command .clone() .or_else(|| config.ssh_auth_command.clone()) .unwrap_or_else(|| "~/.local/bin/dosh-auth".to_string()); let public_key = fetch_host_key_over_ssh( &server, ssh_port, &ssh_auth_command, args.ssh_key.as_deref(), args.ssh_known_hosts.as_deref(), args.ssh_control_path.as_deref(), )?; match trust_host(&path, host, &public_key, "ssh", args.replace)? { TrustResult::AlreadyTrusted => { eprintln!( "dosh trust: {host} already trusted ({})", host_fingerprint(&public_key) ); } TrustResult::Trusted => { eprintln!( "dosh trust: trusted {host} {} in {}", host_fingerprint(&public_key), path.display() ); } } Ok(()) } async fn run_setup_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { let host = args .command .first() .cloned() .ok_or_else(|| anyhow!("usage: dosh setup "))?; println!("Dosh setup for {host}"); let imported = import_ssh_aliases(config, std::slice::from_ref(&host))?; for entry in imported { match entry.action { ImportAction::Added => println!( "[ok] host config: [{}] ssh={} dosh_host={} port={}", entry.alias, entry.ssh, entry.dosh_host, entry.port ), ImportAction::Skipped => println!("[ok] host config: [{}] already exists", entry.alias), } } let hosts = load_hosts_config(None).unwrap_or_default(); let host_config = hosts.hosts.get(&host).cloned().unwrap_or_default(); let raw_server = host_config.ssh.clone().unwrap_or_else(|| host.clone()); let server = ssh_with_user(&raw_server, host_config.user.as_deref()); let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port); let ssh_auth_command = args .ssh_auth_command .clone() .or_else(|| config.ssh_auth_command.clone()) .unwrap_or_else(|| "~/.local/bin/dosh-auth".to_string()); let known_hosts = expand_tilde(&config.known_hosts); let public_key = fetch_host_key_over_ssh( &server, ssh_port, &ssh_auth_command, args.ssh_key.as_deref(), args.ssh_known_hosts.as_deref(), args.ssh_control_path.as_deref(), ) .with_context(|| { format!("fetch Dosh host key over SSH; make sure dosh-auth is installed on {server}") })?; match trust_host(&known_hosts, &host, &public_key, "ssh", args.replace)? { TrustResult::AlreadyTrusted => println!( "[ok] trust: already trusted {}", host_fingerprint(&public_key) ), TrustResult::Trusted => println!( "[ok] trust: pinned {} in {}", host_fingerprint(&public_key), known_hosts.display() ), } println!(); run_doctor_for_host(config, args, &host).await?; println!(); println!("Next:"); println!(" dosh {host}"); println!(" dosh update --check"); Ok(()) } async fn run_selftest_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { let host = args .command .first() .cloned() .ok_or_else(|| anyhow!("usage: dosh selftest "))?; println!("Dosh selftest for {host}"); println!("[ok] terminal size fallback: {:?}", terminal_size()); let overlay = render_status_overlay("[dosh] reconnecting — last contact 3s ago", 24); ensure_tui_safe_status_overlay(&overlay)?; println!("[ok] disconnect UI: save/restore top status bar"); parse_local_forward("18080:127.0.0.1:80")?; parse_remote_forward("19090:127.0.0.1:5432")?; parse_dynamic_forward("11080")?; println!("[ok] forwarding parser: -L, -R, -D"); println!(); run_doctor_for_host(config, args, &host).await?; println!(); println!("[ok] selftest complete"); Ok(()) } async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { let requested = args .command .first() .cloned() .ok_or_else(|| anyhow!("usage: dosh doctor "))?; run_doctor_for_host(config, args, &requested).await } async fn run_recover_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { if args.command.len() != 1 { return Err(anyhow!("usage: dosh recover ")); } let requested = args.command[0].clone(); println!("Dosh recovery for {requested}"); let removed = clear_cached_credentials(&config.credential_cache, &requested) .context("clear cached attach credentials")?; println!("[ok] removed {removed} cached credential(s)"); println!("[info] host trust was left unchanged"); run_doctor_for_host(config, args, &requested).await } fn run_status_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { if args.command.len() != 1 { return Err(anyhow!("usage: dosh status ")); } let requested = args.command[0].clone(); let hosts = load_hosts_config(None).unwrap_or_default(); let host_config = hosts.hosts.get(&requested).cloned().unwrap_or_default(); let server = status_ssh_target(&requested, &host_config); let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port); println!("Dosh status for {requested}"); if is_local_status_target(&server) { return run_local_sessions_command(); } run_sessions_command( &server, ssh_port, args.ssh_key.as_deref(), args.ssh_known_hosts.as_deref(), ) } fn run_restart_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { if args.command.len() != 1 { return Err(anyhow!("usage: dosh restart ")); } let requested = args.command[0].clone(); let hosts = load_hosts_config(None).unwrap_or_default(); let host_config = hosts.hosts.get(&requested).cloned().unwrap_or_default(); let server = status_ssh_target(&requested, &host_config); let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port); println!("Restarting Dosh server for {requested}"); if is_local_status_target(&server) { return run_local_script(RESTART_STATUS_SCRIPT, "restart local Dosh server"); } run_remote_script( &server, ssh_port, args.ssh_key.as_deref(), args.ssh_known_hosts.as_deref(), RESTART_STATUS_SCRIPT, "restart remote Dosh server", ) } fn status_ssh_target(requested: &str, host_config: &dosh::config::HostConfig) -> String { let raw_server = host_config .ssh .clone() .unwrap_or_else(|| requested.to_string()); ssh_with_user(&raw_server, host_config.user.as_deref()) } fn is_local_status_target(server: &str) -> bool { let host = server.rsplit('@').next().unwrap_or(server); matches!(host, "localhost" | "127.0.0.1" | "::1") } const SESSIONS_STATUS_SCRIPT: &str = r#"printf 'tmux sessions:\n'; tmux ls 2>/dev/null || printf ' none\n'; printf '\nDosh service:\n'; systemctl --user --no-pager --plain status dosh-server.service 2>/dev/null | sed -n '1,8p' || pgrep -af dosh-server || printf ' not running\n'"#; const RESTART_STATUS_SCRIPT: &str = r#"if command -v systemctl >/dev/null 2>&1 && systemctl --user list-unit-files dosh-server.service >/dev/null 2>&1; then systemctl --user restart dosh-server.service; else pkill -f 'dosh-server serve' 2>/dev/null || true; nohup "$HOME/.local/bin/dosh-server" serve >/tmp/dosh-server.log 2>&1 & fi; sleep 1; systemctl --user --no-pager --plain status dosh-server.service 2>/dev/null | sed -n '1,8p' || pgrep -af dosh-server || printf ' not running\n'"#; fn run_local_sessions_command() -> Result<()> { run_local_script(SESSIONS_STATUS_SCRIPT, "run local status command") } fn run_local_script(script: &str, context: &str) -> Result<()> { let status = Command::new("sh") .arg("-c") .arg(script) .status() .with_context(|| context.to_string())?; if !status.success() { return Err(anyhow!("{context} failed with status {status}")); } Ok(()) } async fn run_doctor_for_host( config: &dosh::config::ClientConfig, args: &Args, requested: &str, ) -> Result<()> { let hosts = load_hosts_config(None).unwrap_or_default(); let host_config = hosts.hosts.get(requested).cloned().unwrap_or_default(); let raw_server = host_config .ssh .clone() .unwrap_or_else(|| requested.to_string()); let server = ssh_with_user(&raw_server, host_config.user.as_deref()); let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port); let dosh_port = args .dosh_port .or(host_config.port) .unwrap_or(config.dosh_port); println!("Dosh doctor for {requested}"); println!( "[info] config: client={} hosts={} known_hosts={}", expand_tilde("~/.config/dosh/client.toml").display(), expand_tilde("~/.config/dosh/hosts.toml").display(), expand_tilde(&config.known_hosts).display() ); println!( "[info] client: auth={} predict={}({}) disconnect_status={} cache_tickets={}", config.auth_preference, config.predict, config.predict_mode, config.disconnect_status, config.cache_attach_tickets ); let parsed_ssh_config = match ssh_config(&server, ssh_port) { Ok(parsed) => { let hostname = parsed .hostname .clone() .unwrap_or_else(|| ssh_destination_host(&server)); println!( "[ok] ssh config: host={} user={} port={} identities_only={} proxy={}", hostname, parsed.user.as_deref().unwrap_or(""), parsed.port.unwrap_or(ssh_port.unwrap_or(22)), parsed.identities_only, parsed .proxy_jump .as_deref() .or(parsed.proxy_command.as_deref()) .unwrap_or("") ); parsed } Err(err) => { println!("[fail] ssh config: {err:#}"); SshConfig::default() } }; match ssh_fallback_probe( &server, ssh_port, args.ssh_key.as_deref(), args.ssh_known_hosts.as_deref(), ) { Ok(()) => println!("[ok] ssh fallback: reachable"), Err(err) => println!("[warn] ssh fallback: {err:#}"), } let requested_udp_host = args .dosh_host .clone() .or_else(|| host_config.dosh_host.clone()) .or_else(|| config.dosh_host.clone()); let udp_host = match selected_udp_host(requested_udp_host.as_deref(), &server, &parsed_ssh_config) { Ok(host) => host, Err(err) => { println!("[fail] native endpoint: {err:#}"); ssh_destination_host(&server) } }; println!("[info] native endpoint: {udp_host}:{dosh_port}"); match resolve_addr(&udp_host, dosh_port) { Ok(addr) => println!("[ok] udp resolve: {addr}"), Err(err) => println!("[fail] udp resolve: {err:#}"), } println!( "[info] forwarding requested by config: agent={} default_session={}", config.forward_agent, config.default_session ); let socket = UdpSocket::bind("0.0.0.0:0").await?; match try_native_auth_check( &socket, config, requested, &server, ssh_port, &udp_host, dosh_port, args.ssh_key.as_deref(), Some(&parsed_ssh_config), ) .await { Ok(check) => { println!("[ok] native udp: reachable"); println!( "[ok] server version: {}", if check.server_version.is_empty() { "" } else { &check.server_version } ); if server_version_mismatch(&check.server_version) { println!( "[warn] version mismatch: client={} server={}", env!("CARGO_PKG_VERSION"), check.server_version ); } println!("[ok] known host: trusted"); println!("[ok] native auth: authorized as {}", check.requested_user); println!( "[ok] server policy: tcp_forward={} remote_forward={} agent_forward={} file_transfer={} exec_command={}", check.allow_tcp_forwarding, check.allow_remote_forwarding, check.allow_agent_forwarding, check.allow_file_transfer, check.allow_exec_command ); Ok(()) } Err(err) => { println!("[fail] native/auth check: {err:#}"); Err(err.context("dosh doctor failed")) } } } fn server_version_mismatch(server_version: &str) -> bool { !server_version.is_empty() && server_version != env!("CARGO_PKG_VERSION") } fn ensure_tui_safe_status_overlay(bytes: &[u8]) -> Result<()> { anyhow::ensure!( bytes.starts_with(b"\x1b7"), "status overlay must save cursor" ); anyhow::ensure!( bytes.ends_with(b"\x1b8"), "status overlay must restore cursor" ); anyhow::ensure!( !contains_bytes(bytes, b"\n") && !contains_bytes(bytes, b"\r"), "status overlay must not scroll" ); anyhow::ensure!( !contains_bytes(bytes, b"\x1b[?25l") && !contains_bytes(bytes, b"\x1b[?25h"), "status overlay must not toggle cursor visibility" ); Ok(()) } fn ssh_fallback_probe( server: &str, ssh_port: Option, ssh_key: Option<&Path>, ssh_known_hosts: Option<&Path>, ) -> Result<()> { let mut command = Command::new("ssh"); command.arg("-o").arg("BatchMode=yes"); command.arg("-o").arg("ConnectTimeout=5"); command.arg("-T"); if let Some(ssh_port) = ssh_port { command.arg("-p").arg(ssh_port.to_string()); } if let Some(key) = ssh_key { command.arg("-i").arg(key); } if let Some(known_hosts) = ssh_known_hosts { command .arg("-o") .arg(format!("UserKnownHostsFile={}", known_hosts.display())); } let output = command.arg(server).arg("true").output()?; anyhow::ensure!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); Ok(()) } fn fetch_host_key_over_ssh( server: &str, ssh_port: Option, ssh_auth_command: &str, ssh_key: Option<&Path>, ssh_known_hosts: Option<&Path>, ssh_control_path: Option<&Path>, ) -> Result { let mut command = Command::new("ssh"); if let Some(ssh_port) = ssh_port { command.arg("-p").arg(ssh_port.to_string()); } command.arg("-T"); if let Some(key) = ssh_key { command.arg("-i").arg(key); } if let Some(known_hosts) = ssh_known_hosts { command .arg("-o") .arg(format!("UserKnownHostsFile={}", known_hosts.display())); } if let Some(control_path) = ssh_control_path { command.arg("-S").arg(control_path); } let output = command .arg(server) .arg(ssh_auth_command) .arg("--host-key") .output() .context("fetch Dosh host key over SSH")?; if !output.status.success() { return Err(anyhow!( "Dosh host-key fetch failed: {}", String::from_utf8_lossy(&output.stderr) )); } let raw = String::from_utf8(output.stdout)?; parse_host_public_key_line(&raw) } fn startup_command(args: &[String]) -> Option> { if args.is_empty() { return None; } let mut command = args.join(" "); command.push('\r'); Some(command.into_bytes()) } fn resolved_startup_command( args: &[String], config: &dosh::config::ClientConfig, host: &dosh::config::HostConfig, ) -> Option> { if args.is_empty() { return host .default_command .as_deref() .map(startup_command_from_string); } match resolve_command_extension(args, config, host) { ExtensionResolution::Command(command) => Some(startup_command_from_string(&command)), ExtensionResolution::Disabled => None, ExtensionResolution::NoMatch => startup_command(args), } } enum ExtensionResolution { Command(String), Disabled, NoMatch, } fn resolve_command_extension( args: &[String], config: &dosh::config::ClientConfig, host: &dosh::config::HostConfig, ) -> ExtensionResolution { let Some(name) = args.first() else { return ExtensionResolution::NoMatch; }; if let Some(extension) = host.extensions.get(name) { if extension.disabled { return ExtensionResolution::Disabled; } if let Some(command) = extension.command.as_deref() { return ExtensionResolution::Command(expand_command_extension(command, &args[1..])); } } if let Some(extension) = config.extensions.get(name) { if extension.disabled { return ExtensionResolution::Disabled; } if let Some(command) = extension.command.as_deref() { return ExtensionResolution::Command(expand_command_extension(command, &args[1..])); } } ExtensionResolution::NoMatch } fn expand_command_extension(template: &str, args: &[String]) -> String { let quoted_args = args .iter() .map(|arg| shell_word(arg)) .collect::>() .join(" "); if template.contains("{args}") { return template.replace("{args}", "ed_args); } if args.is_empty() { template.to_string() } else { format!("{template} {quoted_args}") } } fn startup_command_from_string(command: &str) -> Vec { let mut command = command.to_string(); command.push('\r'); command.into_bytes() } fn spawn_background_forwarder(args: &Args, forwarding_requested: bool) -> Result<()> { if !forwarding_requested { return Err(anyhow!( "-f requires at least one -L, -R, or -D forwarding request" )); } if !args.forward_only { return Err(anyhow!("-f currently requires -N for forward-only mode")); } let listener = StdTcpListener::bind("127.0.0.1:0").context("bind background readiness socket")?; listener .set_nonblocking(true) .context("configure background readiness socket")?; let readiness_addr = listener.local_addr()?; let token = URL_SAFE_NO_PAD.encode(crypto::random_16()); let exe = std::env::current_exe().context("locate current dosh-client executable")?; let mut child = Command::new(exe) .args(std::env::args_os().skip(1)) .env("DOSH_BACKGROUND_CHILD", "1") .env("DOSH_BACKGROUND_READY_ADDR", readiness_addr.to_string()) .env("DOSH_BACKGROUND_READY_TOKEN", &token) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .context("spawn background dosh forwarder")?; if let Some(path) = std::env::var_os("DOSH_BACKGROUND_PID_FILE") { fs::write(path, child.id().to_string()).context("write background pid file")?; } let deadline = Instant::now() + Duration::from_secs(30); loop { match listener.accept() { Ok((mut stream, _)) => { let mut raw = String::new(); stream.read_to_string(&mut raw).ok(); if raw.trim() == token { eprintln!("dosh background forwarding ready"); return Ok(()); } return Err(anyhow!("background forwarder sent invalid readiness token")); } Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {} Err(err) => return Err(err).context("wait for background readiness"), } if let Some(status) = child.try_wait()? { return Err(anyhow!( "background dosh forwarder exited before readiness: {status}" )); } if Instant::now() >= deadline { let _ = child.kill(); let _ = child.wait(); return Err(anyhow!("background dosh forwarder did not become ready")); } std::thread::sleep(Duration::from_millis(20)); } } fn rewrite_forward_command(mut args: Args) -> Result { let mut tokens = std::mem::take(&mut args.command).into_iter(); let host = tokens.next().ok_or_else(|| { anyhow!("usage: dosh forward [-L spec] [-R spec] [-D spec] [-A] [-f]") })?; let mut trailing_command = Vec::new(); while let Some(token) = tokens.next() { match token.as_str() { "-L" | "--local-forward" => { let spec = tokens .next() .ok_or_else(|| anyhow!("dosh forward: {token} requires a forwarding spec"))?; args.local_forward.push(spec); } "-R" | "--remote-forward" => { let spec = tokens .next() .ok_or_else(|| anyhow!("dosh forward: {token} requires a forwarding spec"))?; args.remote_forward.push(spec); } "-D" | "--dynamic-forward" => { let spec = tokens .next() .ok_or_else(|| anyhow!("dosh forward: {token} requires a forwarding spec"))?; args.dynamic_forward.push(spec); } "-A" | "--forward-agent" => args.forward_agent = true, "-f" | "--background" => args.background = true, "-N" | "--forward-only" => args.forward_only = true, "--" => { trailing_command.extend(tokens); break; } other => return Err(anyhow!("dosh forward: unknown option {other:?}")), } } if !trailing_command.is_empty() { return Err(anyhow!( "dosh forward does not run remote commands; remove {:?}", trailing_command )); } if args.local_forward.is_empty() && args.remote_forward.is_empty() && args.dynamic_forward.is_empty() && !args.forward_agent { return Err(anyhow!( "dosh forward requires at least one -L, -R, -D, or -A request" )); } args.server = Some(host); args.command = Vec::new(); args.forward_only = true; Ok(args) } fn signal_background_ready() { let Some(addr) = std::env::var_os("DOSH_BACKGROUND_READY_ADDR") else { return; }; let Some(token) = std::env::var_os("DOSH_BACKGROUND_READY_TOKEN") else { return; }; if let Ok(mut stream) = StdTcpStream::connect(addr.to_string_lossy().as_ref()) { let _ = stream.write_all(token.to_string_lossy().as_bytes()); let _ = stream.shutdown(std::net::Shutdown::Write); } } #[derive(Debug, Clone)] struct CpOptions { recursive: bool, resume: bool, preserve: bool, progress: bool, overwrite: bool, } impl Default for CpOptions { fn default() -> Self { Self { recursive: false, resume: false, preserve: true, progress: true, overwrite: true, } } } struct FileForwarder { child: Child, } impl Drop for FileForwarder { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } struct FileServiceClient { stream: StdTcpStream, decoder: FrameDecoder, pending: VecDeque, } struct ExecServiceClient { stream: StdTcpStream, decoder: ExecFrameDecoder, pending: VecDeque, } async fn run_proxy_stdio_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { if args.command.len() != 3 { return Err(anyhow!( "usage: dosh proxy-stdio HOST TARGET_HOST TARGET_PORT" )); } anyhow::ensure!( !args.local_auth, "proxy-stdio requires native auth; local auth cannot authenticate SSH ProxyCommand streams" ); let requested_server = args.command[0].clone(); let target_host = args.command[1].clone(); let target_port = args.command[2] .parse::() .with_context(|| format!("invalid proxy target port {:?}", args.command[2]))?; anyhow::ensure!( valid_forward_host(&target_host), "invalid proxy target host" ); anyhow::ensure!(target_port != 0, "proxy target port cannot be 0"); let hosts = load_hosts_config(None).unwrap_or_default(); let host = hosts .hosts .get(&requested_server) .cloned() .unwrap_or_default(); let raw_server = host.ssh.clone().unwrap_or_else(|| requested_server.clone()); let server = ssh_with_user(&raw_server, host.user.as_deref()); let ssh_port = args.ssh_port.or(host.ssh_port).or(config.ssh_port); let resolved_ssh_config = ssh_config(&server, ssh_port).unwrap_or_default(); let requested_udp_host = args .dosh_host .clone() .or_else(|| host.dosh_host.clone()) .or_else(|| config.dosh_host.clone()); let target_udp_host = selected_udp_host(requested_udp_host.as_deref(), &server, &resolved_ssh_config)?; let dosh_port = args.dosh_port.or(host.port).unwrap_or(config.dosh_port); let socket = UdpSocket::bind("0.0.0.0:0").await?; let session = args .session .clone() .unwrap_or_else(protocol::generate_implicit_session_name); let requested_forwardings = vec![ForwardingRequest { kind: ForwardingKind::Local, bind_host: None, listen_port: 0, target_host: Some(target_host.clone()), target_port: Some(target_port), }]; let (_frame, cred) = try_native_auth( &socket, config, &requested_server, &server, ssh_port, &target_udp_host, dosh_port, args.ssh_key.as_deref(), &session, "forward-only", 80, 24, requested_forwardings, Some(&resolved_ssh_config), Vec::new(), false, ) .await?; let peer_addr = resolve_addr(&cred.udp_host, cred.udp_port)?; let mut transport = DoshTransport::new_owned( socket, SessionTransportConfig { role: SessionRole::Client, conn_id: cred.client_id, session_key: cred.session_key, peer_addr, initial_send_seq: 2, initial_ack: cred.last_rendered_seq, stream: TransportConfig::default(), }, ); let stream_id = transport.open_target(target_host, target_port).await?; proxy_stdio_loop(transport, stream_id).await } async fn proxy_stdio_loop(mut transport: DoshTransport, stream_id: u64) -> Result<()> { let (stdin_tx, mut stdin_rx) = mpsc::unbounded_channel::>(); std::thread::Builder::new() .name("dosh-proxy-stdin".to_string()) .spawn(move || { let mut stdin = std::io::stdin(); let mut buf = [0u8; 16 * 1024]; loop { match stdin.read(&mut buf) { Ok(0) => break, Ok(n) => { if stdin_tx.send(buf[..n].to_vec()).is_err() { break; } } Err(_) => break, } } })?; let mut stdout = tokio::io::stdout(); let mut maintenance = tokio::time::interval(Duration::from_millis(50)); let mut stdin_closed = false; loop { tokio::select! { stdin_msg = stdin_rx.recv(), if !stdin_closed => { match stdin_msg { Some(bytes) => transport.send(stream_id, bytes).await?, None => { stdin_closed = true; let _ = transport.close(stream_id).await; } } } event = transport.recv() => { match event? { SessionEvent::Stream(TransportEvent::OpenOk { stream_id: opened, .. }) if opened == stream_id => {} SessionEvent::Stream(TransportEvent::OpenReject { stream_id: rejected, reason }) if rejected == stream_id => { return Err(anyhow!("Dosh proxy stream rejected: {reason}")); } SessionEvent::Stream(TransportEvent::Data(data)) if data.stream_id == stream_id => { for chunk in data.chunks { stdout.write_all(&chunk).await?; } stdout.flush().await?; } SessionEvent::Stream(TransportEvent::Close { stream_id: closed }) if closed == stream_id => { stdout.flush().await?; return Ok(()); } SessionEvent::Stream(_) | SessionEvent::Ping | SessionEvent::Pong | SessionEvent::Ignored => {} } } _ = maintenance.tick() => { transport.maintenance().await?; } } } } fn run_vscode_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { let mut tokens = args.command.iter(); let first = tokens .next() .ok_or_else(|| anyhow!("usage: dosh vscode [setup|open] HOST [REMOTE_PATH]"))?; let (launch, host, remote_path) = match first.as_str() { "setup" => { let host = tokens .next() .ok_or_else(|| anyhow!("usage: dosh vscode setup HOST"))?; (false, host.as_str(), None) } "open" => { let host = tokens .next() .ok_or_else(|| anyhow!("usage: dosh vscode open HOST [REMOTE_PATH]"))?; (true, host.as_str(), tokens.next().map(String::as_str)) } host => (true, host, tokens.next().map(String::as_str)), }; if tokens.next().is_some() { return Err(anyhow!("dosh vscode accepts at most one remote path")); } let generated = ensure_vscode_ssh_host(config, args, host)?; println!("[ok] VS Code SSH host: {}", generated.alias); println!("[ok] SSH target: 127.0.0.1:{}", generated.target_ssh_port); println!("[ok] Dosh proxy: {}", generated.proxy_command); if launch { launch_vscode_remote(&generated.alias, remote_path)?; } else { println!("Open in VS Code Remote-SSH as: {}", generated.alias); } Ok(()) } struct GeneratedVscodeHost { alias: String, proxy_command: String, target_ssh_port: u16, } fn ensure_vscode_ssh_host( config: &dosh::config::ClientConfig, args: &Args, requested: &str, ) -> Result { let hosts = load_hosts_config(None).unwrap_or_default(); let host_config = hosts.hosts.get(requested).cloned().unwrap_or_default(); let raw_server = host_config .ssh .clone() .unwrap_or_else(|| requested.to_string()); let server = ssh_with_user(&raw_server, host_config.user.as_deref()); let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port); let ssh_config = ssh_config(&server, ssh_port).unwrap_or_default(); let target_ssh_port = ssh_config.port.or(ssh_port).unwrap_or(22); let user = host_config .user .or(ssh_username(&server)) .or(ssh_config.user); let alias = format!("dosh-{}", vscode_safe_alias(requested)); let exe = std::env::current_exe() .ok() .map(|path| path.display().to_string()) .unwrap_or_else(|| "dosh".to_string()); let mut proxy_command = ssh_config_word(&exe); if let Some(host) = args .dosh_host .clone() .or_else(|| host_config.dosh_host.clone()) .or_else(|| config.dosh_host.clone()) { proxy_command.push_str(&format!(" --dosh-host {}", ssh_config_word(&host))); } if let Some(port) = args.dosh_port.or(host_config.port) { proxy_command.push_str(&format!(" --dosh-port {port}")); } proxy_command.push_str(&format!( " proxy-stdio {} %h %p", ssh_config_word(requested) )); let ssh_dir = expand_tilde("~/.ssh"); fs::create_dir_all(&ssh_dir).with_context(|| format!("create {}", ssh_dir.display()))?; let include_path = ssh_dir.join("config.dosh"); let main_config = ssh_dir.join("config"); ensure_ssh_include(&main_config, "config.dosh")?; let mut block = String::new(); block.push_str(&format!("# BEGIN DOSH {alias}\n")); block.push_str(&format!("Host {alias}\n")); block.push_str(" HostName 127.0.0.1\n"); block.push_str(&format!(" Port {target_ssh_port}\n")); block.push_str(&format!(" HostKeyAlias {requested}\n")); if let Some(user) = user { block.push_str(&format!(" User {user}\n")); } block.push_str(" ClearAllForwardings yes\n"); block.push_str(" ServerAliveInterval 15\n"); block.push_str(" ServerAliveCountMax 3\n"); block.push_str(&format!(" ProxyCommand {proxy_command}\n")); block.push_str(&format!("# END DOSH {alias}\n")); upsert_managed_block(&include_path, &alias, &block)?; Ok(GeneratedVscodeHost { alias, proxy_command, target_ssh_port, }) } fn ensure_ssh_include(path: &Path, include: &str) -> Result<()> { let raw = fs::read_to_string(path).unwrap_or_default(); if raw.lines().any(|line| { line.trim() .eq_ignore_ascii_case(&format!("include {include}")) }) { return Ok(()); } let mut next = String::new(); next.push_str(&format!("Include {include}\n")); if !raw.is_empty() { next.push('\n'); next.push_str(&raw); } fs::write(path, next).with_context(|| format!("write {}", path.display())) } fn upsert_managed_block(path: &Path, alias: &str, block: &str) -> Result<()> { let raw = fs::read_to_string(path).unwrap_or_default(); let begin = format!("# BEGIN DOSH {alias}"); let end = format!("# END DOSH {alias}"); let mut out = String::new(); let mut skipping = false; let mut replaced = false; for line in raw.lines() { if line.trim() == begin { if !out.ends_with('\n') && !out.is_empty() { out.push('\n'); } out.push_str(block); replaced = true; skipping = true; continue; } if skipping { if line.trim() == end { skipping = false; } continue; } out.push_str(line); out.push('\n'); } if !replaced { if !out.trim().is_empty() && !out.ends_with("\n\n") { out.push('\n'); } out.push_str(block); } fs::write(path, out).with_context(|| format!("write {}", path.display())) } fn vscode_safe_alias(value: &str) -> String { let mut out = String::new(); for byte in value.bytes() { if byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' { out.push(byte as char); } else { out.push('-'); } } let out = out.trim_matches('-').to_string(); if out.is_empty() { "host".to_string() } else { out } } fn launch_vscode_remote(alias: &str, remote_path: Option<&str>) -> Result<()> { let remote = format!("ssh-remote+{alias}"); let mut command = Command::new("code"); command.arg("--remote").arg(&remote); if let Some(path) = remote_path { command.arg(path); } match command.status() { Ok(status) if status.success() => Ok(()), Ok(status) => Err(anyhow!("code exited with status {status}")), Err(err) if err.kind() == std::io::ErrorKind::NotFound => { println!( "Run: code --remote {}{}", shell_word(&remote), remote_path .map(|path| format!(" {}", shell_word(path))) .unwrap_or_default() ); Ok(()) } Err(err) => Err(err).context("launch VS Code"), } } impl ExecServiceClient { fn connect(port: u16) -> Result { let deadline = Instant::now() + Duration::from_secs(10); loop { match StdTcpStream::connect(("127.0.0.1", port)) { Ok(stream) => { stream .set_nodelay(true) .context("set exec service TCP_NODELAY")?; return Ok(Self { stream, decoder: ExecFrameDecoder::default(), pending: VecDeque::new(), }); } Err(err) if Instant::now() < deadline => { std::thread::sleep(Duration::from_millis(20)); if err.kind() == std::io::ErrorKind::ConnectionRefused { continue; } } Err(err) => return Err(err).context("connect Dosh exec service"), } } } fn send(&mut self, request: ExecRequest) -> Result<()> { let bytes = encode_exec_request(&request)?; self.stream.write_all(&bytes).context("send exec request") } fn recv(&mut self) -> Result { if let Some(response) = self.pending.pop_front() { return Ok(response); } let mut buf = [0u8; 16 * 1024]; loop { let n = self.stream.read(&mut buf).context("read exec response")?; if n == 0 { bail!("exec service closed"); } let frames = self.decoder.push(&buf[..n])?; for frame in frames { self.pending.push_back(decode_exec_response(&frame)?); } if let Some(response) = self.pending.pop_front() { return Ok(response); } } } } impl FileServiceClient { fn connect(port: u16) -> Result { let deadline = Instant::now() + Duration::from_secs(10); loop { match StdTcpStream::connect(("127.0.0.1", port)) { Ok(stream) => { stream .set_nodelay(true) .context("set file service TCP_NODELAY")?; return Ok(Self { stream, decoder: FrameDecoder::default(), pending: VecDeque::new(), }); } Err(err) if Instant::now() < deadline => { std::thread::sleep(Duration::from_millis(20)); if err.kind() == std::io::ErrorKind::ConnectionRefused { continue; } } Err(err) => return Err(err).context("connect Dosh file service"), } } } fn send(&mut self, request: FileRequest) -> Result<()> { let bytes = encode_request(&request)?; self.stream.write_all(&bytes).context("send file request") } fn recv(&mut self) -> Result { if let Some(response) = self.pending.pop_front() { return Ok(response); } let mut buf = [0u8; 16 * 1024]; loop { let n = self.stream.read(&mut buf).context("read file response")?; if n == 0 { bail!("file service closed"); } let frames = self.decoder.push(&buf[..n])?; for frame in frames { self.pending.push_back(decode_response(&frame)?); } if let Some(response) = self.pending.pop_front() { return Ok(response); } } } fn stat_optional(&mut self, path: &str) -> Result> { self.send(FileRequest::Stat { path: path.to_string(), })?; match self.recv()? { FileResponse::Stat { meta } => Ok(Some(meta)), FileResponse::Error { .. } => Ok(None), other => bail!("unexpected file stat response: {other:?}"), } } fn stat(&mut self, path: &str) -> Result { self.send(FileRequest::Stat { path: path.to_string(), })?; match self.recv()? { FileResponse::Stat { meta } => Ok(meta), FileResponse::Error { message } => Err(anyhow!(message)), other => bail!("unexpected file stat response: {other:?}"), } } fn list(&mut self, path: &str) -> Result> { self.send(FileRequest::List { path: path.to_string(), })?; match self.recv()? { FileResponse::List { entries } => Ok(entries), FileResponse::Error { message } => Err(anyhow!(message)), other => bail!("unexpected file list response: {other:?}"), } } fn mkdir(&mut self, path: &str, mode: Option) -> Result<()> { self.send(FileRequest::Mkdir { path: path.to_string(), mode, })?; match self.recv()? { FileResponse::Ok => Ok(()), FileResponse::Error { message } => Err(anyhow!(message)), other => bail!("unexpected mkdir response: {other:?}"), } } } fn run_cp_command(_config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { let (opts, src, dst) = parse_cp_args(&args.command)?; let src = parse_copy_endpoint(&src); let dst = parse_copy_endpoint(&dst); match (src, dst) { (CopyEndpoint::Local(src), CopyEndpoint::Remote { host, path }) => { let port = free_loopback_port()?; let _forwarder = spawn_service_forwarder(args, &host, port, FILE_STREAM_SENTINEL)?; let mut client = FileServiceClient::connect(port)?; upload_path(&mut client, &src, &path, &opts) } (CopyEndpoint::Remote { host, path }, CopyEndpoint::Local(dst)) => { let port = free_loopback_port()?; let _forwarder = spawn_service_forwarder(args, &host, port, FILE_STREAM_SENTINEL)?; let mut client = FileServiceClient::connect(port)?; download_path(&mut client, &path, &dst, &opts) } ( CopyEndpoint::Remote { host: src_host, path: src_path, }, CopyEndpoint::Remote { host: dst_host, path: dst_path, }, ) => { let src_port = free_loopback_port()?; let dst_port = free_loopback_port()?; let _src_forwarder = spawn_service_forwarder(args, &src_host, src_port, FILE_STREAM_SENTINEL)?; let _dst_forwarder = spawn_service_forwarder(args, &dst_host, dst_port, FILE_STREAM_SENTINEL)?; let mut src_client = FileServiceClient::connect(src_port)?; let mut dst_client = FileServiceClient::connect(dst_port)?; copy_remote_path( &mut src_client, &mut dst_client, &src_path, &dst_path, &opts, ) } (CopyEndpoint::Local(_), CopyEndpoint::Local(_)) => Err(anyhow!( "dosh cp needs one remote path, e.g. `dosh cp file host:path`" )), } } fn run_exec_command(args: &Args) -> Result<()> { let host = args .command .first() .ok_or_else(|| anyhow!("usage: dosh exec HOST COMMAND"))?; let command = args.command.get(1..).unwrap_or_default(); anyhow::ensure!(!command.is_empty(), "usage: dosh exec HOST COMMAND"); let command = command.join(" "); let port = free_loopback_port()?; let _forwarder = spawn_service_forwarder(args, host, port, EXEC_STREAM_SENTINEL)?; let mut client = ExecServiceClient::connect(port)?; client.send(ExecRequest { command })?; loop { match client.recv()? { ExecResponse::Stdout(bytes) => { let mut stdout = std::io::stdout(); stdout.write_all(&bytes)?; stdout.flush()?; } ExecResponse::Stderr(bytes) => { let mut stderr = std::io::stderr(); stderr.write_all(&bytes)?; stderr.flush()?; } ExecResponse::Exit { code } => { if code.unwrap_or(1) == 0 { return Ok(()); } return Err(anyhow!("remote command exited with status {:?}", code)); } ExecResponse::Error { message } => return Err(anyhow!(message)), } } } fn run_file_ls_command(args: &Args) -> Result<()> { if args.command.len() != 1 { return Err(anyhow!("usage: dosh ls host:path")); } let (host, path) = parse_single_remote_path(&args.command[0], "ls")?; let (_forwarder, mut client) = connect_file_service(args, &host)?; let meta = client.stat(&path)?; if meta.kind == FileKind::Directory { for entry in client.list(&path)? { println!( "{}\t{}\t{}", file_kind_label(entry.meta.kind), entry.meta.len, entry.name ); } } else { println!("{}\t{}\t{}", file_kind_label(meta.kind), meta.len, path); } Ok(()) } fn run_file_mkdir_command(args: &Args) -> Result<()> { if args.command.len() != 1 { return Err(anyhow!("usage: dosh mkdir host:path")); } let (host, path) = parse_single_remote_path(&args.command[0], "mkdir")?; let (_forwarder, mut client) = connect_file_service(args, &host)?; client.mkdir(&path, None) } fn run_file_rm_command(args: &Args) -> Result<()> { let mut recursive = false; let mut paths = Vec::new(); for arg in &args.command { match arg.as_str() { "-r" | "-R" | "--recursive" => recursive = true, other if other.starts_with('-') => { return Err(anyhow!("dosh rm: unknown option {other:?}")); } _ => paths.push(arg.clone()), } } if paths.len() != 1 { return Err(anyhow!("usage: dosh rm [-r] host:path")); } let (host, path) = parse_single_remote_path(&paths[0], "rm")?; let (_forwarder, mut client) = connect_file_service(args, &host)?; client.send(FileRequest::Remove { path, recursive })?; match client.recv()? { FileResponse::Ok => Ok(()), FileResponse::Error { message } => Err(anyhow!(message)), other => bail!("unexpected rm response: {other:?}"), } } fn run_file_cat_command(args: &Args) -> Result<()> { if args.command.len() != 1 { return Err(anyhow!("usage: dosh cat host:path")); } let (host, path) = parse_single_remote_path(&args.command[0], "cat")?; let (_forwarder, mut client) = connect_file_service(args, &host)?; let meta = client.stat(&path)?; anyhow::ensure!(meta.kind == FileKind::File, "{path} is not a file"); client.send(FileRequest::Get { path, offset: 0 })?; match client.recv()? { FileResponse::Start { .. } => {} FileResponse::Error { message } => return Err(anyhow!(message)), other => bail!("unexpected cat start response: {other:?}"), } let mut stdout = std::io::stdout(); let mut hasher = Sha256::new(); loop { match client.recv()? { FileResponse::Chunk { bytes, .. } => { hasher.update(&bytes); stdout.write_all(&bytes)?; } FileResponse::Done { sha256, bytes } => { stdout.flush()?; let digest: [u8; 32] = hasher.finalize().into(); anyhow::ensure!(sha256 == digest, "cat checksum mismatch for remote file"); anyhow::ensure!(bytes == meta.len, "cat size mismatch for remote file"); return Ok(()); } FileResponse::Error { message } => return Err(anyhow!(message)), other => bail!("unexpected cat response: {other:?}"), } } } fn parse_single_remote_path(raw: &str, command: &str) -> Result<(String, String)> { match parse_copy_endpoint(raw) { CopyEndpoint::Remote { host, path } => Ok((host, path)), CopyEndpoint::Local(_) => Err(anyhow!("dosh {command} expects a remote host:path")), } } fn connect_file_service(args: &Args, host: &str) -> Result<(FileForwarder, FileServiceClient)> { let port = free_loopback_port()?; let forwarder = spawn_service_forwarder(args, host, port, FILE_STREAM_SENTINEL)?; let client = FileServiceClient::connect(port)?; Ok((forwarder, client)) } fn file_kind_label(kind: FileKind) -> &'static str { match kind { FileKind::File => "file", FileKind::Directory => "dir", FileKind::Symlink => "symlink", FileKind::Other => "other", } } fn parse_cp_args(raw: &[String]) -> Result<(CpOptions, String, String)> { let mut opts = CpOptions::default(); let mut paths = Vec::new(); let mut stop_options = false; for arg in raw { if stop_options { paths.push(arg.clone()); continue; } match arg.as_str() { "--" => stop_options = true, "-r" | "-R" | "--recursive" => opts.recursive = true, "--resume" => opts.resume = true, "-p" | "--preserve" => opts.preserve = true, "--no-preserve" => opts.preserve = false, "--no-progress" | "-q" | "--quiet" => opts.progress = false, "--no-clobber" | "-n" => opts.overwrite = false, other if other.starts_with('-') => { return Err(anyhow!("dosh cp: unknown option {other:?}")); } _ => paths.push(arg.clone()), } } anyhow::ensure!(paths.len() == 2, "usage: dosh cp [-r] [--resume] SRC DST"); Ok((opts, paths.remove(0), paths.remove(0))) } fn spawn_service_forwarder( args: &Args, host: &str, port: u16, target_host: &str, ) -> Result { let listener = StdTcpListener::bind("127.0.0.1:0").context("bind file forwarder readiness socket")?; listener .set_nonblocking(true) .context("configure file forwarder readiness socket")?; let readiness_addr = listener.local_addr()?; let token = URL_SAFE_NO_PAD.encode(crypto::random_16()); let exe = std::env::current_exe().context("locate current dosh-client executable")?; let mut command = Command::new(exe); command .arg("--local-forward") .arg(format!("127.0.0.1:{port}:{target_host}:0")) .arg("--forward-only") .arg("--auth") .arg("native") .env("DOSH_BACKGROUND_CHILD", "1") .env("DOSH_BACKGROUND_READY_ADDR", readiness_addr.to_string()) .env("DOSH_BACKGROUND_READY_TOKEN", &token) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()); if args.local_auth { command.arg("--local-auth"); } if let Some(value) = args.ssh_port { command.arg("--ssh-port").arg(value.to_string()); } if let Some(value) = &args.ssh_auth_command { command.arg("--ssh-auth-command").arg(value); } if let Some(value) = &args.ssh_key { command.arg("--ssh-key").arg(value); } if let Some(value) = &args.ssh_known_hosts { command.arg("--ssh-known-hosts").arg(value); } if let Some(value) = &args.ssh_control_path { command.arg("--ssh-control-path").arg(value); } if let Some(value) = args.dosh_port { command.arg("--dosh-port").arg(value.to_string()); } if let Some(value) = &args.dosh_host { command.arg("--dosh-host").arg(value); } for _ in 0..args.verbose { command.arg("-v"); } command.arg(host); let mut child = command.spawn().context("spawn Dosh file forwarder")?; let deadline = Instant::now() + Duration::from_secs(30); loop { match listener.accept() { Ok((mut stream, _)) => { let mut raw = String::new(); stream.read_to_string(&mut raw).ok(); if raw.trim() == token { return Ok(FileForwarder { child }); } let _ = child.kill(); let _ = child.wait(); return Err(anyhow!("file forwarder sent invalid readiness token")); } Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {} Err(err) => return Err(err).context("wait for file forwarder readiness"), } if let Some(status) = child.try_wait()? { let mut stderr = String::new(); if let Some(mut pipe) = child.stderr.take() { let _ = pipe.read_to_string(&mut stderr); } return Err(anyhow!( "file forwarder exited before readiness: {status}; stderr={stderr}" )); } if Instant::now() >= deadline { let _ = child.kill(); let _ = child.wait(); return Err(anyhow!("file forwarder did not become ready")); } std::thread::sleep(Duration::from_millis(20)); } } fn free_loopback_port() -> Result { let listener = StdTcpListener::bind("127.0.0.1:0").context("reserve local file port")?; Ok(listener.local_addr()?.port()) } fn upload_path( client: &mut FileServiceClient, local: &Path, remote: &str, opts: &CpOptions, ) -> Result<()> { let metadata = fs::symlink_metadata(local).with_context(|| format!("stat {}", local.display()))?; let remote = upload_destination(client, local, remote, metadata.is_dir())?; if metadata.is_dir() { anyhow::ensure!( opts.recursive, "{} is a directory; pass -r to copy directories", local.display() ); client.mkdir(&remote, mode_for(local, opts.preserve)?)?; let mut entries = fs::read_dir(local) .with_context(|| format!("list {}", local.display()))? .collect::>>()?; entries.sort_by_key(|entry| entry.file_name()); for entry in entries { let name = ensure_relative_child(&entry.path())?; upload_path(client, &entry.path(), &remote_join(&remote, &name), opts)?; } return Ok(()); } anyhow::ensure!( metadata.is_file(), "{} is not a regular file", local.display() ); upload_file( client, local, &remote, metadata.len(), mode_for(local, opts.preserve)?, opts, ) } fn upload_destination( client: &mut FileServiceClient, local: &Path, remote: &str, local_is_dir: bool, ) -> Result { let base = ensure_relative_child(local)?; if remote.ends_with('/') { return Ok(remote_join(remote, &base)); } if let Some(meta) = client.stat_optional(remote)? && meta.kind == FileKind::Directory { return Ok(remote_join(remote, &base)); } if local_is_dir && remote == "." { return Ok(base); } Ok(remote.to_string()) } fn upload_file( client: &mut FileServiceClient, local: &Path, remote: &str, size: u64, mode: Option, opts: &CpOptions, ) -> Result<()> { client.send(FileRequest::PutStart { path: remote.to_string(), size, mode, modified_secs: modified_secs(local, opts.preserve)?, overwrite: opts.overwrite, resume: opts.resume, })?; let offset = match client.recv()? { FileResponse::Resume { offset } => offset.min(size), FileResponse::Error { message } => return Err(anyhow!(message)), other => bail!("unexpected upload start response: {other:?}"), }; let mut file = fs::File::open(local).with_context(|| format!("open {}", local.display()))?; let mut hasher = Sha256::new(); if offset > 0 { hash_prefix(&mut file, offset, &mut hasher)?; } file.seek(std::io::SeekFrom::Start(offset))?; let mut sent = offset; let mut buf = vec![0u8; CHUNK_SIZE]; loop { let n = file.read(&mut buf)?; if n == 0 { break; } hasher.update(&buf[..n]); client.send(FileRequest::PutChunk { offset: sent, bytes: buf[..n].to_vec(), })?; sent = sent.saturating_add(n as u64); } let digest: [u8; 32] = hasher.finalize().into(); client.send(FileRequest::PutFinish { sha256: digest })?; match client.recv()? { FileResponse::Done { sha256, bytes } if sha256 == digest && bytes == size => { if opts.progress { eprintln!( "uploaded {} -> {} ({} bytes)", local.display(), remote, bytes ); } Ok(()) } FileResponse::Done { .. } => Err(anyhow!("upload verification failed for {remote}")), FileResponse::Error { message } => Err(anyhow!(message)), other => bail!("unexpected upload finish response: {other:?}"), } } fn download_path( client: &mut FileServiceClient, remote: &str, local: &Path, opts: &CpOptions, ) -> Result<()> { let meta = client.stat(remote)?; let destination = download_destination(local, remote, meta.kind == FileKind::Directory); match meta.kind { FileKind::Directory => { anyhow::ensure!( opts.recursive, "{remote} is a directory; pass -r to copy directories" ); fs::create_dir_all(&destination) .with_context(|| format!("create {}", destination.display()))?; set_local_mode(&destination, meta.mode, opts.preserve)?; for entry in client.list(remote)? { download_path( client, &remote_join(remote, &entry.name), &destination.join(&entry.name), opts, )?; } Ok(()) } FileKind::File => download_file(client, remote, &destination, &meta, opts), _ => Err(anyhow!( "remote path is not a regular file or directory: {remote}" )), } } fn copy_remote_path( src_client: &mut FileServiceClient, dst_client: &mut FileServiceClient, src: &str, dst: &str, opts: &CpOptions, ) -> Result<()> { let meta = src_client.stat(src)?; let dst = remote_copy_destination(dst_client, src, dst, meta.kind == FileKind::Directory)?; match meta.kind { FileKind::Directory => { anyhow::ensure!( opts.recursive, "{src} is a directory; pass -r to copy directories" ); dst_client.mkdir(&dst, preserve_remote_mode(meta.mode, opts.preserve))?; for entry in src_client.list(src)? { copy_remote_path( src_client, dst_client, &remote_join(src, &entry.name), &remote_join(&dst, &entry.name), opts, )?; } Ok(()) } FileKind::File => copy_remote_file(src_client, dst_client, src, &dst, &meta, opts), _ => Err(anyhow!( "remote path is not a regular file or directory: {src}" )), } } fn remote_copy_destination( dst_client: &mut FileServiceClient, src: &str, dst: &str, src_is_dir: bool, ) -> Result { if dst.ends_with('/') { return Ok(remote_join(dst, &remote_basename(src))); } if let Some(meta) = dst_client.stat_optional(dst)? && meta.kind == FileKind::Directory { return Ok(remote_join(dst, &remote_basename(src))); } if src_is_dir && dst == "." { return Ok(remote_basename(src)); } Ok(dst.to_string()) } fn copy_remote_file( src_client: &mut FileServiceClient, dst_client: &mut FileServiceClient, src: &str, dst: &str, meta: &dosh::file_transfer::FileMeta, opts: &CpOptions, ) -> Result<()> { dst_client.send(FileRequest::PutStart { path: dst.to_string(), size: meta.len, mode: preserve_remote_mode(meta.mode, opts.preserve), modified_secs: preserve_remote_mtime(meta.modified_secs, opts.preserve), overwrite: opts.overwrite, resume: opts.resume, })?; let offset = match dst_client.recv()? { FileResponse::Resume { offset } => offset.min(meta.len), FileResponse::Error { message } => return Err(anyhow!(message)), other => bail!("unexpected remote copy start response: {other:?}"), }; src_client.send(FileRequest::Get { path: src.to_string(), offset, })?; match src_client.recv()? { FileResponse::Start { .. } => {} FileResponse::Error { message } => return Err(anyhow!(message)), other => bail!("unexpected remote copy source start response: {other:?}"), } let mut copied = offset; let source_hash = loop { match src_client.recv()? { FileResponse::Chunk { offset, bytes } => { anyhow::ensure!( offset == copied, "remote copy offset mismatch: got {offset}, expected {copied}" ); let len = bytes.len(); dst_client.send(FileRequest::PutChunk { offset, bytes })?; copied = copied.saturating_add(len as u64); } FileResponse::Done { sha256, bytes } => { anyhow::ensure!( bytes == meta.len, "remote copy source size mismatch for {src}" ); break sha256; } FileResponse::Error { message } => return Err(anyhow!(message)), other => bail!("unexpected remote copy source response: {other:?}"), } }; dst_client.send(FileRequest::PutFinish { sha256: source_hash, })?; match dst_client.recv()? { FileResponse::Done { sha256, bytes } if sha256 == source_hash && bytes == meta.len => { if opts.progress { eprintln!("copied {src} -> {dst} ({bytes} bytes)"); } Ok(()) } FileResponse::Done { .. } => Err(anyhow!("remote copy verification failed for {dst}")), FileResponse::Error { message } => Err(anyhow!(message)), other => bail!("unexpected remote copy finish response: {other:?}"), } } fn preserve_remote_mode(mode: Option, preserve: bool) -> Option { preserve.then_some(mode).flatten() } fn preserve_remote_mtime(modified_secs: Option, preserve: bool) -> Option { preserve.then_some(modified_secs).flatten() } fn download_destination(local: &Path, remote: &str, remote_is_dir: bool) -> PathBuf { if local.is_dir() || path_ends_separator(local) { return local.join(remote_basename(remote)); } if remote_is_dir && !local.exists() { return local.to_path_buf(); } local.to_path_buf() } fn download_file( client: &mut FileServiceClient, remote: &str, local: &Path, meta: &dosh::file_transfer::FileMeta, opts: &CpOptions, ) -> Result<()> { if let Some(parent) = local.parent() && !parent.as_os_str().is_empty() { fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; } let offset = if opts.resume && local.exists() { fs::metadata(local)?.len().min(meta.len) } else { 0 }; let mut file = if offset > 0 { fs::OpenOptions::new() .read(true) .append(true) .open(local) .with_context(|| format!("open {}", local.display()))? } else { fs::OpenOptions::new() .create(true) .truncate(true) .write(true) .open(local) .with_context(|| format!("create {}", local.display()))? }; let mut hasher = Sha256::new(); if offset > 0 { let mut prefix = fs::File::open(local)?; hash_prefix(&mut prefix, offset, &mut hasher)?; } client.send(FileRequest::Get { path: remote.to_string(), offset, })?; match client.recv()? { FileResponse::Start { .. } => {} FileResponse::Error { message } => return Err(anyhow!(message)), other => bail!("unexpected download start response: {other:?}"), } let mut received = offset; loop { match client.recv()? { FileResponse::Chunk { offset, bytes } => { anyhow::ensure!( offset == received, "download offset mismatch: got {offset}, expected {received}" ); file.write_all(&bytes)?; hasher.update(&bytes); received = received.saturating_add(bytes.len() as u64); } FileResponse::Done { sha256, bytes } => { file.flush()?; let digest: [u8; 32] = hasher.finalize().into(); anyhow::ensure!(sha256 == digest, "download checksum mismatch for {remote}"); anyhow::ensure!(bytes == meta.len, "download size mismatch for {remote}"); set_local_mode(local, meta.mode, opts.preserve)?; set_local_mtime(local, meta.modified_secs, opts.preserve)?; if opts.progress { eprintln!( "downloaded {} -> {} ({} bytes)", remote, local.display(), bytes ); } return Ok(()); } FileResponse::Error { message } => return Err(anyhow!(message)), other => bail!("unexpected download response: {other:?}"), } } } fn hash_prefix(file: &mut fs::File, bytes: u64, hasher: &mut Sha256) -> Result<()> { file.seek(std::io::SeekFrom::Start(0))?; let mut remaining = bytes; let mut buf = vec![0u8; CHUNK_SIZE]; while remaining > 0 { let want = remaining.min(buf.len() as u64) as usize; let n = file.read(&mut buf[..want])?; if n == 0 { bail!("file ended while hashing resume prefix"); } hasher.update(&buf[..n]); remaining -= n as u64; } Ok(()) } fn path_ends_separator(path: &Path) -> bool { let raw = path.as_os_str().to_string_lossy(); raw.ends_with('/') || raw.ends_with('\\') } fn modified_secs(path: &Path, preserve: bool) -> Result> { if !preserve { return Ok(None); } Ok(fs::metadata(path)? .modified() .ok() .and_then(|mtime| mtime.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_secs())) } fn set_local_mtime(path: &Path, modified_secs: Option, preserve: bool) -> Result<()> { if !preserve { return Ok(()); } let Some(modified_secs) = modified_secs else { return Ok(()); }; filetime::set_file_mtime( path, filetime::FileTime::from_unix_time(modified_secs as i64, 0), ) .with_context(|| format!("set mtime {}", path.display())) } #[cfg(unix)] fn mode_for(path: &Path, preserve: bool) -> Result> { use std::os::unix::fs::PermissionsExt; if preserve { Ok(Some(fs::metadata(path)?.permissions().mode() & 0o7777)) } else { Ok(None) } } #[cfg(not(unix))] fn mode_for(_path: &Path, _preserve: bool) -> Result> { Ok(None) } #[cfg(unix)] fn set_local_mode(path: &Path, mode: Option, preserve: bool) -> Result<()> { use std::os::unix::fs::PermissionsExt; if !preserve { return Ok(()); } let Some(mode) = mode else { return Ok(()); }; let mut permissions = fs::metadata(path)?.permissions(); permissions.set_mode(mode & 0o7777); fs::set_permissions(path, permissions).with_context(|| format!("chmod {}", path.display())) } #[cfg(not(unix))] fn set_local_mode(_path: &Path, _mode: Option, _preserve: bool) -> Result<()> { Ok(()) } fn parse_local_forwards(raw: &[String]) -> Result> { raw.iter().map(|value| parse_local_forward(value)).collect() } fn parse_remote_forwards(raw: &[String]) -> Result> { raw.iter() .map(|value| parse_remote_forward(value)) .collect() } fn parse_dynamic_forwards(raw: &[String]) -> Result> { raw.iter() .map(|value| parse_dynamic_forward(value)) .collect() } fn parse_local_forward(raw: &str) -> Result { let parts = raw.split(':').collect::>(); let (bind_host, listen, target_host, target_port) = match parts.as_slice() { [listen, target_host, target_port] => { ("127.0.0.1".to_string(), *listen, *target_host, *target_port) } [bind_host, listen, target_host, target_port] => ( (*bind_host).to_string(), *listen, *target_host, *target_port, ), _ => { return Err(anyhow!( "invalid -L {raw:?}; expected listen_port:target_host:target_port or bind_host:listen_port:target_host:target_port" )); } }; let listen_port = listen .parse::() .with_context(|| format!("invalid local forward listen port in {raw:?}"))?; let target_port = target_port .parse::() .with_context(|| format!("invalid local forward target port in {raw:?}"))?; if !valid_forward_host(target_host) { return Err(anyhow!("invalid -L {raw:?}; target host cannot be empty")); } if !valid_forward_host(&bind_host) { return Err(anyhow!("invalid -L {raw:?}; bind host cannot be empty")); } anyhow::ensure!( listen_port != 0, "invalid -L {raw:?}; listen port cannot be 0" ); anyhow::ensure!( target_port != 0 || matches!(target_host, FILE_STREAM_SENTINEL | EXEC_STREAM_SENTINEL), "invalid -L {raw:?}; target port cannot be 0" ); Ok(LocalForward { bind_host, listen_port, target_host: target_host.to_string(), target_port, }) } fn parse_remote_forward(raw: &str) -> Result { let local = parse_local_forward(raw)?; Ok(RemoteForward { bind_host: local.bind_host, listen_port: local.listen_port, target_host: local.target_host, target_port: local.target_port, }) } fn parse_dynamic_forward(raw: &str) -> Result { let parts = raw.split(':').collect::>(); let (bind_host, listen) = match parts.as_slice() { [listen] => ("127.0.0.1".to_string(), *listen), [bind_host, listen] => ((*bind_host).to_string(), *listen), _ => { return Err(anyhow!( "invalid -D {raw:?}; expected listen_port or bind_host:listen_port" )); } }; let listen_port = listen .parse::() .with_context(|| format!("invalid dynamic forward listen port in {raw:?}"))?; if !valid_forward_host(&bind_host) { return Err(anyhow!("invalid -D {raw:?}; bind host cannot be empty")); } anyhow::ensure!( listen_port != 0, "invalid -D {raw:?}; listen port cannot be 0" ); Ok(DynamicForward { bind_host, listen_port, }) } fn valid_forward_host(host: &str) -> bool { !host.is_empty() && !host .bytes() .any(|byte| byte == 0 || byte == b'\n' || byte == b'\r' || byte == b'\t') } fn forwarding_requests( local_forwards: &[LocalForward], remote_forwards: &[RemoteForward], dynamic_forwards: &[DynamicForward], forward_agent: bool, ) -> Vec { let mut requests = local_forwards .iter() .map(|forward| ForwardingRequest { kind: ForwardingKind::Local, bind_host: Some(forward.bind_host.clone()), listen_port: forward.listen_port, target_host: Some(forward.target_host.clone()), target_port: Some(forward.target_port), }) .collect::>(); requests.extend(remote_forwards.iter().map(|forward| ForwardingRequest { kind: ForwardingKind::Remote, bind_host: Some(forward.bind_host.clone()), listen_port: forward.listen_port, target_host: Some(forward.target_host.clone()), target_port: Some(forward.target_port), })); requests.extend(dynamic_forwards.iter().map(|forward| ForwardingRequest { kind: ForwardingKind::Dynamic, bind_host: Some(forward.bind_host.clone()), listen_port: forward.listen_port, target_host: None, target_port: None, })); if forward_agent { requests.push(ForwardingRequest { kind: ForwardingKind::Agent, bind_host: None, listen_port: 0, target_host: None, target_port: None, }); } requests } fn run_sessions_command( server: &str, ssh_port: Option, ssh_key: Option<&std::path::Path>, ssh_known_hosts: Option<&std::path::Path>, ) -> Result<()> { run_remote_script( server, ssh_port, ssh_key, ssh_known_hosts, SESSIONS_STATUS_SCRIPT, "run remote status command", ) } fn run_remote_script( server: &str, ssh_port: Option, ssh_key: Option<&std::path::Path>, ssh_known_hosts: Option<&std::path::Path>, script: &str, context: &str, ) -> Result<()> { let mut command = Command::new("ssh"); if let Some(ssh_port) = ssh_port { command.arg("-p").arg(ssh_port.to_string()); } command.arg("-T"); if let Some(key) = ssh_key { command.arg("-i").arg(key); } if let Some(known_hosts) = ssh_known_hosts { command .arg("-o") .arg(format!("UserKnownHostsFile={}", known_hosts.display())); } let remote_command = format!("sh -c {}", shell_word(script)); let status = command .arg(server) .arg(remote_command) .status() .with_context(|| context.to_string())?; if !status.success() { return Err(anyhow!("{context} failed with status {status}")); } Ok(()) } fn update_check_requested(command: &[String]) -> Result { match command { [] => Ok(false), [flag] if flag == "--check" || flag == "-n" => Ok(true), _ => Err(anyhow!("usage: dosh update [--check]")), } } fn release_artifact_name() -> &'static str { match (std::env::consts::OS, std::env::consts::ARCH) { ("macos", "aarch64") => "dosh-macos-aarch64.tar.gz", ("macos", "x86_64") => "dosh-macos-x86_64.tar.gz", ("linux", "aarch64") => "dosh-linux-aarch64.tar.gz", ("linux", "x86_64") => "dosh-linux-x86_64.tar.gz", ("windows", "x86_64") => "dosh-windows-x86_64.zip", ("windows", "aarch64") => "dosh-windows-aarch64.zip", _ => "dosh-unknown.tar.gz", } } fn latest_release_download_url(repo: &str, artifact: &str) -> Option { let web = repo .strip_suffix(".git") .unwrap_or(repo) .trim_end_matches('/'); if !web.starts_with("http://") && !web.starts_with("https://") { return None; } Some(format!("{web}/releases/latest/download/{artifact}")) } fn release_tag_from_effective_url(web: &str, effective: &str) -> Option { let prefix = format!("{web}/releases/tag/"); let tag = effective.strip_prefix(&prefix)?; if tag.is_empty() { None } else { Some(tag.to_string()) } } fn release_version_from_tag(tag: &str) -> &str { tag.strip_prefix('v').unwrap_or(tag) } fn update_version_status(local: &str, latest: &str) -> &'static str { match compare_dotted_versions(latest, local) { std::cmp::Ordering::Equal => "current", std::cmp::Ordering::Greater => "update available", std::cmp::Ordering::Less => "different", } } fn compare_dotted_versions(left: &str, right: &str) -> std::cmp::Ordering { let left = parse_dotted_version(left); let right = parse_dotted_version(right); let width = left.len().max(right.len()); for index in 0..width { let ordering = left .get(index) .copied() .unwrap_or(0) .cmp(&right.get(index).copied().unwrap_or(0)); if ordering != std::cmp::Ordering::Equal { return ordering; } } std::cmp::Ordering::Equal } fn parse_dotted_version(version: &str) -> Vec { version .split(|byte: char| !byte.is_ascii_digit()) .filter(|part| !part.is_empty()) .map(|part| part.parse::().unwrap_or(0)) .collect() } fn latest_release_tag(repo: &str) -> Result> { let web = repo .strip_suffix(".git") .unwrap_or(repo) .trim_end_matches('/'); if !web.starts_with("http://") && !web.starts_with("https://") { return Ok(None); } let output = Command::new("curl") .arg("-fsSL") .arg("-o") .arg("/dev/null") .arg("-w") .arg("%{url_effective}") .arg(format!("{web}/releases/latest")) .stdin(Stdio::null()) .output() .with_context(|| format!("resolve latest release for {web}"))?; if !output.status.success() { return Ok(None); } let effective = String::from_utf8_lossy(&output.stdout); Ok(release_tag_from_effective_url(web, &effective)) } fn release_tag_download_url(repo: &str, tag: &str, artifact: &str) -> Option { let web = repo .strip_suffix(".git") .unwrap_or(repo) .trim_end_matches('/'); if !web.starts_with("http://") && !web.starts_with("https://") { return None; } Some(format!("{web}/releases/download/{tag}/{artifact}")) } fn url_reachable(url: &str) -> Result { let status = Command::new("curl") .arg("-fsIL") .arg(url) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .with_context(|| format!("check {url}"))?; Ok(status.success()) } fn run_update(config: &dosh::config::ClientConfig, check_only: bool) -> Result<()> { let repo = config .update_repo .clone() .unwrap_or_else(|| "https://git.palav.dev/Palav/dosh.git".to_string()); let raw_base = raw_base_from_repo(&repo); let installer = format!("{raw_base}/raw/branch/main/install.sh"); let artifact = release_artifact_name(); if check_only { let local_version = env!("CARGO_PKG_VERSION"); let latest_tag = latest_release_tag(&repo)?; println!("dosh {local_version}"); println!("repo: {repo}"); println!("installer: {installer}"); match latest_tag.as_deref() { Some(tag) => { let latest_version = release_version_from_tag(tag); println!( "latest: {latest_version} ({})", update_version_status(local_version, latest_version) ); } None => println!("latest: unknown"), } if let Some(latest_url) = latest_release_download_url(&repo, artifact) { let mut status = "missing"; let mut display_url = latest_url.clone(); if let Some(tag_url) = latest_tag .as_deref() .and_then(|tag| release_tag_download_url(&repo, tag, artifact)) && url_reachable(&tag_url)? { status = "available"; display_url = tag_url; } else if url_reachable(&latest_url)? { status = "available"; } println!("prebuilt: {status} ({artifact})"); println!("prebuilt_url: {display_url}"); } else { println!("prebuilt: unavailable for non-HTTP repo"); } return Ok(()); } let update_cache = dirs::cache_dir() .unwrap_or_else(|| expand_tilde("~/.cache")) .join("dosh") .join("source"); let use_prebuilt = std::env::var("DOSH_USE_PREBUILT").unwrap_or_else(|_| "1".to_string()); let mut script = format!( "curl -fsSL {} | DOSH_REPO={} DOSH_PORT={} DOSH_UPDATE_CACHE={} DOSH_UPDATE_QUIET=1 DOSH_USE_PREBUILT={} sh -s -- client", shell_word(&installer), shell_word(&repo), config.update_port.unwrap_or(config.dosh_port), shell_word(&update_cache.display().to_string()), shell_word(&use_prebuilt) ); if config.server != "user@example.com" { script.push_str(&format!(" --server {}", shell_word(&config.server))); } if let Some(host) = &config.dosh_host { script.push_str(&format!(" --dosh-host {}", shell_word(host))); } let status = Command::new("sh") .arg("-c") .arg(script) .stdin(Stdio::null()) .status() .context("run dosh update installer")?; if !status.success() { return Err(anyhow!("dosh update failed with status {status}")); } Ok(()) } #[derive(Debug, Clone, PartialEq, Eq)] enum ImportAction { Added, Skipped, } #[derive(Debug, Clone, PartialEq, Eq)] struct ImportedHost { alias: String, ssh: String, dosh_host: String, port: u16, action: ImportAction, } fn run_import_ssh(config: &dosh::config::ClientConfig, aliases: &[String]) -> Result<()> { if aliases.is_empty() { return Err(anyhow!("usage: dosh import-ssh [ssh-host...]")); } let imported = import_ssh_aliases(config, aliases)?; for entry in imported { match entry.action { ImportAction::Added => eprintln!( "dosh import-ssh: added [{}] ssh={} dosh_host={}", entry.alias, entry.ssh, entry.dosh_host ), ImportAction::Skipped => { eprintln!( "dosh import-ssh: [{}] already exists, skipping", entry.alias ) } } } eprintln!( "dosh import-ssh: wrote {}", expand_tilde("~/.config/dosh/hosts.toml").display() ); Ok(()) } fn import_ssh_aliases( config: &dosh::config::ClientConfig, aliases: &[String], ) -> Result> { let path = expand_tilde("~/.config/dosh/hosts.toml"); let mut raw = if path.exists() { fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))? } else { String::new() }; if !raw.ends_with('\n') && !raw.is_empty() { raw.push('\n'); } let mut imported = Vec::new(); for alias in aliases { let ssh_config = ssh_config(alias, None) .with_context(|| format!("read SSH config for {alias} with ssh -G"))?; let udp_host = ssh_config .hostname .clone() .unwrap_or_else(|| ssh_destination_host(alias)); if raw_contains_host_table(&raw, alias) { imported.push(ImportedHost { alias: alias.clone(), ssh: alias.clone(), dosh_host: udp_host, port: config.dosh_port, action: ImportAction::Skipped, }); continue; } raw.push('\n'); raw.push_str(&format!("[{}]\n", toml_bare_key_or_quoted(alias))); raw.push_str(&format!("ssh = {}\n", toml_string(alias))); raw.push_str(&format!("dosh_host = {}\n", toml_string(&udp_host))); raw.push_str(&format!("port = {}\n", config.dosh_port)); if let Some(user) = &ssh_config.user { raw.push_str(&format!("user = {}\n", toml_string(user))); } if let Some(port) = ssh_config.port && port != 22 { raw.push_str(&format!("ssh_port = {port}\n")); } raw.push_str("predict = true\n"); imported.push(ImportedHost { alias: alias.clone(), ssh: alias.clone(), dosh_host: udp_host, port: config.dosh_port, action: ImportAction::Added, }); } if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } fs::write(&path, raw).with_context(|| format!("write {}", path.display()))?; Ok(imported) } fn raw_contains_host_table(raw: &str, alias: &str) -> bool { let quoted = toml_string(alias); raw.lines().any(|line| { let line = line.trim(); line == format!("[{alias}]") || line == format!("[{quoted}]") }) } fn toml_string(value: &str) -> String { let escaped = value .replace('\\', "\\\\") .replace('"', "\\\"") .replace('\n', "\\n"); format!("\"{escaped}\"") } fn toml_bare_key_or_quoted(value: &str) -> String { if value .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { value.to_string() } else { toml_string(value) } } fn raw_base_from_repo(repo: &str) -> String { repo.trim_end_matches(".git").to_string() } fn shell_word(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } fn ssh_config_word(value: &str) -> String { if cfg!(windows) { format!("\"{}\"", value.replace('"', "\\\"")) } else { shell_word(value) } } fn local_bootstrap( session: &str, mode: &str, cols: u16, rows: u16, port: u16, host: String, ) -> Result { let mut server_config = load_server_config(None)?; server_config.port = port; let secret = load_or_create_server_secret(&server_config)?; let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()); let nonce = crypto::random_12(); build_bootstrap( &server_config, &secret, user, session.to_string(), mode.to_string(), (cols, rows), nonce, host, ) } fn auth_allows(preference: &str, method: &str) -> bool { preference .split(',') .map(str::trim) .any(|entry| entry == "auto" || entry == method) } fn log_timing(verbose: u8, name: &str, elapsed: Duration) { if verbose >= 1 { eprintln!("dosh timing {name}={}ms", elapsed.as_millis()); } } fn log_debug(verbose: u8, level: u8, message: &str) { if verbose >= level { eprintln!("{message}"); } } fn select_session(requested: Option<&str>, _force_new: bool) -> String { if let Some(session) = requested { return session.to_string(); } // Implicit session: this gives plain `dosh host` a fresh terminal by // default. The server can still persist it across restarts because the // reconnect ticket cache keeps the generated name. protocol::generate_implicit_session_name() } #[allow(clippy::too_many_arguments)] fn ssh_bootstrap( server: &str, ssh_port: Option, ssh_auth_command: &str, ssh_key: Option<&Path>, ssh_known_hosts: Option<&Path>, ssh_control_path: Option<&Path>, session: &str, mode: &str, cols: u16, rows: u16, ) -> Result { let nonce = crypto::random_12(); let nonce_b64 = URL_SAFE_NO_PAD.encode(nonce); let size = format!("{cols}x{rows}"); let mut command = Command::new("ssh"); if let Some(ssh_port) = ssh_port { command.arg("-p").arg(ssh_port.to_string()); } command.arg("-T"); if let Some(key) = ssh_key { command.arg("-i").arg(key); } if let Some(known_hosts) = ssh_known_hosts { command .arg("-o") .arg(format!("UserKnownHostsFile={}", known_hosts.display())); } if let Some(control_path) = ssh_control_path { command.arg("-S").arg(control_path); } let output = command .arg(server) .arg(ssh_auth_command) .arg("--protocol") .arg("1") .arg(format!("--nonce={nonce_b64}")) .arg(format!("--session={session}")) .arg(format!("--mode={mode}")) .arg(format!("--size={size}")) .output() .context("run ssh dosh-auth")?; if !output.status.success() { return Err(anyhow!( "ssh bootstrap failed: {}", String::from_utf8_lossy(&output.stderr) )); } let raw = String::from_utf8(output.stdout)?; decode_bootstrap(&raw) } fn ssh_destination_host(server: &str) -> String { let without_user = server.rsplit_once('@').map_or(server, |(_, host)| host); let without_path = without_user .strip_prefix("ssh://") .unwrap_or(without_user) .split('/') .next() .unwrap_or(without_user); if let Some(stripped) = without_path.strip_prefix('[') && let Some((host, _)) = stripped.split_once(']') { return host.to_string(); } without_path .split_once(':') .map_or(without_path, |(host, _)| host) .to_string() } fn selected_udp_host( explicit: Option<&str>, server: &str, ssh_config: &SshConfig, ) -> Result { let ssh_host = || { ssh_config .hostname .clone() .unwrap_or_else(|| ssh_destination_host(server)) }; let Some(raw) = explicit.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(ssh_host()); }; match raw.to_ascii_lowercase().as_str() { "ssh" | "auto" => Ok(ssh_host()), "localhost" => Ok("127.0.0.1".to_string()), "any" => Err(anyhow!( "dosh_host={raw:?} is a server bind policy, not a client destination; use ssh/auto or a host/IP" )), _ => Ok(raw.to_string()), } } fn ssh_username(server: &str) -> Option { let (user, _) = server.rsplit_once('@')?; if user.is_empty() { None } else { Some(user.to_string()) } } fn ssh_with_user(server: &str, user: Option<&str>) -> String { let Some(user) = user else { return server.to_string(); }; if user.is_empty() || ssh_username(server).is_some() || server.starts_with("ssh://") { server.to_string() } else { format!("{user}@{server}") } } #[derive(Debug, Clone, Default)] struct SshConfig { hostname: Option, port: Option, user: Option, identity_files: Vec, user_known_hosts_file: Option, identities_only: bool, proxy_jump: Option, proxy_command: Option, send_env: Vec, set_env: Vec, } fn ssh_config(server: &str, ssh_port: Option) -> Result { let mut command = Command::new("ssh"); command.arg("-G"); if let Some(ssh_port) = ssh_port { command.arg("-p").arg(ssh_port.to_string()); } let output = command .arg(server) .output() .with_context(|| format!("run ssh -G {server}"))?; if !output.status.success() { return Err(anyhow!( "ssh -G failed: {}", String::from_utf8_lossy(&output.stderr) )); } let raw = String::from_utf8(output.stdout)?; Ok(parse_ssh_config(&raw)) } fn parse_ssh_config(raw: &str) -> SshConfig { let mut config = SshConfig::default(); for line in raw.lines() { let line = line.trim(); let Some((key, value)) = line.split_once(char::is_whitespace) else { continue; }; let value = value.trim(); if key.eq_ignore_ascii_case("hostname") && !empty_or_none(value) { config.hostname = Some(value.to_string()); } else if key.eq_ignore_ascii_case("port") { config.port = value.parse().ok(); } else if key.eq_ignore_ascii_case("user") && !empty_or_none(value) { config.user = Some(value.to_string()); } else if key.eq_ignore_ascii_case("identityfile") && !empty_or_none(value) { config.identity_files.push(value.to_string()); } else if key.eq_ignore_ascii_case("userknownhostsfile") && !empty_or_none(value) { config.user_known_hosts_file = Some(value.to_string()); } else if key.eq_ignore_ascii_case("identitiesonly") { config.identities_only = value.eq_ignore_ascii_case("yes"); } else if key.eq_ignore_ascii_case("proxyjump") && !empty_or_none(value) { config.proxy_jump = Some(value.to_string()); } else if key.eq_ignore_ascii_case("proxycommand") && !empty_or_none(value) { config.proxy_command = Some(value.to_string()); } else if key.eq_ignore_ascii_case("sendenv") && !empty_or_none(value) { config .send_env .extend(value.split_whitespace().map(ToString::to_string)); } else if key.eq_ignore_ascii_case("setenv") && !empty_or_none(value) { config.set_env.extend(parse_set_env_values(value)); } } config } fn parse_set_env_values(value: &str) -> Vec { value .split_whitespace() .filter_map(|entry| { let (name, value) = entry.split_once('=')?; if valid_env_name(name) && !value.as_bytes().contains(&0) { Some(EnvVar { name: name.to_string(), value: value.to_string(), }) } else { None } }) .collect() } fn empty_or_none(value: &str) -> bool { value.is_empty() || value.eq_ignore_ascii_case("none") } fn requested_env( config: &dosh::config::ClientConfig, host: &dosh::config::HostConfig, ssh_config: Option<&SshConfig>, ) -> Vec { let mut values = BTreeMap::new(); let mut patterns = host .send_env .clone() .unwrap_or_else(|| config.send_env.clone()); if let Some(ssh_config) = ssh_config { patterns.extend(ssh_config.send_env.clone()); } for (name, value) in std::env::vars() { if valid_env_name(&name) && patterns.iter().any(|pattern| glob_matches(pattern, &name)) { values.insert(name, value); } } for (name, value) in &config.set_env { if valid_env_name(name) && !value.as_bytes().contains(&0) { values.insert(name.clone(), value.clone()); } } for (name, value) in &host.set_env { if valid_env_name(name) && !value.as_bytes().contains(&0) { values.insert(name.clone(), value.clone()); } } if let Some(ssh_config) = ssh_config { for env in &ssh_config.set_env { values.insert(env.name.clone(), env.value.clone()); } } values .into_iter() .map(|(name, value)| EnvVar { name, value }) .collect() } fn valid_env_name(name: &str) -> bool { let mut chars = name.chars(); let Some(first) = chars.next() else { return false; }; if !(first == '_' || first.is_ascii_alphabetic()) { return false; } chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) } fn glob_matches(pattern: &str, value: &str) -> bool { let pattern = pattern.as_bytes(); let value = value.as_bytes(); let (mut p, mut v) = (0, 0); let mut star = None; let mut star_value = 0; while v < value.len() { if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == value[v]) { p += 1; v += 1; } else if p < pattern.len() && pattern[p] == b'*' { star = Some(p); p += 1; star_value = v; } else if let Some(star_pos) = star { p = star_pos + 1; star_value += 1; v = star_value; } else { return false; } } while p < pattern.len() && pattern[p] == b'*' { p += 1; } p == pattern.len() } fn resolve_addr(host: &str, port: u16) -> Result { (host, port) .to_socket_addrs() .with_context(|| format!("resolve UDP target {host}:{port}"))? .next() .ok_or_else(|| anyhow!("no UDP address resolved for {host}:{port}")) } #[allow(clippy::too_many_arguments)] async fn try_native_auth( socket: &UdpSocket, config: &dosh::config::ClientConfig, requested_server: &str, server: &str, ssh_port: Option, udp_host: &str, udp_port: u16, cli_identity: Option<&Path>, session: &str, mode: &str, cols: u16, rows: u16, requested_forwardings: Vec, ssh_config_hint: Option<&SshConfig>, requested_env: Vec, allow_passphrase_prompt: bool, ) -> Result<(Frame, CachedCredential)> { let addr = resolve_addr(udp_host, udp_port)?; let owned_ssh_config; let ssh_config = if let Some(ssh_config) = ssh_config_hint { ssh_config } else { owned_ssh_config = ssh_config(server, ssh_port).unwrap_or_default(); &owned_ssh_config }; let requested_user = ssh_username(server) .or(ssh_config.user.clone()) .unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string())); let (client_secret, client_public) = generate_native_ephemeral(); let hello = dosh::native::NativeClientHello { protocol_version: dosh::native::NATIVE_PROTOCOL_VERSION, client_random: crypto::random_32(), client_ephemeral_public: client_public, requested_host: requested_server.to_string(), requested_user, requested_session: session.to_string(), requested_mode: mode.to_string(), terminal_size: (cols, rows), supported_aead: vec!["chacha20poly1305".to_string()], supported_user_key_algorithms: supported_user_key_algorithms(), cached_host_key_fingerprint: None, attach_ticket_envelope: None, requested_env, }; let packet = protocol::encode_plain( PacketKind::NativeClientHello, [0u8; 16], 1, 0, &protocol::to_body(&NativeClientHelloBody { hello: hello.clone(), })?, )?; socket.send_to(&packet, addr).await?; let mut buf = vec![0u8; 65535]; let (n, _) = tokio::time::timeout( Duration::from_millis(config.native_auth_timeout_ms.max(1)), socket.recv_from(&mut buf), ) .await??; let packet = protocol::decode(&buf[..n])?; if packet.header.kind != PacketKind::NativeServerHello { if packet.header.kind == PacketKind::AttachReject { let reject: AttachReject = protocol::from_body(&packet.body)?; return Err(anyhow!("native auth rejected: {}", reject.reason)); } return Err(anyhow!("native auth received unexpected server response")); } let server_hello: NativeServerHelloBody = protocol::from_body(&packet.body)?; verify_server_hello(&hello, &server_hello.hello)?; let known_hosts = expand_tilde(&config.known_hosts); match verify_known_host(&known_hosts, requested_server, &server_hello.hello.host_key)? { KnownHostStatus::Trusted => {} KnownHostStatus::Unknown if config.trust_on_first_use => { trust_host( &known_hosts, requested_server, &server_hello.hello.host_key, "tofu", false, )?; } KnownHostStatus::Unknown => { return Err(anyhow!( "Dosh host key for {requested_server} is not trusted; run `dosh trust {requested_server}` first" )); } KnownHostStatus::Mismatch { expected, actual } => { return Err(anyhow!( "Dosh host key mismatch for {requested_server}: expected {expected}, got {actual}" )); } } let session_key = derive_native_session_key( &client_secret, server_hello.hello.server_ephemeral_public, &hello, &server_hello.hello, )?; let auth = sign_native_user_auth( config, cli_identity, ssh_config, &hello, &server_hello.hello, requested_forwardings, allow_passphrase_prompt, )?; let mut pending_id = [0u8; 16]; pending_id.copy_from_slice(&server_hello.hello.auth_challenge[..16]); let auth_body = protocol::to_body(&NativeUserAuthBody { auth })?; let packet = protocol::encode_encrypted( PacketKind::NativeUserAuth, pending_id, 2, 1, &session_key, CLIENT_TO_SERVER, &auth_body, )?; socket.send_to(&packet, addr).await?; let (n, _) = tokio::time::timeout( Duration::from_millis(config.native_auth_timeout_ms.max(1)), socket.recv_from(&mut buf), ) .await??; let packet = protocol::decode(&buf[..n])?; if packet.header.kind != PacketKind::NativeAuthOk { if packet.header.kind == PacketKind::AttachReject { let reject: AttachReject = protocol::from_body(&packet.body)?; return Err(anyhow!("native auth rejected: {}", reject.reason)); } return Err(anyhow!("native auth received unexpected auth response")); } let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT)?; let ok: NativeAuthOkBody = protocol::from_body(&plain)?; let frame = Frame { session: ok.ok.session.clone(), output_seq: ok.ok.initial_seq, bytes: ok.ok.snapshot.clone(), snapshot: true, closed: false, }; let cred = CachedCredential { server: requested_server.to_string(), session: ok.ok.session, mode: ok.ok.mode, udp_host: udp_host.to_string(), udp_port, client_id: ok.ok.client_id, session_key: ok.ok.session_key, session_key_id: ok.ok.session_key_id, attach_ticket: ok.ok.attach_ticket, attach_ticket_psk: ok.ok.attach_ticket_psk, last_rendered_seq: ok.ok.initial_seq, }; Ok((frame, cred)) } #[allow(clippy::too_many_arguments)] async fn try_native_auth_check( socket: &UdpSocket, config: &dosh::config::ClientConfig, requested_server: &str, server: &str, ssh_port: Option, udp_host: &str, udp_port: u16, cli_identity: Option<&Path>, ssh_config_hint: Option<&SshConfig>, ) -> Result { let addr = resolve_addr(udp_host, udp_port)?; let owned_ssh_config; let ssh_config = if let Some(ssh_config) = ssh_config_hint { ssh_config } else { owned_ssh_config = ssh_config(server, ssh_port).unwrap_or_default(); &owned_ssh_config }; let requested_user = ssh_username(server) .or(ssh_config.user.clone()) .unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string())); let (client_secret, client_public) = generate_native_ephemeral(); let hello = dosh::native::NativeClientHello { protocol_version: dosh::native::NATIVE_PROTOCOL_VERSION, client_random: crypto::random_32(), client_ephemeral_public: client_public, requested_host: requested_server.to_string(), requested_user, requested_session: "doctor".to_string(), requested_mode: "doctor".to_string(), terminal_size: (80, 24), supported_aead: vec!["chacha20poly1305".to_string()], supported_user_key_algorithms: supported_user_key_algorithms(), cached_host_key_fingerprint: None, attach_ticket_envelope: None, requested_env: Vec::new(), }; let packet = protocol::encode_plain( PacketKind::NativeClientHello, [0u8; 16], 1, 0, &protocol::to_body(&NativeClientHelloBody { hello: hello.clone(), })?, )?; socket.send_to(&packet, addr).await?; let mut buf = vec![0u8; 65535]; let (n, _) = tokio::time::timeout( Duration::from_millis(config.native_auth_timeout_ms.max(1)), socket.recv_from(&mut buf), ) .await??; let packet = protocol::decode(&buf[..n])?; if packet.header.kind != PacketKind::NativeServerHello { if packet.header.kind == PacketKind::AttachReject { let reject: AttachReject = protocol::from_body(&packet.body)?; return Err(anyhow!("native doctor rejected: {}", reject.reason)); } return Err(anyhow!("native doctor received unexpected server response")); } let server_hello: NativeServerHelloBody = protocol::from_body(&packet.body)?; verify_server_hello(&hello, &server_hello.hello)?; let known_hosts = expand_tilde(&config.known_hosts); match verify_known_host(&known_hosts, requested_server, &server_hello.hello.host_key)? { KnownHostStatus::Trusted => {} KnownHostStatus::Unknown if config.trust_on_first_use => { trust_host( &known_hosts, requested_server, &server_hello.hello.host_key, "tofu", false, )?; } KnownHostStatus::Unknown => { return Err(anyhow!( "Dosh host key for {requested_server} is not trusted; run `dosh trust {requested_server}` first" )); } KnownHostStatus::Mismatch { expected, actual } => { return Err(anyhow!( "Dosh host key mismatch for {requested_server}: expected {expected}, got {actual}" )); } } let session_key = derive_native_session_key( &client_secret, server_hello.hello.server_ephemeral_public, &hello, &server_hello.hello, )?; let auth = sign_native_user_auth( config, cli_identity, ssh_config, &hello, &server_hello.hello, Vec::new(), true, )?; let mut pending_id = [0u8; 16]; pending_id.copy_from_slice(&server_hello.hello.auth_challenge[..16]); let packet = protocol::encode_encrypted( PacketKind::NativeUserAuth, pending_id, 2, 1, &session_key, CLIENT_TO_SERVER, &protocol::to_body(&NativeUserAuthBody { auth })?, )?; socket.send_to(&packet, addr).await?; let (n, _) = tokio::time::timeout( Duration::from_millis(config.native_auth_timeout_ms.max(1)), socket.recv_from(&mut buf), ) .await??; let packet = protocol::decode(&buf[..n])?; if packet.header.kind != PacketKind::NativeAuthCheckOk { if packet.header.kind == PacketKind::AttachReject { let reject: AttachReject = protocol::from_body(&packet.body)?; return Err(anyhow!("native doctor rejected: {}", reject.reason)); } return Err(anyhow!("native doctor received unexpected auth response")); } let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT)?; protocol::from_body(&plain) } fn sign_native_user_auth( config: &dosh::config::ClientConfig, cli_identity: Option<&Path>, ssh_config: &SshConfig, hello: &dosh::native::NativeClientHello, server_hello: &dosh::native::NativeServerHello, requested_forwardings: Vec, allow_passphrase_prompt: bool, ) -> Result { let mut errors = Vec::new(); if config.use_ssh_agent && !ssh_config.identities_only { match ssh_agent::sign_user_auth_with_agent( hello, server_hello, requested_forwardings.clone(), ) { Ok(auth) => return Ok(auth), Err(err) => errors.push(format!("ssh-agent: {err:#}")), } } else if config.use_ssh_agent && ssh_config.identities_only { errors.push("ssh-agent: skipped because SSH config sets IdentitiesOnly=yes".to_string()); } let identity = if allow_passphrase_prompt { load_first_native_identity(config, cli_identity, ssh_config) } else { load_first_native_identity_noninteractive(config, cli_identity, ssh_config) }; match identity { Ok(identity) => { sign_user_auth_with_private_key(&identity, hello, server_hello, requested_forwardings) } Err(err) => { errors.push(format!("identity files: {err:#}")); Err(anyhow!( "native auth found no usable key: {}", errors.join("; ") )) } } } fn load_first_native_identity( config: &dosh::config::ClientConfig, cli_identity: Option<&Path>, ssh_config: &SshConfig, ) -> Result { load_first_native_identity_with_prompt( config, cli_identity, ssh_config, prompt_identity_passphrase, ) } fn load_first_native_identity_noninteractive( config: &dosh::config::ClientConfig, cli_identity: Option<&Path>, ssh_config: &SshConfig, ) -> Result { load_first_native_identity_with_prompt(config, cli_identity, ssh_config, |path| { Err(anyhow!( "{} is encrypted; unlock it in ssh-agent or use an unencrypted key for proxy-stdio", path.display() )) }) } fn load_first_native_identity_with_prompt( config: &dosh::config::ClientConfig, cli_identity: Option<&Path>, ssh_config: &SshConfig, mut prompt: F, ) -> Result where F: FnMut(&Path) -> Result, { let mut paths = Vec::new(); if let Some(path) = cli_identity { push_identity_path(&mut paths, path.to_path_buf()); } for path in &ssh_config.identity_files { push_identity_path(&mut paths, expand_tilde(path)); } if !ssh_config.identities_only { for path in &config.identity_files { push_identity_path(&mut paths, expand_tilde(path)); } } let mut errors = Vec::new(); for path in paths { if !path.exists() { continue; } match load_native_identity(&path) { Ok(identity) => return Ok(identity), Err(err) if err.to_string().contains(" is encrypted") => { match prompt(&path).and_then(|passphrase| { load_native_identity_with_passphrase(&path, Some(&passphrase)) }) { Ok(identity) => return Ok(identity), Err(err) => errors.push(format!("{}: {err:#}", path.display())), } } Err(err) => errors.push(format!("{}: {err:#}", path.display())), } } if errors.is_empty() { Err(anyhow!("no usable native identity found")) } else { Err(anyhow!( "no usable native identity found: {}", errors.join("; ") )) } } fn push_identity_path(paths: &mut Vec, path: PathBuf) { if !paths.iter().any(|existing| existing == &path) { paths.push(path); } } fn prompt_identity_passphrase(path: &Path) -> Result { rpassword::prompt_password(format!("Enter passphrase for {}: ", path.display())) .with_context(|| format!("read passphrase for {}", path.display())) } async fn bootstrap_attach( socket: &UdpSocket, server_name: &str, bootstrap: &BootstrapResponse, cols: u16, rows: u16, requested_env: Vec, ) -> Result<(AttachOk, CachedCredential)> { let addr = resolve_addr(&bootstrap.udp_host, bootstrap.udp_port)?; let req = BootstrapAttachRequest { bootstrap: bootstrap.clone(), cols, rows, requested_env, }; let body = protocol::to_body(&req)?; let packet = protocol::encode_plain(PacketKind::BootstrapAttachRequest, [0u8; 16], 1, 0, &body)?; socket.send_to(&packet, addr).await?; let expected_key_id = protocol::session_key_id(&bootstrap.session_key); let ok = recv_response_until(socket, Duration::from_secs(5), |packet| { if packet.header.kind == PacketKind::AttachReject && packet.header.conn_id == [0u8; 16] { let reject: AttachReject = protocol::from_body(&packet.body)?; return Err(anyhow!("attach rejected: {}", reject.reason)); } if packet.header.kind != PacketKind::AttachOk || packet.header.session_key_id != expected_key_id { return Ok(None); } let Ok(plain) = protocol::decrypt_body(&packet, &bootstrap.session_key, SERVER_TO_CLIENT) else { return Ok(None); }; Ok(protocol::from_body::(&plain).ok()) }) .await .with_context(|| { format!( "no Dosh bootstrap response from {}:{}; check UDP reachability and server firewall", bootstrap.udp_host, bootstrap.udp_port ) })?; let cred = CachedCredential { server: server_name.to_string(), session: ok.session.clone(), mode: ok.mode.clone(), udp_host: bootstrap.udp_host.clone(), udp_port: bootstrap.udp_port, client_id: ok.client_id, session_key: ok.session_key, session_key_id: ok.session_key_id, attach_ticket: bootstrap.attach_ticket.clone(), attach_ticket_psk: bootstrap.attach_ticket_psk, last_rendered_seq: ok.initial_seq, }; Ok((ok, cred)) } async fn try_ticket_attach( socket: &UdpSocket, cached: &CachedCredential, cols: u16, rows: u16, requested_env: Vec, ) -> Result<(Frame, CachedCredential)> { let addr = resolve_addr(&cached.udp_host, cached.udp_port)?; let client_nonce = crypto::random_12(); let request_key = crypto::hkdf32( &cached.attach_ticket_psk, &client_nonce, b"dosh/ticket-attach-request/v1", )?; let body = protocol::to_body(&TicketAttachBody { session: cached.session.clone(), mode: cached.mode.clone(), cols, rows, requested_env, })?; let ciphertext = crypto::seal( &request_key, &client_nonce, b"dosh-ticket-attach-request-v1", &body, )?; let envelope = TicketAttachEnvelope { ticket: cached.attach_ticket.clone(), client_nonce, ciphertext, }; let packet = protocol::encode_plain( PacketKind::TicketAttachRequest, [0u8; 16], 1, 0, &protocol::to_body(&envelope)?, )?; socket.send_to(&packet, addr).await?; let ok = recv_response_until(socket, Duration::from_millis(700), |packet| { if packet.header.kind == PacketKind::AttachReject && packet.header.conn_id == [0u8; 16] { let reject: AttachReject = protocol::from_body(&packet.body)?; return Err(anyhow!("ticket attach rejected: {}", reject.reason)); } if packet.header.kind != PacketKind::AttachOk { return Ok(None); } let Ok(envelope) = protocol::from_body::(&packet.body) else { return Ok(None); }; let mut salt = Vec::with_capacity(24); salt.extend_from_slice(&client_nonce); salt.extend_from_slice(&envelope.server_nonce); let response_key = crypto::hkdf32( &cached.attach_ticket_psk, &salt, b"dosh/ticket-attach-ok/v1", )?; let Ok(plain) = crypto::open( &response_key, &envelope.server_nonce, b"dosh-ticket-attach-ok-v1", &envelope.ciphertext, ) else { return Ok(None); }; Ok(protocol::from_body::(&plain).ok()) }) .await .with_context(|| { format!( "cached ticket attach to {}:{} timed out; check UDP reachability or run `dosh recover {}`", cached.udp_host, cached.udp_port, cached.server ) })?; let frame = Frame { session: ok.session.clone(), output_seq: ok.initial_seq, bytes: ok.snapshot.clone(), snapshot: true, closed: false, }; let mut next = cached.clone(); next.client_id = ok.client_id; next.session_key = ok.session_key; next.session_key_id = ok.session_key_id; next.last_rendered_seq = ok.initial_seq; Ok((frame, next)) } async fn try_resume_fresh_process( socket: &UdpSocket, cached: &CachedCredential, cols: u16, rows: u16, ) -> Result<(Frame, CachedCredential)> { let addr = resolve_addr(&cached.udp_host, cached.udp_port)?; let req = ResumeRequest { session: cached.session.clone(), last_rendered_seq: cached.last_rendered_seq, cols, rows, }; let body = protocol::to_body(&req)?; let packet = protocol::encode_encrypted( PacketKind::ResumeRequest, cached.client_id, 1, 0, &cached.session_key, CLIENT_TO_SERVER, &body, )?; socket.send_to(&packet, addr).await?; let frame = recv_response_until(socket, Duration::from_millis(700), |packet| { if !is_resume_response_for_client(&packet, cached.client_id) { return Ok(None); } if packet.header.kind == PacketKind::AttachReject { let reject: AttachReject = protocol::from_body(&packet.body)?; return Err(anyhow!("resume rejected: {}", reject.reason)); } if packet.header.kind != PacketKind::ResumeOk { return Ok(None); } let Ok(plain) = protocol::decrypt_body(&packet, &cached.session_key, SERVER_TO_CLIENT) else { return Ok(None); }; Ok(protocol::from_body::(&plain).ok()) }) .await .with_context(|| { format!( "cached resume to {}:{} timed out; check UDP reachability or run `dosh recover {}`", cached.udp_host, cached.udp_port, cached.server ) })?; let mut next = cached.clone(); next.last_rendered_seq = frame.output_seq; Ok((frame, next)) } async fn try_live_resume( socket: &UdpSocket, cached: &CachedCredential, send_seq: &mut u64, cols: u16, rows: u16, ) -> Result<(Frame, CachedCredential)> { let addr = resolve_addr(&cached.udp_host, cached.udp_port)?; let req = ResumeRequest { session: cached.session.clone(), last_rendered_seq: cached.last_rendered_seq, cols, rows, }; let body = protocol::to_body(&req)?; let packet = protocol::encode_encrypted( PacketKind::ResumeRequest, cached.client_id, *send_seq, cached.last_rendered_seq, &cached.session_key, CLIENT_TO_SERVER, &body, )?; *send_seq += 1; socket.send_to(&packet, addr).await?; let frame = recv_response_until(socket, Duration::from_millis(700), |packet| { if !is_resume_response_for_client(&packet, cached.client_id) { return Ok(None); } if packet.header.kind == PacketKind::AttachReject { let reject: AttachReject = protocol::from_body(&packet.body)?; return Err(anyhow!("resume rejected: {}", reject.reason)); } if packet.header.kind != PacketKind::ResumeOk { return Ok(None); } let Ok(plain) = protocol::decrypt_body(&packet, &cached.session_key, SERVER_TO_CLIENT) else { return Ok(None); }; Ok(protocol::from_body::(&plain).ok()) }) .await .with_context(|| { format!( "live resume to {}:{} timed out; still waiting for the server to become reachable", cached.udp_host, cached.udp_port ) })?; let mut next = cached.clone(); next.last_rendered_seq = frame.output_seq; Ok((frame, next)) } fn is_resume_response_for_client(packet: &protocol::Packet, client_id: [u8; 16]) -> bool { packet.header.conn_id == client_id || (packet.header.kind == PacketKind::AttachReject && packet.header.conn_id == [0u8; 16]) } async fn recv_response_until(socket: &UdpSocket, wait: Duration, mut accept: F) -> Result where F: FnMut(protocol::Packet) -> Result>, { let started = Instant::now(); let mut buf = vec![0u8; 65535]; loop { let elapsed = started.elapsed(); if elapsed >= wait { return Err(anyhow!("timed out waiting for server response")); } let remaining = wait - elapsed; let (n, _) = tokio::time::timeout(remaining, socket.recv_from(&mut buf)) .await .context("timed out waiting for server response")??; let Ok(packet) = protocol::decode(&buf[..n]) else { continue; }; if let Some(response) = accept(packet)? { return Ok(response); } } } #[allow(clippy::too_many_arguments)] async fn run_terminal( socket: UdpSocket, mut cred: CachedCredential, first_frame: Option, predict: bool, predict_mode: PredictMode, escape_key: Option>, startup_command: Option>, reconnect_timeout_secs: u64, local_forwards: Vec, dynamic_forwards: Vec, forward_only: bool, agent_sock: Option, ) -> Result<()> { #[cfg(not(unix))] let _ = &agent_sock; let _raw = if forward_only { None } else { Some(RawMode::enter()?) }; let mut addr = resolve_addr(&cred.udp_host, cred.udp_port)?; let mut send_seq = 2u64; let mut last_packet_at = Instant::now(); let mut status_tick = tokio::time::interval(Duration::from_secs(1)); let mut resize_tick = tokio::time::interval(Duration::from_millis(250)); let mut stream_retransmit_tick = tokio::time::interval(Duration::from_millis(200)); let mut frame_gap_tick = tokio::time::interval(Duration::from_millis(100)); let mut last_size = terminal_size(); // React to terminal resize the instant it happens via SIGWINCH (mosh-style), // instead of waiting up to one `resize_tick`. The 250ms poll below stays as a // fallback for environments where the signal doesn't fire. `signal()` can fail // (rare), in which case we rely on the poll. #[cfg(unix)] let mut winch = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change()).ok(); let mut frame_buffer = FrameBuffer::default(); // Resolve the prediction display policy (off / experimental / always). An // env var wins for ad-hoc tuning; otherwise the client config's // `predict_mode` provides the persistent default. Predictions only run in a // read-write interactive session. let mut predictor = Predictor::with_mode( predict && cred.mode != "view-only" && !forward_only, predict_mode, ); // Non-destructive disconnect status line. Off in forward-only mode (no TTY to // draw on) and respecting the client config (default on, env override). let mut disconnect_status = DisconnectStatus::new(resolve_disconnect_status() && !forward_only); let (forward_tx, mut forward_rx) = mpsc::channel::(1024); let _forward_keepalive = if local_forwards.is_empty() { Some(forward_tx.clone()) } else { None }; let remote_forward_tx = forward_tx.clone(); let stream_ids = Arc::new(AtomicU64::new(1)); start_local_forwards(&local_forwards, forward_tx.clone(), Arc::clone(&stream_ids)).await?; start_dynamic_forwards(&dynamic_forwards, forward_tx.clone(), stream_ids).await?; signal_background_ready(); let mut stream_writers: HashMap = HashMap::new(); // Write halves for server-initiated SSH-agent streams, spliced into the local // unix agent socket. Kept separate from the TCP `stream_writers` so the // existing TCP forwarding paths are untouched. #[cfg(unix)] let mut agent_writers: HashMap = HashMap::new(); let mut pending_socks_replies: HashSet = HashSet::new(); let mut opened_streams: HashSet = HashSet::new(); let mut stream_send_credit: HashMap = HashMap::new(); let mut stream_pending_data: HashMap>> = HashMap::new(); let mut stream_pending_opens: HashMap = HashMap::new(); let mut stream_next_send_offset: HashMap = HashMap::new(); let mut stream_sent_data: HashMap> = HashMap::new(); let mut stream_next_recv_offset: HashMap = HashMap::new(); let mut stream_recv_pending: HashMap>> = HashMap::new(); let mut pending_user_input: VecDeque = VecDeque::new(); let mut pending_user_input_bytes = 0usize; let mut startup_input_hold_until: Option = None; let mut startup_gate_mode = StartupGateMode::HoldControl; if let Some(frame) = first_frame { if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); } if frame.closed { return Ok(()); } cred.last_rendered_seq = frame.output_seq; send_ack(&socket, addr, &cred, &mut send_seq).await?; } if let Some(bytes) = startup_command && cred.mode != "view-only" && !forward_only { send_input(&socket, addr, &cred, &mut send_seq, bytes).await?; startup_input_hold_until = Some(Instant::now() + STARTUP_INPUT_HOLD); startup_gate_mode = StartupGateMode::HoldAll; } let (stdin_tx, mut stdin_rx) = mpsc::unbounded_channel::>(); let _stdin_keepalive = if forward_only { Some(stdin_tx) } else { std::thread::Builder::new() .name("dosh-stdin".to_string()) .spawn(move || { let mut stdin = std::io::stdin(); let mut buf = [0u8; 4096]; loop { match stdin.read(&mut buf) { Ok(0) => break, Ok(n) => { let _ = stdin_tx.send(buf[..n].to_vec()); } Err(_) => break, } } })?; None }; // Retain the previous epoch's key briefly after a rekey so any in-flight // pre-rekey frame still decrypts instead of triggering a needless reconnect. let mut previous_session_key: Option<[u8; 32]> = None; let mut recv_buf = vec![0u8; 65535]; loop { tokio::select! { biased; stdin_msg = stdin_rx.recv() => { match stdin_msg { Some(bytes) => { if input_matches_escape(&bytes, escape_key.as_deref()) { break; } if cred.mode != "view-only" && cred.mode != "forward-only" { if let Some(deadline) = startup_input_hold_until { if !predictor.alternate_screen && Instant::now() < deadline && should_hold_during_startup_gate( startup_gate_mode, !pending_user_input.is_empty(), &bytes, ) { queue_pending_user_input( &mut pending_user_input, &mut pending_user_input_bytes, bytes, )?; continue; } startup_input_hold_until = None; startup_gate_mode = StartupGateMode::HoldControl; flush_pending_user_input( &socket, addr, &cred, &mut send_seq, &mut predictor, &mut pending_user_input, &mut pending_user_input_bytes, ) .await?; } if !predictor.alternate_screen && let Some((send_now, mut hold_for_later)) = split_after_command_submit(&bytes) { predictor.observe_input(&send_now)?; send_input(&socket, addr, &cred, &mut send_seq, send_now).await?; startup_input_hold_until = Some( Instant::now() + post_submit_hold_duration(&hold_for_later), ); startup_gate_mode = if should_hold_post_submit_input(&hold_for_later) { StartupGateMode::HoldControl } else { StartupGateMode::HoldAll }; if should_hold_post_submit_input(&hold_for_later) { queue_pending_user_input( &mut pending_user_input, &mut pending_user_input_bytes, hold_for_later, )?; continue; } else if !hold_for_later.is_empty() { predictor.observe_input(&hold_for_later)?; send_input( &socket, addr, &cred, &mut send_seq, std::mem::take(&mut hold_for_later), ) .await?; } continue; } if last_packet_at.elapsed() >= Duration::from_secs(2) { queue_stale_pending_user_input( &mut pending_user_input, &mut pending_user_input_bytes, bytes, )?; if let Some(frame) = reconnect( &socket, &mut cred, &mut send_seq, last_size, &mut frame_buffer, &mut predictor, ) .await? { refresh_live_addr(&mut addr, &cred)?; if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); } last_packet_at = Instant::now(); flush_pending_user_input( &socket, addr, &cred, &mut send_seq, &mut predictor, &mut pending_user_input, &mut pending_user_input_bytes, ) .await?; } } else { predictor.observe_input(&bytes)?; send_input(&socket, addr, &cred, &mut send_seq, bytes).await?; } } } None => break, } } _ = async { #[cfg(unix)] match winch.as_mut() { Some(w) => { w.recv().await; } None => std::future::pending::<()>().await, } #[cfg(not(unix))] std::future::pending::<()>().await } => { maybe_send_resize(&socket, addr, &cred, &mut send_seq, &mut last_size).await?; } _ = resize_tick.tick() => { maybe_send_resize(&socket, addr, &cred, &mut send_seq, &mut last_size).await?; } _ = frame_gap_tick.tick() => { if frame_buffer.gap_wait_elapsed(Duration::from_millis(FRAME_GAP_RESYNC_AFTER_MS)) && let Some(frame) = reconnect( &socket, &mut cred, &mut send_seq, last_size, &mut frame_buffer, &mut predictor, ) .await? { refresh_live_addr(&mut addr, &cred)?; if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); } last_packet_at = Instant::now(); flush_pending_user_input( &socket, addr, &cred, &mut send_seq, &mut predictor, &mut pending_user_input, &mut pending_user_input_bytes, ) .await?; } } recv = socket.recv_from(&mut recv_buf) => { let (n, _) = recv?; let Ok(packet) = protocol::decode(&recv_buf[..n]) else { continue; }; if packet.header.conn_id != [0u8; 16] && packet.header.conn_id != cred.client_id { continue; } // Contact resumed: clear any disconnect status line immediately. disconnect_status.apply(disconnect_status.on_contact())?; match packet.header.kind { PacketKind::Frame | PacketKind::ResumeOk => { let decrypted = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) .or_else(|err| { // Fall back to the previous epoch key for in-flight // pre-rekey frames before declaring the link stale. match previous_session_key { Some(prev) => protocol::decrypt_body(&packet, &prev, SERVER_TO_CLIENT), None => Err(err), } }); let Ok(plain) = decrypted else { if let Some(frame) = reconnect( &socket, &mut cred, &mut send_seq, last_size, &mut frame_buffer, &mut predictor, ) .await? { refresh_live_addr(&mut addr, &cred)?; if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); } last_packet_at = Instant::now(); flush_pending_user_input( &socket, addr, &cred, &mut send_seq, &mut predictor, &mut pending_user_input, &mut pending_user_input_bytes, ) .await?; } continue; }; let Ok(frame) = protocol::from_body::(&plain) else { continue; }; last_packet_at = Instant::now(); let frames = frame_buffer.accept(frame, &mut cred.last_rendered_seq); for frame in frames { predictor.clear_pending()?; if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); flush_startup_input_if_ready( &socket, addr, &cred, &mut send_seq, &mut predictor, (&mut pending_user_input, &mut pending_user_input_bytes), (&mut startup_input_hold_until, &mut startup_gate_mode), ) .await?; } if frame.closed { send_ack(&socket, addr, &cred, &mut send_seq).await?; return Ok(()); } } send_ack(&socket, addr, &cred, &mut send_seq).await?; } PacketKind::Pong => { last_packet_at = Instant::now(); } PacketKind::Rekey => { // Server-initiated transport rekey (spec §11). The Rekey is // sealed under the current key; once decrypted we derive the // next key from the shipped fresh material + current key, // switch atomically (keeping the old key for grace), and // confirm with a RekeyAck under the NEW key. let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else { continue; }; let Ok(rekey) = protocol::from_body::(&plain) else { continue; }; let previous_key = cred.session_key; let previous_key_id = cred.session_key_id; let Ok(new_key) = dosh::native::derive_rekey_session_key( &previous_key, &rekey.rekey_material, &previous_key_id, rekey.epoch, ) else { continue; }; // Only accept if our derivation matches the server's id. if protocol::session_key_id(&new_key) != rekey.new_session_key_id { continue; } previous_session_key = Some(previous_key); cred.session_key = new_key; cred.session_key_id = rekey.new_session_key_id; last_packet_at = Instant::now(); send_seq += 1; let ack = protocol::encode_encrypted( PacketKind::RekeyAck, cred.client_id, send_seq, cred.last_rendered_seq, &cred.session_key, CLIENT_TO_SERVER, b"", )?; socket.send_to(&ack, addr).await?; } PacketKind::AttachReject => { let reject: AttachReject = protocol::from_body(&packet.body)?; if reject.reason == "unknown client" { if let Some(frame) = reconnect( &socket, &mut cred, &mut send_seq, last_size, &mut frame_buffer, &mut predictor, ) .await? { refresh_live_addr(&mut addr, &cred)?; if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); } last_packet_at = Instant::now(); flush_pending_user_input( &socket, addr, &cred, &mut send_seq, &mut predictor, &mut pending_user_input, &mut pending_user_input_bytes, ) .await?; } } else { return Err(anyhow!("server rejected session: {}", reject.reason)); } } PacketKind::StreamOpen => { let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else { continue; }; let Ok(open) = protocol::from_body::(&plain) else { continue; }; last_packet_at = Instant::now(); if opened_streams.contains(&open.stream_id) { send_stream_open_ok( &socket, addr, &cred, &mut send_seq, open.stream_id, ) .await?; continue; } // SSH-agent stream: the server is asking us to splice this // stream into our LOCAL ssh-agent. Only honor it when we // actually opted in (agent_sock is Some); otherwise reject, // so a server cannot reach our agent without consent. if open.target_host == AGENT_STREAM_SENTINEL { #[cfg(not(unix))] { send_stream_open_reject( &socket, addr, &cred, &mut send_seq, open.stream_id, "agent forwarding is not supported on this client platform" .to_string(), ) .await?; } #[cfg(unix)] { let Some(sock_path) = agent_sock.clone() else { send_stream_open_reject( &socket, addr, &cred, &mut send_seq, open.stream_id, "agent forwarding not enabled by client".to_string(), ) .await?; continue; }; match UnixStream::connect(&sock_path).await { Ok(stream) => { let (mut reader, writer) = stream.into_split(); agent_writers.insert(open.stream_id, writer); opened_streams.insert(open.stream_id); stream_send_credit .insert(open.stream_id, STREAM_INITIAL_WINDOW); stream_next_send_offset.entry(open.stream_id).or_insert(0); stream_next_recv_offset.entry(open.stream_id).or_insert(0); send_stream_open_ok( &socket, addr, &cred, &mut send_seq, open.stream_id, ) .await?; let forward_tx = remote_forward_tx.clone(); tokio::spawn(async move { let mut buf = [0u8; 16 * 1024]; loop { match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { if forward_tx .send(ForwardEvent::Data { stream_id: open.stream_id, bytes: buf[..n].to_vec(), }) .await .is_err() { return; } } Err(_) => break, } } let _ = forward_tx .send(ForwardEvent::Close { stream_id: open.stream_id, }) .await; }); } Err(err) => { send_stream_open_reject( &socket, addr, &cred, &mut send_seq, open.stream_id, format!("connect local agent: {err}"), ) .await?; } } } continue; } match TcpStream::connect((open.target_host.as_str(), open.target_port)).await { Ok(stream) => { let (mut reader, writer) = stream.into_split(); stream_writers.insert(open.stream_id, writer); opened_streams.insert(open.stream_id); stream_send_credit.insert(open.stream_id, STREAM_INITIAL_WINDOW); stream_next_send_offset.entry(open.stream_id).or_insert(0); stream_next_recv_offset.entry(open.stream_id).or_insert(0); send_stream_open_ok(&socket, addr, &cred, &mut send_seq, open.stream_id).await?; let forward_tx = remote_forward_tx.clone(); tokio::spawn(async move { let mut buf = [0u8; 16 * 1024]; loop { match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { if forward_tx .send(ForwardEvent::Data { stream_id: open.stream_id, bytes: buf[..n].to_vec(), }) .await .is_err() { return; } } Err(_) => break, } } let _ = forward_tx.send(ForwardEvent::Close { stream_id: open.stream_id, }).await; }); } Err(err) => { send_stream_open_reject( &socket, addr, &cred, &mut send_seq, open.stream_id, format!("connect {}:{}: {err}", open.target_host, open.target_port), ) .await?; } } } PacketKind::StreamOpenOk => { let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else { continue; }; let Ok(ok) = protocol::from_body::(&plain) else { continue; }; last_packet_at = Instant::now(); stream_pending_opens.remove(&ok.stream_id); opened_streams.insert(ok.stream_id); stream_send_credit.entry(ok.stream_id).or_insert(STREAM_INITIAL_WINDOW); stream_next_send_offset.entry(ok.stream_id).or_insert(0); stream_next_recv_offset.entry(ok.stream_id).or_insert(0); if pending_socks_replies.remove(&ok.stream_id) && let Some(writer) = stream_writers.get_mut(&ok.stream_id) { let _ = writer.write_all(&[5, 0, 0, 1, 0, 0, 0, 0, 0, 0]).await; } flush_stream_pending_data( &socket, addr, &cred, &mut send_seq, ok.stream_id, &mut stream_send_credit, &mut stream_pending_data, &mut stream_next_send_offset, &mut stream_sent_data, ).await?; } PacketKind::StreamOpenReject => { let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else { continue; }; let Ok(reject) = protocol::from_body::(&plain) else { continue; }; last_packet_at = Instant::now(); stream_pending_opens.remove(&reject.stream_id); if pending_socks_replies.remove(&reject.stream_id) && let Some(writer) = stream_writers.get_mut(&reject.stream_id) { let _ = writer.write_all(&[5, 5, 0, 1, 0, 0, 0, 0, 0, 0]).await; } opened_streams.remove(&reject.stream_id); stream_send_credit.remove(&reject.stream_id); stream_pending_data.remove(&reject.stream_id); stream_next_send_offset.remove(&reject.stream_id); stream_sent_data.remove(&reject.stream_id); stream_next_recv_offset.remove(&reject.stream_id); stream_recv_pending.remove(&reject.stream_id); stream_writers.remove(&reject.stream_id); } PacketKind::StreamData => { let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else { continue; }; let Ok(data) = protocol::from_body::(&plain) else { continue; }; last_packet_at = Instant::now(); let stream_id = data.stream_id; let (writes, consumed, received_offset) = accept_stream_data(&mut stream_next_recv_offset, &mut stream_recv_pending, data); if let Some(writer) = stream_writers.get_mut(&stream_id) { for bytes in &writes { let _ = writer.write_all(bytes).await; } } else { #[cfg(unix)] if let Some(writer) = agent_writers.get_mut(&stream_id) { for bytes in &writes { let _ = writer.write_all(bytes).await; } } } // Always return flow-control credit so a stream whose local // writer is already gone cannot wedge the server's send window. send_stream_window_adjust( &socket, addr, &cred, &mut send_seq, stream_id, consumed, received_offset, ).await?; } PacketKind::StreamWindowAdjust => { let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else { continue; }; let Ok(adjust) = protocol::from_body::(&plain) else { continue; }; last_packet_at = Instant::now(); ack_stream_data( &mut stream_sent_data, &mut stream_send_credit, adjust.stream_id, adjust.received_offset, ); flush_stream_pending_data( &socket, addr, &cred, &mut send_seq, adjust.stream_id, &mut stream_send_credit, &mut stream_pending_data, &mut stream_next_send_offset, &mut stream_sent_data, ).await?; } PacketKind::StreamClose => { let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else { continue; }; let Ok(close) = protocol::from_body::(&plain) else { continue; }; last_packet_at = Instant::now(); pending_socks_replies.remove(&close.stream_id); stream_pending_opens.remove(&close.stream_id); opened_streams.remove(&close.stream_id); stream_send_credit.remove(&close.stream_id); stream_pending_data.remove(&close.stream_id); stream_next_send_offset.remove(&close.stream_id); stream_sent_data.remove(&close.stream_id); stream_next_recv_offset.remove(&close.stream_id); stream_recv_pending.remove(&close.stream_id); stream_writers.remove(&close.stream_id); #[cfg(unix)] agent_writers.remove(&close.stream_id); } _ => {} } } forward_event = forward_rx.recv() => { match forward_event { Some(ForwardEvent::Open { stream_id, target_host, target_port, writer, socks5_reply }) => { stream_writers.insert(stream_id, writer); stream_send_credit.insert(stream_id, STREAM_INITIAL_WINDOW); stream_next_send_offset.entry(stream_id).or_insert(0); stream_next_recv_offset.entry(stream_id).or_insert(0); if socks5_reply { pending_socks_replies.insert(stream_id); } stream_pending_opens.insert(stream_id, PendingStreamOpen { target_host: target_host.clone(), target_port, last_sent: Instant::now(), attempts: 1, }); send_stream_open( &socket, addr, &cred, &mut send_seq, stream_id, target_host, target_port, ) .await?; } Some(ForwardEvent::Data { stream_id, bytes }) => { queue_or_send_stream_data( &socket, addr, &cred, &mut send_seq, stream_id, bytes, &opened_streams, &mut stream_send_credit, &mut stream_pending_data, &mut stream_next_send_offset, &mut stream_sent_data, ).await?; } Some(ForwardEvent::Close { stream_id }) => { pending_socks_replies.remove(&stream_id); stream_pending_opens.remove(&stream_id); opened_streams.remove(&stream_id); stream_send_credit.remove(&stream_id); stream_pending_data.remove(&stream_id); stream_next_send_offset.remove(&stream_id); stream_sent_data.remove(&stream_id); stream_next_recv_offset.remove(&stream_id); stream_recv_pending.remove(&stream_id); stream_writers.remove(&stream_id); #[cfg(unix)] agent_writers.remove(&stream_id); send_stream_close(&socket, addr, &cred, &mut send_seq, stream_id).await?; } None if forward_only => break, None => {} } } _ = status_tick.tick() => { let stale = last_packet_at.elapsed(); if stale >= Duration::from_secs(reconnect_timeout_secs.max(1)) { if let Some(frame) = reconnect( &socket, &mut cred, &mut send_seq, last_size, &mut frame_buffer, &mut predictor, ) .await? { refresh_live_addr(&mut addr, &cred)?; if !forward_only { render_frame(&frame)?; predictor.observe_output(&frame.bytes); } last_packet_at = Instant::now(); flush_pending_user_input( &socket, addr, &cred, &mut send_seq, &mut predictor, &mut pending_user_input, &mut pending_user_input_bytes, ) .await?; } } else if stale >= Duration::from_secs(2) { let packet = protocol::encode_encrypted( PacketKind::Ping, cred.client_id, send_seq, cred.last_rendered_seq, &cred.session_key, CLIENT_TO_SERVER, b"", )?; send_seq += 1; socket.send_to(&packet, addr).await?; } // Re-evaluate the prediction display policy on every timer tick so // a latency spike (or recovery) flips speculation on/off promptly // without waiting for the next keystroke to drive `redraw`. if !forward_only { predictor.refresh_policy()?; flush_startup_input_if_ready( &socket, addr, &cred, &mut send_seq, &mut predictor, (&mut pending_user_input, &mut pending_user_input_bytes), (&mut startup_input_hold_until, &mut startup_gate_mode), ) .await?; } // Refresh / clear the non-destructive disconnect status line based // on how long the link has been silent (recomputed after any // reconnect attempt above may have reset `last_packet_at`). if !forward_only { let action = if predictor.alternate_screen { disconnect_status.on_suppressed() } else { disconnect_status.on_tick(last_packet_at.elapsed()) }; disconnect_status.apply(action)?; } } _ = stream_retransmit_tick.tick() => { retransmit_stream_data( &socket, addr, &cred, &mut send_seq, &mut stream_sent_data, ).await?; retransmit_stream_opens( &socket, addr, &cred, &mut send_seq, &mut stream_pending_opens, ).await?; } } } // Leave the terminal clean on exit: erase any lingering status line. let _ = disconnect_status.apply(StatusAction::Clear); let _ = detach_once(&socket, &cred, send_seq).await; Ok(()) } async fn start_local_forwards( local_forwards: &[LocalForward], forward_tx: mpsc::Sender, stream_ids: Arc, ) -> Result<()> { for forward in local_forwards { let bind = format!("{}:{}", forward.bind_host, forward.listen_port); let listener = TcpListener::bind(&bind) .await .with_context(|| format!("bind local forward {bind}"))?; let forward = forward.clone(); let forward_tx = forward_tx.clone(); let stream_ids = Arc::clone(&stream_ids); tokio::spawn(async move { loop { let Ok((stream, _)) = listener.accept().await else { break; }; let stream_id = stream_ids.fetch_add(1, Ordering::Relaxed); let (mut reader, writer) = stream.into_split(); let _ = forward_tx .send(ForwardEvent::Open { stream_id, target_host: forward.target_host.clone(), target_port: forward.target_port, writer, socks5_reply: false, }) .await; let forward_tx = forward_tx.clone(); tokio::spawn(async move { let mut buf = [0u8; 16 * 1024]; loop { match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { if forward_tx .send(ForwardEvent::Data { stream_id, bytes: buf[..n].to_vec(), }) .await .is_err() { return; } } Err(_) => break, } } let _ = forward_tx.send(ForwardEvent::Close { stream_id }).await; }); } }); } Ok(()) } async fn start_dynamic_forwards( dynamic_forwards: &[DynamicForward], forward_tx: mpsc::Sender, stream_ids: Arc, ) -> Result<()> { for forward in dynamic_forwards { let bind = format!("{}:{}", forward.bind_host, forward.listen_port); let listener = TcpListener::bind(&bind) .await .with_context(|| format!("bind dynamic forward {bind}"))?; let forward_tx = forward_tx.clone(); let stream_ids = Arc::clone(&stream_ids); tokio::spawn(async move { loop { let Ok((stream, _)) = listener.accept().await else { break; }; let forward_tx = forward_tx.clone(); let stream_ids = Arc::clone(&stream_ids); tokio::spawn(async move { let _ = handle_socks5_connect(stream, forward_tx, stream_ids).await; }); } }); } Ok(()) } async fn handle_socks5_connect( mut stream: TcpStream, forward_tx: mpsc::Sender, stream_ids: Arc, ) -> Result<()> { let mut hello = [0u8; 2]; stream.read_exact(&mut hello).await?; anyhow::ensure!(hello[0] == 5, "unsupported SOCKS version {}", hello[0]); let mut methods = vec![0u8; hello[1] as usize]; stream.read_exact(&mut methods).await?; if !methods.contains(&0) { stream.write_all(&[5, 0xff]).await?; return Ok(()); } stream.write_all(&[5, 0]).await?; let mut header = [0u8; 4]; stream.read_exact(&mut header).await?; anyhow::ensure!( header[0] == 5, "unsupported SOCKS request version {}", header[0] ); if header[1] != 1 { stream.write_all(&[5, 7, 0, 1, 0, 0, 0, 0, 0, 0]).await?; return Ok(()); } let target_host = match header[3] { 1 => { let mut raw = [0u8; 4]; stream.read_exact(&mut raw).await?; Ipv4Addr::from(raw).to_string() } 3 => { let mut len = [0u8; 1]; stream.read_exact(&mut len).await?; let mut raw = vec![0u8; len[0] as usize]; stream.read_exact(&mut raw).await?; String::from_utf8(raw).context("SOCKS domain is not valid UTF-8")? } 4 => { let mut raw = [0u8; 16]; stream.read_exact(&mut raw).await?; Ipv6Addr::from(raw).to_string() } atyp => { stream.write_all(&[5, 8, 0, 1, 0, 0, 0, 0, 0, 0]).await?; return Err(anyhow!("unsupported SOCKS address type {atyp}")); } }; let mut port = [0u8; 2]; stream.read_exact(&mut port).await?; let target_port = u16::from_be_bytes(port); let stream_id = stream_ids.fetch_add(1, Ordering::Relaxed); let (mut reader, writer) = stream.into_split(); forward_tx .send(ForwardEvent::Open { stream_id, target_host, target_port, writer, socks5_reply: true, }) .await?; tokio::spawn(async move { let mut buf = [0u8; 16 * 1024]; loop { match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { if forward_tx .send(ForwardEvent::Data { stream_id, bytes: buf[..n].to_vec(), }) .await .is_err() { return; } } Err(_) => break, } } let _ = forward_tx.send(ForwardEvent::Close { stream_id }).await; }); Ok(()) } async fn reconnect( socket: &UdpSocket, cred: &mut CachedCredential, send_seq: &mut u64, size: (u16, u16), frame_buffer: &mut FrameBuffer, predictor: &mut Predictor, ) -> Result> { if let Ok((frame, next)) = try_live_resume(socket, cred, send_seq, size.0, size.1).await { *cred = next; frame_buffer.clear(); predictor.reset(); cred.last_rendered_seq = frame.output_seq; send_ack( socket, resolve_addr(&cred.udp_host, cred.udp_port)?, cred, send_seq, ) .await?; return Ok(Some(frame)); } let Ok((frame, next)) = try_ticket_attach(socket, cred, size.0, size.1, Vec::new()).await else { return Ok(None); }; *cred = next; *send_seq = 2; frame_buffer.clear(); predictor.reset(); cred.last_rendered_seq = frame.output_seq; send_ack( socket, resolve_addr(&cred.udp_host, cred.udp_port)?, cred, send_seq, ) .await?; Ok(Some(frame)) } fn refresh_live_addr(addr: &mut SocketAddr, cred: &CachedCredential) -> Result<()> { *addr = resolve_addr(&cred.udp_host, cred.udp_port)?; Ok(()) } fn queue_pending_user_input( pending: &mut VecDeque, pending_bytes: &mut usize, bytes: Vec, ) -> Result<()> { queue_pending_user_input_with_filter(pending, pending_bytes, bytes, false) } fn queue_stale_pending_user_input( pending: &mut VecDeque, pending_bytes: &mut usize, bytes: Vec, ) -> Result<()> { queue_pending_user_input_with_filter(pending, pending_bytes, bytes, true) } fn queue_pending_user_input_with_filter( pending: &mut VecDeque, pending_bytes: &mut usize, bytes: Vec, strip_mouse_reports: bool, ) -> Result<()> { let next = pending_bytes .checked_add(bytes.len()) .ok_or_else(|| anyhow!("pending input buffer overflow"))?; anyhow::ensure!( next <= MAX_PENDING_USER_INPUT_BYTES, "pending input buffer full while disconnected" ); *pending_bytes = next; pending.push_back(PendingUserInput { bytes, strip_mouse_reports, }); Ok(()) } #[derive(Debug, Clone, PartialEq, Eq)] struct PendingUserInput { bytes: Vec, strip_mouse_reports: bool, } fn split_after_command_submit(bytes: &[u8]) -> Option<(Vec, Vec)> { let split_at = bytes .iter() .position(|byte| matches!(byte, b'\r' | b'\n'))? + 1; if split_at == bytes.len() { return Some((bytes.to_vec(), Vec::new())); } let later = bytes[split_at..].to_vec(); let now = bytes[..split_at].to_vec(); Some((now, later)) } fn should_hold_post_submit_input(bytes: &[u8]) -> bool { matches!(bytes.first(), Some(0x1b | 0x9b)) } fn post_submit_hold_duration(followup: &[u8]) -> Duration { if should_hold_post_submit_input(followup) { STARTUP_INPUT_HOLD } else { POST_SUBMIT_ALL_INPUT_HOLD } } fn should_hold_during_startup_gate( mode: StartupGateMode, already_queued: bool, bytes: &[u8], ) -> bool { mode == StartupGateMode::HoldAll || already_queued || should_hold_post_submit_input(bytes) } async fn flush_pending_user_input( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, predictor: &mut Predictor, pending: &mut VecDeque, pending_bytes: &mut usize, ) -> Result<()> { while let Some(input) = pending.pop_front() { let PendingUserInput { bytes, strip_mouse_reports, } = input; *pending_bytes = pending_bytes.saturating_sub(bytes.len()); let bytes = if strip_mouse_reports { strip_stale_mouse_reports(&bytes) } else { bytes }; if bytes.is_empty() { continue; } predictor.observe_input(&bytes)?; send_input(socket, addr, cred, send_seq, bytes).await?; } Ok(()) } fn strip_stale_mouse_reports(bytes: &[u8]) -> Vec { let mut out = Vec::with_capacity(bytes.len()); let mut offset = 0; while offset < bytes.len() { if let Some(len) = stale_mouse_report_len(bytes, offset) { offset += len; continue; } if stale_mouse_report_maybe_incomplete(bytes, offset) { break; } out.push(bytes[offset]); offset += 1; } out } fn stale_mouse_report_len(bytes: &[u8], offset: usize) -> Option { match bytes.get(offset).copied()? { 0x1b if bytes.get(offset + 1) == Some(&b'[') => match bytes.get(offset + 2).copied() { Some(b'M') if offset + 6 <= bytes.len() => Some(6), Some(b'<') => mouse_report_params_len(bytes, offset + 3, 2).map(|len| len + 3), Some(byte) if byte.is_ascii_digit() => { mouse_report_params_len(bytes, offset + 2, 2).map(|len| len + 2) } _ => None, }, 0x9b => match bytes.get(offset + 1).copied() { Some(b'M') if offset + 5 <= bytes.len() => Some(5), Some(b'<') => mouse_report_params_len(bytes, offset + 2, 2).map(|len| len + 2), Some(byte) if byte.is_ascii_digit() => { mouse_report_params_len(bytes, offset + 1, 2).map(|len| len + 1) } _ => None, }, byte if byte.is_ascii_digit() => mouse_report_params_len(bytes, offset, 1), _ => None, } } fn mouse_report_params_len(bytes: &[u8], offset: usize, min_semicolons: usize) -> Option { let mut semicolons = 0usize; let mut cursor = offset; while let Some(byte) = bytes.get(cursor).copied() { match byte { b'0'..=b'9' => cursor += 1, b';' => { semicolons += 1; cursor += 1; } b'M' | b'm' if semicolons >= min_semicolons => return Some(cursor + 1 - offset), _ => return None, } } None } fn stale_mouse_report_maybe_incomplete(bytes: &[u8], offset: usize) -> bool { let Some(rest) = bytes.get(offset..) else { return false; }; match rest { [0x1b] | [0x1b, b'['] | [0x1b, b'[', b'<'] => true, [0x1b, b'[', b'M', ..] if rest.len() < 6 => true, [0x1b, b'[', b'<', params @ ..] | [0x1b, b'[', params @ ..] if params_are_mouse_prefix(params) => { true } [0x9b] | [0x9b, b'<'] => true, [0x9b, b'M', ..] if rest.len() < 5 => true, [0x9b, b'<', params @ ..] | [0x9b, params @ ..] if params_are_mouse_prefix(params) => true, params if params_are_mouse_prefix(params) => true, _ => false, } } fn params_are_mouse_prefix(params: &[u8]) -> bool { let mut semicolons = 0usize; let mut saw_digit = false; for byte in params { match byte { b'0'..=b'9' => saw_digit = true, b';' if saw_digit => semicolons += 1, _ => return false, } } saw_digit && semicolons >= 1 } async fn flush_startup_input_if_ready( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, predictor: &mut Predictor, pending: (&mut VecDeque, &mut usize), hold: (&mut Option, &mut StartupGateMode), ) -> Result<()> { let (startup_hold_until, startup_gate_mode) = hold; let Some(deadline) = *startup_hold_until else { return Ok(()); }; if !predictor.alternate_screen && Instant::now() < deadline { return Ok(()); } *startup_hold_until = None; *startup_gate_mode = StartupGateMode::HoldControl; let (pending, pending_bytes) = pending; flush_pending_user_input( socket, addr, cred, send_seq, predictor, pending, pending_bytes, ) .await } async fn send_input( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, bytes: Vec, ) -> Result<()> { let body = protocol::to_body(&Input { bytes })?; let packet = protocol::encode_encrypted( PacketKind::Input, cred.client_id, *send_seq, cred.last_rendered_seq, &cred.session_key, CLIENT_TO_SERVER, &body, )?; *send_seq += 1; socket.send_to(&packet, addr).await?; Ok(()) } async fn send_stream_open( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, stream_id: u64, target_host: String, target_port: u16, ) -> Result<()> { let body = protocol::to_body(&StreamOpen { stream_id, target_host, target_port, })?; send_stream_packet(socket, addr, cred, send_seq, PacketKind::StreamOpen, &body).await } async fn send_stream_open_ok( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, stream_id: u64, ) -> Result<()> { let body = protocol::to_body(&StreamOpenOk { stream_id })?; send_stream_packet( socket, addr, cred, send_seq, PacketKind::StreamOpenOk, &body, ) .await } async fn send_stream_open_reject( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, stream_id: u64, reason: String, ) -> Result<()> { let body = protocol::to_body(&StreamOpenReject { stream_id, reason })?; send_stream_packet( socket, addr, cred, send_seq, PacketKind::StreamOpenReject, &body, ) .await } async fn send_stream_data( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, stream_id: u64, offset: u64, bytes: Vec, ) -> Result<()> { let body = protocol::to_body(&StreamData { stream_id, offset, bytes, })?; send_stream_packet(socket, addr, cred, send_seq, PacketKind::StreamData, &body).await } #[allow(clippy::too_many_arguments)] async fn queue_or_send_stream_data( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, stream_id: u64, bytes: Vec, opened_streams: &HashSet, stream_send_credit: &mut HashMap, stream_pending_data: &mut HashMap>>, stream_next_send_offset: &mut HashMap, stream_sent_data: &mut HashMap>, ) -> Result<()> { if !opened_streams.contains(&stream_id) || stream_send_credit.get(&stream_id).copied().unwrap_or(0) < bytes.len() || stream_pending_data .get(&stream_id) .is_some_and(|pending| !pending.is_empty()) { stream_pending_data .entry(stream_id) .or_default() .push_back(bytes); return Ok(()); } *stream_send_credit.entry(stream_id).or_default() -= bytes.len(); let offset = *stream_next_send_offset.entry(stream_id).or_default(); stream_next_send_offset.insert(stream_id, offset.saturating_add(bytes.len() as u64)); stream_sent_data.entry(stream_id).or_default().insert( offset, PendingStreamChunk { offset, bytes: bytes.clone(), last_sent: Instant::now(), attempts: 1, }, ); send_stream_data(socket, addr, cred, send_seq, stream_id, offset, bytes).await } #[allow(clippy::too_many_arguments)] async fn flush_stream_pending_data( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, stream_id: u64, stream_send_credit: &mut HashMap, stream_pending_data: &mut HashMap>>, stream_next_send_offset: &mut HashMap, stream_sent_data: &mut HashMap>, ) -> Result<()> { let Some(pending) = stream_pending_data.get_mut(&stream_id) else { return Ok(()); }; while let Some(bytes) = pending.front() { let credit = stream_send_credit.get(&stream_id).copied().unwrap_or(0); if credit < bytes.len() { break; } let bytes = pending.pop_front().expect("pending front exists"); *stream_send_credit.entry(stream_id).or_default() -= bytes.len(); let offset = *stream_next_send_offset.entry(stream_id).or_default(); stream_next_send_offset.insert(stream_id, offset.saturating_add(bytes.len() as u64)); stream_sent_data.entry(stream_id).or_default().insert( offset, PendingStreamChunk { offset, bytes: bytes.clone(), last_sent: Instant::now(), attempts: 1, }, ); send_stream_data(socket, addr, cred, send_seq, stream_id, offset, bytes).await?; } if pending.is_empty() { stream_pending_data.remove(&stream_id); } Ok(()) } async fn retransmit_stream_data( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, stream_sent_data: &mut HashMap>, ) -> Result<()> { let now = Instant::now(); let mut retransmit = Vec::new(); for (stream_id, chunks) in stream_sent_data.iter_mut() { for chunk in chunks.values_mut() { if now.duration_since(chunk.last_sent) < Duration::from_millis(200) { continue; } chunk.last_sent = now; chunk.attempts = chunk.attempts.saturating_add(1); retransmit.push((*stream_id, chunk.offset, chunk.bytes.clone())); } } for (stream_id, offset, bytes) in retransmit { send_stream_data(socket, addr, cred, send_seq, stream_id, offset, bytes).await?; } Ok(()) } async fn retransmit_stream_opens( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, stream_pending_opens: &mut HashMap, ) -> Result<()> { let now = Instant::now(); let mut retransmit = Vec::new(); for (stream_id, pending) in stream_pending_opens.iter_mut() { if now.duration_since(pending.last_sent) < Duration::from_millis(200) { continue; } pending.last_sent = now; pending.attempts = pending.attempts.saturating_add(1); retransmit.push((*stream_id, pending.target_host.clone(), pending.target_port)); } for (stream_id, target_host, target_port) in retransmit { send_stream_open( socket, addr, cred, send_seq, stream_id, target_host, target_port, ) .await?; } Ok(()) } fn accept_stream_data( stream_next_recv_offset: &mut HashMap, stream_recv_pending: &mut HashMap>>, data: StreamData, ) -> (Vec>, usize, u64) { let stream_id = data.stream_id; let expected = stream_next_recv_offset.entry(stream_id).or_insert(0); if data.offset < *expected { return (Vec::new(), 0, *expected); } if data.offset > *expected { stream_recv_pending .entry(stream_id) .or_default() .entry(data.offset) .or_insert(data.bytes); return (Vec::new(), 0, *expected); } let mut writes = vec![data.bytes]; let mut consumed = writes[0].len(); *expected = expected.saturating_add(consumed as u64); while let Some(bytes) = stream_recv_pending .entry(stream_id) .or_default() .remove(expected) { consumed = consumed.saturating_add(bytes.len()); *expected = expected.saturating_add(bytes.len() as u64); writes.push(bytes); } if stream_recv_pending .get(&stream_id) .is_some_and(BTreeMap::is_empty) { stream_recv_pending.remove(&stream_id); } (writes, consumed, *expected) } fn ack_stream_data( stream_sent_data: &mut HashMap>, stream_send_credit: &mut HashMap, stream_id: u64, received_offset: u64, ) { let Some(sent) = stream_sent_data.get_mut(&stream_id) else { return; }; let acked_offsets: Vec = sent .iter() .filter_map(|(offset, chunk)| { let end = chunk.offset.saturating_add(chunk.bytes.len() as u64); (end <= received_offset).then_some(*offset) }) .collect(); let mut acked_bytes = 0usize; for offset in acked_offsets { if let Some(chunk) = sent.remove(&offset) { acked_bytes = acked_bytes.saturating_add(chunk.bytes.len()); } } if sent.is_empty() { stream_sent_data.remove(&stream_id); } add_stream_credit(stream_send_credit, stream_id, acked_bytes); } fn add_stream_credit(stream_send_credit: &mut HashMap, stream_id: u64, bytes: usize) { let credit = stream_send_credit.entry(stream_id).or_default(); *credit = credit.saturating_add(bytes).min(STREAM_INITIAL_WINDOW); } async fn send_stream_window_adjust( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, stream_id: u64, bytes: usize, received_offset: u64, ) -> Result<()> { let body = protocol::to_body(&StreamWindowAdjust { stream_id, received_offset, bytes: bytes.min(u32::MAX as usize) as u32, })?; send_stream_packet( socket, addr, cred, send_seq, PacketKind::StreamWindowAdjust, &body, ) .await } async fn send_stream_close( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, stream_id: u64, ) -> Result<()> { let body = protocol::to_body(&StreamClose { stream_id })?; send_stream_packet(socket, addr, cred, send_seq, PacketKind::StreamClose, &body).await } async fn send_stream_packet( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, kind: PacketKind, body: &[u8], ) -> Result<()> { let packet = protocol::encode_encrypted( kind, cred.client_id, *send_seq, cred.last_rendered_seq, &cred.session_key, CLIENT_TO_SERVER, body, )?; *send_seq += 1; socket.send_to(&packet, addr).await?; Ok(()) } // --------------------------------------------------------------------------- // Predictive local echo (speculative echo). // // This is a clean-room Rust implementation written from the design described in // the Mosh paper (Winstein & Balakrishnan, "Mosh: An Interactive Remote Shell // for Mobile Clients", USENIX ATC 2012) and a conceptual reading of how a // prediction overlay behaves. No Mosh source was copied or translated; the data // structures, byte handling, confirmation strategy, and drawing here are // original to dosh. // // Why dosh's approach differs from Mosh's: Mosh runs a full terminal emulator // on the client and keeps a cell-accurate framebuffer, so it can confirm each // predicted character by comparing the predicted cell to the actual cell the // server produced. dosh's client has no client-side emulator -- it blits opaque // server byte-frames straight to the real terminal. So dosh cannot do // content-level confirmation. Instead it confirms predictions by *frame // sequencing*: when the server emits a new authoritative frame (output_seq // advances) that reflects the keystrokes we predicted, that frame is rendered // and supersedes our overlay; we erase the speculative glyphs and let the // server's bytes stand. To draw and erase safely without an emulator, dosh // keeps a tiny model of just the current line near the cursor. // // Correctness over coverage: a visibly wrong/sticky prediction is worse than no // prediction, so anything we cannot model with confidence (escape sequences, // control chars other than backspace, multi-byte / wide chars, the right // margin) ends the current epoch and stops further speculation until the next // confirmation re-bases us against authoritative output. /// Display policy for predictions, mirroring Mosh's off / adaptive / always /// design (we call the adaptive mode "experimental" to match Mosh's naming for /// the SRTT-gated mode that only shows predictions on a laggy link). #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PredictMode { /// Never show predictions. Off, /// Show predictions only when the link is laggy (SRTT above a trigger). Experimental, /// Always show predictions whenever any are outstanding. Always, } impl PredictMode { fn parse(s: &str) -> Self { match s.trim().to_ascii_lowercase().as_str() { "off" | "never" | "false" | "no" => PredictMode::Off, "always" => PredictMode::Always, // "experimental" / "adaptive" / "auto" / anything else -> adaptive. _ => PredictMode::Experimental, } } } /// SRTT (in milliseconds) above which the experimental mode begins showing /// predictions, and below which it stops. Hysteresis avoids flapping on a link /// hovering around the threshold. On a faster-than-this link, native local echo /// already feels instant, so speculation only adds risk. const SRTT_TRIGGER_HIGH_MS: f64 = 30.0; const SRTT_TRIGGER_LOW_MS: f64 = 20.0; /// SRTT (ms) above which we additionally underline ("flag") predicted cells so /// the user can tell speculative glyphs from confirmed ones on a very slow link. const FLAG_TRIGGER_HIGH_MS: f64 = 80.0; const FLAG_TRIGGER_LOW_MS: f64 = 50.0; /// If a prediction is still outstanding after this long, treat the link as /// laggy enough to force display even if the SRTT estimate hasn't caught up yet /// (covers a sudden latency spike on the very first slow keystroke). const GLITCH_FORCE_MS: u128 = 250; /// One speculatively-echoed character on the current line. #[derive(Clone, Debug)] struct PredictedCell { /// The byte we drew (always a single printable ASCII byte, 0x20..=0x7e). byte: u8, /// Epoch this prediction belongs to (a keystroke burst). epoch: u64, } /// Predictive local-echo engine. See the module comment above for the model. struct Predictor { mode: PredictMode, enabled: bool, /// True while the server is in the alternate screen (a full-screen TUI such /// as vim/htop); we never speculate there because we cannot model arbitrary /// cursor addressing safely. alternate_screen: bool, /// Predicted cells for the current line, left-to-right starting at the /// column where the cursor sat when this run of predictions began. cells: Vec, /// Cursor offset, in cells, from the start of `cells`. Equals `cells.len()` /// while typing at the end of the line; can be less after Left-arrow or /// backspace within the predicted span. cursor: usize, /// Current keystroke-burst epoch. Bumped whenever we hit something we cannot /// model (CR/LF, escape, control byte, wide char, right margin) so those /// speculative cells become "tentative" and stop being drawn. epoch: u64, /// Highest epoch confirmed by authoritative server output. Cells whose epoch /// is greater than this are tentative and are not displayed. confirmed_epoch: u64, /// How many columns the current overlay occupies on screen (0 = nothing /// drawn). Used to erase exactly what was painted before re-rendering. drawn_width: usize, /// Whether the last draw underlined the cells (flagging on). drawn_flagged: bool, // --- SRTT estimation (display-only; never gates whether input is sent) --- /// Smoothed round-trip time estimate in milliseconds, or None until we have /// a sample. srtt_ms: Option, /// When the oldest still-outstanding prediction was made. Used both to /// sample SRTT on the next frame and to force display on a latency spike. oldest_pending_at: Option, /// Hysteresis latches. srtt_trigger: bool, flagging: bool, } impl Predictor { /// Construct with the default adaptive (experimental) display policy. #[cfg(test)] fn new(enabled: bool) -> Self { Self::with_mode(enabled, PredictMode::Experimental) } fn with_mode(enabled: bool, mode: PredictMode) -> Self { Self { mode, enabled: enabled && mode != PredictMode::Off, alternate_screen: false, cells: Vec::new(), cursor: 0, epoch: 1, confirmed_epoch: 0, drawn_width: 0, drawn_flagged: false, srtt_ms: None, oldest_pending_at: None, srtt_trigger: false, flagging: false, } } /// Drop all speculative state. Called on reconnect/resume and screen resize, /// where any cached line model would be stale. fn reset(&mut self) { let _ = self.erase_drawn(); self.cells.clear(); self.cursor = 0; self.epoch += 1; self.confirmed_epoch = self.epoch - 1; self.alternate_screen = false; self.oldest_pending_at = None; } /// Number of speculative cells currently outstanding (active in any epoch). #[cfg(test)] fn pending_len(&self) -> usize { self.cells.len() } /// End the current keystroke burst (Mosh's "become tentative"). In dosh's /// frame-confirmation model we cannot content-verify lingering cells, so the /// safe choice is to drop the current epoch's speculative cells outright and /// start a fresh epoch. This is how we bail out on anything we cannot model /// (escape sequences, CR/LF, control bytes, wide chars, the right margin) /// without risking a stale/wrong glyph. fn become_tentative(&mut self) { let _ = self.erase_drawn(); self.cells.clear(); self.cursor = 0; self.epoch += 1; self.oldest_pending_at = None; } /// Observe raw input bytes that are *also* being sent to the server. This is /// display-only and never consumes input. The caller still forwards `bytes` /// unconditionally. fn observe_input(&mut self, bytes: &[u8]) -> Result<()> { if !self.enabled || self.alternate_screen { return Ok(()); } // A large burst is almost certainly a paste, not interactive typing. // Predicting it would risk wrapping a long dim run across many lines, so // we bail: drop any overlay and let the authoritative frame draw it. if bytes.len() > PREDICT_BURST_LIMIT { self.become_tentative(); return self.redraw(); } let mut i = 0; while i < bytes.len() { let b = bytes[i]; match b { // Printable ASCII: append a predicted cell at the cursor. 0x20..=0x7e => { self.predict_char(b); i += 1; } // Backspace (^H) or DEL (^?): remove the previous predicted cell. 0x08 | 0x7f => { self.predict_backspace(); i += 1; } // ESC: try to recognize a cursor-motion CSI/SS3 we can model // (Left/Right within the line); otherwise become tentative. 0x1b => { let consumed = self.predict_escape(&bytes[i..]); if consumed == 0 { // Unrecognized / incomplete escape: bail safely. self.become_tentative(); i = bytes.len(); } else { i += consumed; } } // CR / LF and any other control byte: we cannot model where the // cursor lands (shell prompt, scroll, etc.), so end the epoch. _ => { self.become_tentative(); i += 1; } } } if self.oldest_pending_at.is_none() && !self.cells.is_empty() { self.oldest_pending_at = Some(Instant::now()); } self.redraw() } /// Predict one printable character at the current cursor position. fn predict_char(&mut self, byte: u8) { // Right margin is tricky (shells, editors, and wrap behavior differ), so // we refuse to predict past a conservative column budget and bail out // instead of risking a wrong glyph at a wrap point. if self.cells.len() >= PREDICT_MAX_LINE { self.become_tentative(); return; } let cell = PredictedCell { byte, epoch: self.epoch, }; if self.cursor < self.cells.len() { // Typing over an existing predicted cell (after a Left arrow): // overwrite in place, as a terminal in insert-off mode would. self.cells[self.cursor] = cell; } else { self.cells.push(cell); } self.cursor += 1; } /// Predict a backspace: erase the previous predicted cell if we have one. fn predict_backspace(&mut self) { if self.cursor == 0 { // We'd be backspacing past the start of our predicted span into // territory we don't model -> bail safely. self.become_tentative(); return; } self.cursor -= 1; // Only safe to shrink the line if we're at its end; otherwise a // mid-line backspace shifts everything left, which we don't model, so // bail. if self.cursor == self.cells.len() - 1 { self.cells.pop(); } else { self.become_tentative(); } } /// Recognize Left/Right cursor motion escape sequences we can model and /// apply them to the local cursor. Returns the number of bytes consumed, or /// 0 if `data` does not start with a sequence we handle. /// /// Improvement over the previous dosh predictor (which bailed on any escape) /// and parity with Mosh: predicting in-line arrow-key motion so cursor /// repositioning feels instant too. fn predict_escape(&mut self, data: &[u8]) -> usize { // Both CSI (ESC [) and application-cursor SS3 (ESC O) are used for arrows. if data.len() < 3 || data[0] != 0x1b { return 0; } if data[1] != b'[' && data[1] != b'O' { return 0; } match data[2] { // Right arrow: only within already-predicted cells. b'C' if self.cursor < self.cells.len() => { self.cursor += 1; 3 } // Left arrow. b'D' if self.cursor > 0 => { self.cursor -= 1; 3 } // Recognized arrow but at the edge of our predicted span, or // Up/Down/Home/End/etc. which cross lines or jump unpredictably: // refuse so the caller becomes tentative rather than mispredicting. _ => 0, } } /// Observe authoritative server output. Detects alternate-screen transitions /// and confirms outstanding predictions: a fresh frame means the server has /// re-rendered the line, so our speculative glyphs are superseded and erased. fn observe_output(&mut self, bytes: &[u8]) { if contains_bytes(bytes, b"\x1b[?1049h") || contains_bytes(bytes, b"\x1b[?1047h") || contains_bytes(bytes, b"\x1b[?47h") { self.alternate_screen = true; let _ = self.discard_all(); } if contains_bytes(bytes, b"\x1b[?1049l") || contains_bytes(bytes, b"\x1b[?1047l") || contains_bytes(bytes, b"\x1b[?47l") { self.alternate_screen = false; let _ = self.discard_all(); } } /// Sample SRTT from a newly arrived frame and confirm/clear outstanding /// predictions. Called once per accepted authoritative frame, *before* the /// frame's bytes are written to the terminal so the server render lands on a /// clean line. Returns nothing; drawing is handled by the caller's render /// followed by `redraw` on the next input. fn confirm_with_frame(&mut self) -> Result<()> { if let Some(sent_at) = self.oldest_pending_at.take() { let sample = sent_at.elapsed().as_secs_f64() * 1000.0; self.update_srtt(sample); } // The frame is authoritative for the line; clear our overlay so the // server's bytes are the only thing on screen. This is dosh's // confirmation: frame arrival == the predicted keystrokes are now echoed // by the server. Faster than waiting for per-cell content match. self.discard_all() } fn update_srtt(&mut self, sample_ms: f64) { // Standard exponentially weighted moving average (RFC 6298 style alpha). const ALPHA: f64 = 0.125; self.srtt_ms = Some(match self.srtt_ms { None => sample_ms, Some(prev) => (1.0 - ALPHA) * prev + ALPHA * sample_ms, }); } /// Test seam: pin the SRTT estimate and refresh the display triggers so the /// experimental-mode threshold behavior can be exercised deterministically. #[cfg(test)] fn set_srtt_for_test(&mut self, ms: f64) { self.srtt_ms = Some(ms); self.update_triggers(); } /// Test seam: backdate the oldest outstanding prediction so the glitch-force /// display path (latency spike) can be exercised without real time passing. #[cfg(test)] fn backdate_pending_for_test(&mut self, ms: u64) { self.oldest_pending_at = Some(Instant::now() - Duration::from_millis(ms)); } /// Test seam: whether an overlay is currently painted on screen. #[cfg(test)] fn is_drawn(&self) -> bool { self.drawn_width > 0 } /// Whether we should *display* predictions right now under the active policy. fn should_display(&self) -> bool { match self.mode { PredictMode::Off => false, PredictMode::Always => true, PredictMode::Experimental => { if self.srtt_trigger { return true; } // Force display on a latency spike even before SRTT catches up. if let Some(at) = self.oldest_pending_at && at.elapsed().as_millis() >= GLITCH_FORCE_MS { return true; } false } } } /// Update the SRTT/flag hysteresis latches from the current estimate. fn update_triggers(&mut self) { let srtt = self.srtt_ms.unwrap_or(0.0); if srtt > SRTT_TRIGGER_HIGH_MS { self.srtt_trigger = true; } else if srtt <= SRTT_TRIGGER_LOW_MS { self.srtt_trigger = false; } if srtt > FLAG_TRIGGER_HIGH_MS { self.flagging = true; } else if srtt <= FLAG_TRIGGER_LOW_MS { self.flagging = false; } } /// The active (displayable) predicted bytes: cells in confirmed-or-current /// epochs only, drawn from the start of the line up to the cursor budget. fn visible_bytes(&self) -> Vec { self.cells .iter() .filter(|c| c.epoch > self.confirmed_epoch || c.epoch == self.epoch) .map(|c| c.byte) .collect() } /// Repaint the prediction overlay: erase the old one, draw the new one if /// policy says so. Uses save/restore cursor so it never moves the real /// cursor and, when nothing should show, leaves the terminal untouched. fn redraw(&mut self) -> Result<()> { self.update_triggers(); // Always erase whatever we drew before so we never leave a stale glyph. self.erase_drawn()?; if !self.should_display() { return Ok(()); } let bytes = self.visible_bytes(); if bytes.is_empty() { return Ok(()); } let flag = self.flagging; // Save cursor, dim (and optionally underline) predicted glyphs, draw, // reset attributes, restore cursor. let mut out = Vec::with_capacity(bytes.len() + 12); out.extend_from_slice(b"\x1b7"); out.extend_from_slice(if flag { b"\x1b[2;4m" } else { b"\x1b[2m" }); out.extend_from_slice(&bytes); out.extend_from_slice(b"\x1b[0m\x1b8"); emit_overlay(&out)?; // Record exactly how many columns we painted so erasure overwrites the // right amount even if the model changes before the next repaint. self.drawn_width = bytes.len(); self.drawn_flagged = flag; Ok(()) } /// Re-evaluate the display policy on a select-loop timer tick (not just on a /// keystroke). The experimental policy can decide to *start* showing purely /// with the passage of time — `should_display` force-shows once a prediction /// has been outstanding longer than `GLITCH_FORCE_MS` — so without this, a /// sudden latency spike would only surface predictions on the *next* key, /// one event late. Calling this from the status tick flips speculation on (or /// off, or re-flags it) promptly. Repaints only when the on-screen result /// would actually change, so an idle tick is a cheap no-op (no flicker). fn refresh_policy(&mut self) -> Result<()> { if !self.enabled || self.alternate_screen { return Ok(()); } self.update_triggers(); let want = self.should_display() && !self.visible_bytes().is_empty(); let have = self.drawn_width > 0; let flag_changed = want && have && self.flagging != self.drawn_flagged; if want != have || flag_changed { self.redraw()?; } Ok(()) } /// Erase the currently drawn overlay (if any) by overwriting the previously /// painted columns with spaces, using save/restore cursor so the real cursor /// and surrounding text are untouched. fn erase_drawn(&mut self) -> Result<()> { let width = self.drawn_width; self.drawn_width = 0; if width == 0 { return Ok(()); } let mut out = Vec::with_capacity(width + 4); out.extend_from_slice(b"\x1b7"); out.resize(out.len() + width, b' '); out.extend_from_slice(b"\x1b8"); emit_overlay(&out) } /// Erase the overlay and drop all speculative state, advancing the confirmed /// epoch so nothing lingers. fn discard_all(&mut self) -> Result<()> { self.erase_drawn()?; self.cells.clear(); self.cursor = 0; self.epoch += 1; self.confirmed_epoch = self.epoch - 1; self.oldest_pending_at = None; Ok(()) } /// Erase the overlay without dropping the SRTT estimate or model; used right /// before an authoritative frame is rendered so its bytes land cleanly. /// (Kept as the public name the run loop calls.) fn clear_pending(&mut self) -> Result<()> { self.confirm_with_frame() } /// Whether a byte sequence is a plain printable run. Retained as a small /// predicate exercised by the unit tests. #[cfg(test)] fn predictable(bytes: &[u8]) -> bool { !bytes.is_empty() && bytes.iter().all(|byte| matches!(byte, 0x20..=0x7e)) } } /// Conservative cap on how many predicted cells we keep on one line before we /// stop speculating (a backstop against runaway predictions; the right-margin / /// wrap point is genuinely ambiguous without a client-side emulator). const PREDICT_MAX_LINE: usize = 256; /// Largest single input burst we treat as interactive typing. Anything larger /// is assumed to be a paste and is not speculated (would risk a long dim run /// wrapping across lines before the server frame supersedes it). const PREDICT_BURST_LIMIT: usize = 32; /// Write a prepared overlay byte sequence to the real terminal. Suppressed /// under `cfg(test)` so unit tests exercise the prediction model without /// emitting escape sequences to the test runner's terminal. #[cfg(not(test))] fn emit_overlay(bytes: &[u8]) -> Result<()> { let mut stdout = std::io::stdout(); stdout.write_all(bytes)?; stdout.flush()?; Ok(()) } #[cfg(test)] fn emit_overlay(_bytes: &[u8]) -> Result<()> { Ok(()) } /// Resolve the prediction display policy. `DOSH_PREDICT_MODE` (off / /// experimental / always) overrides for quick experiments; otherwise the /// already-selected CLI/config policy is used. fn selected_predict_mode(args: &Args, config: &dosh::config::ClientConfig) -> Result { if args.predict_always && args.predict_never { return Err(anyhow!("--predict-always and --predict-never conflict")); } if args.predict_always { return Ok(PredictMode::Always); } if args.predict_never { return Ok(PredictMode::Off); } if let Some(mode) = args.predict_mode.as_deref() { return Ok(PredictMode::parse(mode)); } if let Ok(env) = std::env::var("DOSH_PREDICT_MODE") { return Ok(PredictMode::parse(&env)); } Ok(PredictMode::parse(&config.predict_mode)) } fn selected_escape_key( args: &Args, config: &dosh::config::ClientConfig, ) -> Result>> { if let Some(raw) = args.escape_key.as_deref() { return parse_escape_key(raw); } if let Ok(raw) = std::env::var("DOSH_ESCAPE_KEY") { return parse_escape_key(&raw); } parse_escape_key(&config.escape_key) } fn parse_escape_key(raw: &str) -> Result>> { let value = raw.trim(); if value.eq_ignore_ascii_case("none") || value.eq_ignore_ascii_case("off") || value.eq_ignore_ascii_case("disabled") { return Ok(None); } if let Some(rest) = value.strip_prefix("^") { anyhow::ensure!( rest.chars().count() == 1, "invalid control escape key {raw:?}" ); let ch = rest.as_bytes()[0]; let byte = match ch { b'?' => 0x7f, b'@'..=b'_' => ch - b'@', b'a'..=b'z' => ch - b'a' + 1, _ => return Err(anyhow!("invalid control escape key {raw:?}")), }; return Ok(Some(vec![byte])); } if let Some(hex) = value.strip_prefix("0x") { let byte = u8::from_str_radix(hex, 16) .with_context(|| format!("invalid hex escape key {raw:?}"))?; return Ok(Some(vec![byte])); } if value.len() == 1 { return Ok(Some(value.as_bytes().to_vec())); } Err(anyhow!( "escape key must be none, ^X, 0xNN, or one ASCII character" )) } fn input_matches_escape(bytes: &[u8], escape_key: Option<&[u8]>) -> bool { let Some(escape_key) = escape_key else { return false; }; !escape_key.is_empty() && bytes.windows(escape_key.len()).any(|w| w == escape_key) } /// Resolve whether the non-destructive disconnect status line is enabled. /// `DOSH_DISCONNECT_STATUS` (0/false/off to disable) overrides for quick /// experiments; otherwise the client config's `disconnect_status` applies, /// defaulting to on. fn resolve_disconnect_status() -> bool { if let Ok(env) = std::env::var("DOSH_DISCONNECT_STATUS") { let v = env.trim().to_ascii_lowercase(); return !matches!(v.as_str(), "0" | "false" | "off" | "no"); } match load_client_config(None) { Ok(cfg) => cfg.disconnect_status, Err(_) => true, } } fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { haystack .windows(needle.len()) .any(|window| window == needle) } const FRAME_GAP_RESYNC_AFTER_MS: u64 = 500; #[derive(Default)] struct FrameBuffer { pending: BTreeMap, gap_since: Option, } impl FrameBuffer { fn clear(&mut self) { self.pending.clear(); self.gap_since = None; } fn accept(&mut self, frame: Frame, last_rendered_seq: &mut u64) -> Vec { if frame.snapshot { if frame.output_seq < *last_rendered_seq { return Vec::new(); } self.pending.clear(); self.gap_since = None; *last_rendered_seq = frame.output_seq; return vec![frame]; } if frame.output_seq <= *last_rendered_seq { return Vec::new(); } self.pending.entry(frame.output_seq).or_insert(frame); let mut ready = Vec::new(); while let Some(frame) = self.pending.remove(&(*last_rendered_seq + 1)) { *last_rendered_seq = frame.output_seq; ready.push(frame); } self.refresh_gap(*last_rendered_seq); ready } fn refresh_gap(&mut self, last_rendered_seq: u64) { let waiting_on_missing = self .pending .first_key_value() .is_some_and(|(&seq, _)| seq > last_rendered_seq.saturating_add(1)); if waiting_on_missing { self.gap_since.get_or_insert_with(Instant::now); } else { self.gap_since = None; } } fn gap_wait_elapsed(&self, wait: Duration) -> bool { self.gap_since.is_some_and(|since| since.elapsed() >= wait) } #[cfg(test)] fn backdate_gap_for_test(&mut self, elapsed: Duration) { self.gap_since = Some(Instant::now() - elapsed); } } /// Read the current terminal size and, if it changed since `last_size`, send a /// resize to the server. Shared by the SIGWINCH branch and the poll fallback. async fn maybe_send_resize( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, last_size: &mut (u16, u16), ) -> Result<()> { if let Ok((cols, rows)) = size() && cols > 0 && rows > 0 && (cols, rows) != *last_size { *last_size = (cols, rows); if cred.mode != "view-only" && cred.mode != "forward-only" { send_resize(socket, addr, cred, send_seq, cols, rows).await?; } } Ok(()) } async fn send_resize( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, cols: u16, rows: u16, ) -> Result<()> { let body = protocol::to_body(&Resize { cols, rows })?; let packet = protocol::encode_encrypted( PacketKind::Resize, cred.client_id, *send_seq, cred.last_rendered_seq, &cred.session_key, CLIENT_TO_SERVER, &body, )?; *send_seq += 1; socket.send_to(&packet, addr).await?; Ok(()) } async fn send_ack( socket: &UdpSocket, addr: SocketAddr, cred: &CachedCredential, send_seq: &mut u64, ) -> Result<()> { let packet = protocol::encode_encrypted( PacketKind::Ack, cred.client_id, *send_seq, cred.last_rendered_seq, &cred.session_key, CLIENT_TO_SERVER, b"", )?; *send_seq += 1; socket.send_to(&packet, addr).await?; Ok(()) } async fn detach_once(socket: &UdpSocket, cred: &CachedCredential, seq: u64) -> Result<()> { let addr = resolve_addr(&cred.udp_host, cred.udp_port)?; let packet = protocol::encode_encrypted( PacketKind::Detach, cred.client_id, seq, cred.last_rendered_seq, &cred.session_key, CLIENT_TO_SERVER, b"", )?; socket.send_to(&packet, addr).await?; Ok(()) } fn render_frame(frame: &Frame) -> Result<()> { let mut stdout = std::io::stdout(); stdout.write_all(&frame.bytes)?; stdout.flush()?; Ok(()) } /// Seconds of silence from the server before the disconnect status line appears. /// Short enough to give quick feedback on a lost link, long enough that a normal /// idle period (the run loop only pings after 2s of quiet) never flashes it. const DISCONNECT_STATUS_THRESHOLD_SECS: u64 = 2; /// Non-destructive, mosh-style disconnect status bar. /// /// When server packets stop arriving we paint one blue line on the terminal's /// top row — e.g. `[dosh] reconnecting — last contact 3s ago` — using /// save/restore cursor (ESC 7 / ESC 8) so the real application cursor never /// moves. The run loop suppresses the bar while the server is in the alternate /// screen because a saved cursor does not make writing row 1 non-destructive for /// full-screen TUIs. The bar is refreshed on the status timer tick and cleared /// the instant a packet resumes. /// /// The decision logic and the rendered text are pure and unit-tested; only /// [`DisconnectStatus::emit`] / [`DisconnectStatus::emit_clear`] touch stdout. struct DisconnectStatus { enabled: bool, /// Whether a status line is currently painted on screen (so we know to clear /// it and to avoid redundant repaints of identical text). shown: bool, /// The text currently painted, to skip no-op repaints (the elapsed seconds /// only change once per second so most ticks are no-ops). drawn_text: String, } /// What the run loop should do with the status line on a given tick or packet. #[derive(Debug, Clone, PartialEq, Eq)] enum StatusAction { /// Leave the screen as-is (already correct). None, /// Paint/repaint the status bar with this exact text on the top row. Show(String), /// Erase the status line (link recovered or feature disabled). Clear, /// Drop tracked status state without touching the terminal. Forget, } impl DisconnectStatus { fn new(enabled: bool) -> Self { Self { enabled, shown: false, drawn_text: String::new(), } } /// Pure: render the status text for a given elapsed silence. Kept separate /// from any I/O so it can be asserted in tests. fn render_text(elapsed: Duration) -> String { let secs = elapsed.as_secs(); if secs >= 30 { format!("[dosh] still reconnecting — last contact {secs}s ago") } else { format!("[dosh] reconnecting — last contact {secs}s ago") } } /// Pure decision for a status timer tick: given how long the link has been /// silent, decide whether to show (and with what text), clear, or do nothing. /// Does not mutate screen state; the caller applies the returned action and /// then calls [`Self::applied`]. fn on_tick(&self, stale: Duration) -> StatusAction { if !self.enabled { return if self.shown { StatusAction::Clear } else { StatusAction::None }; } if stale >= Duration::from_secs(DISCONNECT_STATUS_THRESHOLD_SECS) { let text = Self::render_text(stale); if self.shown && text == self.drawn_text { StatusAction::None } else { StatusAction::Show(text) } } else if self.shown { StatusAction::Clear } else { StatusAction::None } } /// Pure decision for "a packet just arrived": clear any visible status line /// the instant contact resumes, otherwise do nothing. fn on_contact(&self) -> StatusAction { if self.shown { StatusAction::Clear } else { StatusAction::None } } /// Pure decision for periods where drawing the top-row status is unsafe /// (alternate-screen TUIs). Forget any tracked bar without writing a clear /// sequence, because clearing row 1 would corrupt the TUI just like showing /// the bar would. fn on_suppressed(&self) -> StatusAction { if self.shown { StatusAction::Forget } else { StatusAction::None } } /// Record that an action was applied to the screen, updating internal state. fn applied(&mut self, action: &StatusAction) { match action { StatusAction::Show(text) => { self.shown = true; self.drawn_text = text.clone(); } StatusAction::Clear => { self.shown = false; self.drawn_text.clear(); } StatusAction::Forget => { self.shown = false; self.drawn_text.clear(); } StatusAction::None => {} } } /// Apply an action to the real terminal (idempotent for `None`). fn apply(&mut self, action: StatusAction) -> Result<()> { match &action { StatusAction::Show(text) => self.emit(text)?, StatusAction::Clear => self.emit_clear()?, StatusAction::Forget => {} StatusAction::None => {} } self.applied(&action); Ok(()) } /// Paint `text` on the terminal's top row using save/restore cursor so the /// app's cursor is untouched. fn emit(&self, text: &str) -> Result<()> { let (_, rows) = terminal_size(); let bytes = render_status_overlay(text, rows); emit_status(&bytes) } /// Erase the top-row status bar, again with save/restore cursor. fn emit_clear(&self) -> Result<()> { let (_, rows) = terminal_size(); emit_status(&render_status_clear(rows)) } } /// Pure: build the escape sequence that paints `text` on a top blue status bar, /// leaving the cursor where it was. fn render_status_overlay(text: &str, _rows: u16) -> Vec { let mut out = Vec::with_capacity(text.len() + 24); out.extend_from_slice(b"\x1b7"); // save cursor out.extend_from_slice(b"\x1b[1;1H"); // move to top row, column 1 out.extend_from_slice(b"\x1b[2K"); // clear entire row out.extend_from_slice(b"\x1b[44;37m"); // blue background + white text out.extend_from_slice(text.as_bytes()); out.extend_from_slice(b"\x1b[0m"); // reset attributes out.extend_from_slice(b"\x1b8"); // restore cursor out } /// Pure: build the escape sequence that clears the top status bar. fn render_status_clear(_rows: u16) -> Vec { let mut out = Vec::with_capacity(12); out.extend_from_slice(b"\x1b7"); out.extend_from_slice(b"\x1b[1;1H"); out.extend_from_slice(b"\x1b[2K"); out.extend_from_slice(b"\x1b8"); out } #[cfg(not(test))] fn emit_status(bytes: &[u8]) -> Result<()> { let mut stdout = std::io::stdout(); stdout.write_all(bytes)?; stdout.flush()?; Ok(()) } #[cfg(test)] fn emit_status(_bytes: &[u8]) -> Result<()> { Ok(()) } fn cache_path(root: &str, server: &str, session: &str, mode: &str) -> std::path::PathBuf { let safe = cache_key(server, session, mode); expand_tilde(root).join(format!("{safe}.bin")) } fn cache_key(server: &str, session: &str, mode: &str) -> String { format!("{server}_{session}_{mode}") .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .collect::() } fn cache_server_prefix(server: &str) -> String { format!("{server}_") .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .collect::() } fn clear_cached_credentials(root: &str, server: &str) -> Result { let dir = expand_tilde(root); let Ok(entries) = fs::read_dir(&dir) else { return Ok(0); }; let prefix = cache_server_prefix(server); let mut removed = 0usize; for entry in entries { let entry = entry?; let path = entry.path(); if !path.is_file() { continue; } let Some(name) = path.file_name().and_then(|value| value.to_str()) else { continue; }; if name.starts_with(&prefix) && name.ends_with(".bin") { fs::remove_file(&path)?; removed += 1; } } Ok(removed) } fn load_cache(path: &std::path::Path) -> Result { let raw = fs::read(path)?; Ok(bincode::deserialize(&raw)?) } fn save_cache(path: &std::path::Path, cred: &CachedCredential) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } fs::write(path, bincode::serialize(cred)?)?; Ok(()) } struct RawMode; impl RawMode { fn enter() -> Result { flush_local_terminal_input(); enable_raw_mode()?; flush_local_terminal_input(); drain_local_terminal_input(); Ok(Self) } } #[cfg(unix)] fn flush_local_terminal_input() { unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH); } } #[cfg(not(unix))] fn flush_local_terminal_input() {} #[cfg(unix)] fn drain_local_terminal_input() { unsafe { let fd = libc::STDIN_FILENO; let flags = libc::fcntl(fd, libc::F_GETFL); if flags < 0 { return; } if libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 { return; } let mut buf = [0u8; 1024]; loop { let n = libc::read(fd, buf.as_mut_ptr().cast(), buf.len()); if n <= 0 { break; } } let _ = libc::fcntl(fd, libc::F_SETFL, flags); } } #[cfg(not(unix))] fn drain_local_terminal_input() {} impl Drop for RawMode { fn drop(&mut self) { let mut stdout = std::io::stdout(); let _ = stdout.write_all(TERMINAL_CLEANUP); let _ = stdout.flush(); let _ = disable_raw_mode(); } } const TERMINAL_CLEANUP: &[u8] = concat!( "\x1b[?1l", "\x1b[0m", "\x1b[?25h", "\x1b[?1003l", "\x1b[?1002l", "\x1b[?1001l", "\x1b[?1000l", "\x1b[?1015l", "\x1b[?1006l", "\x1b[?1005l", "\x1b[?2004l", "\x1b[?1049l" ) .as_bytes(); #[cfg(test)] mod tests { use super::{ CachedCredential, DisconnectStatus, DynamicForward, FRAME_GAP_RESYNC_AFTER_MS, FrameBuffer, LocalForward, MAX_PENDING_USER_INPUT_BYTES, POST_SUBMIT_ALL_INPUT_HOLD, PendingStreamOpen, PredictMode, Predictor, RESTART_STATUS_SCRIPT, RemoteForward, STARTUP_INPUT_HOLD, SshConfig, StartupGateMode, StatusAction, auth_allows, cache_key, cache_server_prefix, clear_cached_credentials, ensure_tui_safe_status_overlay, input_matches_escape, is_local_status_target, is_resume_response_for_client, latest_release_download_url, load_first_native_identity_with_prompt, parse_dynamic_forward, parse_escape_key, parse_local_forward, parse_remote_forward, parse_ssh_config, post_submit_hold_duration, queue_pending_user_input, raw_contains_host_table, recv_response_until, refresh_live_addr, release_tag_download_url, release_tag_from_effective_url, release_version_from_tag, render_status_clear, render_status_overlay, requested_env, resolved_startup_command, retransmit_stream_opens, rewrite_forward_command, selected_predict_mode, selected_udp_host, server_version_mismatch, should_hold_during_startup_gate, should_hold_post_submit_input, split_after_command_submit, ssh_destination_host, ssh_username, ssh_with_user, startup_command, status_ssh_target, strip_stale_mouse_reports, toml_bare_key_or_quoted, update_check_requested, update_version_status, upsert_managed_block, valid_forward_host, vscode_safe_alias, }; use dosh::config::{ClientConfig, CommandExtension, HostConfig}; use dosh::native::EnvVar; use dosh::protocol::{self, Frame, PacketKind}; use ed25519_dalek::{SigningKey, VerifyingKey}; use std::collections::{HashMap, VecDeque}; use std::fs; use std::time::Duration; fn test_args(server: &str, command: &[&str]) -> super::Args { super::Args { server: Some(server.to_string()), command: command.iter().map(|value| value.to_string()).collect(), session: None, new: false, view_only: false, local_auth: false, auth: None, no_cache: false, remove: false, replace: false, attach_only: false, local_forward: Vec::new(), remote_forward: Vec::new(), dynamic_forward: Vec::new(), forward_agent: false, forward_only: false, background: false, predict: false, predict_mode: None, predict_always: false, predict_never: false, escape_key: None, ssh_port: None, ssh_auth_command: None, ssh_key: None, ssh_known_hosts: None, ssh_control_path: None, dosh_port: None, dosh_host: None, verbose: 0, } } #[test] fn vscode_alias_sanitizes_host_names() { assert_eq!(vscode_safe_alias("palav"), "palav"); assert_eq!(vscode_safe_alias("user@example.com"), "user-example-com"); assert_eq!(vscode_safe_alias(":///"), "host"); } #[test] fn managed_ssh_block_is_replaced_not_duplicated() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.dosh"); upsert_managed_block( &path, "dosh-palav", "# BEGIN DOSH dosh-palav\nHost dosh-palav\n HostName 127.0.0.1\n# END DOSH dosh-palav\n", ) .unwrap(); upsert_managed_block( &path, "dosh-palav", "# BEGIN DOSH dosh-palav\nHost dosh-palav\n HostName localhost\n# END DOSH dosh-palav\n", ) .unwrap(); let raw = fs::read_to_string(path).unwrap(); assert_eq!(raw.matches("# BEGIN DOSH dosh-palav").count(), 1); assert!(raw.contains("HostName localhost")); assert!(!raw.contains("HostName 127.0.0.1")); } #[test] fn parses_ssh_config_hostname() { let raw = "user alice\nhostname server.example.com\nport 22\n"; assert_eq!( parse_ssh_config(raw).hostname, Some("server.example.com".to_string()) ); } #[test] fn parses_ssh_config_hostname_case_insensitively() { let raw = "HostName 192.0.2.10\n"; assert_eq!( parse_ssh_config(raw).hostname, Some("192.0.2.10".to_string()) ); } #[test] fn parses_ssh_config_port() { let parsed = parse_ssh_config("hostname example.com\nport 2222\n"); assert_eq!(parsed.hostname, Some("example.com".to_string())); assert_eq!(parsed.port, Some(2222)); } #[test] fn parses_ssh_config_user_and_identity_files() { let parsed = parse_ssh_config( "user alice\nidentityfile ~/.ssh/id_ed25519\nidentityfile ~/.ssh/work\n", ); assert_eq!(parsed.user, Some("alice".to_string())); assert_eq!( parsed.identity_files, vec!["~/.ssh/id_ed25519".to_string(), "~/.ssh/work".to_string()] ); } #[test] fn parses_ssh_config_native_parity_options() { let parsed = parse_ssh_config( "userknownhostsfile ~/.ssh/known_hosts ~/.ssh/work_hosts\n\ identitiesonly yes\n\ proxyjump bastion\n\ proxycommand ssh bastion -W %h:%p\n\ identityfile none\n\ sendenv LANG LC_*\n\ setenv DOSH_MODE=native DOSH_COLOR=true\n", ); assert_eq!( parsed.user_known_hosts_file, Some("~/.ssh/known_hosts ~/.ssh/work_hosts".to_string()) ); assert!(parsed.identities_only); assert_eq!(parsed.proxy_jump, Some("bastion".to_string())); assert_eq!( parsed.proxy_command, Some("ssh bastion -W %h:%p".to_string()) ); assert_eq!( parsed.send_env, vec!["LANG".to_string(), "LC_*".to_string()] ); assert_eq!( parsed.set_env, vec![ EnvVar { name: "DOSH_MODE".to_string(), value: "native".to_string() }, EnvVar { name: "DOSH_COLOR".to_string(), value: "true".to_string() } ] ); assert!(parsed.identity_files.is_empty()); } #[test] fn requested_env_merges_config_host_and_ssh_setenv() { let mut config = ClientConfig::default(); config.send_env.clear(); config.set_env.insert("DOSH_MODE".into(), "client".into()); config.set_env.insert("DOSH_KEEP".into(), "yes".into()); let mut host = HostConfig::default(); host.set_env.insert("DOSH_MODE".into(), "host".into()); let ssh_config = SshConfig { set_env: vec![EnvVar { name: "DOSH_SSH".to_string(), value: "true".to_string(), }], ..SshConfig::default() }; assert_eq!( requested_env(&config, &host, Some(&ssh_config)), vec![ EnvVar { name: "DOSH_KEEP".to_string(), value: "yes".to_string() }, EnvVar { name: "DOSH_MODE".to_string(), value: "host".to_string() }, EnvVar { name: "DOSH_SSH".to_string(), value: "true".to_string() } ] ); } #[test] fn detects_existing_imported_host_tables() { assert!(raw_contains_host_table( "[homelab]\nssh = \"homelab\"\n", "homelab" )); assert!(raw_contains_host_table("[\"home box\"]\n", "home box")); assert_eq!(toml_bare_key_or_quoted("homelab"), "homelab"); assert_eq!(toml_bare_key_or_quoted("home box"), "\"home box\""); } #[test] fn strips_user_from_fallback_udp_host() { assert_eq!(ssh_destination_host("alice@example.com"), "example.com"); } #[test] fn selected_udp_host_supports_ssh_auto_and_localhost() { let ssh = SshConfig { hostname: Some("server.lan".to_string()), ..SshConfig::default() }; assert_eq!( selected_udp_host(None, "alias", &ssh).unwrap(), "server.lan" ); assert_eq!( selected_udp_host(Some("ssh"), "alias", &ssh).unwrap(), "server.lan" ); assert_eq!( selected_udp_host(Some("auto"), "alias", &ssh).unwrap(), "server.lan" ); assert_eq!( selected_udp_host(Some("localhost"), "alias", &ssh).unwrap(), "127.0.0.1" ); assert!(selected_udp_host(Some("any"), "alias", &ssh).is_err()); } #[test] fn parses_user_from_ssh_destination() { assert_eq!(ssh_username("alice@example.com"), Some("alice".to_string())); assert_eq!(ssh_username("example.com"), None); } #[test] fn applies_host_config_user_to_plain_ssh_destination() { assert_eq!( ssh_with_user("example.com", Some("alice")), "alice@example.com" ); assert_eq!( ssh_with_user("root@example.com", Some("alice")), "root@example.com" ); assert_eq!( ssh_with_user("ssh://example.com", Some("alice")), "ssh://example.com" ); } #[test] fn auth_preference_accepts_auto_or_named_method() { assert!(auth_allows("native,ssh", "native")); assert!(auth_allows("native,ssh", "ssh")); assert!(auth_allows("auto", "native")); assert!(!auth_allows("native", "ssh")); } #[test] fn encrypted_identity_file_prompts_and_loads() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("id_ed25519"); let signing = SigningKey::from_bytes(&[61u8; 32]); write_encrypted_identity(&path, &signing, "let-me-in"); let mut config = ClientConfig::default(); config.identity_files.clear(); let loaded = load_first_native_identity_with_prompt( &config, Some(&path), &SshConfig::default(), |_| Ok("let-me-in".to_string()), ) .unwrap(); assert_eq!( loaded.public_key().key_data().ed25519().unwrap().as_ref(), VerifyingKey::from(&signing).as_bytes() ); } #[test] fn encrypted_identity_file_reports_wrong_passphrase() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("id_ed25519"); let signing = SigningKey::from_bytes(&[62u8; 32]); write_encrypted_identity(&path, &signing, "correct"); let mut config = ClientConfig::default(); config.identity_files.clear(); let err = load_first_native_identity_with_prompt( &config, Some(&path), &SshConfig::default(), |_| Ok("wrong".to_string()), ) .unwrap_err(); assert!(err.to_string().contains("no usable native identity")); } #[test] fn identities_only_skips_dosh_config_identity_fallback() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("id_ed25519"); let signing = SigningKey::from_bytes(&[63u8; 32]); write_identity(&path, &signing); let config = ClientConfig { identity_files: vec![path.display().to_string()], ..ClientConfig::default() }; let ssh_config = SshConfig { identities_only: true, ..SshConfig::default() }; let err = load_first_native_identity_with_prompt(&config, None, &ssh_config, |_| { unreachable!("unencrypted key should not prompt") }) .unwrap_err(); assert!(err.to_string().contains("no usable native identity")); } #[test] fn identities_only_still_allows_ssh_config_identity_files() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("id_ed25519"); let signing = SigningKey::from_bytes(&[64u8; 32]); write_identity(&path, &signing); let mut config = ClientConfig::default(); config.identity_files.clear(); let ssh_config = SshConfig { identity_files: vec![path.display().to_string()], identities_only: true, ..SshConfig::default() }; let loaded = load_first_native_identity_with_prompt(&config, None, &ssh_config, |_| { unreachable!("unencrypted key should not prompt") }) .unwrap(); assert_eq!( loaded.public_key().key_data().ed25519().unwrap().as_ref(), VerifyingKey::from(&signing).as_bytes() ); } #[tokio::test] async fn recv_response_until_ignores_unmatched_packets() { let receiver = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); let sender = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); let addr = receiver.local_addr().unwrap(); let stale = protocol::encode_plain(PacketKind::Ping, [1u8; 16], 1, 0, b"stale").unwrap(); let valid = protocol::encode_plain(PacketKind::Pong, [2u8; 16], 2, 0, b"valid").unwrap(); sender.send_to(&stale, addr).await.unwrap(); sender.send_to(&valid, addr).await.unwrap(); let packet = recv_response_until(&receiver, std::time::Duration::from_secs(1), |packet| { if packet.header.kind == PacketKind::Pong { Ok(Some(packet)) } else { Ok(None) } }) .await .unwrap(); assert_eq!(packet.header.kind, PacketKind::Pong); assert_eq!(packet.body, b"valid"); } fn write_identity(path: &std::path::Path, signing: &SigningKey) { let keypair = ssh_key::private::Ed25519Keypair::from(signing); let private = ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "test").unwrap(); private .write_openssh_file(path, ssh_key::LineEnding::LF) .unwrap(); } fn write_encrypted_identity(path: &std::path::Path, signing: &SigningKey, passphrase: &str) { let keypair = ssh_key::private::Ed25519Keypair::from(signing); let private = ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "test").unwrap(); let encrypted = private.encrypt(&mut rand::rngs::OsRng, passphrase).unwrap(); encrypted .write_openssh_file(path, ssh_key::LineEnding::LF) .unwrap(); } fn test_frame(seq: u64, snapshot: bool) -> Frame { Frame { session: "test".to_string(), output_seq: seq, bytes: format!("frame-{seq}").into_bytes(), snapshot, closed: false, } } #[test] fn frame_buffer_renders_only_contiguous_frames() { let mut buffer = FrameBuffer::default(); let mut last = 10; assert!(buffer.accept(test_frame(12, false), &mut last).is_empty()); assert_eq!(last, 10); assert!(!buffer.gap_wait_elapsed(Duration::from_secs(60))); let ready = buffer.accept(test_frame(11, false), &mut last); assert_eq!(ready.len(), 2); assert_eq!(ready[0].output_seq, 11); assert_eq!(ready[1].output_seq, 12); assert_eq!(last, 12); assert!(!buffer.gap_wait_elapsed(Duration::from_secs(0))); } #[test] fn frame_buffer_ignores_duplicate_frames() { let mut buffer = FrameBuffer::default(); let mut last = 4; assert_eq!(buffer.accept(test_frame(5, false), &mut last).len(), 1); assert!(buffer.accept(test_frame(5, false), &mut last).is_empty()); assert_eq!(last, 5); } #[test] fn frame_buffer_accepts_snapshot_as_complete_state() { let mut buffer = FrameBuffer::default(); let mut last = 10; assert!(buffer.accept(test_frame(12, false), &mut last).is_empty()); buffer.backdate_gap_for_test(Duration::from_secs(10)); assert!(buffer.gap_wait_elapsed(Duration::from_millis(1))); let ready = buffer.accept(test_frame(20, true), &mut last); assert_eq!(ready.len(), 1); assert_eq!(ready[0].output_seq, 20); assert_eq!(last, 20); assert!(!buffer.gap_wait_elapsed(Duration::from_millis(1))); assert!(buffer.accept(test_frame(12, false), &mut last).is_empty()); } #[test] fn frame_buffer_marks_gap_ready_for_resync_after_wait() { let mut buffer = FrameBuffer::default(); let mut last = 10; assert!(buffer.accept(test_frame(13, false), &mut last).is_empty()); assert_eq!(last, 10); assert!(!buffer.gap_wait_elapsed(Duration::from_secs(60))); buffer.backdate_gap_for_test(Duration::from_secs(1)); assert!(buffer.gap_wait_elapsed(Duration::from_millis(FRAME_GAP_RESYNC_AFTER_MS))); } #[test] fn frame_buffer_ignores_stale_snapshot() { let mut buffer = FrameBuffer::default(); let mut last = 20; assert!(buffer.accept(test_frame(19, true), &mut last).is_empty()); assert_eq!(last, 20); } #[test] fn predictor_only_accepts_plain_printable_input() { assert!(Predictor::predictable(b"hello world")); assert!(!Predictor::predictable(b"")); assert!(!Predictor::predictable(b"\n")); assert!(!Predictor::predictable(b"\r")); assert!(!Predictor::predictable(b"\x1b[A")); assert!(!Predictor::predictable("snowman: \u{2603}".as_bytes())); assert!(!Predictor::predictable("wide: 你".as_bytes())); assert!(!Predictor::predictable("combine: e\u{301}".as_bytes())); } #[test] fn predictor_tracks_alternate_screen_state() { let mut predictor = Predictor::new(true); predictor.observe_output(b"\x1b[?1049h"); assert!(predictor.alternate_screen); predictor.observe_output(b"\x1b[?1049l"); assert!(!predictor.alternate_screen); } /// Typing printable characters builds an in-order predicted line and the /// local cursor tracks the end of it. #[test] fn predictor_predicts_printable_run() { let mut p = Predictor::with_mode(true, PredictMode::Always); p.observe_input(b"hi").unwrap(); assert_eq!(p.visible_bytes(), b"hi"); assert_eq!(p.cursor, 2); assert_eq!(p.pending_len(), 2); } /// An authoritative server frame confirms (and clears) outstanding /// predictions: after a frame the overlay is empty. #[test] fn predictor_confirmation_clears_prediction() { let mut p = Predictor::with_mode(true, PredictMode::Always); p.observe_input(b"abc").unwrap(); assert_eq!(p.pending_len(), 3); // Server echoes the keystrokes; a new frame arrives. p.clear_pending().unwrap(); // run loop calls this before render_frame assert_eq!(p.pending_len(), 0); assert!(p.visible_bytes().is_empty()); } /// A glitch -- server output that contradicts the prediction (here the /// server enters the alternate screen mid-burst) -- discards the epoch's /// predictions so nothing wrong is left on screen. #[test] fn predictor_glitch_discards_epoch() { let mut p = Predictor::with_mode(true, PredictMode::Always); p.observe_input(b"vim").unwrap(); assert_eq!(p.pending_len(), 3); let epoch_before = p.epoch; // Server diverges: enters a full-screen TUI. Our line prediction is now // meaningless and must be dropped. p.observe_output(b"\x1b[?1049h"); assert!(p.alternate_screen); assert_eq!(p.pending_len(), 0); // The contradicted epoch is fully retired (confirmed past). assert!(p.confirmed_epoch >= epoch_before); // While in the alternate screen we refuse to speculate at all. p.observe_input(b"x").unwrap(); assert_eq!(p.pending_len(), 0); } /// Backspace prediction removes the last predicted cell from the end of the /// line and moves the local cursor back. #[test] fn predictor_predicts_backspace() { let mut p = Predictor::with_mode(true, PredictMode::Always); p.observe_input(b"hello").unwrap(); assert_eq!(p.visible_bytes(), b"hello"); p.observe_input(b"\x7f").unwrap(); // DEL assert_eq!(p.visible_bytes(), b"hell"); assert_eq!(p.cursor, 4); p.observe_input(&[0x08]).unwrap(); // ^H assert_eq!(p.visible_bytes(), b"hel"); assert_eq!(p.cursor, 3); // Backspacing past the start of our predicted span bails safely (we // don't model what's to the left), ending the epoch with no glyphs. p.observe_input(b"\x7f\x7f\x7f\x7f").unwrap(); assert_eq!(p.pending_len(), 0); } /// Right-margin / line-wrap edge: we refuse to predict past a conservative /// per-line budget instead of risking a wrong glyph at the wrap point. /// Bytes are fed in small interactive bursts so the per-line budget (not the /// paste guard) is what trips. #[test] fn predictor_line_wrap_edge_bails_at_budget() { let mut p = Predictor::with_mode(true, PredictMode::Always); let chunk = vec![b'x'; super::PREDICT_BURST_LIMIT]; let mut typed = 0; while typed + chunk.len() <= super::PREDICT_MAX_LINE { p.observe_input(&chunk).unwrap(); typed += chunk.len(); } assert_eq!(p.pending_len(), super::PREDICT_MAX_LINE); // One more character crosses the budget -> bail, drop the epoch. p.observe_input(b"y").unwrap(); assert_eq!(p.pending_len(), 0); } /// A paste-sized burst is not speculated at all (no overlay), but the input /// is still forwarded by the caller -- prediction is display-only. #[test] fn predictor_skips_paste_sized_burst() { let mut p = Predictor::with_mode(true, PredictMode::Always); let paste = vec![b'a'; super::PREDICT_BURST_LIMIT + 1]; p.observe_input(&paste).unwrap(); assert_eq!(p.pending_len(), 0); } /// Carriage return / newline cannot be modeled (prompt, scroll), so it ends /// the epoch and clears the speculative line. #[test] fn predictor_newline_ends_epoch() { let mut p = Predictor::with_mode(true, PredictMode::Always); p.observe_input(b"ls").unwrap(); assert_eq!(p.pending_len(), 2); p.observe_input(b"\r").unwrap(); assert_eq!(p.pending_len(), 0); } /// In-line arrow-key motion (improvement over the old printable-only /// predictor): Left/Right move the local cursor within the predicted span /// and a following keystroke overwrites in place. #[test] fn predictor_predicts_inline_arrows() { let mut p = Predictor::with_mode(true, PredictMode::Always); p.observe_input(b"abc").unwrap(); assert_eq!(p.cursor, 3); p.observe_input(b"\x1b[D").unwrap(); // left assert_eq!(p.cursor, 2); p.observe_input(b"\x1bOD").unwrap(); // left (application/SS3) assert_eq!(p.cursor, 1); p.observe_input(b"\x1b[C").unwrap(); // right assert_eq!(p.cursor, 2); // Overwrite the cell at the cursor (index 2, the 'c') in place. p.observe_input(b"Z").unwrap(); assert_eq!(p.visible_bytes(), b"abZ"); assert_eq!(p.cursor, 3); } /// Experimental (adaptive) mode shows no prediction while SRTT is below the /// trigger, and shows it once SRTT rises above the trigger. #[test] fn predictor_experimental_respects_srtt_threshold() { let mut p = Predictor::with_mode(true, PredictMode::Experimental); // Fast link: no fresh prediction yet, no SRTT sample -> not displayed. p.set_srtt_for_test(5.0); p.observe_input(b"abc").unwrap(); assert!(p.pending_len() > 0); // model still tracks the prediction... assert!(!p.should_display()); // ...but policy hides it on a fast link. // Slow link: SRTT above the high trigger -> predictions are displayed. p.set_srtt_for_test(60.0); assert!(p.should_display()); } /// Always mode displays regardless of SRTT; Off never does. #[test] fn predictor_mode_policies() { let mut always = Predictor::with_mode(true, PredictMode::Always); always.observe_input(b"x").unwrap(); assert!(always.should_display()); let off = Predictor::with_mode(true, PredictMode::Off); assert!(!off.enabled); assert!(!off.should_display()); assert_eq!(PredictMode::parse("off"), PredictMode::Off); assert_eq!(PredictMode::parse("ALWAYS"), PredictMode::Always); assert_eq!(PredictMode::parse("adaptive"), PredictMode::Experimental); assert_eq!(PredictMode::parse("anything"), PredictMode::Experimental); } #[test] fn cli_prediction_flags_override_config() { let mut args = test_args("host", &[]); let mut config = ClientConfig { predict_mode: "experimental".to_string(), ..ClientConfig::default() }; args.predict_always = true; assert_eq!( selected_predict_mode(&args, &config).unwrap(), PredictMode::Always ); args.predict_always = false; args.predict_never = true; assert_eq!( selected_predict_mode(&args, &config).unwrap(), PredictMode::Off ); args.predict_never = false; args.predict_mode = Some("always".to_string()); assert_eq!( selected_predict_mode(&args, &config).unwrap(), PredictMode::Always ); args.predict_mode = None; config.predict_mode = "off".to_string(); assert_eq!( selected_predict_mode(&args, &config).unwrap(), PredictMode::Off ); } #[test] fn startup_command_joins_args_for_remote_shell() { assert_eq!(startup_command(&[]), None); assert_eq!(startup_command(&["tm".to_string()]), Some(b"tm\r".to_vec())); assert_eq!( startup_command(&["tm".to_string(), "work".to_string()]), Some(b"tm work\r".to_vec()) ); } #[test] fn split_after_command_submit_holds_followup_input() { assert_eq!(split_after_command_submit(b"tm"), None); assert_eq!( split_after_command_submit(b"tm\r"), Some((b"tm\r".to_vec(), Vec::new())) ); assert_eq!( split_after_command_submit(b"tm\r\x1b[B"), Some((b"tm\r".to_vec(), b"\x1b[B".to_vec())) ); } #[test] fn post_submit_hold_only_catches_terminal_control_input() { assert!(should_hold_post_submit_input(b"\x1b[B")); assert!(should_hold_post_submit_input(b"\x9bB")); assert!(!should_hold_post_submit_input(b"next command")); assert!(!should_hold_post_submit_input(b"\r")); assert!(!should_hold_post_submit_input(b"")); } #[test] fn startup_gate_keeps_later_input_behind_queued_control_input() { assert!(should_hold_during_startup_gate( StartupGateMode::HoldControl, false, b"\x1b[B" )); assert!(should_hold_during_startup_gate( StartupGateMode::HoldControl, true, b"x" )); assert!(should_hold_during_startup_gate( StartupGateMode::HoldAll, false, b"x" )); assert!(!should_hold_during_startup_gate( StartupGateMode::HoldControl, false, b"x" )); } #[test] fn post_submit_hold_duration_has_short_printable_phase() { assert_eq!(post_submit_hold_duration(b"\x1b[B"), STARTUP_INPUT_HOLD); assert_eq!(post_submit_hold_duration(b"x"), POST_SUBMIT_ALL_INPUT_HOLD); } #[test] fn reconnect_refreshes_live_send_address_from_credentials() { let mut addr = "127.0.0.1:50000".parse().unwrap(); let cred = CachedCredential { server: "host".to_string(), session: "term".to_string(), mode: "normal".to_string(), udp_host: "127.0.0.1".to_string(), udp_port: 50001, client_id: [1; 16], session_key: [2; 32], session_key_id: [3; 16], attach_ticket: Vec::new(), attach_ticket_psk: [4; 32], last_rendered_seq: 0, }; refresh_live_addr(&mut addr, &cred).unwrap(); assert_eq!(addr.port(), 50001); } #[test] fn resume_accepts_unknown_client_reject_from_legacy_zero_id_server() { let client_id = [7u8; 16]; let matching_reject = protocol::Packet { header: protocol::Header { kind: PacketKind::AttachReject, flags: 0, conn_id: client_id, seq: 0, ack: 0, session_key_id: [0u8; 16], body_len: 0, }, body: Vec::new(), }; let zero_id_reject = protocol::Packet { header: protocol::Header { conn_id: [0u8; 16], ..matching_reject.header.clone() }, body: Vec::new(), }; let unrelated_frame = protocol::Packet { header: protocol::Header { kind: PacketKind::Frame, conn_id: [9u8; 16], ..matching_reject.header.clone() }, body: Vec::new(), }; assert!(is_resume_response_for_client(&matching_reject, client_id)); assert!(is_resume_response_for_client(&zero_id_reject, client_id)); assert!(!is_resume_response_for_client(&unrelated_frame, client_id)); } #[tokio::test] async fn pending_stream_open_is_retransmitted_until_answered() { let receiver = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); let sender = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); let addr = receiver.local_addr().unwrap(); let cred = CachedCredential { server: "host".to_string(), session: "term".to_string(), mode: "forward-only".to_string(), udp_host: "127.0.0.1".to_string(), udp_port: addr.port(), client_id: [1; 16], session_key: [2; 32], session_key_id: protocol::session_key_id(&[2; 32]), attach_ticket: Vec::new(), attach_ticket_psk: [3; 32], last_rendered_seq: 0, }; let mut send_seq = 10; let mut pending = HashMap::from([( 44, PendingStreamOpen { target_host: "127.0.0.1".to_string(), target_port: 8080, last_sent: std::time::Instant::now() - Duration::from_millis(250), attempts: 1, }, )]); retransmit_stream_opens(&sender, addr, &cred, &mut send_seq, &mut pending) .await .unwrap(); let mut buf = [0u8; 2048]; let (n, _) = tokio::time::timeout(Duration::from_secs(1), receiver.recv_from(&mut buf)) .await .unwrap() .unwrap(); let packet = protocol::decode(&buf[..n]).unwrap(); assert_eq!(packet.header.kind, PacketKind::StreamOpen); assert_eq!(packet.header.seq, 10); let plain = protocol::decrypt_body(&packet, &cred.session_key, protocol::CLIENT_TO_SERVER).unwrap(); let open: protocol::StreamOpen = protocol::from_body(&plain).unwrap(); assert_eq!(open.stream_id, 44); assert_eq!(open.target_port, 8080); assert_eq!(send_seq, 11); assert_eq!(pending[&44].attempts, 2); } #[test] fn update_check_accepts_only_check_flag() { assert!(!update_check_requested(&[]).unwrap()); assert!(update_check_requested(&["--check".to_string()]).unwrap()); assert!(update_check_requested(&["-n".to_string()]).unwrap()); assert!(update_check_requested(&["--wat".to_string()]).is_err()); assert!(update_check_requested(&["--check".to_string(), "extra".to_string()]).is_err()); } #[test] fn status_uses_host_alias_and_user() { let host = HostConfig { ssh: Some("server.example.com".to_string()), user: Some("palav".to_string()), ..HostConfig::default() }; assert_eq!( status_ssh_target("palav", &host), "palav@server.example.com" ); assert_eq!( status_ssh_target("rawhost", &HostConfig::default()), "rawhost" ); } #[test] fn status_detects_local_targets() { assert!(is_local_status_target("localhost")); assert!(is_local_status_target("palav@127.0.0.1")); assert!(is_local_status_target("::1")); assert!(!is_local_status_target("server.example.com")); } #[test] fn restart_script_has_systemd_and_nohup_fallback() { assert!(RESTART_STATUS_SCRIPT.contains("systemctl --user restart dosh-server.service")); assert!(RESTART_STATUS_SCRIPT.contains("nohup")); assert!(RESTART_STATUS_SCRIPT.contains("dosh-server")); } #[test] fn doctor_detects_server_version_mismatch() { assert!(!server_version_mismatch("")); assert!(!server_version_mismatch(env!("CARGO_PKG_VERSION"))); assert!(server_version_mismatch("0.0.0-old")); } #[test] fn release_download_url_uses_latest_release_asset() { assert_eq!( latest_release_download_url( "https://git.palav.dev/Palav/dosh.git", "dosh-linux-x86_64.tar.gz" ) .unwrap(), "https://git.palav.dev/Palav/dosh/releases/latest/download/dosh-linux-x86_64.tar.gz" ); assert!( latest_release_download_url("git@git.palav.dev:Palav/dosh.git", "artifact").is_none() ); } #[test] fn release_tag_parses_effective_latest_url() { assert_eq!( release_tag_from_effective_url( "https://example.com/owner/dosh", "https://example.com/owner/dosh/releases/tag/v1.0.0" ) .as_deref(), Some("v1.0.0") ); assert_eq!(release_version_from_tag("v1.0.0"), "1.0.0"); assert_eq!(release_version_from_tag("2026.06.28"), "2026.06.28"); assert_eq!( release_tag_download_url( "https://example.com/owner/dosh.git", "v1.0.0", "dosh-linux-x86_64.tar.gz" ) .as_deref(), Some( "https://example.com/owner/dosh/releases/download/v1.0.0/dosh-linux-x86_64.tar.gz" ) ); assert!(release_tag_from_effective_url("https://example.com/owner/dosh", "").is_none()); } #[test] fn update_version_status_compares_numeric_parts() { assert_eq!(update_version_status("0.1.5", "0.1.5"), "current"); assert_eq!(update_version_status("0.1.9", "0.1.10"), "update available"); assert_eq!(update_version_status("1.0.0", "0.9.9"), "different"); } #[test] fn resolved_startup_command_expands_global_extension() { let mut config = ClientConfig::default(); config.extensions.insert( "tm".to_string(), CommandExtension { command: Some("tm {args}".to_string()), description: Some("server tmux dashboard".to_string()), disabled: false, }, ); let host = HostConfig::default(); assert_eq!( resolved_startup_command(&["tm".into(), "work space".into()], &config, &host), Some(b"tm 'work space'\r".to_vec()) ); } #[test] fn resolved_startup_command_uses_host_extension_override() { let mut config = ClientConfig::default(); config.extensions.insert( "tm".to_string(), CommandExtension { command: Some("tm {args}".to_string()), description: None, disabled: false, }, ); let mut host = HostConfig::default(); host.extensions.insert( "tm".to_string(), CommandExtension { command: Some("/opt/tm/bin/tm --remote {args}".to_string()), description: None, disabled: false, }, ); assert_eq!( resolved_startup_command(&["tm".into(), "dosh".into()], &config, &host), Some(b"/opt/tm/bin/tm --remote 'dosh'\r".to_vec()) ); } #[test] fn resolved_startup_command_host_can_disable_global_extension() { let mut config = ClientConfig::default(); config.extensions.insert( "tm".to_string(), CommandExtension { command: Some("tm {args}".to_string()), description: None, disabled: false, }, ); let mut host = HostConfig::default(); host.extensions.insert( "tm".to_string(), CommandExtension { command: None, description: None, disabled: true, }, ); assert_eq!( resolved_startup_command(&["tm".into(), "dosh".into()], &config, &host), None ); } #[test] fn resolved_startup_command_keeps_raw_command_fallback_and_default_command() { let config = ClientConfig::default(); let mut host = HostConfig { default_command: Some("tm".to_string()), ..HostConfig::default() }; assert_eq!( resolved_startup_command(&["echo".into(), "ok".into()], &config, &host), Some(b"echo ok\r".to_vec()) ); assert_eq!( resolved_startup_command(&[], &config, &host), Some(b"tm\r".to_vec()) ); host.default_command = None; assert_eq!(resolved_startup_command(&[], &config, &host), None); } #[test] fn parses_local_forward_with_default_bind() { assert_eq!( parse_local_forward("8080:127.0.0.1:80").unwrap(), LocalForward { bind_host: "127.0.0.1".to_string(), listen_port: 8080, target_host: "127.0.0.1".to_string(), target_port: 80, } ); } #[test] fn parses_local_forward_with_explicit_bind() { assert_eq!( parse_local_forward("0.0.0.0:8080:localhost:8000").unwrap(), LocalForward { bind_host: "0.0.0.0".to_string(), listen_port: 8080, target_host: "localhost".to_string(), target_port: 8000, } ); } #[test] fn parses_remote_forward_with_default_bind() { assert_eq!( parse_remote_forward("9090:127.0.0.1:5432").unwrap(), RemoteForward { bind_host: "127.0.0.1".to_string(), listen_port: 9090, target_host: "127.0.0.1".to_string(), target_port: 5432, } ); } #[test] fn parses_dynamic_forward_with_default_bind() { assert_eq!( parse_dynamic_forward("1080").unwrap(), DynamicForward { bind_host: "127.0.0.1".to_string(), listen_port: 1080, } ); } #[test] fn forward_command_rewrites_to_forward_only_connection() { let args = test_args( "forward", &["homelab", "-L", "8080:127.0.0.1:80", "-D", "1080", "-f"], ); let rewritten = rewrite_forward_command(args).unwrap(); assert_eq!(rewritten.server.as_deref(), Some("homelab")); assert!(rewritten.forward_only); assert!(rewritten.background); assert_eq!(rewritten.command, Vec::::new()); assert_eq!(rewritten.local_forward, vec!["8080:127.0.0.1:80"]); assert_eq!(rewritten.dynamic_forward, vec!["1080"]); } #[test] fn forward_command_requires_a_forward_request() { let err = rewrite_forward_command(test_args("forward", &["homelab"])) .unwrap_err() .to_string(); assert!(err.contains("requires at least one")); } #[test] fn forward_parser_rejects_zero_ports_and_control_chars() { assert!(parse_local_forward("0:127.0.0.1:80").is_err()); assert!(parse_local_forward("8080:127.0.0.1:0").is_err()); assert!(parse_dynamic_forward("127.0.0.1:0").is_err()); assert!(!valid_forward_host("bad\nhost")); assert!(!valid_forward_host("bad\thost")); assert!(valid_forward_host("server.example.com")); } #[test] fn recover_removes_only_matching_cached_credentials() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().display().to_string(); let matching = dir .path() .join(format!("{}.bin", cache_key("user@host", "default", "rw"))); let other = dir .path() .join(format!("{}.bin", cache_key("other", "default", "rw"))); let similar = dir .path() .join(format!("{}.bin", cache_key("user@host2", "default", "rw"))); fs::write(&matching, b"cached").unwrap(); fs::write(&other, b"cached").unwrap(); fs::write(&similar, b"cached").unwrap(); assert_eq!(cache_server_prefix("user@host"), "user_host_"); assert_eq!(clear_cached_credentials(&root, "user@host").unwrap(), 1); assert!(!matching.exists()); assert!(other.exists()); assert!(similar.exists()); } #[test] fn pending_user_input_is_bounded_and_ordered() { let mut pending = VecDeque::new(); let mut pending_bytes = 0usize; queue_pending_user_input(&mut pending, &mut pending_bytes, b"abc".to_vec()).unwrap(); queue_pending_user_input(&mut pending, &mut pending_bytes, b"def".to_vec()).unwrap(); assert_eq!(pending_bytes, 6); assert_eq!(pending.pop_front().unwrap().bytes, b"abc"); assert_eq!(pending.pop_front().unwrap().bytes, b"def"); let mut pending = VecDeque::new(); let mut pending_bytes = MAX_PENDING_USER_INPUT_BYTES; assert!(queue_pending_user_input(&mut pending, &mut pending_bytes, b"x".to_vec()).is_err()); assert!(pending.is_empty()); assert_eq!(pending_bytes, MAX_PENDING_USER_INPUT_BYTES); } #[test] fn stale_mouse_reports_are_stripped_from_pending_input() { let input = b"\x1b[<35;152;1Mhello\x1b[<0;107;10m"; assert_eq!(strip_stale_mouse_reports(input), b"hello"); } #[test] fn stale_mouse_tail_reports_are_stripped_from_pending_input() { let input = b"35;152;1M35;149;1Mls\r"; assert_eq!(strip_stale_mouse_reports(input), b"ls\r"); assert_eq!(strip_stale_mouse_reports(b"152;1Mls\r"), b"ls\r"); } #[test] fn non_mouse_escape_input_survives_stale_filter() { assert_eq!(strip_stale_mouse_reports(b"\x1b[A"), b"\x1b[A"); assert_eq!( strip_stale_mouse_reports(b"\x1b[200~paste\x1b[201~"), b"\x1b[200~paste\x1b[201~" ); } #[test] fn escape_key_parser_accepts_mosh_style_controls() { assert_eq!(parse_escape_key("^]").unwrap(), Some(vec![0x1d])); assert_eq!(parse_escape_key("^C").unwrap(), Some(vec![0x03])); assert_eq!(parse_escape_key("^?").unwrap(), Some(vec![0x7f])); assert_eq!(parse_escape_key("0x1d").unwrap(), Some(vec![0x1d])); assert_eq!(parse_escape_key("~").unwrap(), Some(vec![b'~'])); assert_eq!(parse_escape_key("none").unwrap(), None); assert!(parse_escape_key("too-long").is_err()); } #[test] fn escape_key_matches_inside_stdin_chunk() { assert!(input_matches_escape(b"abc\x1d", Some(&[0x1d]))); assert!(!input_matches_escape(b"abc", Some(&[0x1d]))); assert!(!input_matches_escape(b"abc\x1d", None)); } // --- Item 1: disconnect status line state machine --- /// The status line stays hidden while the link is fresh and only appears once /// silence crosses the threshold; the rendered text reports elapsed seconds. #[test] fn disconnect_status_shows_after_threshold() { let mut status = DisconnectStatus::new(true); // Fresh link: nothing to do. assert_eq!(status.on_tick(Duration::from_secs(0)), StatusAction::None); assert_eq!(status.on_tick(Duration::from_secs(1)), StatusAction::None); // Crossed the threshold: show with the elapsed seconds in the text. let action = status.on_tick(Duration::from_secs(3)); assert_eq!( action, StatusAction::Show("[dosh] reconnecting — last contact 3s ago".to_string()) ); status.applied(&action); assert!(status.shown); } /// Once shown, a same-second tick is a no-op but a new elapsed value repaints. #[test] fn disconnect_status_repaints_only_on_text_change() { let mut status = DisconnectStatus::new(true); let first = status.on_tick(Duration::from_secs(3)); status.applied(&first); // Same elapsed second -> no redundant repaint. assert_eq!(status.on_tick(Duration::from_secs(3)), StatusAction::None); // Next second -> repaint with updated text. assert_eq!( status.on_tick(Duration::from_secs(4)), StatusAction::Show("[dosh] reconnecting — last contact 4s ago".to_string()) ); } /// Contact resuming clears a shown line immediately, and a sub-threshold tick /// also clears it; clearing when nothing is shown is a no-op. #[test] fn disconnect_status_clears_on_contact_and_recovery() { let mut status = DisconnectStatus::new(true); // Nothing shown yet: contact is a no-op. assert_eq!(status.on_contact(), StatusAction::None); let show = status.on_tick(Duration::from_secs(5)); status.applied(&show); assert!(status.shown); // A packet arrives: clear immediately. let clear = status.on_contact(); assert_eq!(clear, StatusAction::Clear); status.applied(&clear); assert!(!status.shown); // Show again, then a sub-threshold tick (link recovered) clears it too. let show = status.on_tick(Duration::from_secs(5)); status.applied(&show); assert_eq!(status.on_tick(Duration::from_secs(0)), StatusAction::Clear); } /// When the feature is disabled the machine never shows, and clears anything /// already on screen (e.g. config toggled off at runtime). #[test] fn disconnect_status_disabled_never_shows() { let mut status = DisconnectStatus::new(false); assert_eq!(status.on_tick(Duration::from_secs(10)), StatusAction::None); // Simulate a stale "shown" flag and confirm it gets cleared. status.shown = true; assert_eq!(status.on_tick(Duration::from_secs(10)), StatusAction::Clear); } #[test] fn disconnect_status_suppresses_without_touching_alternate_screen() { let mut status = DisconnectStatus::new(true); let show = status.on_tick(Duration::from_secs(3)); status.applied(&show); assert!(status.shown); let suppressed = status.on_suppressed(); assert_eq!(suppressed, StatusAction::Forget); status.applied(&suppressed); assert!(!status.shown); assert_eq!(status.drawn_text, ""); } /// The rendered overlay must preserve cursor position: save cursor (ESC 7), /// park on the top row, paint the blue reconnect bar, then restore cursor /// (ESC 8). The run loop suppresses this renderer entirely in full-screen /// alternate-screen TUIs. #[test] fn disconnect_status_overlay_is_save_restore_top_blue_bar() { let bytes = render_status_overlay("[dosh] reconnecting — last contact 3s ago", 24); ensure_tui_safe_status_overlay(&bytes).unwrap(); assert!(bytes.starts_with(b"\x1b7"), "must save cursor first"); assert!(bytes.ends_with(b"\x1b8"), "must restore cursor last"); // Positions to the first row, column 1. assert!(bytes.windows(6).any(|w| w == b"\x1b[1;1H")); // Clears the row and uses a blue background with white text. assert!(bytes.windows(4).any(|w| w == b"\x1b[2K")); assert!(bytes.windows(8).any(|w| w == b"\x1b[44;37m")); // Resets attributes before restoring the cursor. assert!(bytes.windows(4).any(|w| w == b"\x1b[0m")); let clear = render_status_clear(24); assert!(clear.starts_with(b"\x1b7")); assert!(clear.ends_with(b"\x1b8")); assert!(clear.windows(4).any(|w| w == b"\x1b[2K")); } #[test] fn disconnect_status_text_escalates_after_long_silence() { assert_eq!( DisconnectStatus::render_text(Duration::from_secs(29)), "[dosh] reconnecting — last contact 29s ago" ); assert_eq!( DisconnectStatus::render_text(Duration::from_secs(30)), "[dosh] still reconnecting — last contact 30s ago" ); } // --- Item 2: predictor policy re-evaluated on a timer tick --- /// A latency spike must surface predictions on a *timer tick*, not only on /// the next keystroke: `refresh_policy` force-shows once a prediction has been /// outstanding past the glitch threshold, even though SRTT hasn't caught up. #[test] fn predictor_refresh_policy_force_shows_on_spike() { let mut p = Predictor::with_mode(true, PredictMode::Experimental); // Fast SRTT so the SRTT trigger stays off; only the spike timer can show. p.set_srtt_for_test(5.0); p.observe_input(b"abc").unwrap(); assert!(p.pending_len() > 0); // Just typed: not outstanding long enough yet -> still hidden. assert!(!p.is_drawn()); // Simulate the spike: the prediction has now been outstanding well past // the glitch threshold. A keystroke-free timer tick must reveal it. p.backdate_pending_for_test(super::GLITCH_FORCE_MS as u64 + 50); p.refresh_policy().unwrap(); assert!(p.is_drawn(), "tick should reveal prediction on a spike"); } /// `refresh_policy` also turns the overlay *off* on a tick once SRTT recovers /// below the trigger (no keystroke required). #[test] fn predictor_refresh_policy_hides_on_recovery() { let mut p = Predictor::with_mode(true, PredictMode::Experimental); // Slow link: SRTT trigger latched on -> prediction is shown on input. p.set_srtt_for_test(60.0); p.observe_input(b"abc").unwrap(); assert!(p.is_drawn()); // Link recovers below the low trigger; a timer tick must hide it. p.set_srtt_for_test(5.0); p.refresh_policy().unwrap(); assert!(!p.is_drawn(), "tick should hide prediction after recovery"); } /// An idle tick with nothing outstanding is a cheap no-op (no spurious draw). #[test] fn predictor_refresh_policy_idle_is_noop() { let mut p = Predictor::with_mode(true, PredictMode::Experimental); p.set_srtt_for_test(60.0); p.refresh_policy().unwrap(); assert!(!p.is_drawn()); } }