feat(ssh): support ProxyCommand, Include, Match, and none auth from ~/.ssh/config (#1704)

Read Include, Match (host/user/exec), Key=Value, ProxyCommand with % tokens, StrictHostKeyChecking and UserKnownHostsFile from ~/.ssh/config, connect over a ProxyCommand's stdio, and try none authentication first in OpenSSH mode. accept-new records unknown host keys and still refuses changed ones; Match exec runs off the async runtime with a 10 s limit.
This commit is contained in:
Deng Liu authored and GitHub committed 2026-11-07 20:19:49 +08:00
1 parent 619daa1489
commit e367f6757e
5 files changed
+758 -92

No files matched your search

Generated
+3 -53
View File
@@ -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"
-1
View File
@@ -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"
+177 -11
View File
@@ -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<PathBuf>,
}
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<Session, ConnectError> {
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<Handle<Client>> = Vec::new();
for (i, hop) in hops.iter().enumerate() {
let last = i == hops.len() - 1;
let mut handle = match sessions.last() {
None => connect::<tokio::net::TcpStream>(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<Mutex<Option<HostKeyIssue>>>,
}
@@ -373,6 +384,9 @@ impl client::Handler for Client {
type Error = russh::Error;
async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result<bool, Self::Error> {
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<tokio::process::ChildStdin>,
stdout: Option<tokio::process::ChildStdout>,
stderr: Arc<Mutex<Vec<u8>>>,
}
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<std::io::Result<()>> {
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<std::io::Result<usize>> {
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<std::io::Result<()>> {
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<std::io::Result<()>> {
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<ProxyStream, std::io::Error> {
#[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<Handle<Client>, 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::<tokio::net::TcpStream>(hop, known_hosts, None).await
}
}
async fn connect<S>(hop: &Hop, known_hosts: &Path, stream: Option<S>) -> Result<Handle<Client>, 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(());
}
+531 -27
View File
@@ -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<PathBuf>,
pub proxy_command: Option<String>,
pub host_key_policy: HostKeyPolicy,
pub known_hosts: Option<PathBuf>,
}
/// `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<String>,
user: Option<String>,
port: Option<u16>,
identity_files: Vec<String>,
proxy_jump: Option<String>,
proxy_command: Option<String>,
host_key_policy: Option<HostKeyPolicy>,
user_known_hosts_file: Option<String>,
}
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<String> {
let mut args = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = 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<char> = pattern.chars().collect();
let t: Vec<char> = 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<Item = &'a str>, 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<u16>,
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<PathBuf> {
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<PathBuf> = 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<u16>,
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<String>) {
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<u16>,
user_override: Option<&str>,
) -> (Hop, Option<String>) {
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::<u16>().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<Hop> = 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<Hop> = 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() };
+47
View File
@@ -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<Auth, Self::Error> {
Ok(if user == "coder" { Auth::Accept } else { Auth::reject() })
}
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
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}");
}