Skip to main content

russh_config/
lib.rs

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)]
16/// anyhow::Errors.
17pub 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    /// http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5#User
32    pub user: Option<String>,
33    /// http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5#Hostname
34    pub hostname: Option<String>,
35    /// http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5#Port
36    pub port: Option<u16>,
37    /// http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5#IdentityFile
38    pub identity_file: Option<Vec<PathBuf>>,
39    /// http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5#ProxyCommand
40    pub proxy_command: Option<String>,
41    /// http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5#ProxyJump
42    pub proxy_jump: Option<String>,
43    /// http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5#AddKeysToAgent
44    pub add_keys_to_agent: Option<AddKeysToAgent>,
45    /// http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5#UserKnownHostsFile
46    pub user_known_hosts_file: Option<PathBuf>,
47    /// http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5#StrictHostKeyChecking
48    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        // Special-case IdentityFile param
75        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/// https://man.openbsd.org/OpenBSD-current/man5/ssh_config.5#PATTERNS
87#[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                    // "If a negated entry is matched, then the Host entry is ignored, regardless of whether any other patterns on the line match."
106                    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    // Look for any of the ssh_config(5) percent-style tokens and expand them
172    // based on current data in the struct, returning a new String. This function
173    // can be employed late/lazy eg just before establishing a stream using ProxyCommand
174    // but also can be used to modify Hostname as config parse time
175    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()); // remote hostname (from context "host")
179        string = string.replace("%H", self.host()); // remote hostname (from context "host")
180        string = string.replace("%p", &format!("{}", self.port())); // original typed hostname (from context "host")
181        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            // skip comments and empty lines
210            //
211            // Reference: http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5
212            // "Lines starting with ‘#’ and empty lines are interpreted as comments."
213            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        // Found configurations, but no Host (or Match) key.
306        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)] // length checked
369            &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}