diff --git a/Cargo.lock b/Cargo.lock index 6700111..69be675 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -387,6 +387,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dns-lookup" version = "2.0.4" @@ -726,6 +747,16 @@ version = "0.2.168" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -851,6 +882,12 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "overload" version = "0.1.1" @@ -997,6 +1034,7 @@ dependencies = [ "serde", "serde_json", "serde_test", + "ssh2-config", "static_assertions", "struct-field-names-as-array", "strum_macros", @@ -1022,7 +1060,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.6", "tokio", "tracing", ] @@ -1041,7 +1079,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.6", "tinyvec", "tracing", "web-time", @@ -1113,6 +1151,17 @@ dependencies = [ "yasna", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.11.1" @@ -1358,6 +1407,18 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "ssh2-config" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98150bad1e8fe53df07f38b53364f4d34e84a6cc2ee9f933e43629571060af65" +dependencies = [ + "bitflags", + "dirs", + "thiserror 1.0.69", + "wildmatch", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -1495,13 +1556,33 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec2a1820ebd077e2b90c4df007bebf344cd394098a13c563957d0afc83ea47" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.6", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -1830,6 +1911,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wildmatch" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ce1ab1f8c62655ebe1350f589c61e505cf94d385bc6a12899442d9081e71fd" + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 210e259..c98af48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/client/job.rs b/src/client/job.rs index ad0e7e2..4cc52d8 100644 --- a/src/client/job.rs +++ b/src/client/job.rs @@ -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, @@ -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. diff --git a/src/client/main_loop.rs b/src/client/main_loop.rs index 7841b29..c457163 100644 --- a/src/client/main_loop.rs +++ b/src/client/main_loop.rs @@ -48,17 +48,19 @@ pub async fn client_main( // Prep -------------------------- let job_spec = crate::client::CopyJobSpec::try_from(¶meters)?; 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, diff --git a/src/client/mod.rs b/src/client/mod.rs index 21978a4..9faf56c 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -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; diff --git a/src/client/ssh.rs b/src/client/ssh.rs new file mode 100644 index 0000000..1304469 --- /dev/null +++ b/src/client/ssh.rs @@ -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 { + 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 { + 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()); + } +}