mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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:
1 parent
619daa1489
commit
e367f6757e
5 files changed
+758
-92
No files matched your search
Generated
+3
-53
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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() };
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in new issue
Block a user