diff --git a/Cargo.lock b/Cargo.lock index 530c0c96..23d15e83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4676,7 +4676,6 @@ dependencies = [ "keyring-core", "parking_lot", "russh", - "russh-config", "russh-sftp", "serde", "serde_json", @@ -5426,10 +5425,7 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ - "bitflags 2.13.2", "libc", - "plain", - "redox_syscall 0.9.5", ] [[package]] @@ -6604,7 +6600,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -6774,12 +6770,6 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "plist" version = "1.10.2" @@ -7237,15 +7227,6 @@ dependencies = [ "bitflags 2.13.2", ] -[[package]] -name = "redox_syscall" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" -dependencies = [ - "bitflags 2.13.2", -] - [[package]] name = "redox_users" version = "0.5.3" @@ -7530,20 +7511,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "russh-config" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993346101d4741a59c82bafc6b645c52ce3e7b0820f13e4b5d48cdb591e3d955" -dependencies = [ - "futures", - "globset", - "log", - "thiserror 2.0.21", - "tokio", - "whoami 1.6.1", -] - [[package]] name = "russh-cryptovec" version = "0.62.0" @@ -8361,7 +8328,7 @@ dependencies = [ "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", "raw-window-handle", - "redox_syscall 0.5.18", + "redox_syscall", "tracing", "wasm-bindgen", "web-sys", @@ -8625,7 +8592,7 @@ dependencies = [ "stringprep", "thiserror 2.0.21", "tracing", - "whoami 2.1.3", + "whoami", ] [[package]] @@ -10567,12 +10534,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -11147,17 +11108,6 @@ dependencies = [ "libc", ] -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", - "web-sys", -] - [[package]] name = "whoami" version = "2.1.3" diff --git a/crates/ide-ssh/Cargo.toml b/crates/ide-ssh/Cargo.toml index 3d99cdf0..4d1f6147 100644 --- a/crates/ide-ssh/Cargo.toml +++ b/crates/ide-ssh/Cargo.toml @@ -18,7 +18,6 @@ uuid.workspace = true keyring.workspace = true keyring-core.workspace = true russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] } -russh-config = "0.58.0" russh-sftp = "2.4" flate2 = "1" diff --git a/crates/ide-ssh/src/session.rs b/crates/ide-ssh/src/session.rs index c1e959d4..2d6f4825 100644 --- a/crates/ide-ssh/src/session.rs +++ b/crates/ide-ssh/src/session.rs @@ -1,11 +1,13 @@ //! An authenticated SSH connection (russh), optionally through `ProxyJump` -//! hosts, and what runs over it: commands, processes, SFTP and TCP channels. +//! hosts or a `ProxyCommand`, and what runs over it: commands, processes, SFTP +//! and TCP channels. //! //! Host keys are checked against `known_hosts`; an unknown key fails the -//! connection with [`ConnectError::UnknownHostKey`] so the UI can ask the user, -//! and a changed key is always refused. +//! connection with [`ConnectError::UnknownHostKey`] so the UI can ask the user +//! (or is recorded silently under `StrictHostKeyChecking accept-new`), and a +//! changed key is refused unless host key checking is off. -use crate::target::{Hop, SshFiles, SshTarget, expand, home, resolve_hops}; +use crate::target::{Hop, HostKeyPolicy, SshFiles, SshTarget, expand, home, resolve_hops}; use ide_core::remote_settings::SshAuth; use ide_core::{IdeError, IdeResult, error::ErrorKind}; use parking_lot::Mutex; @@ -27,6 +29,7 @@ pub struct UnknownHostKey { pub host: String, pub port: u16, pub key: PublicKey, + pub known_hosts: Option, } impl UnknownHostKey { @@ -37,6 +40,7 @@ impl UnknownHostKey { /// Records the key in `known_hosts` (blocking). pub fn trust(&self, known_hosts: &Path) -> IdeResult<()> { + let known_hosts = self.known_hosts.as_deref().unwrap_or(known_hosts); if let Some(dir) = known_hosts.parent() { std::fs::create_dir_all(dir)?; } @@ -128,12 +132,18 @@ impl Session { secret: Option<&str>, files: &SshFiles, ) -> Result { - let hops = resolve_hops(target, files.config.as_deref())?; + // Resolving may run `Match exec` commands from the config. + let hops = { + let (target, config) = (target.clone(), files.config.clone()); + tokio::task::spawn_blocking(move || resolve_hops(&target, config.as_deref())) + .await + .map_err(|e| io(format!("cannot read the SSH config: {e}")))?? + }; let mut sessions: Vec> = Vec::new(); for (i, hop) in hops.iter().enumerate() { let last = i == hops.len() - 1; let mut handle = match sessions.last() { - None => connect::(hop, &files.known_hosts, None).await?, + None => connect_first_hop(hop, &files.known_hosts).await?, Some(prev) => { let channel = prev .channel_open_direct_tcpip(hop.host.as_str(), u32::from(hop.port), "127.0.0.1", 0) @@ -366,6 +376,7 @@ pub struct Client { host: String, port: u16, known_hosts: PathBuf, + host_key_policy: HostKeyPolicy, issue: Arc>>, } @@ -373,6 +384,9 @@ impl client::Handler for Client { type Error = russh::Error; async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result { + if self.host_key_policy == HostKeyPolicy::Off { + return Ok(true); + } let key = match key { PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(), PublicKeyOrCertificate::Certificate(cert) => PublicKey::from(cert.public_key().clone()), @@ -380,6 +394,9 @@ impl client::Handler for Client { let issue = match known_hosts::check_known_hosts_path(&self.host, self.port, &key, &self.known_hosts) { Ok(true) => return Ok(true), Err(keys::Error::KeyChanged { line }) => HostKeyIssue::Changed(line), + Ok(false) | Err(_) if self.host_key_policy == HostKeyPolicy::AcceptNew && self.learn(&key) => { + return Ok(true); + } Ok(false) | Err(_) => HostKeyIssue::Unknown(key), }; *self.issue.lock() = Some(issue); @@ -387,6 +404,143 @@ impl client::Handler for Client { } } +impl Client { + fn learn(&self, key: &PublicKey) -> bool { + if let Some(dir) = self.known_hosts.parent() { + let _ = std::fs::create_dir_all(dir); + } + known_hosts::learn_known_hosts_path(&self.host, self.port, key, &self.known_hosts).is_ok() + } +} + +struct ProxyStream { + child: tokio::process::Child, + stdin: Option, + stdout: Option, + stderr: Arc>>, +} + +impl Drop for ProxyStream { + fn drop(&mut self) { + if let Some(pid) = self.child.id() { + ide_core::process::kill_tree_of(pid); + } + } +} + +impl tokio::io::AsyncRead for ProxyStream { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + match self.stdout.as_mut() { + Some(stdout) => std::pin::Pin::new(stdout).poll_read(cx, buf), + None => std::task::Poll::Ready(Ok(())), + } + } +} + +impl tokio::io::AsyncWrite for ProxyStream { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + match self.stdin.as_mut() { + Some(stdin) => std::pin::Pin::new(stdin).poll_write(cx, buf), + None => std::task::Poll::Ready(Ok(0)), + } + } + + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match self.stdin.as_mut() { + Some(stdin) => std::pin::Pin::new(stdin).poll_flush(cx), + None => std::task::Poll::Ready(Ok(())), + } + } + + fn poll_shutdown( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + if let Some(stdin) = self.stdin.as_mut() + && let std::task::Poll::Pending = std::pin::Pin::new(stdin).poll_shutdown(cx) + { + return std::task::Poll::Pending; + } + self.stdin = None; + std::task::Poll::Ready(Ok(())) + } +} + +fn spawn_proxy_command(command: &str) -> Result { + #[cfg(unix)] + let mut cmd = { + let mut c = tokio::process::Command::new("/bin/sh"); + c.arg("-c").arg(command); + c + }; + #[cfg(windows)] + let mut cmd = { + let mut c = tokio::process::Command::new("cmd"); + c.arg("/C").arg(command); + c + }; + ide_core::process::prepare(cmd.as_std_mut()); + ide_core::process::own_group(cmd.as_std_mut()); + cmd.stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let mut child = cmd.spawn()?; + let stdin = child.stdin.take(); + let stdout = child.stdout.take(); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + if let Some(mut stderr) = child.stderr.take() { + let sink = stderr_buf.clone(); + tokio::spawn(async move { + use tokio::io::AsyncReadExt; + let mut chunk = [0u8; 1024]; + while let Ok(n) = stderr.read(&mut chunk).await { + if n == 0 { + break; + } + let mut g = sink.lock(); + if g.len() < 4096 { + let take = (4096 - g.len()).min(n); + g.extend_from_slice(&chunk[..take]); + } + } + }); + } + Ok(ProxyStream { child, stdin, stdout, stderr: stderr_buf }) +} + +async fn connect_first_hop(hop: &Hop, known_hosts: &Path) -> Result, ConnectError> { + if let Some(command) = hop.proxy_command.as_deref() { + let stream = spawn_proxy_command(command).map_err(|e| { + ConnectError::Other(io(format!("cannot start ProxyCommand for {}:{}: {e}", hop.host, hop.port))) + })?; + let stderr = stream.stderr.clone(); + connect(hop, known_hosts, Some(stream)).await.map_err(|e| match e { + ConnectError::Other(mut err) => { + let detail = String::from_utf8_lossy(&stderr.lock()).trim().to_owned(); + if !detail.is_empty() { + err.message = format!("{}: {detail}", err.message); + } + ConnectError::Other(err) + } + other => other, + }) + } else { + connect::(hop, known_hosts, None).await + } +} + async fn connect(hop: &Hop, known_hosts: &Path, stream: Option) -> Result, ConnectError> where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, @@ -399,16 +553,25 @@ where ..Default::default() }); let issue = Arc::new(Mutex::new(None)); - let handler = - Client { host: hop.host.clone(), port: hop.port, known_hosts: known_hosts.to_owned(), issue: issue.clone() }; + let effective_known_hosts = hop.known_hosts.as_deref().unwrap_or(known_hosts).to_owned(); + let handler = Client { + host: hop.host.clone(), + port: hop.port, + known_hosts: effective_known_hosts, + host_key_policy: hop.host_key_policy, + issue: issue.clone(), + }; let result = match stream { None => client::connect(config, (hop.host.as_str(), hop.port), handler).await, Some(s) => client::connect_stream(config, s, handler).await, }; result.map_err(|e| match issue.lock().take() { - Some(HostKeyIssue::Unknown(key)) => { - ConnectError::UnknownHostKey(Box::new(UnknownHostKey { host: hop.host.clone(), port: hop.port, key })) - } + Some(HostKeyIssue::Unknown(key)) => ConnectError::UnknownHostKey(Box::new(UnknownHostKey { + host: hop.host.clone(), + port: hop.port, + key, + known_hosts: hop.known_hosts.clone(), + })), Some(HostKeyIssue::Changed(line)) => ConnectError::Other(IdeError::new( ErrorKind::PermissionDenied, format!( @@ -487,6 +650,9 @@ async fn authenticate( if with_key(handle, user, key).await? { Ok(()) } else { Err(failed()) } } SshAuth::OpenSsh => { + if handle.authenticate_none(user).await.map(|r| r.success()).unwrap_or(false) { + return Ok(()); + } if with_agent(handle, user).await { return Ok(()); } diff --git a/crates/ide-ssh/src/target.rs b/crates/ide-ssh/src/target.rs index 18ef6721..bf6bdde9 100644 --- a/crates/ide-ssh/src/target.rs +++ b/crates/ide-ssh/src/target.rs @@ -4,6 +4,9 @@ use ide_core::remote_settings::{SshAuth, SshConfiguration}; use ide_core::{IdeError, IdeResult}; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +const MATCH_EXEC_TIMEOUT: Duration = Duration::from_secs(10); /// An SSH host and how to authenticate to it. #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -44,6 +47,20 @@ pub(crate) struct Hop { pub port: u16, pub user: String, pub identity_files: Vec, + pub proxy_command: Option, + pub host_key_policy: HostKeyPolicy, + pub known_hosts: Option, +} + +/// `StrictHostKeyChecking`: what happens to a host key missing from `known_hosts`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HostKeyPolicy { + /// The user decides on an unknown key; a changed key is refused (`yes`, `ask`). + Ask, + /// An unknown key is recorded without asking; a changed key is refused (`accept-new`). + AcceptNew, + /// Every key is accepted (`no`, `off`, or no `known_hosts` file to check against). + Off, } impl Hop { @@ -68,14 +85,408 @@ fn local_user() -> String { std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default() } +#[derive(Default)] +struct RawConfig { + hostname: Option, + user: Option, + port: Option, + identity_files: Vec, + proxy_jump: Option, + proxy_command: Option, + host_key_policy: Option, + user_known_hosts_file: Option, +} + +fn strip_quotes(s: &str) -> &str { + let s = s.trim(); + if s.len() >= 2 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\''))) { + &s[1..s.len() - 1] + } else { + s + } +} + +/// Splits an `ssh_config` line into `(keyword, rest)`, supporting both whitespace and `=` separators. +fn split_directive(line: &str) -> Option<(&str, &str)> { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let end = line.find(|c: char| c.is_ascii_whitespace() || c == '=')?; + let key = &line[..end]; + let rest = line[end..].trim_start(); + let rest = rest.strip_prefix('=').map_or(rest, str::trim_start); + Some((key, rest)) +} + +/// Splits whitespace-delimited arguments while respecting single and double quotes. +fn split_args(input: &str) -> Vec { + let mut args = Vec::new(); + let mut cur = String::new(); + let mut quote: Option = None; + let mut in_token = false; + for ch in input.chars() { + match quote { + Some(q) if ch == q => quote = None, + Some(_) => cur.push(ch), + None if ch == '"' || ch == '\'' => { + quote = Some(ch); + in_token = true; + } + None if ch.is_ascii_whitespace() => { + if in_token { + args.push(std::mem::take(&mut cur)); + in_token = false; + } + } + None => { + cur.push(ch); + in_token = true; + } + } + } + if in_token { + args.push(cur); + } + args +} + +/// Matches `text` against a glob `pattern` with `*` and `?` wildcards. +fn glob_match(pattern: &str, text: &str) -> bool { + let p: Vec = pattern.chars().collect(); + let t: Vec = text.chars().collect(); + let (mut pi, mut ti) = (0, 0); + let (mut star_pi, mut star_ti) = (None, 0); + while ti < t.len() { + if pi < p.len() && (p[pi] == '?' || p[pi].eq_ignore_ascii_case(&t[ti])) { + pi += 1; + ti += 1; + } else if pi < p.len() && p[pi] == '*' { + star_pi = Some(pi); + pi += 1; + star_ti = ti; + } else if let Some(sp) = star_pi { + pi = sp + 1; + star_ti += 1; + ti = star_ti; + } else { + return false; + } + } + while pi < p.len() && p[pi] == '*' { + pi += 1; + } + pi == p.len() +} + +/// OpenSSH pattern-list matching: any matching `!pattern` rejects, else any matching pattern accepts. +fn pattern_list_matches<'a>(patterns: impl IntoIterator, candidate: &str) -> bool { + let mut matched = false; + for raw in patterns { + if raw.is_empty() { + continue; + } + if let Some(neg) = raw.strip_prefix('!') { + if glob_match(neg, candidate) { + return false; + } + } else if glob_match(raw, candidate) { + matched = true; + } + } + matched +} + +/// Expands OpenSSH percent tokens (`%h`, `%H`, `%n`, `%p`, `%r`, `%u`, `%d`, `%%`). +fn expand_tokens(template: &str, alias: &str, host: &str, port: u16, remote_user: &str, local_user: &str) -> String { + let mut out = String::with_capacity(template.len()); + let mut chars = template.chars(); + let home_dir = home().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default(); + while let Some(ch) = chars.next() { + if ch != '%' { + out.push(ch); + continue; + } + match chars.next() { + Some('%') => out.push('%'), + Some('h' | 'H') => out.push_str(host), + Some('n') => out.push_str(alias), + Some('p') => out.push_str(&port.to_string()), + Some('r') => out.push_str(remote_user), + Some('u') => out.push_str(local_user), + Some('d') => out.push_str(&home_dir), + Some(other) => { + out.push('%'); + out.push(other); + } + None => out.push('%'), + } + } + out +} + +/// Runs a `Match exec` command; one that does not finish in time counts as not matching. +fn eval_match_exec(command: &str) -> bool { + #[cfg(unix)] + let mut cmd = { + let mut c = std::process::Command::new("/bin/sh"); + c.arg("-c").arg(command); + c + }; + #[cfg(windows)] + let mut cmd = { + let mut c = std::process::Command::new("cmd"); + c.arg("/C").arg(command); + c + }; + ide_core::process::prepare(&mut cmd); + ide_core::process::own_group(&mut cmd); + cmd.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); + let Ok(mut child) = cmd.spawn() else { return false }; + let deadline = Instant::now() + MATCH_EXEC_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(status)) => return status.success(), + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), + _ => { + ide_core::process::kill_tree(&mut child); + return false; + } + } + } +} + +fn eval_match( + value: &str, + alias: &str, + port_override: Option, + user_override: Option<&str>, + out: &RawConfig, +) -> bool { + let tokens = split_args(value); + if tokens.is_empty() { + return false; + } + let local = local_user(); + let host = out.hostname.as_deref().unwrap_or(alias); + let port = port_override.or(out.port).unwrap_or(22); + let user = user_override.map(str::trim).filter(|s| !s.is_empty()).or(out.user.as_deref()).unwrap_or(&local); + + let mut i = 0; + while i < tokens.len() { + let tok = tokens[i].as_str(); + let (raw_key, inline_val) = match tok.split_once('=') { + Some((k, v)) => (k, Some(v)), + None => (tok, None), + }; + let (negated, key) = match raw_key.strip_prefix('!') { + Some(k) => (true, k.to_ascii_lowercase()), + None => (false, raw_key.to_ascii_lowercase()), + }; + match key.as_str() { + "all" | "canonical" | "final" => { + if negated { + return false; + } + i += 1; + } + "host" | "originalhost" | "user" | "localuser" | "exec" => { + let arg = match inline_val { + Some(v) => { + i += 1; + v + } + None => { + let Some(next) = tokens.get(i + 1) else { return false }; + i += 2; + next.as_str() + } + }; + let ok = match key.as_str() { + "host" => pattern_list_matches(arg.split(','), host), + "originalhost" => pattern_list_matches(arg.split(','), alias), + "user" => pattern_list_matches(arg.split(','), user), + "localuser" => pattern_list_matches(arg.split(','), &local), + "exec" => { + let cmd = expand_tokens(arg, alias, host, port, user, &local); + eval_match_exec(&cmd) + } + _ => false, + }; + if ok == negated { + return false; + } + } + _ => return false, + } + } + true +} + +/// Expands a path pattern that may contain `*` or `?` in its path segments. +fn expand_include_glob(pattern: &Path) -> Vec { + use std::path::Component; + let mut paths = vec![PathBuf::new()]; + for comp in pattern.components() { + match comp { + Component::Normal(os) => { + let s = os.to_string_lossy(); + if s.contains('*') || s.contains('?') { + let mut next = Vec::new(); + for base in &paths { + let dir = if base.as_os_str().is_empty() { Path::new(".") } else { base.as_path() }; + let Ok(entries) = std::fs::read_dir(dir) else { continue }; + let mut matched: Vec = entries + .filter_map(Result::ok) + .filter(|e| glob_match(&s, &e.file_name().to_string_lossy())) + .map(|e| e.path()) + .collect(); + matched.sort(); + next.extend(matched); + } + paths = next; + } else { + for p in &mut paths { + p.push(os); + } + } + } + other => { + for p in &mut paths { + p.push(other.as_os_str()); + } + } + } + } + paths.into_iter().filter(|p| p.is_file()).collect() +} + +fn parse_config_file( + path: &Path, + alias: &str, + port_override: Option, + user_override: Option<&str>, + out: &mut RawConfig, + depth: usize, +) { + if depth >= 8 { + return; + } + let Ok(contents) = std::fs::read_to_string(path) else { return }; + let base_dir = path.parent().unwrap_or_else(|| Path::new(".")); + let mut active = true; + for line in contents.lines() { + let Some((key, value)) = split_directive(line) else { continue }; + match key.to_ascii_lowercase().as_str() { + "host" => { + let patterns = split_args(value); + active = pattern_list_matches(patterns.iter().map(String::as_str), alias); + } + "match" => { + active = eval_match(value, alias, port_override, user_override, out); + } + _ if !active => {} + "include" => { + for raw in split_args(value) { + let expanded = expand(&raw); + let pat = if expanded.is_relative() { base_dir.join(expanded) } else { expanded }; + for file in expand_include_glob(&pat) { + parse_config_file(&file, alias, port_override, user_override, out, depth + 1); + } + } + } + "hostname" => { + if out.hostname.is_none() { + let raw = strip_quotes(value); + out.hostname = Some(raw.replace("%h", alias).replace("%%", "%")); + } + } + "user" => { + if out.user.is_none() { + out.user = Some(strip_quotes(value).to_owned()); + } + } + "port" => { + if out.port.is_none() { + out.port = strip_quotes(value).parse().ok(); + } + } + "identityfile" => { + let raw = strip_quotes(value); + if !raw.is_empty() && !raw.eq_ignore_ascii_case("none") { + out.identity_files.push(raw.to_owned()); + } + } + // Whichever of `ProxyJump` and `ProxyCommand` comes first wins, as in OpenSSH. + "proxyjump" => { + if out.proxy_jump.is_none() && out.proxy_command.is_none() { + out.proxy_jump = Some(strip_quotes(value).to_owned()); + } + } + "proxycommand" => { + if out.proxy_command.is_none() && out.proxy_jump.is_none() { + out.proxy_command = Some(strip_quotes(value).to_owned()); + } + } + "stricthostkeychecking" => { + if out.host_key_policy.is_none() { + out.host_key_policy = Some(match strip_quotes(value).to_ascii_lowercase().as_str() { + "no" | "off" | "false" => HostKeyPolicy::Off, + "accept-new" => HostKeyPolicy::AcceptNew, + _ => HostKeyPolicy::Ask, + }); + } + } + "userknownhostsfile" => { + if out.user_known_hosts_file.is_none() + && let Some(first) = split_args(value).into_iter().next() + { + out.user_known_hosts_file = Some(first); + } + } + _ => {} + } + } +} + /// One `~/.ssh/config` lookup (a missing or unreadable file means defaults). -fn config_hop(config: Option<&Path>, alias: &str) -> (Hop, Option) { - let c = config - .and_then(|p| russh_config::parse_path(p, alias).ok()) - .unwrap_or_else(|| russh_config::Config::default(alias)); - let identity_files = c.host_config.identity_file.clone().unwrap_or_default(); - let hop = Hop { host: c.host().to_owned(), port: c.port(), user: c.user(), identity_files }; - (hop, c.host_config.proxy_jump.clone()) +fn config_hop( + config: Option<&Path>, + alias: &str, + port_override: Option, + user_override: Option<&str>, +) -> (Hop, Option) { + let mut raw = RawConfig::default(); + if let Some(p) = config { + parse_config_file(p, alias, port_override, user_override, &mut raw, 0); + } + let local = local_user(); + let host = raw.hostname.unwrap_or_else(|| alias.to_owned()); + let port = port_override.or(raw.port).unwrap_or(22); + let user = user_override + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .or(raw.user) + .unwrap_or_else(|| local.clone()); + let identity_files = + raw.identity_files.iter().map(|p| expand(&expand_tokens(p, alias, &host, port, &user, &local))).collect(); + let proxy_command = raw + .proxy_command + .filter(|c| !c.is_empty() && !c.eq_ignore_ascii_case("none")) + .map(|c| expand_tokens(&c, alias, &host, port, &user, &local)); + let proxy_jump = raw.proxy_jump.filter(|j| !j.is_empty() && !j.eq_ignore_ascii_case("none")); + let (known_hosts, null_known_hosts) = match raw.user_known_hosts_file.as_deref() { + Some(kh) if kh.eq_ignore_ascii_case("none") || kh == "/dev/null" || kh.eq_ignore_ascii_case("nul") => { + (None, true) + } + Some(kh) => (Some(expand(&expand_tokens(kh, alias, &host, port, &user, &local))), false), + None => (None, false), + }; + let host_key_policy = + if null_known_hosts { HostKeyPolicy::Off } else { raw.host_key_policy.unwrap_or(HostKeyPolicy::Ask) }; + let hop = Hop { host, port, user, identity_files, proxy_command, host_key_policy, known_hosts }; + (hop, proxy_jump) } /// `[user@]host[:port]` of a `ProxyJump` entry. @@ -88,13 +499,7 @@ fn jump_hop(config: Option<&Path>, spec: &str) -> Hop { Some((h, p)) if p.parse::().is_ok() => (h, p.parse().ok()), _ => (rest, None), }; - let (mut hop, _) = config_hop(config, host); - if let Some(u) = user { - hop.user = u.to_owned(); - } - if let Some(p) = port { - hop.port = p; - } + let (hop, _) = config_hop(config, host, port, user); hop } @@ -104,21 +509,30 @@ pub(crate) fn resolve_hops(target: &SshTarget, config: Option<&Path>) -> IdeResu if host.is_empty() { return Err(IdeError::invalid("SSH host is empty")); } - let (mut hop, proxy_jump) = if target.auth == SshAuth::OpenSsh { - config_hop(config, host) + let user_override = Some(target.user.trim()).filter(|u| !u.is_empty()); + let (hop, proxy_jump) = if target.auth == SshAuth::OpenSsh { + config_hop(config, host, target.port, user_override) } else { - (Hop { host: host.to_owned(), port: 22, user: local_user(), identity_files: Vec::new() }, None) + ( + Hop { + host: host.to_owned(), + port: target.port.unwrap_or(22), + user: user_override.map(str::to_owned).unwrap_or_else(local_user), + identity_files: Vec::new(), + proxy_command: None, + host_key_policy: HostKeyPolicy::Ask, + known_hosts: None, + }, + None, + ) + }; + let mut hops: Vec = if hop.proxy_command.is_some() { + Vec::new() + } else { + proxy_jump + .map(|j| j.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| jump_hop(config, s)).collect()) + .unwrap_or_default() }; - if let Some(p) = target.port { - hop.port = p; - } - if !target.user.trim().is_empty() { - hop.user = target.user.trim().to_owned(); - } - let mut hops: Vec = proxy_jump - .filter(|j| !j.eq_ignore_ascii_case("none")) - .map(|j| j.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| jump_hop(config, s)).collect()) - .unwrap_or_default(); hops.push(hop); Ok(hops) } @@ -173,6 +587,96 @@ mod tests { assert!(resolve_hops(&SshTarget::default(), None).is_err()); } + #[test] + fn resolves_include_match_equals_and_proxy_command() { + let tmp = tempfile::tempdir().unwrap(); + let sub = tmp.path().join("ssh.d"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write( + sub.join("coder.conf"), + "Host coder-vscode--*\n ProxyCommand /bin/coder ssh --stdio %h\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n", + ) + .unwrap(); + let config = tmp.path().join("config"); + std::fs::write( + &config, + "Include \"ssh.d/*.conf\"\n\nHost *.coder\n\tConnectTimeout=0\n\tStrictHostKeyChecking=no\n\tUserKnownHostsFile=/dev/null\n\nMatch host *.coder !exec \"false\"\n\tProxyCommand /opt/homebrew/bin/coder --global-config \"/path with spaces/coderv2\" ssh --stdio --hostname-suffix coder %h %p %r\n", + ) + .unwrap(); + + let target = SshTarget { + host: "deng-devbox.coder".into(), + port: Some(22), + user: "dengliu".into(), + auth: SshAuth::OpenSsh, + ..Default::default() + }; + let hops = resolve_hops(&target, Some(&config)).unwrap(); + assert_eq!(hops.len(), 1); + assert_eq!(hops[0].host, "deng-devbox.coder"); + assert_eq!(hops[0].port, 22); + assert_eq!(hops[0].user, "dengliu"); + assert_eq!(hops[0].host_key_policy, HostKeyPolicy::Off); + assert_eq!(hops[0].known_hosts, None); + assert_eq!( + hops[0].proxy_command.as_deref(), + Some( + "/opt/homebrew/bin/coder --global-config \"/path with spaces/coderv2\" ssh --stdio --hostname-suffix coder deng-devbox.coder 22 dengliu" + ) + ); + + let included = SshTarget { + host: "coder-vscode--devbox".into(), + user: "coder".into(), + auth: SshAuth::OpenSsh, + ..Default::default() + }; + let hops = resolve_hops(&included, Some(&config)).unwrap(); + assert_eq!(hops[0].proxy_command.as_deref(), Some("/bin/coder ssh --stdio coder-vscode--devbox")); + assert_eq!(hops[0].host_key_policy, HostKeyPolicy::Off); + } + + #[test] + fn the_first_of_proxy_jump_and_proxy_command_wins() { + let tmp = tempfile::tempdir().unwrap(); + let config = tmp.path().join("config"); + std::fs::write( + &config, + "Host a\n ProxyJump bastion\n ProxyCommand nc %h %p\nHost b\n ProxyCommand nc %h %p\n ProxyJump bastion\n", + ) + .unwrap(); + let target = |host: &str| SshTarget { host: host.into(), auth: SshAuth::OpenSsh, ..Default::default() }; + let hops = resolve_hops(&target("a"), Some(&config)).unwrap(); + assert_eq!(hops.len(), 2); + assert_eq!(hops[0].host, "bastion"); + assert_eq!(hops[1].proxy_command, None); + let hops = resolve_hops(&target("b"), Some(&config)).unwrap(); + assert_eq!(hops.len(), 1); + assert_eq!(hops[0].proxy_command.as_deref(), Some("nc b 22")); + } + + #[test] + fn strict_host_key_checking_values() { + let tmp = tempfile::tempdir().unwrap(); + let config = tmp.path().join("config"); + std::fs::write( + &config, + "Host new\n StrictHostKeyChecking accept-new\nHost off\n StrictHostKeyChecking=no\n\ + Host ask\n StrictHostKeyChecking ask\nHost null\n UserKnownHostsFile /dev/null\n\ + Match exec \"exit 1\"\n StrictHostKeyChecking no\n", + ) + .unwrap(); + let policy = |host: &str| { + let target = SshTarget { host: host.into(), auth: SshAuth::OpenSsh, ..Default::default() }; + resolve_hops(&target, Some(&config)).unwrap()[0].host_key_policy + }; + assert_eq!(policy("new"), HostKeyPolicy::AcceptNew); + assert_eq!(policy("off"), HostKeyPolicy::Off); + assert_eq!(policy("ask"), HostKeyPolicy::Ask); + assert_eq!(policy("null"), HostKeyPolicy::Off); + assert_eq!(policy("other"), HostKeyPolicy::Ask); + } + #[test] fn builds_openssh_arguments() { let t = SshTarget { host: "box".into(), user: "me".into(), port: Some(2200), ..Default::default() }; diff --git a/crates/ide-ssh/tests/session.rs b/crates/ide-ssh/tests/session.rs index 6c89baa3..41225b97 100644 --- a/crates/ide-ssh/tests/session.rs +++ b/crates/ide-ssh/tests/session.rs @@ -36,6 +36,10 @@ impl server::Server for Sshd { impl server::Handler for Sshd { type Error = russh::Error; + async fn auth_none(&mut self, user: &str) -> Result { + Ok(if user == "coder" { Auth::Accept } else { Auth::reject() }) + } + async fn auth_password(&mut self, user: &str, password: &str) -> Result { Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() }) } @@ -292,3 +296,46 @@ async fn upload_reports_a_command_that_stops_reading() { .unwrap_err(); assert!(err.message.contains("oops"), "{}", err.message); } + +#[cfg(unix)] +#[tokio::test] +async fn openssh_proxy_command_and_none_authentication() { + let mut env = env().await; + let config = env._tmp.path().join("ssh_config"); + std::fs::write( + &config, + format!( + "Host *.coder\n\tStrictHostKeyChecking=no\n\tUserKnownHostsFile=/dev/null\n\tProxyCommand nc 127.0.0.1 {}\n", + env.ssh_port + ), + ) + .unwrap(); + env.files.config = Some(config); + let target = SshTarget { + host: "deng-devbox.coder".into(), + port: Some(22), + user: "coder".into(), + auth: SshAuth::OpenSsh, + ..Default::default() + }; + let session = env.connect(&target, None).await.expect("ProxyCommand + none auth should connect"); + let out = session.exec("uname -srm", None).await.unwrap(); + assert_eq!(out.stdout_str(), "ran: uname -srm"); +} + +#[tokio::test] +async fn accept_new_records_an_unknown_key_and_still_refuses_a_changed_one() { + let mut env = env().await; + let config = env._tmp.path().join("ssh_config"); + std::fs::write(&config, "Host 127.0.0.1\n\tStrictHostKeyChecking accept-new\n").unwrap(); + env.files.config = Some(config); + let target = SshTarget { user: "coder".into(), ..env.target(SshAuth::OpenSsh) }; + assert!(env.connect(&target, None).await.is_ok()); + let recorded = std::fs::read_to_string(&env.files.known_hosts).unwrap(); + assert!(recorded.trim_start().starts_with(&format!("[127.0.0.1]:{} ssh-ed25519 ", env.ssh_port)), "{recorded}"); + + let other = key("client", None).public_key().to_openssh().unwrap(); + std::fs::write(&env.files.known_hosts, format!("[127.0.0.1]:{} {other}\n", env.ssh_port)).unwrap(); + let msg = denied(env.connect(&target, None).await); + assert!(msg.contains("has changed"), "{msg}"); +}