russh_config/
lib.rs

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