Skip to content

Commit 522afc1

Browse files
committed
feat: look up host name aliases in ssh_config
1 parent 4b89e62 commit 522afc1

File tree

6 files changed

+213
-8
lines changed

6 files changed

+213
-8
lines changed

Cargo.lock

Lines changed: 90 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ quinn = { version = "0.11.6", default-features = false, features = ["runtime-tok
4545
rcgen = { version = "0.13.1" }
4646
rustls-pki-types = "1.10.0"
4747
serde = { version = "1.0.216", features = ["derive"] }
48+
ssh2-config = "0.2.3"
4849
static_assertions = "1.1.0"
4950
struct-field-names-as-array = "0.3.0"
5051
strum_macros = "0.26.4"

src/client/job.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ use crate::transport::ThroughputMode;
88
/// A file source or destination specified by the user
99
#[derive(Debug, Clone, Default, PartialEq, Eq)]
1010
pub struct FileSpec {
11-
/// The remote host for the file.
11+
/// The remote host for the file. This may be a hostname or an IP address.
12+
/// It may also be a _hostname alias_ that matches a Host section in the user's ssh config file.
13+
/// (In that case, the ssh config file must specify a HostName.)
1214
///
1315
/// If not present, this is a local file.
1416
pub host: Option<String>,
@@ -68,13 +70,15 @@ impl CopyJobSpec {
6870
}
6971
}
7072

71-
pub(crate) fn remote_user_host(&self) -> &str {
73+
/// The [user@]hostname portion of whichever of the arguments contained a hostname.
74+
fn remote_user_host(&self) -> &str {
7275
self.source
7376
.host
7477
.as_ref()
7578
.unwrap_or_else(|| self.destination.host.as_ref().unwrap())
7679
}
7780

81+
/// The hostname portion of whichever of the arguments contained one.
7882
pub(crate) fn remote_host(&self) -> &str {
7983
let user_host = self.remote_user_host();
8084
// It might be user@host, or it might be just the hostname or IP.

src/client/main_loop.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,19 @@ pub async fn client_main(
4848
// Prep --------------------------
4949
let job_spec = crate::client::CopyJobSpec::try_from(&parameters)?;
5050
let credentials = Credentials::generate()?;
51-
let remote_host = job_spec.remote_host();
51+
let user_hostname = job_spec.remote_host();
52+
let remote_host =
53+
super::ssh::resolve_host_alias(user_hostname).unwrap_or_else(|| user_hostname.into());
5254

5355
// If the user didn't specify the address family: we do the DNS lookup, figure it out and tell ssh to use that.
5456
// (Otherwise if we resolved a v4 and ssh a v6 - as might happen with round-robin DNS - that could be surprising.)
55-
let remote_address = lookup_host_by_family(remote_host, config.address_family)?;
57+
let remote_address = lookup_host_by_family(&remote_host, config.address_family)?;
5658

5759
// Control channel ---------------
5860
timers.next("control channel");
5961
let (mut control, server_message) = Channel::transact(
6062
&credentials,
61-
remote_host,
63+
&remote_host,
6264
remote_address.into(),
6365
&display,
6466
config,

src/client/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub use job::FileSpec;
1313
mod main_loop;
1414
mod meter;
1515
mod progress;
16+
pub mod ssh;
1617

1718
#[allow(clippy::module_name_repetitions)]
1819
pub use main_loop::client_main;

src/client/ssh.rs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//! Interaction with ssh configuration
2+
// (c) 2024 Ross Younger
3+
4+
use std::{fs::File, io::BufReader};
5+
6+
use ssh2_config::{ParseRule, SshConfig};
7+
use tracing::{debug, warn};
8+
9+
use crate::os::{AbstractPlatform as _, Platform};
10+
11+
/// Attempts to resolve a hostname from a single OpenSSH-style config file
12+
///
13+
/// If `path` is None, uses the default user ssh config file.
14+
fn resolve_one(path: Option<&str>, host: &str) -> Option<String> {
15+
let source = path.unwrap_or("~/.ssh/config");
16+
let result = match path {
17+
Some(p) => {
18+
let mut reader = match File::open(p) {
19+
Ok(f) => BufReader::new(f),
20+
Err(e) => {
21+
// This is not automatically an error, as the file might not exist.
22+
debug!("Unable to read {p}; continuing without. {e}");
23+
return None;
24+
}
25+
};
26+
SshConfig::default().parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
27+
}
28+
None => SshConfig::parse_default_file(ParseRule::ALLOW_UNKNOWN_FIELDS),
29+
};
30+
let cfg = match result {
31+
Ok(cfg) => cfg,
32+
Err(e) => {
33+
warn!("Unable to parse {source}; continuing without. [{e}]");
34+
return None;
35+
}
36+
};
37+
38+
cfg.query(host).host_name.inspect(|h| {
39+
debug!("Using hostname '{h}' for '{host}' (from {source})");
40+
})
41+
}
42+
43+
/// Attempts to resolve hostname aliasing from the user's and system's ssh config files to resolve aliasing.
44+
///
45+
/// ## Returns
46+
/// Some(hostname) if any config file matched.
47+
/// None if no config files matched.
48+
///
49+
/// ## ssh_config features not currently supported
50+
/// * Include directives
51+
/// * Match patterns
52+
/// * CanonicalizeHostname and friends
53+
#[must_use]
54+
pub fn resolve_host_alias(host: &str) -> Option<String> {
55+
let files = vec![None, Some(Platform::system_ssh_config())];
56+
files.into_iter().find_map(|it| resolve_one(it, host))
57+
}
58+
59+
#[cfg(test)]
60+
mod test {
61+
use super::resolve_one;
62+
use crate::util::make_test_tempfile;
63+
64+
#[test]
65+
fn hosts_resolve() {
66+
let (path, _dir) = make_test_tempfile(
67+
r"
68+
Host aaa
69+
HostName zzz
70+
Host bbb ccc.ddd
71+
HostName yyy
72+
",
73+
"test_ssh_config",
74+
);
75+
let f = path.to_string_lossy().to_string();
76+
assert!(resolve_one(Some(&f), "nope").is_none());
77+
assert_eq!(resolve_one(Some(&f), "aaa").unwrap(), "zzz");
78+
assert_eq!(resolve_one(Some(&f), "bbb").unwrap(), "yyy");
79+
assert_eq!(resolve_one(Some(&f), "ccc.ddd").unwrap(), "yyy");
80+
}
81+
82+
#[test]
83+
fn wildcards_match() {
84+
let (path, _dir) = make_test_tempfile(
85+
r"
86+
Host *.bar
87+
HostName baz
88+
Host 10.11.*.13
89+
# this is a silly example but it shows that wildcards match by IP
90+
HostName wibble
91+
Host fr?d
92+
hostname barney
93+
",
94+
"test_ssh_config",
95+
);
96+
let f = path.to_string_lossy().to_string();
97+
assert_eq!(resolve_one(Some(&f), "foo.bar").unwrap(), "baz");
98+
assert_eq!(resolve_one(Some(&f), "qux.qix.bar").unwrap(), "baz");
99+
assert!(resolve_one(Some(&f), "qux.qix").is_none());
100+
assert_eq!(resolve_one(Some(&f), "10.11.12.13").unwrap(), "wibble");
101+
assert_eq!(resolve_one(Some(&f), "10.11.0.13").unwrap(), "wibble");
102+
assert_eq!(resolve_one(Some(&f), "10.11.256.13").unwrap(), "wibble"); // yes I know this isn't a real IP address
103+
assert!(resolve_one(Some(&f), "10.11.0.130").is_none());
104+
105+
assert_eq!(resolve_one(Some(&f), "fred").unwrap(), "barney");
106+
assert_eq!(resolve_one(Some(&f), "frid").unwrap(), "barney");
107+
assert!(resolve_one(Some(&f), "freed").is_none());
108+
assert!(resolve_one(Some(&f), "fredd").is_none());
109+
}
110+
}

0 commit comments

Comments
 (0)