Add native local port forwarding
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
DuProcess
2026-06-13 15:36:10 -04:00
parent f324a7627f
commit 6aa81d0ce3
4 changed files with 844 additions and 55 deletions
+394 -47
View File
@@ -9,27 +9,31 @@ use dosh::auth::{
use dosh::config::{expand_tilde, load_client_config, load_hosts_config, load_server_config};
use dosh::crypto;
use dosh::native::{
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,
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,
NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind,
Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope,
TicketAttachOkEnvelope,
Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose, StreamData, StreamOpen, StreamOpenReject,
TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope,
};
use dosh::ssh_agent;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fs;
use std::io::{Read, Write};
use std::net::{SocketAddr, 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::net::UdpSocket;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, UdpSocket};
use tokio::sync::mpsc;
#[derive(Debug, Parser)]
@@ -57,6 +61,10 @@ struct Args {
replace: bool,
#[arg(long)]
attach_only: bool,
#[arg(short = 'L', long = "local-forward")]
local_forward: Vec<String>,
#[arg(short = 'N', long)]
forward_only: bool,
#[arg(long)]
predict: bool,
#[arg(long)]
@@ -92,6 +100,30 @@ struct CachedCredential {
last_rendered_seq: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LocalForward {
bind_host: String,
listen_port: u16,
target_host: String,
target_port: u16,
}
enum ForwardEvent {
Open {
stream_id: u64,
target_host: String,
target_port: u16,
writer: tokio::net::tcp::OwnedWriteHalf,
},
Data {
stream_id: u64,
bytes: Vec<u8>,
},
Close {
stream_id: u64,
},
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
let args = Args::parse();
@@ -118,6 +150,8 @@ async fn main() -> Result<()> {
.as_deref()
.map(startup_command_from_string)
});
let local_forwards = parse_local_forwards(&args.local_forward)?;
let forwarding_requested = !local_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.view_only || config.view_only {
@@ -147,6 +181,19 @@ async fn main() -> Result<()> {
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) = size().unwrap_or((80, 24));
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"));
}
let allow_cache = !args.no_cache && !forwarding_requested;
let started = Instant::now();
let target_udp_host = if let Some(host) = args
@@ -169,7 +216,7 @@ async fn main() -> Result<()> {
})
};
let credential = if !args.no_cache {
let credential = if allow_cache {
load_cache(&cache_path).ok()
} else {
None
@@ -181,7 +228,7 @@ async fn main() -> Result<()> {
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 && !args.no_cache {
if config.cache_attach_tickets && allow_cache {
match try_ticket_attach(&socket, &cached, cols, rows).await {
Ok((frame, cred)) => {
log_timing(args.verbose, "udp_ticket_attach_ready", started.elapsed());
@@ -198,6 +245,8 @@ async fn main() -> Result<()> {
predict,
startup_command,
config.reconnect_timeout_secs,
Vec::new(),
false,
)
.await;
}
@@ -215,7 +264,7 @@ async fn main() -> Result<()> {
match try_resume_fresh_process(&socket, &cached, cols, rows).await {
Ok((frame, cred)) => {
log_timing(args.verbose, "udp_resume_ready", started.elapsed());
if !args.no_cache {
if allow_cache {
save_cache(&cache_path, &cred)?;
}
if args.attach_only {
@@ -230,6 +279,8 @@ async fn main() -> Result<()> {
predict,
startup_command,
config.reconnect_timeout_secs,
Vec::new(),
false,
)
.await;
}
@@ -244,10 +295,6 @@ async fn main() -> Result<()> {
}
}
let auth_preference = args
.auth
.clone()
.unwrap_or_else(|| config.auth_preference.clone());
if !args.local_auth && auth_allows(&auth_preference, "native") {
let native_start = Instant::now();
match try_native_auth(
@@ -263,12 +310,13 @@ async fn main() -> Result<()> {
&mode,
cols,
rows,
forwarding_requests(&local_forwards),
)
.await
{
Ok((frame, cred)) => {
log_timing(args.verbose, "native_auth", native_start.elapsed());
if !args.no_cache {
if allow_cache {
save_cache(&cache_path, &cred)?;
}
log_timing(args.verbose, "terminal_ready", started.elapsed());
@@ -284,10 +332,12 @@ async fn main() -> Result<()> {
predict,
startup_command,
config.reconnect_timeout_secs,
local_forwards,
args.forward_only,
)
.await;
}
Err(err) if auth_allows(&auth_preference, "ssh") => {
Err(err) if !forwarding_requested && auth_allows(&auth_preference, "ssh") => {
log_debug(
args.verbose,
2,
@@ -328,7 +378,7 @@ async fn main() -> Result<()> {
let (ok, mut cred) = bootstrap_attach(&socket, &server, &bootstrap, cols, rows).await?;
cred.last_rendered_seq = ok.initial_seq;
if !args.no_cache {
if allow_cache {
save_cache(&cache_path, &cred)?;
}
log_timing(args.verbose, "terminal_ready", started.elapsed());
@@ -351,6 +401,8 @@ async fn main() -> Result<()> {
predict,
startup_command,
config.reconnect_timeout_secs,
Vec::new(),
false,
)
.await
}
@@ -460,6 +512,61 @@ fn startup_command_from_string(command: &str) -> Vec<u8> {
command.into_bytes()
}
fn parse_local_forwards(raw: &[String]) -> Result<Vec<LocalForward>> {
raw.iter().map(|value| parse_local_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 forwarding_requests(local_forwards: &[LocalForward]) -> Vec<ForwardingRequest> {
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()
}
fn run_sessions_command(
server: &str,
ssh_port: Option<u16>,
@@ -822,6 +929,7 @@ async fn try_native_auth(
mode: &str,
cols: u16,
rows: u16,
requested_forwardings: Vec<ForwardingRequest>,
) -> Result<(Frame, CachedCredential)> {
let addr = resolve_addr(udp_host, udp_port)?;
let ssh_config = ssh_config(server, ssh_port).unwrap_or_default();
@@ -907,6 +1015,7 @@ async fn try_native_auth(
&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]);
@@ -966,17 +1075,22 @@ fn sign_native_user_auth(
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 {
match ssh_agent::sign_user_auth_with_agent(hello, server_hello, Vec::new()) {
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:#}")),
}
}
match load_first_native_identity(config, cli_identity, ssh_config) {
Ok(identity) => sign_user_auth(&identity, hello, server_hello, Vec::new()),
Ok(identity) => sign_user_auth(&identity, hello, server_hello, requested_forwardings),
Err(err) => {
errors.push(format!("identity files: {err:#}"));
Err(anyhow!(
@@ -1281,8 +1395,14 @@ async fn run_terminal(
predict: bool,
startup_command: Option<Vec<u8>>,
reconnect_timeout_secs: u64,
local_forwards: Vec<LocalForward>,
forward_only: bool,
) -> Result<()> {
let _raw = RawMode::enter()?;
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();
@@ -1290,10 +1410,21 @@ async fn run_terminal(
let mut resize_tick = tokio::time::interval(Duration::from_millis(250));
let mut last_size = size().unwrap_or((80, 24));
let mut frame_buffer = FrameBuffer::default();
let mut predictor = Predictor::new(predict && cred.mode != "view-only");
let mut predictor = Predictor::new(predict && cred.mode != "view-only" && !forward_only);
let (forward_tx, mut forward_rx) = mpsc::unbounded_channel::<ForwardEvent>();
let _forward_keepalive = if local_forwards.is_empty() {
Some(forward_tx.clone())
} else {
None
};
let stream_ids = Arc::new(AtomicU64::new(1));
start_local_forwards(&local_forwards, forward_tx, stream_ids).await?;
let mut stream_writers: HashMap<u64, tokio::net::tcp::OwnedWriteHalf> = HashMap::new();
if let Some(frame) = first_frame {
render_frame(&frame)?;
predictor.observe_output(&frame.bytes);
if !forward_only {
render_frame(&frame)?;
predictor.observe_output(&frame.bytes);
}
if frame.closed {
return Ok(());
}
@@ -1302,26 +1433,32 @@ async fn run_terminal(
}
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>>();
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());
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,
}
Err(_) => break,
}
}
})?;
})?;
None
};
let mut recv_buf = vec![0u8; 65535];
loop {
@@ -1371,8 +1508,10 @@ async fn run_terminal(
)
.await?
{
render_frame(&frame)?;
predictor.observe_output(&frame.bytes);
if !forward_only {
render_frame(&frame)?;
predictor.observe_output(&frame.bytes);
}
last_packet_at = Instant::now();
}
continue;
@@ -1384,8 +1523,10 @@ async fn run_terminal(
let frames = frame_buffer.accept(frame, &mut cred.last_rendered_seq);
for frame in frames {
predictor.clear_pending()?;
render_frame(&frame)?;
predictor.observe_output(&frame.bytes);
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(());
@@ -1409,17 +1550,78 @@ async fn run_terminal(
)
.await?
{
render_frame(&frame)?;
predictor.observe_output(&frame.bytes);
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::StreamOpenOk => {}
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();
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();
if let Some(writer) = stream_writers.get_mut(&data.stream_id) {
let _ = writer.write_all(&data.bytes).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();
stream_writers.remove(&close.stream_id);
}
_ => {}
}
}
forward_event = forward_rx.recv() => {
match forward_event {
Some(ForwardEvent::Open { stream_id, target_host, target_port, writer }) => {
stream_writers.insert(stream_id, writer);
send_stream_open(
&socket,
addr,
&cred,
&mut send_seq,
stream_id,
target_host,
target_port,
)
.await?;
}
Some(ForwardEvent::Data { stream_id, bytes }) => {
send_stream_data(&socket, addr, &cred, &mut send_seq, stream_id, bytes).await?;
}
Some(ForwardEvent::Close { 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)) {
@@ -1433,8 +1635,10 @@ async fn run_terminal(
)
.await?
{
render_frame(&frame)?;
predictor.observe_output(&frame.bytes);
if !forward_only {
render_frame(&frame)?;
predictor.observe_output(&frame.bytes);
}
last_packet_at = Instant::now();
}
} else if stale >= Duration::from_secs(2) {
@@ -1457,6 +1661,60 @@ async fn run_terminal(
Ok(())
}
async fn start_local_forwards(
local_forwards: &[LocalForward],
forward_tx: mpsc::UnboundedSender<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,
});
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(),
})
.is_err()
{
return;
}
}
Err(_) => break,
}
}
let _ = forward_tx.send(ForwardEvent::Close { stream_id });
});
}
});
}
Ok(())
}
async fn reconnect(
socket: &UdpSocket,
cred: &mut CachedCredential,
@@ -1520,6 +1778,68 @@ async fn send_input(
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_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 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,
@@ -1763,9 +2083,10 @@ const TERMINAL_CLEANUP: &[u8] = concat!(
#[cfg(test)]
mod tests {
use super::{
FrameBuffer, Predictor, SshConfig, auth_allows, load_first_native_identity_with_prompt,
parse_ssh_config, raw_contains_host_table, ssh_destination_host, ssh_username,
startup_command, toml_bare_key_or_quoted,
FrameBuffer, LocalForward, Predictor, SshConfig, auth_allows,
load_first_native_identity_with_prompt, parse_local_forward, parse_ssh_config,
raw_contains_host_table, ssh_destination_host, ssh_username, startup_command,
toml_bare_key_or_quoted,
};
use dosh::config::ClientConfig;
use dosh::protocol::Frame;
@@ -1976,4 +2297,30 @@ mod tests {
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,
}
);
}
}