Add Dosh stdio proxy for SSH streams
This commit is contained in:
+389
-2
@@ -32,6 +32,10 @@ use dosh::protocol::{
|
||||
TicketAttachEnvelope, TicketAttachOkEnvelope,
|
||||
};
|
||||
use dosh::ssh_agent;
|
||||
use dosh::transport::{
|
||||
DoshTransport, SessionEvent, SessionRole, SessionTransportConfig, TransportConfig,
|
||||
TransportEvent,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
@@ -258,6 +262,12 @@ async fn main() -> Result<()> {
|
||||
if args.server.as_deref() == Some("selftest") {
|
||||
return run_selftest_command(&config, &args).await;
|
||||
}
|
||||
if args.server.as_deref() == Some("proxy-stdio") {
|
||||
return run_proxy_stdio_command(&config, &args).await;
|
||||
}
|
||||
if args.server.as_deref() == Some("vscode") {
|
||||
return run_vscode_command(&config, &args);
|
||||
}
|
||||
if matches!(args.server.as_deref(), Some("recover" | "repair")) {
|
||||
return run_recover_command(&config, &args).await;
|
||||
}
|
||||
@@ -512,6 +522,7 @@ async fn main() -> Result<()> {
|
||||
),
|
||||
resolved_ssh_config.as_ref(),
|
||||
cold_requested_env,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1349,6 +1360,330 @@ struct ExecServiceClient {
|
||||
pending: VecDeque<ExecResponse>,
|
||||
}
|
||||
|
||||
async fn run_proxy_stdio_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> {
|
||||
if args.command.len() != 3 {
|
||||
return Err(anyhow!(
|
||||
"usage: dosh proxy-stdio HOST TARGET_HOST TARGET_PORT"
|
||||
));
|
||||
}
|
||||
anyhow::ensure!(
|
||||
!args.local_auth,
|
||||
"proxy-stdio requires native auth; local auth cannot authenticate SSH ProxyCommand streams"
|
||||
);
|
||||
let requested_server = args.command[0].clone();
|
||||
let target_host = args.command[1].clone();
|
||||
let target_port = args.command[2]
|
||||
.parse::<u16>()
|
||||
.with_context(|| format!("invalid proxy target port {:?}", args.command[2]))?;
|
||||
anyhow::ensure!(
|
||||
valid_forward_host(&target_host),
|
||||
"invalid proxy target host"
|
||||
);
|
||||
anyhow::ensure!(target_port != 0, "proxy target port cannot be 0");
|
||||
|
||||
let hosts = load_hosts_config(None).unwrap_or_default();
|
||||
let host = hosts
|
||||
.hosts
|
||||
.get(&requested_server)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let raw_server = host.ssh.clone().unwrap_or_else(|| requested_server.clone());
|
||||
let server = ssh_with_user(&raw_server, host.user.as_deref());
|
||||
let ssh_port = args.ssh_port.or(host.ssh_port).or(config.ssh_port);
|
||||
let resolved_ssh_config = ssh_config(&server, ssh_port).unwrap_or_default();
|
||||
let requested_udp_host = args
|
||||
.dosh_host
|
||||
.clone()
|
||||
.or_else(|| host.dosh_host.clone())
|
||||
.or_else(|| config.dosh_host.clone());
|
||||
let target_udp_host =
|
||||
selected_udp_host(requested_udp_host.as_deref(), &server, &resolved_ssh_config)?;
|
||||
let dosh_port = args.dosh_port.or(host.port).unwrap_or(config.dosh_port);
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
let session = args
|
||||
.session
|
||||
.clone()
|
||||
.unwrap_or_else(protocol::generate_implicit_session_name);
|
||||
let requested_forwardings = vec![ForwardingRequest {
|
||||
kind: ForwardingKind::Local,
|
||||
bind_host: None,
|
||||
listen_port: 0,
|
||||
target_host: Some(target_host.clone()),
|
||||
target_port: Some(target_port),
|
||||
}];
|
||||
let (_frame, cred) = try_native_auth(
|
||||
&socket,
|
||||
config,
|
||||
&requested_server,
|
||||
&server,
|
||||
ssh_port,
|
||||
&target_udp_host,
|
||||
dosh_port,
|
||||
args.ssh_key.as_deref(),
|
||||
&session,
|
||||
"forward-only",
|
||||
80,
|
||||
24,
|
||||
requested_forwardings,
|
||||
Some(&resolved_ssh_config),
|
||||
Vec::new(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let peer_addr = resolve_addr(&cred.udp_host, cred.udp_port)?;
|
||||
let mut transport = DoshTransport::new_owned(
|
||||
socket,
|
||||
SessionTransportConfig {
|
||||
role: SessionRole::Client,
|
||||
conn_id: cred.client_id,
|
||||
session_key: cred.session_key,
|
||||
peer_addr,
|
||||
initial_send_seq: 2,
|
||||
initial_ack: cred.last_rendered_seq,
|
||||
stream: TransportConfig::default(),
|
||||
},
|
||||
);
|
||||
let stream_id = transport.open_target(target_host, target_port).await?;
|
||||
proxy_stdio_loop(transport, stream_id).await
|
||||
}
|
||||
|
||||
async fn proxy_stdio_loop(mut transport: DoshTransport, stream_id: u64) -> Result<()> {
|
||||
let (stdin_tx, mut stdin_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
std::thread::Builder::new()
|
||||
.name("dosh-proxy-stdin".to_string())
|
||||
.spawn(move || {
|
||||
let mut stdin = std::io::stdin();
|
||||
let mut buf = [0u8; 16 * 1024];
|
||||
loop {
|
||||
match stdin.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
if stdin_tx.send(buf[..n].to_vec()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
})?;
|
||||
let mut stdout = tokio::io::stdout();
|
||||
let mut maintenance = tokio::time::interval(Duration::from_millis(50));
|
||||
let mut stdin_closed = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
stdin_msg = stdin_rx.recv(), if !stdin_closed => {
|
||||
match stdin_msg {
|
||||
Some(bytes) => transport.send(stream_id, bytes).await?,
|
||||
None => {
|
||||
stdin_closed = true;
|
||||
let _ = transport.close(stream_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
event = transport.recv() => {
|
||||
match event? {
|
||||
SessionEvent::Stream(TransportEvent::OpenOk { stream_id: opened, .. }) if opened == stream_id => {}
|
||||
SessionEvent::Stream(TransportEvent::OpenReject { stream_id: rejected, reason }) if rejected == stream_id => {
|
||||
return Err(anyhow!("Dosh proxy stream rejected: {reason}"));
|
||||
}
|
||||
SessionEvent::Stream(TransportEvent::Data(data)) if data.stream_id == stream_id => {
|
||||
for chunk in data.chunks {
|
||||
stdout.write_all(&chunk).await?;
|
||||
}
|
||||
}
|
||||
SessionEvent::Stream(TransportEvent::Close { stream_id: closed }) if closed == stream_id => {
|
||||
stdout.flush().await?;
|
||||
return Ok(());
|
||||
}
|
||||
SessionEvent::Stream(_) | SessionEvent::Ping | SessionEvent::Pong | SessionEvent::Ignored => {}
|
||||
}
|
||||
}
|
||||
_ = maintenance.tick() => {
|
||||
transport.maintenance().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_vscode_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> {
|
||||
let mut tokens = args.command.iter();
|
||||
let first = tokens
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("usage: dosh vscode [setup|open] HOST [REMOTE_PATH]"))?;
|
||||
let (launch, host, remote_path) = match first.as_str() {
|
||||
"setup" => {
|
||||
let host = tokens
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("usage: dosh vscode setup HOST"))?;
|
||||
(false, host.as_str(), None)
|
||||
}
|
||||
"open" => {
|
||||
let host = tokens
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("usage: dosh vscode open HOST [REMOTE_PATH]"))?;
|
||||
(true, host.as_str(), tokens.next().map(String::as_str))
|
||||
}
|
||||
host => (true, host, tokens.next().map(String::as_str)),
|
||||
};
|
||||
if tokens.next().is_some() {
|
||||
return Err(anyhow!("dosh vscode accepts at most one remote path"));
|
||||
}
|
||||
let alias = ensure_vscode_ssh_host(config, args, host)?;
|
||||
println!("[ok] VS Code SSH host: {alias}");
|
||||
println!("[ok] Dosh proxy: dosh proxy-stdio {host} 127.0.0.1 22");
|
||||
if launch {
|
||||
launch_vscode_remote(&alias, remote_path)?;
|
||||
} else {
|
||||
println!("Open in VS Code Remote-SSH as: {alias}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_vscode_ssh_host(
|
||||
config: &dosh::config::ClientConfig,
|
||||
args: &Args,
|
||||
requested: &str,
|
||||
) -> Result<String> {
|
||||
let hosts = load_hosts_config(None).unwrap_or_default();
|
||||
let host_config = hosts.hosts.get(requested).cloned().unwrap_or_default();
|
||||
let raw_server = host_config
|
||||
.ssh
|
||||
.clone()
|
||||
.unwrap_or_else(|| requested.to_string());
|
||||
let server = ssh_with_user(&raw_server, host_config.user.as_deref());
|
||||
let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port);
|
||||
let ssh_config = ssh_config(&server, ssh_port).unwrap_or_default();
|
||||
let user = host_config
|
||||
.user
|
||||
.or(ssh_username(&server))
|
||||
.or(ssh_config.user);
|
||||
let alias = format!("dosh-{}", vscode_safe_alias(requested));
|
||||
let exe = std::env::current_exe()
|
||||
.ok()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "dosh".to_string());
|
||||
let mut proxy_command = shell_word(&exe);
|
||||
if let Some(port) = args.dosh_port.or(host_config.port) {
|
||||
proxy_command.push_str(&format!(" --dosh-port {port}"));
|
||||
}
|
||||
proxy_command.push_str(&format!(" proxy-stdio {} %h %p", shell_word(requested)));
|
||||
let ssh_dir = expand_tilde("~/.ssh");
|
||||
fs::create_dir_all(&ssh_dir).with_context(|| format!("create {}", ssh_dir.display()))?;
|
||||
let include_path = ssh_dir.join("config.dosh");
|
||||
let main_config = ssh_dir.join("config");
|
||||
ensure_ssh_include(&main_config, "config.dosh")?;
|
||||
let mut block = String::new();
|
||||
block.push_str(&format!("# BEGIN DOSH {alias}\n"));
|
||||
block.push_str(&format!("Host {alias}\n"));
|
||||
block.push_str(" HostName 127.0.0.1\n");
|
||||
block.push_str(" Port 22\n");
|
||||
block.push_str(&format!(" HostKeyAlias {requested}\n"));
|
||||
if let Some(user) = user {
|
||||
block.push_str(&format!(" User {user}\n"));
|
||||
}
|
||||
block.push_str(" ClearAllForwardings yes\n");
|
||||
block.push_str(" ServerAliveInterval 15\n");
|
||||
block.push_str(" ServerAliveCountMax 3\n");
|
||||
block.push_str(&format!(" ProxyCommand {proxy_command}\n"));
|
||||
block.push_str(&format!("# END DOSH {alias}\n"));
|
||||
upsert_managed_block(&include_path, &alias, &block)?;
|
||||
Ok(alias)
|
||||
}
|
||||
|
||||
fn ensure_ssh_include(path: &Path, include: &str) -> Result<()> {
|
||||
let raw = fs::read_to_string(path).unwrap_or_default();
|
||||
if raw.lines().any(|line| {
|
||||
line.trim()
|
||||
.eq_ignore_ascii_case(&format!("include {include}"))
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut next = String::new();
|
||||
next.push_str(&format!("Include {include}\n"));
|
||||
if !raw.is_empty() {
|
||||
next.push('\n');
|
||||
next.push_str(&raw);
|
||||
}
|
||||
fs::write(path, next).with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
|
||||
fn upsert_managed_block(path: &Path, alias: &str, block: &str) -> Result<()> {
|
||||
let raw = fs::read_to_string(path).unwrap_or_default();
|
||||
let begin = format!("# BEGIN DOSH {alias}");
|
||||
let end = format!("# END DOSH {alias}");
|
||||
let mut out = String::new();
|
||||
let mut skipping = false;
|
||||
let mut replaced = false;
|
||||
for line in raw.lines() {
|
||||
if line.trim() == begin {
|
||||
if !out.ends_with('\n') && !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(block);
|
||||
replaced = true;
|
||||
skipping = true;
|
||||
continue;
|
||||
}
|
||||
if skipping {
|
||||
if line.trim() == end {
|
||||
skipping = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
if !replaced {
|
||||
if !out.trim().is_empty() && !out.ends_with("\n\n") {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(block);
|
||||
}
|
||||
fs::write(path, out).with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
|
||||
fn vscode_safe_alias(value: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' {
|
||||
out.push(byte as char);
|
||||
} else {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
let out = out.trim_matches('-').to_string();
|
||||
if out.is_empty() {
|
||||
"host".to_string()
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_vscode_remote(alias: &str, remote_path: Option<&str>) -> Result<()> {
|
||||
let remote = format!("ssh-remote+{alias}");
|
||||
let mut command = Command::new("code");
|
||||
command.arg("--remote").arg(&remote);
|
||||
if let Some(path) = remote_path {
|
||||
command.arg(path);
|
||||
}
|
||||
match command.status() {
|
||||
Ok(status) if status.success() => Ok(()),
|
||||
Ok(status) => Err(anyhow!("code exited with status {status}")),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
println!(
|
||||
"Run: code --remote {}{}",
|
||||
shell_word(&remote),
|
||||
remote_path
|
||||
.map(|path| format!(" {}", shell_word(path)))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err).context("launch VS Code"),
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecServiceClient {
|
||||
fn connect(port: u16) -> Result<Self> {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
@@ -3174,6 +3509,7 @@ async fn try_native_auth(
|
||||
requested_forwardings: Vec<ForwardingRequest>,
|
||||
ssh_config_hint: Option<&SshConfig>,
|
||||
requested_env: Vec<EnvVar>,
|
||||
allow_passphrase_prompt: bool,
|
||||
) -> Result<(Frame, CachedCredential)> {
|
||||
let addr = resolve_addr(udp_host, udp_port)?;
|
||||
let owned_ssh_config;
|
||||
@@ -3267,6 +3603,7 @@ async fn try_native_auth(
|
||||
&hello,
|
||||
&server_hello.hello,
|
||||
requested_forwardings,
|
||||
allow_passphrase_prompt,
|
||||
)?;
|
||||
let mut pending_id = [0u8; 16];
|
||||
pending_id.copy_from_slice(&server_hello.hello.auth_challenge[..16]);
|
||||
@@ -3424,6 +3761,7 @@ async fn try_native_auth_check(
|
||||
&hello,
|
||||
&server_hello.hello,
|
||||
Vec::new(),
|
||||
true,
|
||||
)?;
|
||||
let mut pending_id = [0u8; 16];
|
||||
pending_id.copy_from_slice(&server_hello.hello.auth_challenge[..16]);
|
||||
@@ -3462,6 +3800,7 @@ fn sign_native_user_auth(
|
||||
hello: &dosh::native::NativeClientHello,
|
||||
server_hello: &dosh::native::NativeServerHello,
|
||||
requested_forwardings: Vec<ForwardingRequest>,
|
||||
allow_passphrase_prompt: bool,
|
||||
) -> Result<dosh::native::NativeUserAuth> {
|
||||
let mut errors = Vec::new();
|
||||
if config.use_ssh_agent && !ssh_config.identities_only {
|
||||
@@ -3477,7 +3816,12 @@ fn sign_native_user_auth(
|
||||
errors.push("ssh-agent: skipped because SSH config sets IdentitiesOnly=yes".to_string());
|
||||
}
|
||||
|
||||
match load_first_native_identity(config, cli_identity, ssh_config) {
|
||||
let identity = if allow_passphrase_prompt {
|
||||
load_first_native_identity(config, cli_identity, ssh_config)
|
||||
} else {
|
||||
load_first_native_identity_noninteractive(config, cli_identity, ssh_config)
|
||||
};
|
||||
match identity {
|
||||
Ok(identity) => {
|
||||
sign_user_auth_with_private_key(&identity, hello, server_hello, requested_forwardings)
|
||||
}
|
||||
@@ -3504,6 +3848,19 @@ fn load_first_native_identity(
|
||||
)
|
||||
}
|
||||
|
||||
fn load_first_native_identity_noninteractive(
|
||||
config: &dosh::config::ClientConfig,
|
||||
cli_identity: Option<&Path>,
|
||||
ssh_config: &SshConfig,
|
||||
) -> Result<ssh_key::PrivateKey> {
|
||||
load_first_native_identity_with_prompt(config, cli_identity, ssh_config, |path| {
|
||||
Err(anyhow!(
|
||||
"{} is encrypted; unlock it in ssh-agent or use an unencrypted key for proxy-stdio",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn load_first_native_identity_with_prompt<F>(
|
||||
config: &dosh::config::ClientConfig,
|
||||
cli_identity: Option<&Path>,
|
||||
@@ -6629,7 +6986,8 @@ mod tests {
|
||||
server_version_mismatch, should_hold_during_startup_gate, should_hold_post_submit_input,
|
||||
split_after_command_submit, ssh_destination_host, ssh_username, ssh_with_user,
|
||||
startup_command, status_ssh_target, strip_stale_mouse_reports, toml_bare_key_or_quoted,
|
||||
update_check_requested, update_version_status, valid_forward_host,
|
||||
update_check_requested, update_version_status, upsert_managed_block, valid_forward_host,
|
||||
vscode_safe_alias,
|
||||
};
|
||||
use dosh::config::{ClientConfig, CommandExtension, HostConfig};
|
||||
use dosh::native::EnvVar;
|
||||
@@ -6674,6 +7032,35 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vscode_alias_sanitizes_host_names() {
|
||||
assert_eq!(vscode_safe_alias("palav"), "palav");
|
||||
assert_eq!(vscode_safe_alias("user@example.com"), "user-example-com");
|
||||
assert_eq!(vscode_safe_alias(":///"), "host");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_ssh_block_is_replaced_not_duplicated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.dosh");
|
||||
upsert_managed_block(
|
||||
&path,
|
||||
"dosh-palav",
|
||||
"# BEGIN DOSH dosh-palav\nHost dosh-palav\n HostName 127.0.0.1\n# END DOSH dosh-palav\n",
|
||||
)
|
||||
.unwrap();
|
||||
upsert_managed_block(
|
||||
&path,
|
||||
"dosh-palav",
|
||||
"# BEGIN DOSH dosh-palav\nHost dosh-palav\n HostName localhost\n# END DOSH dosh-palav\n",
|
||||
)
|
||||
.unwrap();
|
||||
let raw = fs::read_to_string(path).unwrap();
|
||||
assert_eq!(raw.matches("# BEGIN DOSH dosh-palav").count(), 1);
|
||||
assert!(raw.contains("HostName localhost"));
|
||||
assert!(!raw.contains("HostName 127.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ssh_config_hostname() {
|
||||
let raw = "user alice\nhostname server.example.com\nport 22\n";
|
||||
|
||||
Reference in New Issue
Block a user