2a6f28d529
The native-v1 work changed the UDP wire format (HEADER_LEN 42->58) without a
version bump, so a current client silently timed out against the older
still-deployed server. The server just needed redeploying; alongside that,
fix the robustness bugs surfaced while restoring the connection:
- Server: clamp terminal size to >=1 at every PTY/parser resize site. A client
reporting a 0x0 terminal made vt100::Parser::set_size panic ("attempt to
subtract with overflow") and took down the whole single-threaded daemon.
- Client: treat size() == Ok((0,0)) like a missing size and fall back to 80x24
instead of sending 0x0 to the server.
- Reap abandoned sessions and their shells. PtyHandle now retains the child and
kills it on drop, and the cleanup task removes clientless, non-prewarmed
sessions after the grace period. Previously the shell leaked forever (one
zsh per abandoned session); prewarmed sessions still stay hot.
- Evict half-finished native handshakes (pending_native) after 30s so an
unauthenticated ClientHello flood can't grow the map without bound.
- Drop the per-client, per-packet vt100 Screen clone on the output hot path and
remove the write-only last_screen field.
- Guard load_or_create_server_secret against a <32-byte secret file (was an
index-out-of-range panic at startup).
- Reconcile stream flow-control credit (client and server) when StreamData hits
a missing writer, so a closed/unknown stream can't wedge the peer's window.
Adds a unit test for session reaping. cargo fmt + cargo test green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3596 lines
120 KiB
Rust
3596 lines
120 KiB
Rust
use anyhow::{Context, Result, anyhow};
|
|
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::native::{
|
|
EnvVar, ForwardingKind, ForwardingRequest, KnownHostStatus, TrustResult,
|
|
derive_native_session_key, generate_native_ephemeral, host_fingerprint, load_ed25519_identity,
|
|
load_ed25519_identity_with_passphrase, parse_host_public_key_line, remove_trusted_host,
|
|
sign_user_auth, 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, Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose,
|
|
StreamData, StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust, TicketAttachBody,
|
|
TicketAttachEnvelope, TicketAttachOkEnvelope,
|
|
};
|
|
use dosh::ssh_agent;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::BTreeMap;
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::collections::VecDeque;
|
|
use std::fs;
|
|
use std::io::{Read, Write};
|
|
use std::net::{
|
|
Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener as StdTcpListener, TcpStream as StdTcpStream,
|
|
ToSocketAddrs,
|
|
};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::{TcpListener, TcpStream, UdpSocket};
|
|
use tokio::sync::mpsc;
|
|
|
|
const STREAM_INITIAL_WINDOW: usize = 1024 * 1024;
|
|
|
|
/// 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, Parser)]
|
|
#[command(name = "dosh-client")]
|
|
struct Args {
|
|
#[arg()]
|
|
server: Option<String>,
|
|
#[arg()]
|
|
command: Vec<String>,
|
|
#[arg(long)]
|
|
session: Option<String>,
|
|
#[arg(long)]
|
|
new: bool,
|
|
#[arg(long)]
|
|
view_only: bool,
|
|
#[arg(long)]
|
|
local_auth: bool,
|
|
#[arg(long)]
|
|
auth: Option<String>,
|
|
#[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<String>,
|
|
#[arg(short = 'R', long = "remote-forward")]
|
|
remote_forward: Vec<String>,
|
|
#[arg(short = 'D', long = "dynamic-forward")]
|
|
dynamic_forward: Vec<String>,
|
|
#[arg(short = 'N', long)]
|
|
forward_only: bool,
|
|
#[arg(short = 'f', long)]
|
|
background: bool,
|
|
#[arg(long)]
|
|
predict: bool,
|
|
#[arg(long)]
|
|
ssh_port: Option<u16>,
|
|
#[arg(long)]
|
|
ssh_auth_command: Option<String>,
|
|
#[arg(long)]
|
|
ssh_key: Option<PathBuf>,
|
|
#[arg(long)]
|
|
ssh_known_hosts: Option<PathBuf>,
|
|
#[arg(long)]
|
|
ssh_control_path: Option<PathBuf>,
|
|
#[arg(long)]
|
|
dosh_port: Option<u16>,
|
|
#[arg(long)]
|
|
dosh_host: Option<String>,
|
|
#[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<u8>,
|
|
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<u8>,
|
|
},
|
|
Close {
|
|
stream_id: u64,
|
|
},
|
|
}
|
|
|
|
#[tokio::main(flavor = "current_thread")]
|
|
async fn main() -> Result<()> {
|
|
let args = Args::parse();
|
|
let config = load_client_config(None).unwrap_or_default();
|
|
if args.server.as_deref() == Some("update") {
|
|
return run_update(&config);
|
|
}
|
|
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 = startup_command(&args.command).or_else(|| {
|
|
host.default_command
|
|
.as_deref()
|
|
.map(startup_command_from_string)
|
|
});
|
|
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)?;
|
|
let forwarding_requested =
|
|
!local_forwards.is_empty() || !remote_forwards.is_empty() || !dynamic_forwards.is_empty();
|
|
let predict = args.predict || host.predict.unwrap_or(config.predict);
|
|
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 target_udp_host = if let Some(host) = args
|
|
.dosh_host
|
|
.clone()
|
|
.or_else(|| host.dosh_host.clone())
|
|
.or_else(|| config.dosh_host.clone())
|
|
{
|
|
host
|
|
} else if args.local_auth {
|
|
"127.0.0.1".to_string()
|
|
} else {
|
|
match ssh_config(&server, ssh_port) {
|
|
Ok(parsed) => {
|
|
let hostname = parsed
|
|
.hostname
|
|
.clone()
|
|
.unwrap_or_else(|| ssh_destination_host(&server));
|
|
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());
|
|
ssh_destination_host(&server)
|
|
}
|
|
}
|
|
};
|
|
|
|
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,
|
|
startup_command,
|
|
config.reconnect_timeout_secs,
|
|
Vec::new(),
|
|
Vec::new(),
|
|
false,
|
|
)
|
|
.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,
|
|
startup_command,
|
|
config.reconnect_timeout_secs,
|
|
Vec::new(),
|
|
Vec::new(),
|
|
false,
|
|
)
|
|
.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),
|
|
resolved_ssh_config.as_ref(),
|
|
cold_requested_env,
|
|
)
|
|
.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,
|
|
startup_command,
|
|
config.reconnect_timeout_secs,
|
|
local_forwards,
|
|
dynamic_forwards,
|
|
args.forward_only,
|
|
)
|
|
.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,
|
|
startup_command,
|
|
config.reconnect_timeout_secs,
|
|
Vec::new(),
|
|
Vec::new(),
|
|
false,
|
|
)
|
|
.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] <host>"))?;
|
|
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_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> {
|
|
let requested = args
|
|
.command
|
|
.first()
|
|
.cloned()
|
|
.ok_or_else(|| anyhow!("usage: dosh doctor <host>"))?;
|
|
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.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 dosh_port = args
|
|
.dosh_port
|
|
.or(host_config.port)
|
|
.unwrap_or(config.dosh_port);
|
|
println!("Dosh doctor for {requested}");
|
|
|
|
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("<default>"),
|
|
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("<none>")
|
|
);
|
|
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 udp_host = args
|
|
.dosh_host
|
|
.clone()
|
|
.or_else(|| host_config.dosh_host.clone())
|
|
.or_else(|| config.dosh_host.clone())
|
|
.unwrap_or_else(|| {
|
|
parsed_ssh_config
|
|
.hostname
|
|
.clone()
|
|
.unwrap_or_else(|| ssh_destination_host(&server))
|
|
});
|
|
println!("[info] native endpoint: {udp_host}:{dosh_port}");
|
|
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() {
|
|
"<unknown>"
|
|
} else {
|
|
&check.server_version
|
|
}
|
|
);
|
|
println!("[ok] known host: trusted");
|
|
println!("[ok] native auth: authorized as {}", check.requested_user);
|
|
println!(
|
|
"[ok] forwarding policy: tcp={} remote={} agent={}",
|
|
check.allow_tcp_forwarding,
|
|
check.allow_remote_forwarding,
|
|
check.allow_agent_forwarding
|
|
);
|
|
Ok(())
|
|
}
|
|
Err(err) => {
|
|
println!("[fail] native/auth check: {err:#}");
|
|
Err(err.context("dosh doctor failed"))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn ssh_fallback_probe(
|
|
server: &str,
|
|
ssh_port: Option<u16>,
|
|
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<u16>,
|
|
ssh_auth_command: &str,
|
|
ssh_key: Option<&Path>,
|
|
ssh_known_hosts: Option<&Path>,
|
|
ssh_control_path: Option<&Path>,
|
|
) -> Result<dosh::native::HostPublicKey> {
|
|
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<Vec<u8>> {
|
|
if args.is_empty() {
|
|
return None;
|
|
}
|
|
let mut command = args.join(" ");
|
|
command.push('\n');
|
|
Some(command.into_bytes())
|
|
}
|
|
|
|
fn startup_command_from_string(command: &str) -> Vec<u8> {
|
|
let mut command = command.to_string();
|
|
command.push('\n');
|
|
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 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);
|
|
}
|
|
}
|
|
|
|
fn parse_local_forwards(raw: &[String]) -> Result<Vec<LocalForward>> {
|
|
raw.iter().map(|value| parse_local_forward(value)).collect()
|
|
}
|
|
|
|
fn parse_remote_forwards(raw: &[String]) -> Result<Vec<RemoteForward>> {
|
|
raw.iter()
|
|
.map(|value| parse_remote_forward(value))
|
|
.collect()
|
|
}
|
|
|
|
fn parse_dynamic_forwards(raw: &[String]) -> Result<Vec<DynamicForward>> {
|
|
raw.iter()
|
|
.map(|value| parse_dynamic_forward(value))
|
|
.collect()
|
|
}
|
|
|
|
fn parse_local_forward(raw: &str) -> Result<LocalForward> {
|
|
let parts = raw.split(':').collect::<Vec<_>>();
|
|
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::<u16>()
|
|
.with_context(|| format!("invalid local forward listen port in {raw:?}"))?;
|
|
let target_port = target_port
|
|
.parse::<u16>()
|
|
.with_context(|| format!("invalid local forward target port in {raw:?}"))?;
|
|
if target_host.is_empty() {
|
|
return Err(anyhow!("invalid -L {raw:?}; target host cannot be empty"));
|
|
}
|
|
if bind_host.is_empty() {
|
|
return Err(anyhow!("invalid -L {raw:?}; bind host cannot be empty"));
|
|
}
|
|
Ok(LocalForward {
|
|
bind_host,
|
|
listen_port,
|
|
target_host: target_host.to_string(),
|
|
target_port,
|
|
})
|
|
}
|
|
|
|
fn parse_remote_forward(raw: &str) -> Result<RemoteForward> {
|
|
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<DynamicForward> {
|
|
let parts = raw.split(':').collect::<Vec<_>>();
|
|
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::<u16>()
|
|
.with_context(|| format!("invalid dynamic forward listen port in {raw:?}"))?;
|
|
if bind_host.is_empty() {
|
|
return Err(anyhow!("invalid -D {raw:?}; bind host cannot be empty"));
|
|
}
|
|
Ok(DynamicForward {
|
|
bind_host,
|
|
listen_port,
|
|
})
|
|
}
|
|
|
|
fn forwarding_requests(
|
|
local_forwards: &[LocalForward],
|
|
remote_forwards: &[RemoteForward],
|
|
dynamic_forwards: &[DynamicForward],
|
|
) -> Vec<ForwardingRequest> {
|
|
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::<Vec<_>>();
|
|
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,
|
|
}));
|
|
requests
|
|
}
|
|
|
|
fn run_sessions_command(
|
|
server: &str,
|
|
ssh_port: Option<u16>,
|
|
ssh_key: Option<&std::path::Path>,
|
|
ssh_known_hosts: Option<&std::path::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()));
|
|
}
|
|
let script = 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'"#;
|
|
let remote_command = format!("sh -c {}", shell_word(script));
|
|
let status = command
|
|
.arg(server)
|
|
.arg(remote_command)
|
|
.status()
|
|
.context("run remote sessions command")?;
|
|
if !status.success() {
|
|
return Err(anyhow!("sessions command failed with status {status}"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_update(config: &dosh::config::ClientConfig) -> 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 update_cache = dirs::cache_dir()
|
|
.unwrap_or_else(|| expand_tilde("~/.cache"))
|
|
.join("dosh")
|
|
.join("source");
|
|
let mut script = format!(
|
|
"curl -fsSL {} | DOSH_REPO={} DOSH_PORT={} DOSH_UPDATE_CACHE={} DOSH_UPDATE_QUIET=1 sh -s -- client",
|
|
shell_word(&installer),
|
|
shell_word(&repo),
|
|
config.update_port.unwrap_or(config.dosh_port),
|
|
shell_word(&update_cache.display().to_string())
|
|
);
|
|
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(())
|
|
}
|
|
|
|
fn run_import_ssh(config: &dosh::config::ClientConfig, aliases: &[String]) -> Result<()> {
|
|
if aliases.is_empty() {
|
|
return Err(anyhow!("usage: dosh import-ssh <ssh-host> [ssh-host...]"));
|
|
}
|
|
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');
|
|
}
|
|
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) {
|
|
eprintln!("dosh import-ssh: [{alias}] already exists, skipping");
|
|
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 = false\n");
|
|
}
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
fs::write(&path, raw).with_context(|| format!("write {}", path.display()))?;
|
|
eprintln!("dosh import-ssh: wrote {}", path.display());
|
|
Ok(())
|
|
}
|
|
|
|
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 local_bootstrap(
|
|
session: &str,
|
|
mode: &str,
|
|
cols: u16,
|
|
rows: u16,
|
|
port: u16,
|
|
host: String,
|
|
) -> Result<BootstrapResponse> {
|
|
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();
|
|
}
|
|
unique_session_name()
|
|
}
|
|
|
|
fn unique_session_name() -> String {
|
|
let millis = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis();
|
|
format!("term-{millis}-{}", std::process::id())
|
|
}
|
|
|
|
fn ssh_bootstrap(
|
|
server: &str,
|
|
ssh_port: Option<u16>,
|
|
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<BootstrapResponse> {
|
|
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('[') {
|
|
if let Some((host, _)) = stripped.split_once(']') {
|
|
return host.to_string();
|
|
}
|
|
}
|
|
without_path
|
|
.split_once(':')
|
|
.map_or(without_path, |(host, _)| host)
|
|
.to_string()
|
|
}
|
|
|
|
fn ssh_username(server: &str) -> Option<String> {
|
|
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<String>,
|
|
port: Option<u16>,
|
|
user: Option<String>,
|
|
identity_files: Vec<String>,
|
|
user_known_hosts_file: Option<String>,
|
|
identities_only: bool,
|
|
proxy_jump: Option<String>,
|
|
proxy_command: Option<String>,
|
|
send_env: Vec<String>,
|
|
set_env: Vec<EnvVar>,
|
|
}
|
|
|
|
fn ssh_config(server: &str, ssh_port: Option<u16>) -> Result<SshConfig> {
|
|
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<EnvVar> {
|
|
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<EnvVar> {
|
|
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<SocketAddr> {
|
|
(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}"))
|
|
}
|
|
|
|
async fn try_native_auth(
|
|
socket: &UdpSocket,
|
|
config: &dosh::config::ClientConfig,
|
|
requested_server: &str,
|
|
server: &str,
|
|
ssh_port: Option<u16>,
|
|
udp_host: &str,
|
|
udp_port: u16,
|
|
cli_identity: Option<&Path>,
|
|
session: &str,
|
|
mode: &str,
|
|
cols: u16,
|
|
rows: u16,
|
|
requested_forwardings: Vec<ForwardingRequest>,
|
|
ssh_config_hint: Option<&SshConfig>,
|
|
requested_env: Vec<EnvVar>,
|
|
) -> 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: vec!["ssh-ed25519".to_string()],
|
|
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,
|
|
)?;
|
|
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))
|
|
}
|
|
|
|
async fn try_native_auth_check(
|
|
socket: &UdpSocket,
|
|
config: &dosh::config::ClientConfig,
|
|
requested_server: &str,
|
|
server: &str,
|
|
ssh_port: Option<u16>,
|
|
udp_host: &str,
|
|
udp_port: u16,
|
|
cli_identity: Option<&Path>,
|
|
ssh_config_hint: Option<&SshConfig>,
|
|
) -> Result<NativeAuthCheckOkBody> {
|
|
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: vec!["ssh-ed25519".to_string()],
|
|
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(),
|
|
)?;
|
|
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<ForwardingRequest>,
|
|
) -> Result<dosh::native::NativeUserAuth> {
|
|
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());
|
|
}
|
|
|
|
match load_first_native_identity(config, cli_identity, ssh_config) {
|
|
Ok(identity) => sign_user_auth(&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<ed25519_dalek::SigningKey> {
|
|
load_first_native_identity_with_prompt(
|
|
config,
|
|
cli_identity,
|
|
ssh_config,
|
|
prompt_identity_passphrase,
|
|
)
|
|
}
|
|
|
|
fn load_first_native_identity_with_prompt<F>(
|
|
config: &dosh::config::ClientConfig,
|
|
cli_identity: Option<&Path>,
|
|
ssh_config: &SshConfig,
|
|
mut prompt: F,
|
|
) -> Result<ed25519_dalek::SigningKey>
|
|
where
|
|
F: FnMut(&Path) -> Result<String>,
|
|
{
|
|
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_ed25519_identity(&path) {
|
|
Ok(identity) => return Ok(identity),
|
|
Err(err) if err.to_string().contains(" is encrypted") => {
|
|
match prompt(&path).and_then(|passphrase| {
|
|
load_ed25519_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 ssh-ed25519 identity found"))
|
|
} else {
|
|
Err(anyhow!(
|
|
"no usable ssh-ed25519 identity found: {}",
|
|
errors.join("; ")
|
|
))
|
|
}
|
|
}
|
|
|
|
fn push_identity_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
|
|
if !paths.iter().any(|existing| existing == &path) {
|
|
paths.push(path);
|
|
}
|
|
}
|
|
|
|
fn prompt_identity_passphrase(path: &Path) -> Result<String> {
|
|
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<EnvVar>,
|
|
) -> 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::<AttachOk>(&plain).ok())
|
|
})
|
|
.await?;
|
|
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<EnvVar>,
|
|
) -> 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::<TicketAttachOkEnvelope>(&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::<AttachOk>(&plain).ok())
|
|
})
|
|
.await?;
|
|
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 packet.header.conn_id != 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::<Frame>(&plain).ok())
|
|
})
|
|
.await?;
|
|
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 packet.header.conn_id != 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::<Frame>(&plain).ok())
|
|
})
|
|
.await?;
|
|
let mut next = cached.clone();
|
|
next.last_rendered_seq = frame.output_seq;
|
|
Ok((frame, next))
|
|
}
|
|
|
|
async fn recv_response_until<T, F>(socket: &UdpSocket, wait: Duration, mut accept: F) -> Result<T>
|
|
where
|
|
F: FnMut(protocol::Packet) -> Result<Option<T>>,
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_terminal(
|
|
socket: UdpSocket,
|
|
mut cred: CachedCredential,
|
|
first_frame: Option<Frame>,
|
|
predict: bool,
|
|
startup_command: Option<Vec<u8>>,
|
|
reconnect_timeout_secs: u64,
|
|
local_forwards: Vec<LocalForward>,
|
|
dynamic_forwards: Vec<DynamicForward>,
|
|
forward_only: bool,
|
|
) -> Result<()> {
|
|
let _raw = if forward_only {
|
|
None
|
|
} else {
|
|
Some(RawMode::enter()?)
|
|
};
|
|
let 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 last_size = terminal_size();
|
|
let mut frame_buffer = FrameBuffer::default();
|
|
let mut predictor = Predictor::new(predict && cred.mode != "view-only" && !forward_only);
|
|
let (forward_tx, mut forward_rx) = mpsc::channel::<ForwardEvent>(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<u64, tokio::net::tcp::OwnedWriteHalf> = HashMap::new();
|
|
let mut pending_socks_replies: HashSet<u64> = HashSet::new();
|
|
let mut opened_streams: HashSet<u64> = HashSet::new();
|
|
let mut stream_send_credit: HashMap<u64, usize> = HashMap::new();
|
|
let mut stream_pending_data: HashMap<u64, VecDeque<Vec<u8>>> = HashMap::new();
|
|
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?;
|
|
}
|
|
|
|
let (stdin_tx, mut stdin_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
|
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
|
|
};
|
|
|
|
let mut recv_buf = vec![0u8; 65535];
|
|
loop {
|
|
tokio::select! {
|
|
biased;
|
|
stdin_msg = stdin_rx.recv() => {
|
|
match stdin_msg {
|
|
Some(bytes) => {
|
|
if bytes == [0x1d] {
|
|
break;
|
|
}
|
|
if cred.mode != "view-only" && cred.mode != "forward-only" {
|
|
predictor.observe_input(&bytes)?;
|
|
send_input(&socket, addr, &cred, &mut send_seq, bytes).await?;
|
|
}
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
_ = resize_tick.tick() => {
|
|
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, &mut send_seq, cols, rows).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;
|
|
}
|
|
match packet.header.kind {
|
|
PacketKind::Frame | PacketKind::ResumeOk => {
|
|
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
|
|
if let Some(frame) = reconnect(
|
|
&socket,
|
|
&mut cred,
|
|
&mut send_seq,
|
|
last_size,
|
|
&mut frame_buffer,
|
|
&mut predictor,
|
|
)
|
|
.await?
|
|
{
|
|
if !forward_only {
|
|
render_frame(&frame)?;
|
|
predictor.observe_output(&frame.bytes);
|
|
}
|
|
last_packet_at = Instant::now();
|
|
}
|
|
continue;
|
|
};
|
|
let Ok(frame) = protocol::from_body::<Frame>(&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);
|
|
}
|
|
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::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?
|
|
{
|
|
if !forward_only {
|
|
render_frame(&frame)?;
|
|
predictor.observe_output(&frame.bytes);
|
|
}
|
|
last_packet_at = Instant::now();
|
|
}
|
|
} 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::<StreamOpen>(&plain) else {
|
|
continue;
|
|
};
|
|
last_packet_at = Instant::now();
|
|
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);
|
|
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::<StreamOpenOk>(&plain) else {
|
|
continue;
|
|
};
|
|
last_packet_at = Instant::now();
|
|
opened_streams.insert(ok.stream_id);
|
|
stream_send_credit.entry(ok.stream_id).or_insert(STREAM_INITIAL_WINDOW);
|
|
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,
|
|
).await?;
|
|
}
|
|
PacketKind::StreamOpenReject => {
|
|
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
|
|
continue;
|
|
};
|
|
let Ok(reject) = protocol::from_body::<StreamOpenReject>(&plain) else {
|
|
continue;
|
|
};
|
|
last_packet_at = Instant::now();
|
|
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_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::<StreamData>(&plain) else {
|
|
continue;
|
|
};
|
|
last_packet_at = Instant::now();
|
|
let len = data.bytes.len();
|
|
if let Some(writer) = stream_writers.get_mut(&data.stream_id) {
|
|
let _ = writer.write_all(&data.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,
|
|
data.stream_id,
|
|
len,
|
|
).await?;
|
|
}
|
|
PacketKind::StreamWindowAdjust => {
|
|
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
|
|
continue;
|
|
};
|
|
let Ok(adjust) = protocol::from_body::<StreamWindowAdjust>(&plain) else {
|
|
continue;
|
|
};
|
|
last_packet_at = Instant::now();
|
|
add_stream_credit(&mut stream_send_credit, adjust.stream_id, adjust.bytes);
|
|
flush_stream_pending_data(
|
|
&socket,
|
|
addr,
|
|
&cred,
|
|
&mut send_seq,
|
|
adjust.stream_id,
|
|
&mut stream_send_credit,
|
|
&mut stream_pending_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::<StreamClose>(&plain) else {
|
|
continue;
|
|
};
|
|
last_packet_at = Instant::now();
|
|
pending_socks_replies.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_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);
|
|
if socks5_reply {
|
|
pending_socks_replies.insert(stream_id);
|
|
}
|
|
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,
|
|
).await?;
|
|
}
|
|
Some(ForwardEvent::Close { stream_id }) => {
|
|
pending_socks_replies.remove(&stream_id);
|
|
opened_streams.remove(&stream_id);
|
|
stream_send_credit.remove(&stream_id);
|
|
stream_pending_data.remove(&stream_id);
|
|
stream_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?
|
|
{
|
|
if !forward_only {
|
|
render_frame(&frame)?;
|
|
predictor.observe_output(&frame.bytes);
|
|
}
|
|
last_packet_at = Instant::now();
|
|
}
|
|
} 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?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let _ = detach_once(&socket, &cred, send_seq).await;
|
|
Ok(())
|
|
}
|
|
|
|
async fn start_local_forwards(
|
|
local_forwards: &[LocalForward],
|
|
forward_tx: mpsc::Sender<ForwardEvent>,
|
|
stream_ids: Arc<AtomicU64>,
|
|
) -> 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<ForwardEvent>,
|
|
stream_ids: Arc<AtomicU64>,
|
|
) -> 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<ForwardEvent>,
|
|
stream_ids: Arc<AtomicU64>,
|
|
) -> 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<Option<Frame>> {
|
|
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))
|
|
}
|
|
|
|
async fn send_input(
|
|
socket: &UdpSocket,
|
|
addr: SocketAddr,
|
|
cred: &CachedCredential,
|
|
send_seq: &mut u64,
|
|
bytes: Vec<u8>,
|
|
) -> 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,
|
|
bytes: Vec<u8>,
|
|
) -> Result<()> {
|
|
let body = protocol::to_body(&StreamData { stream_id, bytes })?;
|
|
send_stream_packet(socket, addr, cred, send_seq, PacketKind::StreamData, &body).await
|
|
}
|
|
|
|
async fn queue_or_send_stream_data(
|
|
socket: &UdpSocket,
|
|
addr: SocketAddr,
|
|
cred: &CachedCredential,
|
|
send_seq: &mut u64,
|
|
stream_id: u64,
|
|
bytes: Vec<u8>,
|
|
opened_streams: &HashSet<u64>,
|
|
stream_send_credit: &mut HashMap<u64, usize>,
|
|
stream_pending_data: &mut HashMap<u64, VecDeque<Vec<u8>>>,
|
|
) -> 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();
|
|
send_stream_data(socket, addr, cred, send_seq, stream_id, bytes).await
|
|
}
|
|
|
|
async fn flush_stream_pending_data(
|
|
socket: &UdpSocket,
|
|
addr: SocketAddr,
|
|
cred: &CachedCredential,
|
|
send_seq: &mut u64,
|
|
stream_id: u64,
|
|
stream_send_credit: &mut HashMap<u64, usize>,
|
|
stream_pending_data: &mut HashMap<u64, VecDeque<Vec<u8>>>,
|
|
) -> Result<()> {
|
|
let Some(pending) = stream_pending_data.get_mut(&stream_id) else {
|
|
return Ok(());
|
|
};
|
|
loop {
|
|
let Some(bytes) = pending.front() else {
|
|
break;
|
|
};
|
|
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();
|
|
send_stream_data(socket, addr, cred, send_seq, stream_id, bytes).await?;
|
|
}
|
|
if pending.is_empty() {
|
|
stream_pending_data.remove(&stream_id);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn add_stream_credit(stream_send_credit: &mut HashMap<u64, usize>, stream_id: u64, bytes: u32) {
|
|
let credit = stream_send_credit.entry(stream_id).or_default();
|
|
*credit = credit.saturating_add(bytes as usize);
|
|
}
|
|
|
|
async fn send_stream_window_adjust(
|
|
socket: &UdpSocket,
|
|
addr: SocketAddr,
|
|
cred: &CachedCredential,
|
|
send_seq: &mut u64,
|
|
stream_id: u64,
|
|
bytes: usize,
|
|
) -> Result<()> {
|
|
let body = protocol::to_body(&StreamWindowAdjust {
|
|
stream_id,
|
|
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(())
|
|
}
|
|
|
|
struct Predictor {
|
|
enabled: bool,
|
|
alternate_screen: bool,
|
|
pending: Vec<u8>,
|
|
}
|
|
|
|
impl Predictor {
|
|
fn new(enabled: bool) -> Self {
|
|
Self {
|
|
enabled,
|
|
alternate_screen: false,
|
|
pending: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn reset(&mut self) {
|
|
self.pending.clear();
|
|
self.alternate_screen = false;
|
|
}
|
|
|
|
fn observe_input(&mut self, bytes: &[u8]) -> Result<()> {
|
|
if !self.enabled || self.alternate_screen {
|
|
return Ok(());
|
|
}
|
|
if !Self::predictable(bytes) {
|
|
self.clear_pending()?;
|
|
return Ok(());
|
|
}
|
|
self.pending.extend_from_slice(bytes);
|
|
self.draw_pending()
|
|
}
|
|
|
|
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;
|
|
self.pending.clear();
|
|
}
|
|
if contains_bytes(bytes, b"\x1b[?1049l")
|
|
|| contains_bytes(bytes, b"\x1b[?1047l")
|
|
|| contains_bytes(bytes, b"\x1b[?47l")
|
|
{
|
|
self.alternate_screen = false;
|
|
self.pending.clear();
|
|
}
|
|
}
|
|
|
|
fn predictable(bytes: &[u8]) -> bool {
|
|
!bytes.is_empty() && bytes.iter().all(|byte| matches!(byte, 0x20..=0x7e))
|
|
}
|
|
|
|
fn draw_pending(&self) -> Result<()> {
|
|
if self.pending.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let mut stdout = std::io::stdout();
|
|
stdout.write_all(b"\x1b7\x1b[4m")?;
|
|
stdout.write_all(&self.pending)?;
|
|
stdout.write_all(b"\x1b[24m\x1b8")?;
|
|
stdout.flush()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn clear_pending(&mut self) -> Result<()> {
|
|
if self.pending.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let mut stdout = std::io::stdout();
|
|
stdout.write_all(b"\x1b7")?;
|
|
for _ in 0..self.pending.len() {
|
|
stdout.write_all(b" ")?;
|
|
}
|
|
stdout.write_all(b"\x1b8")?;
|
|
stdout.flush()?;
|
|
self.pending.clear();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
|
|
haystack
|
|
.windows(needle.len())
|
|
.any(|window| window == needle)
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FrameBuffer {
|
|
pending: BTreeMap<u64, Frame>,
|
|
}
|
|
|
|
impl FrameBuffer {
|
|
fn clear(&mut self) {
|
|
self.pending.clear();
|
|
}
|
|
|
|
fn accept(&mut self, frame: Frame, last_rendered_seq: &mut u64) -> Vec<Frame> {
|
|
if frame.snapshot {
|
|
if frame.output_seq < *last_rendered_seq {
|
|
return Vec::new();
|
|
}
|
|
self.pending.clear();
|
|
*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);
|
|
}
|
|
ready
|
|
}
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
fn cache_path(root: &str, server: &str, session: &str, mode: &str) -> std::path::PathBuf {
|
|
let safe = format!("{server}_{session}_{mode}")
|
|
.chars()
|
|
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
|
.collect::<String>();
|
|
expand_tilde(root).join(format!("{safe}.bin"))
|
|
}
|
|
|
|
fn load_cache(path: &std::path::Path) -> Result<CachedCredential> {
|
|
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<Self> {
|
|
enable_raw_mode()?;
|
|
Ok(Self)
|
|
}
|
|
}
|
|
|
|
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::{
|
|
DynamicForward, FrameBuffer, LocalForward, Predictor, RemoteForward, SshConfig,
|
|
auth_allows, load_first_native_identity_with_prompt, parse_dynamic_forward,
|
|
parse_local_forward, parse_remote_forward, parse_ssh_config, raw_contains_host_table,
|
|
recv_response_until, requested_env, ssh_destination_host, ssh_username, ssh_with_user,
|
|
startup_command, toml_bare_key_or_quoted,
|
|
};
|
|
use dosh::config::{ClientConfig, HostConfig};
|
|
use dosh::native::EnvVar;
|
|
use dosh::protocol::{self, Frame, PacketKind};
|
|
use ed25519_dalek::{SigningKey, VerifyingKey};
|
|
|
|
#[test]
|
|
fn parses_ssh_config_hostname() {
|
|
let raw = "user palav\nhostname palav.dev\nport 22\n";
|
|
assert_eq!(
|
|
parse_ssh_config(raw).hostname,
|
|
Some("palav.dev".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 palav\nidentityfile ~/.ssh/id_ed25519\nidentityfile ~/.ssh/work\n",
|
|
);
|
|
assert_eq!(parsed.user, Some("palav".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(
|
|
"[palav]\nssh = \"palav\"\n",
|
|
"palav"
|
|
));
|
|
assert!(raw_contains_host_table("[\"home box\"]\n", "home box"));
|
|
assert_eq!(toml_bare_key_or_quoted("palav"), "palav");
|
|
assert_eq!(toml_bare_key_or_quoted("home box"), "\"home box\"");
|
|
}
|
|
|
|
#[test]
|
|
fn strips_user_from_fallback_udp_host() {
|
|
assert_eq!(ssh_destination_host("palav@example.com"), "example.com");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_from_ssh_destination() {
|
|
assert_eq!(ssh_username("palav@example.com"), Some("palav".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("palav")),
|
|
"palav@example.com"
|
|
);
|
|
assert_eq!(
|
|
ssh_with_user("root@example.com", Some("palav")),
|
|
"root@example.com"
|
|
);
|
|
assert_eq!(
|
|
ssh_with_user("ssh://example.com", Some("palav")),
|
|
"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!(
|
|
VerifyingKey::from(&loaded).to_bytes(),
|
|
VerifyingKey::from(&signing).to_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 ssh-ed25519 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 mut config = ClientConfig::default();
|
|
config.identity_files = vec![path.display().to_string()];
|
|
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 ssh-ed25519 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!(
|
|
VerifyingKey::from(&loaded).to_bytes(),
|
|
VerifyingKey::from(&signing).to_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);
|
|
|
|
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);
|
|
}
|
|
|
|
#[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());
|
|
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.accept(test_frame(12, false), &mut last).is_empty());
|
|
}
|
|
|
|
#[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"\x1b[A"));
|
|
assert!(!Predictor::predictable("snowman: \u{2603}".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);
|
|
}
|
|
|
|
#[test]
|
|
fn startup_command_joins_args_for_remote_shell() {
|
|
assert_eq!(startup_command(&[]), None);
|
|
assert_eq!(startup_command(&["tm".to_string()]), Some(b"tm\n".to_vec()));
|
|
assert_eq!(
|
|
startup_command(&["tm".to_string(), "work".to_string()]),
|
|
Some(b"tm work\n".to_vec())
|
|
);
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
);
|
|
}
|
|
}
|