use std::io::{ErrorKind, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::{Arc, Mutex};
use std::thread;
use puressh::auth::ClientCredential;
use puressh::client::{
ChannelStream, Client, ClientHandlers, Config, ForwardedTcpipCallback, ForwardedTcpipOrigin,
ServeContext,
};
#[path = "common.rs"]
mod common;
use common::{
build_host_key_policy, connect_agent_credentials, default_identity_paths, expand_tilde,
load_identity, parse_target, read_password_from_stdin, resolve_user, set_verbose,
try_load_default_identity, vlog, StrictMode,
};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const USAGE: &str = "usage: ssh [-v[v[v]]] [-F configfile] [-p port] [-i identity_file] [-l user] \
[-o StrictHostKeyChecking={yes,no,accept-new,ask}] \
[-o UserKnownHostsFile=PATH] [-o HashKnownHosts={yes,no}] \
[-o IdentitiesOnly={yes,no}] \
[-L LPORT:RHOST:RPORT] [-R RPORT:LHOST:LPORT] \
[-N] [-A] [-X] [-Y] \
[user@]host [command...]";
#[derive(Clone, Debug)]
struct LocalForward {
listen_port: u16,
remote_host: String,
remote_port: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum X11Forward {
Untrusted,
Trusted,
}
#[derive(Clone, Debug)]
struct RemoteForward {
remote_port: u16,
local_host: String,
local_port: u16,
}
struct Cli {
config_file: Option<PathBuf>,
port: Option<u16>,
identities: Vec<String>,
cli_user: Option<String>,
strict: Option<StrictMode>,
known_hosts_path: Option<PathBuf>,
hash_known_hosts: Option<bool>,
identities_only: Option<bool>,
locals: Vec<LocalForward>,
remotes: Vec<RemoteForward>,
no_command: bool,
verbose: u8,
agent_forward: bool,
x11_forward: Option<X11Forward>,
host: String,
user_in_host: Option<String>,
command: Option<String>,
}
fn parse_local_forward(s: &str) -> Result<LocalForward, String> {
let (listen_port, remote_host, remote_port) = split_forward_triple(s, "-L")?;
Ok(LocalForward {
listen_port,
remote_host,
remote_port,
})
}
fn parse_remote_forward(s: &str) -> Result<RemoteForward, String> {
let (remote_port, local_host, local_port) = split_forward_triple(s, "-R")?;
Ok(RemoteForward {
remote_port,
local_host,
local_port,
})
}
fn split_forward_triple(s: &str, flag: &str) -> Result<(u16, String, u16), String> {
let (port1_str, after_p1) = s
.split_once(':')
.ok_or_else(|| format!("{flag} expects PORT:HOST:PORT, got {s:?}"))?;
let port1: u16 = port1_str
.parse()
.map_err(|_| format!("{flag}: invalid leading port {port1_str:?}"))?;
let (host, after_host) = if let Some(rest) = after_p1.strip_prefix('[') {
let close = rest
.find(']')
.ok_or_else(|| format!("{flag}: missing `]` in {s:?}"))?;
let host = rest[..close].to_string();
let after = &rest[close + 1..];
let after = after
.strip_prefix(':')
.ok_or_else(|| format!("{flag}: expected `:port` after `]` in {s:?}"))?;
(host, after)
} else {
let (h, p) = after_p1
.split_once(':')
.ok_or_else(|| format!("{flag} expects PORT:HOST:PORT, got {s:?}"))?;
if h.is_empty() {
return Err(format!("{flag}: HOST cannot be empty"));
}
(h.to_string(), p)
};
if host.is_empty() {
return Err(format!("{flag}: HOST cannot be empty"));
}
let port2: u16 = after_host
.parse()
.map_err(|_| format!("{flag}: invalid trailing port {after_host:?}"))?;
Ok((port1, host, port2))
}
fn parse_args(args: &[String]) -> Result<Cli, String> {
let mut config_file: Option<PathBuf> = None;
let mut port: Option<u16> = None;
let mut identities: Vec<String> = Vec::new();
let mut cli_user: Option<String> = None;
let mut strict: Option<StrictMode> = None;
let mut known_hosts_path: Option<PathBuf> = None;
let mut hash_known_hosts: Option<bool> = None;
let mut identities_only: Option<bool> = None;
let mut locals: Vec<LocalForward> = Vec::new();
let mut remotes: Vec<RemoteForward> = Vec::new();
let mut no_command = false;
let mut agent_forward = false;
let mut x11_forward: Option<X11Forward> = None;
let mut verbose: u8 = 0;
let mut positional: Vec<String> = Vec::new();
let mut i = 0;
while i < args.len() {
let a = &args[i];
if a == "--" {
positional.extend_from_slice(&args[i + 1..]);
break;
}
match a.as_str() {
"-p" => {
i += 1;
let v = args.get(i).ok_or("-p requires a value")?;
port = Some(v.parse::<u16>().map_err(|_| "invalid port".to_string())?);
}
"-F" => {
i += 1;
let v = args.get(i).ok_or("-F requires a value")?.clone();
config_file = Some(PathBuf::from(v));
}
"-i" => {
i += 1;
let v = args.get(i).ok_or("-i requires a value")?.clone();
identities.push(v);
}
"-l" => {
i += 1;
let v = args.get(i).ok_or("-l requires a value")?.clone();
cli_user = Some(v);
}
"-L" => {
i += 1;
let v = args.get(i).ok_or("-L requires a value")?;
locals.push(parse_local_forward(v)?);
}
"-R" => {
i += 1;
let v = args.get(i).ok_or("-R requires a value")?;
remotes.push(parse_remote_forward(v)?);
}
"-N" => {
no_command = true;
}
"-A" => {
agent_forward = true;
}
"-X" => {
x11_forward = Some(X11Forward::Untrusted);
}
"-Y" => {
x11_forward = Some(X11Forward::Trusted);
}
"-v" => {
verbose = verbose.saturating_add(1).min(3);
}
"-vv" => {
verbose = verbose.max(2);
}
"-vvv" => {
verbose = 3;
}
"-o" => {
i += 1;
let v = args.get(i).ok_or("-o requires a value")?;
let (k, val) = v
.split_once('=')
.ok_or_else(|| format!("-o expects KEY=VALUE, got {v:?}"))?;
match k.to_ascii_lowercase().as_str() {
"stricthostkeychecking" => {
strict = Some(match val.to_ascii_lowercase().as_str() {
"yes" => StrictMode::Yes,
"no" | "off" => StrictMode::No,
"accept-new" => StrictMode::AcceptNew,
"ask" => StrictMode::Ask,
other => return Err(format!("unknown StrictHostKeyChecking={other}")),
});
}
"userknownhostsfile" => {
known_hosts_path = Some(PathBuf::from(val));
}
"hashknownhosts" => {
hash_known_hosts =
Some(matches!(val.to_ascii_lowercase().as_str(), "yes" | "on"));
}
"identitiesonly" => {
identities_only =
Some(matches!(val.to_ascii_lowercase().as_str(), "yes" | "on"));
}
other => {
return Err(format!("unsupported -o option: {other}={val}"));
}
}
}
s if s.starts_with('-') => {
return Err(format!("unknown flag: {s}"));
}
_ => positional.push(a.clone()),
}
i += 1;
}
if positional.is_empty() {
return Err("missing host argument".into());
}
let target = positional.remove(0);
let (user_in_host, host, target_port) = parse_target(&target)?;
if port.is_none() {
port = target_port;
}
let command = if positional.is_empty() {
None
} else {
Some(positional.join(" "))
};
Ok(Cli {
config_file,
port,
identities,
cli_user,
strict,
known_hosts_path,
hash_known_hosts,
identities_only,
locals,
remotes,
no_command,
agent_forward,
x11_forward,
verbose,
host,
user_in_host,
command,
})
}
fn run() -> Result<i32, String> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "-h" || a == "--help") {
println!("{USAGE}");
println!();
println!("A pure-Rust SSH client built on puressh {VERSION}.");
return Ok(0);
}
if args.iter().any(|a| a == "-V" || a == "--version") {
println!("puressh ssh {VERSION}");
return Ok(0);
}
let mut cli = parse_args(&args).map_err(|e| format!("{e}\n{USAGE}"))?;
set_verbose(cli.verbose);
let ssh_cfg = common::load_client_config(cli.config_file.as_deref())?;
let cfg_block = ssh_cfg.lookup(&cli.host);
for lf in &cfg_block.local_forwards {
cli.locals.push(LocalForward {
listen_port: lf.listen_port,
remote_host: lf.remote_host.clone(),
remote_port: lf.remote_port,
});
}
for rf in &cfg_block.remote_forwards {
cli.remotes.push(RemoteForward {
remote_port: rf.remote_port,
local_host: rf.local_host.clone(),
local_port: rf.local_port,
});
}
if !cli.agent_forward && cfg_block.forward_agent == Some(true) {
cli.agent_forward = true;
}
if cli.x11_forward.is_none() && cfg_block.forward_x11 == Some(true) {
cli.x11_forward = Some(if cfg_block.forward_x11_trusted == Some(true) {
X11Forward::Trusted
} else {
X11Forward::Untrusted
});
}
if cli.verbose == 0 {
if let Some(level) = cfg_block.log_level {
set_verbose(level);
}
}
let cli_user = cli.cli_user.clone().or_else(|| cfg_block.user.clone());
let user = resolve_user(cli_user.as_deref(), cli.user_in_host.as_deref())?;
let strict = common::pick(cli.strict, cfg_block.strict_host_key, StrictMode::Ask);
let known_hosts_path = cli
.known_hosts_path
.clone()
.or_else(|| cfg_block.user_known_hosts.as_ref().map(PathBuf::from));
let hash_known_hosts = common::pick(cli.hash_known_hosts, cfg_block.hash_known_hosts, false);
let identities_only = common::pick(cli.identities_only, cfg_block.identities_only, false);
let port = common::pick(cli.port, cfg_block.port, 22);
let connect_host = cfg_block
.host_name
.clone()
.unwrap_or_else(|| cli.host.clone());
let policy = build_host_key_policy(strict, known_hosts_path, hash_known_hosts)?;
let cfg = Config {
host_key_policy: policy,
timeout: None,
};
vlog(1, &format!("connecting to {connect_host}:{port}"));
let mut client = Client::connect_to_host(connect_host.as_str(), port, cfg)
.map_err(|e| format!("connect: {e}"))?;
vlog(1, &format!("connected to {connect_host}:{port}"));
let mut credentials: Vec<ClientCredential> = Vec::new();
if !identities_only {
match connect_agent_credentials() {
Ok(mut from_agent) => {
if !from_agent.is_empty() {
vlog(
1,
&format!("agent contributed {} identities", from_agent.len()),
);
}
credentials.append(&mut from_agent);
}
Err(e) => eprintln!("warning: agent: {e}"),
}
}
for id_path in &cli.identities {
let pk = match load_identity(id_path) {
Ok(p) => p,
Err(e) => {
eprintln!("warning: {e}");
continue;
}
};
match pk.into_host_key() {
Ok(hk) => {
vlog(1, &format!("identity {id_path}: loaded"));
credentials.push(ClientCredential::PublicKey(hk));
}
Err(e) => eprintln!("warning: identity {id_path}: {e}"),
}
}
for id_path_raw in &cfg_block.identity_files {
let id_path = expand_tilde(id_path_raw);
let pk = match load_identity(&id_path) {
Ok(p) => p,
Err(e) => {
eprintln!("warning: {e}");
continue;
}
};
match pk.into_host_key() {
Ok(hk) => {
vlog(1, &format!("config identity {id_path}: loaded"));
credentials.push(ClientCredential::PublicKey(hk));
}
Err(e) => eprintln!("warning: config identity {id_path}: {e}"),
}
}
if !identities_only {
for path in default_identity_paths() {
match try_load_default_identity(&path) {
Ok(Some(pk)) => match pk.into_host_key() {
Ok(hk) => {
vlog(1, &format!("default identity {}: loaded", path.display()));
credentials.push(ClientCredential::PublicKey(hk));
}
Err(e) => {
eprintln!("warning: default identity {}: {e}", path.display());
}
},
Ok(None) => {
vlog(2, &format!("default identity {}: skipped", path.display()));
}
Err(msg) => eprintln!("warning: {msg}"),
}
}
}
let authed = if !credentials.is_empty() {
vlog(
1,
&format!(
"attempting publickey auth with {} credentials",
credentials.len()
),
);
match client.authenticate(&user, credentials) {
Ok(()) => {
vlog(1, &format!("authenticated as {user} via publickey"));
true
}
Err(e) => {
eprintln!("publickey auth: {e}");
false
}
}
} else {
vlog(
1,
"no publickey credentials available; falling back to password",
);
false
};
if !authed {
let password = read_password_from_stdin().map_err(|e| format!("read password: {e}"))?;
client
.authenticate_password(&user, &password)
.map_err(|e| format!("Auth failed: {e}"))?;
vlog(1, &format!("authenticated as {user} via password"));
}
let want_forwarding = cli.no_command
|| !cli.remotes.is_empty()
|| !cli.locals.is_empty()
|| cli.agent_forward
|| cli.x11_forward.is_some();
if want_forwarding {
if cli.command.is_some() {
return Err(
"running a command alongside -A/-L/-R/-N/-X/-Y is not yet supported; \
invoke ssh twice or wire the forward without a command"
.into(),
);
}
if cli.no_command
&& cli.remotes.is_empty()
&& cli.locals.is_empty()
&& !cli.agent_forward
&& cli.x11_forward.is_none()
{
return Err("-N requires at least one of -A, -L, -R, -X, -Y".into());
}
return run_forwarding(client, &cli);
}
if let Some(command) = cli.command {
let out = client.exec(&command).map_err(|e| format!("exec: {e}"))?;
let _ = std::io::stdout().write_all(&out.stdout);
let _ = std::io::stderr().write_all(&out.stderr);
return Ok(out.exit_status.map(|s| s as i32).unwrap_or(255));
}
let shared: puressh::shared::SharedClient = client.into();
#[cfg(unix)]
{
if stdin_is_tty() {
run_interactive_pty_shell(shared)
} else {
run_interactive_pipe_shell(shared)
}
}
#[cfg(not(unix))]
{
let _ = shared;
Err(
"interactive shell on non-Unix needs the pipe fallback path, which has \
not been wired up for Windows in this version"
.into(),
)
}
}
#[cfg(unix)]
fn stdin_is_tty() -> bool {
unsafe { nix::libc::isatty(0) == 1 }
}
#[cfg(unix)]
fn run_interactive_pty_shell(shared: puressh::shared::SharedClient) -> Result<i32, String> {
use std::sync::atomic::{AtomicBool, Ordering};
let (cols, rows, px_w, px_h) = query_window_size();
let term = std::env::var("TERM").unwrap_or_else(|_| "xterm".to_string());
let mut original_termios: nix::libc::termios = unsafe { core::mem::zeroed() };
let tcget_ok = unsafe { nix::libc::tcgetattr(0, &mut original_termios) } == 0;
let modes = if tcget_ok {
puressh::client::encode_termios_modes(&original_termios)
} else {
Vec::new()
};
let _raw_guard = if tcget_ok {
Some(common::TermiosRawGuard::install(&original_termios))
} else {
None
};
let stream = shared
.shell_stream(&term, cols, rows, px_w, px_h, modes)
.map_err(|e| format!("shell: {e}"))?;
let channel_id = stream.channel_id();
let _ = shared.set_read_timeout(Some(std::time::Duration::from_millis(50)));
let stdout_done = Arc::new(AtomicBool::new(false));
let writer_shared = shared.clone();
let t_in = thread::spawn(move || {
let mut buf = [0u8; 8 * 1024];
let mut stdin = std::io::stdin();
loop {
match stdin.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let mut off = 0;
while off < n {
match writer_shared.channel_send_data(channel_id, &buf[off..n]) {
Ok(0) => return,
Err(_) => return,
Ok(taken) => off += taken,
}
}
}
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
let _ = writer_shared.channel_send_eof(channel_id);
});
let stdout_flag = stdout_done.clone();
let (stream_tx, stream_rx) = std::sync::mpsc::channel::<puressh::shared::OwnedChannelStream>();
let t_out = thread::spawn(move || {
let mut stream = stream;
let mut buf = [0u8; 32 * 1024];
let mut stdout = std::io::stdout();
loop {
match stream.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if stdout.write_all(&buf[..n]).is_err() {
break;
}
let _ = stdout.flush();
}
Err(_) => break,
}
}
stdout_flag.store(true, Ordering::Relaxed);
let _ = stream_tx.send(stream);
});
let err_shared = shared.clone();
let t_err = thread::spawn(move || {
let mut buf = [0u8; 32 * 1024];
let mut stderr = std::io::stderr();
loop {
match err_shared.channel_recv_stderr(channel_id, &mut buf) {
Ok(0) => break,
Ok(n) => {
if stderr.write_all(&buf[..n]).is_err() {
break;
}
let _ = stderr.flush();
}
Err(_) => break,
}
}
});
static RESIZED: AtomicBool = AtomicBool::new(false);
extern "C" fn on_winch(_sig: nix::libc::c_int) {
RESIZED.store(true, Ordering::Relaxed);
}
unsafe {
nix::libc::signal(
nix::libc::SIGWINCH,
on_winch as *const () as nix::libc::sighandler_t,
);
}
let winch_shared = shared.clone();
let winch_stop = stdout_done.clone();
let t_winch = thread::spawn(move || {
while !winch_stop.load(Ordering::Relaxed) {
thread::sleep(std::time::Duration::from_millis(100));
if RESIZED.swap(false, Ordering::Relaxed) {
let (cols, rows, px_w, px_h) = query_window_size();
let _ = winch_shared.send_window_change(channel_id, cols, rows, px_w, px_h);
}
}
});
let _ = t_out.join();
let _ = t_err.join();
drop(t_in);
drop(t_winch);
let stream = stream_rx
.recv_timeout(std::time::Duration::from_secs(1))
.ok();
Ok(stream.and_then(|s| s.exit_status()).unwrap_or(0))
}
#[cfg(unix)]
fn run_interactive_pipe_shell(shared: puressh::shared::SharedClient) -> Result<i32, String> {
let stream = shared
.shell_stream_no_pty()
.map_err(|e| format!("shell: {e}"))?;
let channel_id = stream.channel_id();
let _ = shared.set_read_timeout(Some(std::time::Duration::from_millis(50)));
let writer_shared = shared.clone();
let t_in = thread::spawn(move || {
let mut buf = [0u8; 8 * 1024];
let mut stdin = std::io::stdin();
loop {
match stdin.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let mut off = 0;
while off < n {
match writer_shared.channel_send_data(channel_id, &buf[off..n]) {
Ok(0) | Err(_) => return,
Ok(taken) => off += taken,
}
}
}
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
let _ = writer_shared.channel_send_eof(channel_id);
});
let (stream_tx, stream_rx) = std::sync::mpsc::channel::<puressh::shared::OwnedChannelStream>();
let t_out = thread::spawn(move || {
let mut stream = stream;
let mut buf = [0u8; 32 * 1024];
let mut stdout = std::io::stdout();
loop {
match stream.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if stdout.write_all(&buf[..n]).is_err() {
break;
}
let _ = stdout.flush();
}
Err(_) => break,
}
}
let _ = stream_tx.send(stream);
});
let err_shared = shared.clone();
let t_err = thread::spawn(move || {
let mut buf = [0u8; 32 * 1024];
let mut stderr = std::io::stderr();
loop {
match err_shared.channel_recv_stderr(channel_id, &mut buf) {
Ok(0) => break,
Ok(n) => {
if stderr.write_all(&buf[..n]).is_err() {
break;
}
let _ = stderr.flush();
}
Err(_) => break,
}
}
});
let _ = t_out.join();
let _ = t_err.join();
drop(t_in);
let stream = stream_rx
.recv_timeout(std::time::Duration::from_secs(1))
.ok();
Ok(stream.and_then(|s| s.exit_status()).unwrap_or(0))
}
#[cfg(unix)]
fn query_window_size() -> (u32, u32, u32, u32) {
let mut ws: nix::libc::winsize = unsafe { core::mem::zeroed() };
let ok = unsafe { nix::libc::ioctl(0, nix::libc::TIOCGWINSZ, &mut ws) } == 0;
if ok {
(
ws.ws_col as u32,
ws.ws_row as u32,
ws.ws_xpixel as u32,
ws.ws_ypixel as u32,
)
} else {
(80, 24, 0, 0)
}
}
fn spawn_splice_to_tcp(stream: ChannelStream, tcp: TcpStream) {
use puressh::client::ChannelEgress;
let (chan_rx, chan_tx) = stream.into_raw();
let tcp_in = match tcp.try_clone() {
Ok(c) => c,
Err(_) => {
let _ = chan_tx.send(ChannelEgress::Eof);
let _ = chan_tx.send(ChannelEgress::Close);
return;
}
};
let tcp_out = tcp;
let chan_tx_a = chan_tx.clone();
let mut tcp_in_a = tcp_in;
let a = thread::spawn(move || {
let mut buf = [0u8; 32 * 1024];
loop {
match tcp_in_a.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if chan_tx_a
.send(ChannelEgress::Data(buf[..n].to_vec()))
.is_err()
{
break;
}
}
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
let _ = chan_tx_a.send(ChannelEgress::Eof);
});
let mut tcp_out_b = tcp_out;
let b = thread::spawn(move || {
while let Ok(Some(chunk)) = chan_rx.recv() {
if tcp_out_b.write_all(&chunk).is_err() {
break;
}
}
let _ = tcp_out_b.shutdown(std::net::Shutdown::Read);
});
thread::spawn(move || {
let _ = a.join();
let _ = b.join();
let _ = chan_tx.send(ChannelEgress::Close);
});
}
fn run_forwarding(mut client: Client, cli: &Cli) -> Result<i32, String> {
let mut routes: std::collections::BTreeMap<(String, u16), (String, u16)> =
std::collections::BTreeMap::new();
for r in &cli.remotes {
let bound_port = client
.request_tcpip_forward("127.0.0.1", r.remote_port)
.map_err(|e| format!("tcpip-forward 127.0.0.1:{}: {e}", r.remote_port))?;
eprintln!(
"ssh: -R 127.0.0.1:{}:{}:{} active",
bound_port, r.local_host, r.local_port,
);
routes.insert(
("127.0.0.1".to_string(), bound_port),
(r.local_host.clone(), r.local_port),
);
}
let routes = Arc::new(Mutex::new(routes));
let routes_for_cb = Arc::clone(&routes);
let cb: Arc<ForwardedTcpipCallback> =
Arc::new(move |origin: ForwardedTcpipOrigin, stream: ChannelStream| {
let target = {
let map = match routes_for_cb.lock() {
Ok(g) => g,
Err(_) => return,
};
map.get(&(origin.bound_address.clone(), origin.bound_port))
.cloned()
};
let (local_host, local_port) = match target {
Some(t) => t,
None => {
eprintln!(
"ssh: forwarded-tcpip for unknown binding {}:{}; dropping",
origin.bound_address, origin.bound_port
);
return;
}
};
match TcpStream::connect((local_host.as_str(), local_port)) {
Ok(tcp) => spawn_splice_to_tcp(stream, tcp),
Err(e) => eprintln!(
"ssh: dial {}:{} for forwarded-tcpip from {}:{}: {e}",
local_host, local_port, origin.orig_address, origin.orig_port
),
}
});
let mut handlers = ClientHandlers::new().with_forwarded_tcpip(cb);
let agent_fwd_channel: Option<u32> = if cli.agent_forward {
#[cfg(unix)]
{
use puressh::forwarding::agent::splice_to_local_agent_callback;
let cb = splice_to_local_agent_callback().ok_or_else(|| {
"-A: $SSH_AUTH_SOCK is unset or names a socket that doesn't exist".to_string()
})?;
handlers = handlers.with_auth_agent(cb);
let id = client
.open_session_for_agent_forward()
.map_err(|e| format!("agent-forward session: {e}"))?;
eprintln!("ssh: -A agent forwarding requested");
Some(id)
}
#[cfg(not(unix))]
{
return Err("-A agent forwarding is not supported on this platform".to_string());
}
} else {
None
};
let x11_fwd_channel: Option<u32> = if let Some(mode) = cli.x11_forward {
#[cfg(not(unix))]
{
let _ = mode;
return Err("-X/-Y X11 forwarding is not supported on this platform".to_string());
}
#[cfg(unix)]
{
use puressh::forwarding::x11::splice_to_local_display_callback;
let cb = splice_to_local_display_callback().ok_or_else(|| {
"-X/-Y: $DISPLAY is unset or names a display we don't know how to dial".to_string()
})?;
handlers = handlers.with_x11(cb);
if mode == X11Forward::Untrusted {
eprintln!(
"warning: -X is currently equivalent to -Y in puressh \
(no SECURITY-extension cookie); the remote can read \
X11 input from your local display."
);
}
let cookie = mint_x11_cookie()?;
let id = client
.open_session_for_x11_forward(false, "MIT-MAGIC-COOKIE-1", &cookie, 0)
.map_err(|e| format!("x11-forward session: {e}"))?;
eprintln!(
"ssh: -{} X11 forwarding requested (cookie={} chars)",
if mode == X11Forward::Trusted {
"Y"
} else {
"X"
},
cookie.len(),
);
Some(id)
}
} else {
None
};
let ctx_opt: Option<ServeContext> = if cli.locals.is_empty() {
None
} else {
let (h, ctx) = handlers.with_serve_context();
handlers = h;
for l in &cli.locals {
let listener = TcpListener::bind(("127.0.0.1", l.listen_port))
.map_err(|e| format!("-L bind 127.0.0.1:{}: {e}", l.listen_port))?;
eprintln!(
"ssh: -L 127.0.0.1:{}:{}:{} active",
l.listen_port, l.remote_host, l.remote_port,
);
spawn_local_forward_listener(listener, l.clone(), ctx.clone());
}
Some(ctx)
};
let result = match client.serve(handlers) {
Ok(()) => Ok(0),
Err(e) => Err(format!("serve: {e}")),
};
if let Some(id) = agent_fwd_channel {
let _ = client.close_session(id);
}
if let Some(id) = x11_fwd_channel {
let _ = client.close_session(id);
}
drop(ctx_opt);
result
}
fn spawn_local_forward_listener(listener: TcpListener, spec: LocalForward, ctx: ServeContext) {
thread::spawn(move || {
for accept in listener.incoming() {
let tcp = match accept {
Ok(s) => s,
Err(e) => {
eprintln!("ssh: -L accept on 127.0.0.1:{}: {e}", spec.listen_port);
continue;
}
};
let orig = tcp
.peer_addr()
.map(|a| (a.ip().to_string(), a.port()))
.unwrap_or_else(|_| ("127.0.0.1".to_string(), 0));
let stream =
match ctx.open_direct_tcpip(&spec.remote_host, spec.remote_port, &orig.0, orig.1) {
Ok(s) => s,
Err(e) => {
eprintln!(
"ssh: -L direct-tcpip {}:{}: {e}",
spec.remote_host, spec.remote_port
);
continue;
}
};
spawn_splice_to_tcp(stream, tcp);
}
});
}
#[cfg(unix)]
fn mint_x11_cookie() -> Result<String, String> {
use purecrypto::rng::{OsRng, RngCore};
let mut bytes = [0u8; 16];
OsRng.fill_bytes(&mut bytes);
if bytes.iter().all(|&b| b == 0) {
return Err("x11 cookie: OS RNG returned all-zero entropy; refusing to forward".into());
}
let mut s = String::with_capacity(32);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
Ok(s)
}
fn main() -> ExitCode {
match run() {
Ok(code) => {
let clamped = code.clamp(0, 255) as u8;
ExitCode::from(clamped)
}
Err(msg) => {
eprintln!("ssh: {msg}");
ExitCode::from(255)
}
}
}
#[cfg(test)]
mod forward_tests {
use super::*;
#[test]
fn local_forward_plain() {
let f = parse_local_forward("8080:example.com:80").unwrap();
assert_eq!(f.listen_port, 8080);
assert_eq!(f.remote_host, "example.com");
assert_eq!(f.remote_port, 80);
}
#[test]
fn local_forward_v4() {
let f = parse_local_forward("8080:192.0.2.1:80").unwrap();
assert_eq!(f.remote_host, "192.0.2.1");
assert_eq!(f.remote_port, 80);
}
#[test]
fn local_forward_bracketed_v6() {
let f = parse_local_forward("8080:[2001:db8::1]:80").unwrap();
assert_eq!(f.listen_port, 8080);
assert_eq!(f.remote_host, "2001:db8::1");
assert_eq!(f.remote_port, 80);
}
#[test]
fn local_forward_bracketed_v6_loopback() {
let f = parse_local_forward("8080:[::1]:80").unwrap();
assert_eq!(f.remote_host, "::1");
assert_eq!(f.remote_port, 80);
}
#[test]
fn local_forward_rejects_missing_close_bracket() {
assert!(parse_local_forward("8080:[2001:db8::1:80").is_err());
}
#[test]
fn local_forward_rejects_missing_trailing_port() {
assert!(parse_local_forward("8080:[2001:db8::1]").is_err());
assert!(parse_local_forward("8080:[2001:db8::1]junk").is_err());
}
#[test]
fn local_forward_rejects_too_few_fields() {
assert!(parse_local_forward("only-one-field").is_err());
assert!(parse_local_forward("80:hostonly").is_err());
}
#[test]
fn remote_forward_plain() {
let f = parse_remote_forward("9090:127.0.0.1:22").unwrap();
assert_eq!(f.remote_port, 9090);
assert_eq!(f.local_host, "127.0.0.1");
assert_eq!(f.local_port, 22);
}
#[test]
fn remote_forward_bracketed_v6() {
let f = parse_remote_forward("9090:[2001:db8::2]:22").unwrap();
assert_eq!(f.remote_port, 9090);
assert_eq!(f.local_host, "2001:db8::2");
assert_eq!(f.local_port, 22);
}
}