Skip to content

Commit

Permalink
feat: look up host name aliases in ssh_config (#22)
Browse files Browse the repository at this point in the history
  • Loading branch information
crazyscot committed Dec 26, 2024
1 parent 642c64a commit 46c450d
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 8 deletions.
93 changes: 90 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ quinn = { version = "0.11.6", default-features = false, features = ["runtime-tok
rcgen = { version = "0.13.1" }
rustls-pki-types = "1.10.0"
serde = { version = "1.0.216", features = ["derive"] }
ssh2-config = "0.2.3"
static_assertions = "1.1.0"
struct-field-names-as-array = "0.3.0"
strum_macros = "0.26.4"
Expand Down
8 changes: 6 additions & 2 deletions src/client/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ use crate::transport::ThroughputMode;
/// A file source or destination specified by the user
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FileSpec {
/// The remote host for the file.
/// The remote host for the file. This may be a hostname or an IP address.
/// It may also be a _hostname alias_ that matches a Host section in the user's ssh config file.
/// (In that case, the ssh config file must specify a HostName.)
///
/// If not present, this is a local file.
pub host: Option<String>,
Expand Down Expand Up @@ -68,13 +70,15 @@ impl CopyJobSpec {
}
}

pub(crate) fn remote_user_host(&self) -> &str {
/// The [user@]hostname portion of whichever of the arguments contained a hostname.
fn remote_user_host(&self) -> &str {
self.source
.host
.as_ref()
.unwrap_or_else(|| self.destination.host.as_ref().unwrap())
}

/// The hostname portion of whichever of the arguments contained one.
pub(crate) fn remote_host(&self) -> &str {
let user_host = self.remote_user_host();
// It might be user@host, or it might be just the hostname or IP.
Expand Down
8 changes: 5 additions & 3 deletions src/client/main_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,19 @@ pub async fn client_main(
// Prep --------------------------
let job_spec = crate::client::CopyJobSpec::try_from(&parameters)?;
let credentials = Credentials::generate()?;
let remote_host = job_spec.remote_host();
let user_hostname = job_spec.remote_host();
let remote_host =
super::ssh::resolve_host_alias(user_hostname).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.)
let remote_address = lookup_host_by_family(remote_host, config.address_family)?;
let remote_address = lookup_host_by_family(&remote_host, config.address_family)?;

// Control channel ---------------
timers.next("control channel");
let (mut control, server_message) = Channel::transact(
&credentials,
remote_host,
&remote_host,
remote_address.into(),
&display,
config,
Expand Down
1 change: 1 addition & 0 deletions src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub use job::FileSpec;
mod main_loop;
mod meter;
mod progress;
pub mod ssh;

#[allow(clippy::module_name_repetitions)]
pub use main_loop::client_main;
Expand Down
110 changes: 110 additions & 0 deletions src/client/ssh.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//! Interaction with ssh configuration
// (c) 2024 Ross Younger

use std::{fs::File, io::BufReader};

use ssh2_config::{ParseRule, SshConfig};
use tracing::{debug, warn};

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

/// Attempts to resolve a hostname from a single OpenSSH-style config file
///
/// If `path` is None, uses the default user ssh config file.
fn resolve_one(path: Option<&str>, host: &str) -> Option<String> {
let source = path.unwrap_or("~/.ssh/config");
let result = match path {
Some(p) => {
let mut reader = match File::open(p) {
Ok(f) => BufReader::new(f),
Err(e) => {
// This is not automatically an error, as the file might not exist.
debug!("Unable to read {p}; continuing without. {e}");
return None;
}
};
SshConfig::default().parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
}
None => SshConfig::parse_default_file(ParseRule::ALLOW_UNKNOWN_FIELDS),
};
let cfg = match result {
Ok(cfg) => cfg,
Err(e) => {
warn!("Unable to parse {source}; continuing without. [{e}]");
return None;
}
};

cfg.query(host).host_name.inspect(|h| {
debug!("Using hostname '{h}' for '{host}' (from {source})");
})
}

/// Attempts to resolve hostname aliasing from the user's and system's ssh config files to resolve aliasing.
///
/// ## Returns
/// Some(hostname) if any config file matched.
/// None if no config files matched.
///
/// ## ssh_config features not currently supported
/// * Include directives
/// * Match patterns
/// * CanonicalizeHostname and friends
#[must_use]
pub fn resolve_host_alias(host: &str) -> Option<String> {
let files = vec![None, Some(Platform::system_ssh_config())];
files.into_iter().find_map(|it| resolve_one(it, host))
}

#[cfg(test)]
mod test {
use super::resolve_one;
use crate::util::make_test_tempfile;

#[test]
fn hosts_resolve() {
let (path, _dir) = make_test_tempfile(
r"
Host aaa
HostName zzz
Host bbb ccc.ddd
HostName yyy
",
"test_ssh_config",
);
let f = path.to_string_lossy().to_string();
assert!(resolve_one(Some(&f), "nope").is_none());
assert_eq!(resolve_one(Some(&f), "aaa").unwrap(), "zzz");
assert_eq!(resolve_one(Some(&f), "bbb").unwrap(), "yyy");
assert_eq!(resolve_one(Some(&f), "ccc.ddd").unwrap(), "yyy");
}

#[test]
fn wildcards_match() {
let (path, _dir) = make_test_tempfile(
r"
Host *.bar
HostName baz
Host 10.11.*.13
# this is a silly example but it shows that wildcards match by IP
HostName wibble
Host fr?d
hostname barney
",
"test_ssh_config",
);
let f = path.to_string_lossy().to_string();
assert_eq!(resolve_one(Some(&f), "foo.bar").unwrap(), "baz");
assert_eq!(resolve_one(Some(&f), "qux.qix.bar").unwrap(), "baz");
assert!(resolve_one(Some(&f), "qux.qix").is_none());
assert_eq!(resolve_one(Some(&f), "10.11.12.13").unwrap(), "wibble");
assert_eq!(resolve_one(Some(&f), "10.11.0.13").unwrap(), "wibble");
assert_eq!(resolve_one(Some(&f), "10.11.256.13").unwrap(), "wibble"); // yes I know this isn't a real IP address
assert!(resolve_one(Some(&f), "10.11.0.130").is_none());

assert_eq!(resolve_one(Some(&f), "fred").unwrap(), "barney");
assert_eq!(resolve_one(Some(&f), "frid").unwrap(), "barney");
assert!(resolve_one(Some(&f), "freed").is_none());
assert!(resolve_one(Some(&f), "fredd").is_none());
}
}

0 comments on commit 46c450d

Please sign in to comment.