1#![deny(
2 clippy::unwrap_used,
3 clippy::expect_used,
4 clippy::indexing_slicing,
5 clippy::panic
6)]
7use std::env;
8use std::io::Read;
9use std::path::{Path, PathBuf};
10
11use globset::Glob;
12use log::debug;
13use thiserror::*;
14
15#[derive(Debug, Error)]
16pub enum Error {
18 #[error("Host not found")]
19 HostNotFound,
20 #[error("No home directory")]
21 NoHome,
22 #[error("{}", 0)]
23 Io(#[from] std::io::Error),
24}
25
26mod proxy;
27pub use proxy::*;
28
29#[derive(Clone, Debug, Default)]
30pub struct HostConfig {
31 pub user: Option<String>,
33 pub hostname: Option<String>,
35 pub port: Option<u16>,
37 pub identity_file: Option<Vec<PathBuf>>,
39 pub proxy_command: Option<String>,
41 pub proxy_jump: Option<String>,
43 pub add_keys_to_agent: Option<AddKeysToAgent>,
45 pub user_known_hosts_file: Option<PathBuf>,
47 pub strict_host_key_checking: Option<bool>,
49}
50
51impl HostConfig {
52 fn merge(mut left: Self, right: &Self) -> Self {
53 macro_rules! clone_if_none {
54 ($left:ident, $right:ident, $($field:ident),+) => {
55 $(if $left.$field.is_none() {
56 $left.$field = $right.$field.clone();
57 })+
58 };
59 }
60
61 clone_if_none!(
62 left,
63 right,
64 user,
65 hostname,
66 port,
67 proxy_command,
68 proxy_jump,
69 add_keys_to_agent,
70 user_known_hosts_file,
71 strict_host_key_checking
72 );
73
74 if let Some(right_identity_files) = right.identity_file.as_deref() {
76 if let Some(identity_files) = left.identity_file.as_mut() {
77 identity_files.extend(right_identity_files.iter().cloned())
78 } else {
79 left.identity_file = Some(Vec::from_iter(right_identity_files.iter().cloned()))
80 }
81 }
82 left
83 }
84}
85
86#[derive(Clone, Debug)]
88struct HostPattern {
89 pattern: String,
90 negated: bool,
91}
92
93#[derive(Clone, Debug, Default)]
94struct HostEntry {
95 host_patterns: Vec<HostPattern>,
96 host_config: HostConfig,
97}
98
99impl HostEntry {
100 fn matches(&self, host: &str) -> bool {
101 let mut matches = false;
102 for host_pattern in self.host_patterns.iter() {
103 if check_host_against_glob_pattern(host, &host_pattern.pattern) {
104 if host_pattern.negated {
105 return false;
107 }
108 matches = true;
109 }
110 }
111 matches
112 }
113}
114
115struct SshConfig {
116 entries: Vec<HostEntry>,
117}
118
119impl SshConfig {
120 pub fn query(&self, host: &str) -> HostConfig {
121 self.entries
122 .iter()
123 .filter_map(|e| {
124 if e.matches(host) {
125 Some(&e.host_config)
126 } else {
127 None
128 }
129 })
130 .fold(HostConfig::default(), HostConfig::merge)
131 }
132}
133
134#[derive(Clone, Debug)]
135pub struct Config {
136 pub host_name: String,
137 pub user: Option<String>,
138 pub port: Option<u16>,
139 pub host_config: HostConfig,
140}
141
142impl Config {
143 pub fn default(host: &str) -> Self {
144 Self {
145 host_name: host.to_string(),
146 user: None,
147 port: None,
148 host_config: HostConfig::default(),
149 }
150 }
151
152 pub fn user(&self) -> String {
153 self.user
154 .as_deref()
155 .or(self.host_config.user.as_deref())
156 .map(ToString::to_string)
157 .unwrap_or_else(whoami::username)
158 }
159
160 pub fn port(&self) -> u16 {
161 self.host_config.port.or(self.port).unwrap_or(22)
162 }
163
164 pub fn host(&self) -> &str {
165 self.host_config
166 .hostname
167 .as_ref()
168 .unwrap_or(&self.host_name)
169 }
170
171 fn expand_tokens(&self, original: &str) -> String {
176 let mut string = original.to_string();
177 string = string.replace("%u", &self.user());
178 string = string.replace("%h", self.host()); string = string.replace("%H", self.host()); string = string.replace("%p", &format!("{}", self.port())); string = string.replace("%%", "%");
182 string
183 }
184
185 pub async fn stream(&self) -> Result<Stream, Error> {
186 if let Some(ref proxy_command) = self.host_config.proxy_command {
187 let proxy_command = self.expand_tokens(proxy_command);
188 let cmd: Vec<&str> = proxy_command.split(' ').collect();
189 Stream::proxy_command(cmd.first().unwrap_or(&""), cmd.get(1..).unwrap_or(&[]))
190 .await
191 .map_err(Into::into)
192 } else {
193 Stream::tcp_connect((self.host(), self.port()))
194 .await
195 .map_err(Into::into)
196 }
197 }
198}
199
200fn parse_ssh_config(contents: &str) -> Result<SshConfig, Error> {
201 let mut entries = Vec::new();
202
203 let mut host_patterns: Option<Vec<HostPattern>> = None;
204 let mut config = HostConfig::default();
205 let mut found_params = false;
206
207 for line in contents.lines().map(|line| line.trim()) {
208 if line.is_empty() || line.starts_with('#') {
209 continue;
214 }
215 let tokens = line.splitn(2, ' ').collect::<Vec<&str>>();
216 if tokens.len() == 2 {
217 let (key, value) = (tokens.first().unwrap_or(&""), tokens.get(1).unwrap_or(&""));
218 let lower = key.to_lowercase();
219 if lower != "host" {
220 found_params = true;
221 }
222 match lower.as_str() {
223 "host" => {
224 let patterns = value
225 .split_ascii_whitespace()
226 .filter_map(|pattern| {
227 if pattern.is_empty() {
228 None
229 } else {
230 let (pattern, negated) =
231 if let Some(pattern) = pattern.strip_prefix('!') {
232 (pattern, true)
233 } else {
234 (pattern, false)
235 };
236 Some(HostPattern {
237 pattern: pattern.to_string(),
238 negated,
239 })
240 }
241 })
242 .collect();
243
244 if let Some(host_patterns) = host_patterns.take() {
245 let host_config = std::mem::take(&mut config);
246 entries.push(HostEntry {
247 host_patterns,
248 host_config,
249 });
250 } else if found_params {
251 return Err(Error::HostNotFound);
252 }
253
254 found_params = false;
255 host_patterns = Some(patterns);
256 }
257 "user" => config.user = Some(value.trim_start().to_string()),
258 "hostname" => config.hostname = Some(value.trim_start().to_string()),
259 "port" => {
260 if let Ok(port) = value.trim_start().parse::<u16>() {
261 config.port = Some(port)
262 }
263 }
264 "identityfile" => {
265 let identity_file = value.trim_start().strip_quotes().expand_home()?;
266 if let Some(files) = config.identity_file.as_mut() {
267 files.push(identity_file);
268 } else {
269 config.identity_file = Some(vec![identity_file])
270 }
271 }
272 "proxycommand" => config.proxy_command = Some(value.trim_start().to_string()),
273 "proxyjump" => config.proxy_jump = Some(value.trim_start().to_string()),
274 "addkeystoagent" => {
275 let value = match value.to_lowercase().as_str() {
276 "yes" => AddKeysToAgent::Yes,
277 "confirm" => AddKeysToAgent::Confirm,
278 "ask" => AddKeysToAgent::Ask,
279 _ => AddKeysToAgent::No,
280 };
281 config.add_keys_to_agent = Some(value)
282 }
283 "userknownhostsfile" => {
284 config.user_known_hosts_file =
285 Some(value.trim_start().strip_quotes().expand_home()?);
286 }
287 "stricthostkeychecking" => match value.to_lowercase().as_str() {
288 "no" => config.strict_host_key_checking = Some(false),
289 _ => config.strict_host_key_checking = Some(true),
290 },
291 key => {
292 debug!("{key:?}");
293 }
294 }
295 }
296 }
297
298 if let Some(host_patterns) = host_patterns.take() {
299 let host_config = std::mem::take(&mut config);
300 entries.push(HostEntry {
301 host_patterns,
302 host_config,
303 });
304 } else if found_params {
305 return Err(Error::HostNotFound);
307 }
308
309 Ok(SshConfig { entries })
310}
311
312pub fn parse(file: &str, host: &str) -> Result<Config, Error> {
313 let ssh_config = parse_ssh_config(file)?;
314 let host_config = ssh_config.query(host);
315 Ok(Config {
316 host_name: host.to_string(),
317 user: None,
318 port: None,
319 host_config,
320 })
321}
322
323pub fn parse_home(host: &str) -> Result<Config, Error> {
324 let mut home = if let Some(home) = env::home_dir() {
325 home
326 } else {
327 return Err(Error::NoHome);
328 };
329 home.push(".ssh");
330 home.push("config");
331 parse_path(&home, host)
332}
333
334pub fn parse_path<P: AsRef<Path>>(path: P, host: &str) -> Result<Config, Error> {
335 let mut s = String::new();
336 let mut b = std::fs::File::open(path)?;
337 b.read_to_string(&mut s)?;
338 parse(&s, host)
339}
340
341#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
342pub enum AddKeysToAgent {
343 Yes,
344 Confirm,
345 Ask,
346 #[default]
347 No,
348}
349
350fn check_host_against_glob_pattern(candidate: &str, glob_pattern: &str) -> bool {
351 match Glob::new(glob_pattern) {
352 Ok(glob) => glob.compile_matcher().is_match(candidate),
353 _ => false,
354 }
355}
356
357trait SshConfigStrExt {
358 fn strip_quotes(&self) -> Self;
359 fn expand_home(&self) -> Result<PathBuf, Error>;
360}
361
362impl SshConfigStrExt for &str {
363 fn strip_quotes(&self) -> Self {
364 if self.len() > 1
365 && ((self.starts_with('\'') && self.ends_with('\''))
366 || (self.starts_with('\"') && self.ends_with('\"')))
367 {
368 #[allow(clippy::indexing_slicing)] &self[1..self.len() - 1]
370 } else {
371 self
372 }
373 }
374
375 fn expand_home(&self) -> Result<PathBuf, Error> {
376 if self.starts_with("~/") {
377 if let Some(mut home) = env::home_dir() {
378 home.push(self.split_at(2).1);
379 Ok(home)
380 } else {
381 Err(Error::NoHome)
382 }
383 } else {
384 Ok(self.into())
385 }
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 #![allow(clippy::expect_used)]
392 use std::env;
393 use std::path::{Path, PathBuf};
394
395 use crate::{AddKeysToAgent, Config, Error, SshConfigStrExt, parse};
396
397 #[test]
398 fn strip_quotes() {
399 let value = "'this is a test'";
400 assert_eq!("this is a test", value.strip_quotes());
401 let value = "\"this is a test\"";
402 assert_eq!("this is a test", value.strip_quotes());
403 let value = "'this is a test\"";
404 assert_eq!("'this is a test\"", value.strip_quotes());
405 let value = "'this is a test";
406 assert_eq!("'this is a test", value.strip_quotes());
407 let value = "this is a test'";
408 assert_eq!("this is a test'", value.strip_quotes());
409 let value = "this is a test";
410 assert_eq!("this is a test", value.strip_quotes());
411 let value = "";
412 assert_eq!("", value.strip_quotes());
413 let value = "'";
414 assert_eq!("'", value.strip_quotes());
415 let value = "''";
416 assert_eq!("", value.strip_quotes());
417 }
418
419 #[test]
420 fn expand_home() {
421 let value = "~/some/folder".expand_home().expect("expand_home");
422 assert_eq!(
423 format!(
424 "{}{}",
425 env::home_dir().expect("homedir").to_str().expect("to_str"),
426 "/some/folder"
427 ),
428 value.to_str().unwrap()
429 );
430 }
431
432 #[test]
433 fn default_config() {
434 let config: Config = Config::default("some_host");
435 assert_eq!(whoami::username(), config.user());
436 assert_eq!("some_host", config.host_name);
437 assert_eq!(22, config.port());
438 assert_eq!(None, config.host_config.identity_file);
439 assert_eq!(None, config.host_config.proxy_command);
440 assert_eq!(None, config.host_config.proxy_jump);
441 assert_eq!(None, config.host_config.add_keys_to_agent);
442 assert_eq!(None, config.host_config.user_known_hosts_file);
443 assert_eq!(None, config.host_config.strict_host_key_checking);
444 }
445
446 #[test]
447 fn basic_config() {
448 let value = r"#
449Host test_host
450 IdentityFile '~/.ssh/id_ed25519'
451 User trinity
452 Hostname foo.com
453 Port 23
454 AddKeysToAgent confirm
455 UserKnownHostsFile /some/special/host_file
456 StrictHostKeyChecking no
457#";
458 let identity_file = PathBuf::from(format!(
459 "{}{}",
460 env::home_dir().expect("homedir").to_str().expect("to_str"),
461 "/.ssh/id_ed25519"
462 ));
463 let config = parse(value, "test_host").expect("parse");
464 assert_eq!("trinity", config.user());
465 assert_eq!("foo.com", config.host());
466 assert_eq!(23, config.port());
467 assert_eq!(Some(vec![identity_file,]), config.host_config.identity_file);
468 assert_eq!(None, config.host_config.proxy_command);
469 assert_eq!(None, config.host_config.proxy_jump);
470 assert_eq!(
471 Some(AddKeysToAgent::Confirm),
472 config.host_config.add_keys_to_agent
473 );
474 assert_eq!(
475 Some(Path::new("/some/special/host_file")),
476 config.host_config.user_known_hosts_file.as_deref()
477 );
478 assert_eq!(Some(false), config.host_config.strict_host_key_checking);
479 }
480
481 #[test]
482 fn multiple_patterns() {
483 let config = parse(
484 r#"
485Host a.test_host
486 Port 42
487 IdentityFile '/path/to/id_ed25519'
488Host b.test_host
489 User invalid
490Host *.test_host
491 Hostname foo.com
492Host *.test_host !a.test_host
493 User invalid
494Host *
495 User trinity
496 Hostname invalid
497 IdentityFile '/path/to/id_rsa'
498 "#,
499 "a.test_host",
500 )
501 .expect("config is valid");
502
503 assert_eq!("trinity", config.user());
504 assert_eq!("foo.com", config.host());
505 assert_eq!(42, config.port());
506 assert_eq!(
507 Some(vec![
508 PathBuf::from("/path/to/id_ed25519"),
509 PathBuf::from("/path/to/id_rsa")
510 ]),
511 config.host_config.identity_file
512 )
513 }
514
515 #[test]
516 fn empty_ssh_config() {
517 let ssh_config = parse("\n\n\n", "test_host").expect("parse");
518 assert_eq!(ssh_config.host(), "test_host");
519 assert_eq!(ssh_config.port(), 22);
520 }
521
522 #[test]
523 fn malformed() {
524 assert!(matches!(
525 parse("Hostname foo.com", "malformed"),
526 Err(Error::HostNotFound)
527 ));
528 assert!(matches!(
529 parse("Hostname foo.com\nHost foo", "malformed"),
530 Err(Error::HostNotFound)
531 ))
532 }
533
534 #[test]
535 fn is_clone() {
536 let config: Config = Config::default("some_host");
537 let _ = config.clone();
538 }
539
540 #[test]
541 fn comment_handling() {
542 const CONFIG: &str = r#"
543# top of the config file
544Host a.test_host
545 # indented comment
546 User a
547 # indented comment between parameters
548 Hostname alias_of_a
549# middle of the config file
550Host b.test_host
551 # multiple line
552 # indented comment
553 User b
554 # multiple line
555 # indented comment between parameters
556 Hostname alias_of_b
557# end of the config file
558 "#;
559 let config = parse(CONFIG, "a.test_host").expect("config is invalid");
560 assert_eq!("a", config.user());
561 assert_eq!("alias_of_a", config.host());
562
563 let config = parse(CONFIG, "b.test_host").expect("config is invalid");
564 assert_eq!("b", config.user());
565 assert_eq!("alias_of_b", config.host());
566 }
567}