дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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.
689 рядки
25 KiB
Rust
689 рядки
25 KiB
Rust
//! Where to connect: an [`SshTarget`] and the hosts to go through to reach it
|
|
//! (`~/.ssh/config` aliases and `ProxyJump` chains).
|
|
|
|
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)]
|
|
pub struct SshTarget {
|
|
/// Host name, or a `Host` alias of `~/.ssh/config` with [`SshAuth::OpenSsh`].
|
|
pub host: String,
|
|
pub port: Option<u16>,
|
|
pub user: String,
|
|
pub auth: SshAuth,
|
|
/// Private key for [`SshAuth::KeyPair`]; `~/` is the home directory.
|
|
pub key_file: String,
|
|
}
|
|
|
|
impl From<&SshConfiguration> for SshTarget {
|
|
fn from(c: &SshConfiguration) -> Self {
|
|
Self { host: c.host.clone(), port: c.port, user: c.user.clone(), auth: c.auth, key_file: c.key_file.clone() }
|
|
}
|
|
}
|
|
|
|
/// The user's OpenSSH files.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SshFiles {
|
|
pub known_hosts: PathBuf,
|
|
/// `~/.ssh/config` (used with [`SshAuth::OpenSsh`]).
|
|
pub config: Option<PathBuf>,
|
|
}
|
|
|
|
impl Default for SshFiles {
|
|
fn default() -> Self {
|
|
let dir = home().unwrap_or_default().join(".ssh");
|
|
Self { known_hosts: dir.join("known_hosts"), config: Some(dir.join("config")) }
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct Hop {
|
|
pub host: String,
|
|
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 {
|
|
pub fn label(&self) -> String {
|
|
format!("{}@{}:{}", self.user, self.host, self.port)
|
|
}
|
|
}
|
|
|
|
pub(crate) fn home() -> Option<PathBuf> {
|
|
dirs::home_dir()
|
|
}
|
|
|
|
/// `~/x` → `$HOME/x`.
|
|
pub fn expand(path: &str) -> PathBuf {
|
|
match (path.strip_prefix("~/"), home()) {
|
|
(Some(rest), Some(h)) => h.join(rest),
|
|
_ => PathBuf::from(path),
|
|
}
|
|
}
|
|
|
|
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,
|
|
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.
|
|
fn jump_hop(config: Option<&Path>, spec: &str) -> Hop {
|
|
let (user, rest) = match spec.rsplit_once('@') {
|
|
Some((u, r)) => (Some(u), r),
|
|
None => (None, spec),
|
|
};
|
|
let (host, port) = match rest.rsplit_once(':') {
|
|
Some((h, p)) if p.parse::<u16>().is_ok() => (h, p.parse().ok()),
|
|
_ => (rest, None),
|
|
};
|
|
let (hop, _) = config_hop(config, host, port, user);
|
|
hop
|
|
}
|
|
|
|
/// The SSH hosts to go through, the target last.
|
|
pub(crate) fn resolve_hops(target: &SshTarget, config: Option<&Path>) -> IdeResult<Vec<Hop>> {
|
|
let host = target.host.trim();
|
|
if host.is_empty() {
|
|
return Err(IdeError::invalid("SSH host is empty"));
|
|
}
|
|
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: 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()
|
|
};
|
|
hops.push(hop);
|
|
Ok(hops)
|
|
}
|
|
|
|
/// `ssh` arguments that reach `target` (the system client: terminals, git).
|
|
pub fn openssh_args(target: &SshTarget) -> Vec<String> {
|
|
let mut args = Vec::new();
|
|
if let Some(p) = target.port {
|
|
args.extend(["-p".into(), p.to_string()]);
|
|
}
|
|
if target.auth == SshAuth::KeyPair && !target.key_file.trim().is_empty() {
|
|
args.extend(["-i".into(), expand(target.key_file.trim()).to_string_lossy().into_owned()]);
|
|
args.extend(["-o".into(), "IdentitiesOnly=yes".into()]);
|
|
}
|
|
if target.auth == SshAuth::Password {
|
|
args.extend(["-o".into(), "PreferredAuthentications=password,keyboard-interactive".into()]);
|
|
}
|
|
let host = target.host.trim();
|
|
// A host starting with `-` must not be read as an option.
|
|
args.push("--".into());
|
|
args.push(if target.user.trim().is_empty() { host.to_owned() } else { format!("{}@{host}", target.user.trim()) });
|
|
args
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn resolves_aliases_and_proxy_jumps_from_ssh_config() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let config = tmp.path().join("config");
|
|
std::fs::write(
|
|
&config,
|
|
"Host db-bastion\n HostName 10.0.0.5\n User ops\n Port 2222\n ProxyJump [email protected]:2200,edge\n IdentityFile /keys/id_bastion\n\nHost edge\n HostName edge.example.com\n User edgeuser\n",
|
|
)
|
|
.unwrap();
|
|
let target = SshTarget { host: "db-bastion".into(), auth: SshAuth::OpenSsh, ..Default::default() };
|
|
let hops = resolve_hops(&target, Some(&config)).unwrap();
|
|
let labels: Vec<String> = hops.iter().map(Hop::label).collect();
|
|
assert_eq!(labels, ["[email protected]:2200", "[email protected]:22", "[email protected]:2222"]);
|
|
assert_eq!(hops[2].identity_files, [PathBuf::from("/keys/id_bastion")]);
|
|
|
|
// Explicit fields win over the config.
|
|
let target = SshTarget { user: "me".into(), port: Some(22), ..target };
|
|
assert_eq!(resolve_hops(&target, Some(&config)).unwrap()[2].label(), "[email protected]:22");
|
|
|
|
// Without OpenSSH mode the host is used as is.
|
|
let target = SshTarget { auth: SshAuth::Password, user: "u".into(), port: None, ..target };
|
|
let hops = resolve_hops(&target, Some(&config)).unwrap();
|
|
assert_eq!(hops.iter().map(Hop::label).collect::<Vec<_>>(), ["u@db-bastion:22"]);
|
|
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() };
|
|
assert_eq!(openssh_args(&t), ["-p", "2200", "--", "me@box"]);
|
|
let t =
|
|
SshTarget { host: "box".into(), auth: SshAuth::KeyPair, key_file: "/k/id".into(), ..Default::default() };
|
|
assert_eq!(openssh_args(&t), ["-i", "/k/id", "-o", "IdentitiesOnly=yes", "--", "box"]);
|
|
}
|
|
}
|