Skip to content

Commit

Permalink
feat: option to not parse ssh config file
Browse files Browse the repository at this point in the history
  • Loading branch information
crazyscot committed Dec 19, 2024
1 parent 8f397d9 commit f6860c2
Show file tree
Hide file tree
Showing 3 changed files with 108 additions and 38 deletions.
4 changes: 2 additions & 2 deletions src/client/main_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ pub async fn client_main(
let job_spec = crate::client::CopyJobSpec::try_from(&parameters)?;
let credentials = Credentials::generate()?;
let user_hostname = job_spec.remote_host();
let remote_host =
super::ssh::resolve_host_alias(user_hostname).unwrap_or_else(|| user_hostname.into());
let remote_host = super::ssh::resolve_host_alias(user_hostname, &config.ssh_config)
.unwrap_or_else(|| user_hostname.into());

// If the user didn't specify the address family: we do the DNS lookup, figure it out and tell ssh to use that.
// (Otherwise if we resolved a v4 and ssh a v6 - as might happen with round-robin DNS - that could be surprising.)
Expand Down
129 changes: 93 additions & 36 deletions src/client/ssh.rs
Original file line number Diff line number Diff line change
@@ -1,48 +1,83 @@
//! Interaction with ssh configuration
// (c) 2024 Ross Younger

use std::path::PathBuf;
use std::{path::PathBuf, str::FromStr};

use crate::config::ssh::Parser;
use anyhow::Context;
use anyhow::{Context, Result};
use tracing::{debug, warn};

use crate::os::{AbstractPlatform as _, Platform};

/// Attempts to resolve a hostname from a single OpenSSH-style config file
fn resolve_one(path: &PathBuf, user_config_file: bool, host: &str) -> Option<String> {
if !std::fs::exists(path).is_ok_and(|b| b) {
// file could not be verified to exist. this is not intrinsically an error; keep quiet
return None;
struct ConfigFile {
path: PathBuf,
user: bool, // this is a user file i.e. ~ expansion is allowed
warn_on_error: bool,
}

impl ConfigFile {
fn for_path(path: PathBuf, user: bool) -> Self {
Self {
path,
user,
warn_on_error: false,
}
}
let mut parser = match Parser::for_path(path, user_config_file) {
Ok(p) => p,
Err(e) => {
// file permissions issue?
warn!("failed to open {path:?}: {e}");
fn for_str(path: &str, user: bool, warn_on_error: bool) -> Result<Self> {
Ok(Self {
path: PathBuf::from_str(path)?,
user,
warn_on_error,
})
}

/// Attempts to resolve a hostname from a single OpenSSH-style config file
fn resolve_one(&self, host: &str) -> Option<String> {
let path = &self.path;
if !std::fs::exists(path).is_ok_and(|b| b) {
// file could not be verified to exist.
// This is not intrinsically an error; the user or system file might legitimately not be there.
// But if this was a file explicitly specified by the user, assume they do care and let them know.
if self.warn_on_error {
warn!("ssh-config file {path:?} not found");
}
return None;
}
};
let data = match parser
.parse_file_for(host)
.with_context(|| format!("reading configuration file {path:?}"))
{
Ok(data) => data,
Err(e) => {
warn!("{e}");
return None;
let mut parser = match Parser::for_path(path, self.user) {
Ok(p) => p,
Err(e) => {
// file permissions issue?
warn!("failed to open {path:?}: {e}");
return None;
}
};
let data = match parser
.parse_file_for(host)
.with_context(|| format!("reading configuration file {path:?}"))
{
Ok(data) => data,
Err(e) => {
warn!("{e}");
return None;
}
};
if let Some(s) = data.get("hostname") {
let result = s.first_arg();
debug!("Using hostname '{result}' for '{host}' (from {})", s.source);
Some(result)
} else {
None
}
};
if let Some(s) = data.get("hostname") {
let result = s.first_arg();
debug!("Using hostname '{result}' for '{host}' (from {})", s.source);
Some(result)
} else {
None
}
}

/// Attempts to resolve hostname aliasing from the user's and system's ssh config files to resolve aliasing.
/// Attempts to resolve hostname aliasing from ssh config files.
///
/// ## Arguments
/// * host: the host name alias to look up (matching a 'Host' block in ssh_config)
/// * config_files: The list of ssh config files to use, in priority order.
///
/// If the list is empty, the user's and system's ssh config files will be used.
///
/// ## Returns
/// Some(hostname) if any config file matched.
Expand All @@ -52,20 +87,42 @@ fn resolve_one(path: &PathBuf, user_config_file: bool, host: &str) -> Option<Str
/// * Match patterns
/// * CanonicalizeHostname and friends
#[must_use]
pub fn resolve_host_alias(host: &str) -> Option<String> {
let f = Platform::user_ssh_config().map(|pb| resolve_one(&pb, true, host));
if let Ok(Some(s)) = f {
return Some(s);
pub fn resolve_host_alias(host: &str, config_files: &[String]) -> Option<String> {
let files = if config_files.is_empty() {
let mut v = Vec::new();
if let Ok(f) = Platform::user_ssh_config() {
v.push(ConfigFile::for_path(f, true));
}
if let Ok(f) = ConfigFile::for_str(Platform::system_ssh_config(), false, false) {
v.push(f);
}
v
} else {
config_files
.iter()
.flat_map(|s| ConfigFile::for_str(s, true, true))
.collect()
};
for cfg in files {
let result = cfg.resolve_one(host);
if result.is_some() {
return result;
}
}

resolve_one(&PathBuf::from(Platform::system_ssh_config()), false, host)
None
}

#[cfg(test)]
mod test {
use super::resolve_one;
use std::path::Path;

use super::ConfigFile;
use crate::util::make_test_tempfile;

fn resolve_one(path: &Path, user: bool, host: &str) -> Option<String> {
ConfigFile::for_path(path.to_path_buf(), user).resolve_one(host)
}

#[test]
fn hosts_resolve() {
let (path, _dir) = make_test_tempfile(
Expand Down
13 changes: 13 additions & 0 deletions src/config/structure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,18 @@ pub struct Configuration {
/// Specifies the time format to use when printing messages to the console or to file
#[arg(short = 'T', long, value_name("FORMAT"), help_heading("Output"))]
pub time_format: TimeFormat,

/// Alternative ssh config file(s)
///
/// By default, qcp reads your user and system ssh config files to look for Hostname aliases.
/// In some cases the logic in qcp may not read them successfully; this is an escape hatch,
/// allowing you to specify one or more alternative files to read instead (which may be empty,
/// nonexistent or /dev/null).
///
/// This option is really intended to be used in a qcp configuration file.
/// On the command line, you can repeat `--ssh-config file` as many times as needed.
#[arg(long, value_name("FILE"), help_heading("Connection"))]
pub ssh_config: Vec<String>,
}

impl Configuration {
Expand Down Expand Up @@ -250,6 +262,7 @@ impl Default for Configuration {
ssh_opt: vec![],
remote_port: PortRange::default(),
time_format: TimeFormat::Local,
ssh_config: Vec::new(),
}
}
}
Expand Down

0 comments on commit f6860c2

Please sign in to comment.