Skip to main content

ssh2_config/
lib.rs

1#![crate_name = "ssh2_config"]
2#![crate_type = "lib"]
3
4//! # ssh2-config
5//!
6//! ssh2-config a library which provides a parser for the SSH configuration file,
7//! to be used in pair with the [ssh2](https://github.com/alexcrichton/ssh2-rs) crate, or
8//! in general with any other OpenSSH compatible SSH client implementation.
9//!
10//! This library provides a method to parse the configuration file and returns the
11//! configuration parsed into a structure.
12//! The [`SshConfig`] structure provides all the attributes which **can** be used to configure the **ssh2 Session**
13//! and to resolve the host, port and username.
14//!
15//! Once the configuration has been parsed you can use the [`SshConfig::query`]
16//! method to query configuration for a certain host, based on the configured patterns.
17//! Even if many attributes are not exposed, since not supported, there is anyway a validation of the configuration,
18//! so invalid configuration will result in a parsing error.
19//!
20//! The reference used for the configuration file and how parameters are resolved is the OpenSSH one,
21//! is described at <http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5>.
22//!
23//! ## Get started
24//!
25//! First of you need to add **ssh2-config** to your project dependencies:
26//!
27//! ```toml
28//! ssh2-config = "0.9"
29//! ```
30//!
31//! ## Feature flags
32//!
33//! | name              | description                                               | default |
34//! | ----------------- | --------------------------------------------------------- | ------- |
35//! | `default`         | Enable the default feature set, which is currently empty. | ✔       |
36//! | `nolog`           | Disable logging at compile time.                          |         |
37//! | `reload-ssh-algo` | Regenerate default algorithms from OpenSSH source.        |         |
38//!
39//! ## Example
40//!
41//! Here is a basic example:
42//!
43//! ```rust
44//!
45//! use ssh2::Session;
46//! use ssh2_config::{HostParams, ParseRule, SshConfig};
47//! use std::fs::File;
48//! use std::io::BufReader;
49//! use std::path::Path;
50//!
51//! let mut reader = BufReader::new(
52//!     File::open(Path::new("./assets/ssh.config"))
53//!         .expect("Could not open configuration file")
54//! );
55//!
56//! let config = SshConfig::default().parse(&mut reader, ParseRule::STRICT).expect("Failed to parse configuration");
57//!
58//! // Query parameters for your host
59//! // If there's no rule for your host, default params are returned
60//! let params = config.query("192.168.1.2");
61//!
62//! // ...
63//!
64//! // serialize configuration to string
65//! let s = config.to_string();
66//!
67//! ```
68//!
69//! ---
70//!
71//! ## How host parameters are resolved
72//!
73//! This topic has been debated a lot over the years, so finally since 0.5 this has been fixed to follow the official ssh configuration file rules, as described in the MAN <https://man.openbsd.org/OpenBSD-current/man5/ssh_config.5#DESCRIPTION>.
74//!
75//! > Unless noted otherwise, for each parameter, the first obtained value will be used. The configuration files contain sections separated by Host specifications, and that section is only applied for hosts that match one of the patterns given in the specification. The matched host name is usually the one given on the command line (see the CanonicalizeHostname option for exceptions).
76//! >
77//! > Since the first obtained value for each parameter is used, more host-specific declarations should be given near the beginning of the file, and general defaults at the end.
78//!
79//! This means that:
80//!
81//! 1. The first obtained value parsing the configuration top-down will be used
82//! 2. Host specific rules ARE not overriding default ones if they are not the first obtained value
83//! 3. If you want to achieve default values to be less specific than host specific ones, you should put the default values at the end of the configuration file using `Host *`.
84//! 4. Algorithms, so `KexAlgorithms`, `Ciphers`, `MACs` and `HostKeyAlgorithms` use a different resolvers which supports appending, excluding and heading insertions, as described in the man page at ciphers: <https://man.openbsd.org/OpenBSD-current/man5/ssh_config.5#Ciphers>.
85//!
86//! ### Resolvers examples
87//!
88//! ```ssh
89//! Compression yes
90//!
91//! Host 192.168.1.1
92//!     Compression no
93//! ```
94//!
95//! If we get rules for `192.168.1.1`, compression will be `yes`, because it's the first obtained value.
96//!
97//! ```ssh
98//! Host 192.168.1.1
99//!     Compression no
100//!
101//! Host *
102//!     Compression yes
103//! ```
104//!
105//! If we get rules for `192.168.1.1`, compression will be `no`, because it's the first obtained value.
106//!
107//! If we get rules for `172.168.1.1`, compression will be `yes`, because it's the first obtained value MATCHING the host rule.
108//!
109//! ```ssh
110//!
111//! Host 192.168.1.1
112//!     Ciphers +c
113//! ```
114//!
115//! If we get rules for `192.168.1.1`, ciphers will be `c` appended to default algorithms, which can be specified in the [`SshConfig`] constructor.
116//!
117//! ## Configuring default algorithms
118//!
119//! When you invoke [`SshConfig::default`], the default algorithms are set from openssh source code,
120//! which can be seen in the [`default_openssh_algorithms`] function documentation.
121//!
122//! If you want you can use a custom constructor [`SshConfig::default_algorithms`] to set your own default algorithms.
123
124#![doc(html_playground_url = "https://play.rust-lang.org")]
125
126#[macro_use]
127extern crate log;
128
129use std::fmt;
130use std::fs::File;
131use std::io::{self, BufRead, BufReader};
132use std::path::PathBuf;
133use std::time::Duration;
134// -- modules
135mod default_algorithms;
136mod host;
137mod params;
138mod parser;
139mod serializer;
140
141// -- export
142pub use self::default_algorithms::{
143    DefaultAlgorithms, default_algorithms as default_openssh_algorithms,
144};
145use self::host::clauses_intersect;
146pub use self::host::{Host, HostClause};
147#[doc(inline)]
148pub use self::params::{
149    Algorithms, HostParams, RemoteForward, RemoteForwardDestination, RemoteForwardListen,
150};
151pub use self::parser::{ParseRule, SshParserError, SshParserResult};
152
153/// Describes the ssh configuration.
154/// Configuration is described in this document: <http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5>
155#[derive(Debug, Clone, Eq, Default)]
156pub struct SshConfig {
157    /// Default algorithms for ssh.
158    default_algorithms: DefaultAlgorithms,
159    /// Home directory used to expand `~` and to resolve relative `Include` paths against `<home>/.ssh`.
160    /// `None` means the home directory is taken from [`dirs::home_dir`].
161    home_dir: Option<PathBuf>,
162    /// Rulesets for hosts.
163    /// Default config will be stored with key `*`
164    hosts: Vec<Host>,
165    /// Inherited scopes for each host rule.
166    host_scopes: Vec<HostScope>,
167}
168
169pub(crate) type HostScope = Vec<Vec<HostClause>>;
170
171/// Compares parsed configurations, ignoring the home directory used to parse them.
172impl PartialEq for SshConfig {
173    fn eq(&self, other: &Self) -> bool {
174        let Self {
175            default_algorithms,
176            home_dir: _,
177            hosts,
178            host_scopes,
179        } = self;
180        let Self {
181            default_algorithms: other_default_algorithms,
182            home_dir: _,
183            hosts: other_hosts,
184            host_scopes: other_host_scopes,
185        } = other;
186        default_algorithms == other_default_algorithms
187            && hosts == other_hosts
188            && host_scopes == other_host_scopes
189    }
190}
191
192impl fmt::Display for SshConfig {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        serializer::SshConfigSerializer::from(self).serialize(f)
195    }
196}
197
198impl SshConfig {
199    /// Constructs a new [`SshConfig`] from a list of [`Host`]s.
200    ///
201    /// You can later also set the [`DefaultAlgorithms`] using [`SshConfig::default_algorithms`].
202    ///
203    /// ```rust
204    /// use ssh2_config::{DefaultAlgorithms, Host, SshConfig};
205    ///
206    /// let config = SshConfig::from_hosts(vec![/* put your hosts here */]).default_algorithms(DefaultAlgorithms::default());
207    /// ```
208    pub fn from_hosts(hosts: Vec<Host>) -> Self {
209        Self {
210            default_algorithms: DefaultAlgorithms::default(),
211            home_dir: None,
212            host_scopes: vec![Vec::new(); hosts.len()],
213            hosts,
214        }
215    }
216
217    /// Appends a host rule and its inherited scope.
218    pub(crate) fn append_host(&mut self, host: Host, scope: HostScope) -> usize {
219        self.hosts.push(host);
220        self.host_scopes.push(scope);
221        self.hosts.len() - 1
222    }
223
224    /// Returns whether a host rule and all its inherited scopes match a query.
225    pub(crate) fn host_matches(&self, index: usize, pattern: &str) -> bool {
226        let Some(host) = self.hosts.get(index) else {
227            return false;
228        };
229        if !host.intersects(pattern) {
230            return false;
231        }
232
233        self.host_scopes.get(index).is_none_or(|scope| {
234            scope
235                .iter()
236                .all(|clauses| clauses_intersect(clauses, pattern))
237        })
238    }
239
240    /// Query params for a certain host. Returns [`HostParams`] for the host.
241    pub fn query<S: AsRef<str>>(&self, pattern: S) -> HostParams {
242        let mut params = HostParams::new(&self.default_algorithms);
243        // iter keys, overwrite if None top-down
244        for (index, host) in self.hosts.iter().enumerate() {
245            if self.host_matches(index, pattern.as_ref()) {
246                debug!(
247                    "Merging params for host: {:?} into params {params:?}",
248                    host.pattern
249                );
250                params.overwrite_if_none(&host.params);
251                trace!("Params after merge: {params:?}");
252            }
253        }
254        // return calculated params
255        params
256    }
257
258    /// Get an iterator over the [`Host`]s which intersect with the given host pattern
259    pub fn intersecting_hosts(&self, pattern: &str) -> impl Iterator<Item = &'_ Host> {
260        self.hosts
261            .iter()
262            .enumerate()
263            .filter(move |(index, _)| self.host_matches(*index, pattern))
264            .map(|(_, host)| host)
265    }
266
267    /// Set default algorithms for ssh.
268    ///
269    /// If you want to use the default algorithms from the system, you can use the `Default::default()` method.
270    pub fn default_algorithms(mut self, algos: DefaultAlgorithms) -> Self {
271        self.default_algorithms = algos;
272
273        self
274    }
275
276    /// Sets the home directory used to resolve `~` and relative `Include` paths.
277    ///
278    /// A leading `~` or `~/` is expanded against this directory, and relative `Include`
279    /// paths are resolved against `<home>/.ssh`. Other `~`-prefixed paths, such as `~user/...`,
280    /// are kept as-is in both `Include` and path options such as `IdentityFile`; an `Include`
281    /// pattern that matches no file is skipped. When it is not set, the home directory is taken
282    /// from [`dirs::home_dir`].
283    ///
284    /// # Examples
285    ///
286    /// ```rust
287    /// use std::io::BufReader;
288    /// use std::path::PathBuf;
289    ///
290    /// use ssh2_config::{ParseRule, SshConfig};
291    ///
292    /// let mut reader = BufReader::new("IdentityFile ~/.ssh/id_ed25519\n".as_bytes());
293    /// let config = SshConfig::default()
294    ///     .home_dir("/home/alice")
295    ///     .parse(&mut reader, ParseRule::STRICT)
296    ///     .expect("Failed to parse configuration");
297    ///
298    /// assert_eq!(
299    ///     config.query("any").identity_file,
300    ///     Some(vec![PathBuf::from("/home/alice/.ssh/id_ed25519")])
301    /// );
302    /// ```
303    pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
304        self.home_dir = Some(home_dir.into());
305
306        self
307    }
308
309    /// Parse [`SshConfig`] from stream which implements [`BufRead`] and return parsed configuration or parser error
310    ///
311    /// `~` is expanded against the home set with [`SshConfig::home_dir`], or against
312    /// [`dirs::home_dir`] when none is set, and relative `Include` paths are resolved against
313    /// `<home>/.ssh`.
314    ///
315    /// ## Example
316    ///
317    /// ```rust,ignore
318    /// let mut reader = BufReader::new(
319    ///    File::open(Path::new("./assets/ssh.config"))
320    ///       .expect("Could not open configuration file")
321    /// );
322    ///
323    /// let config = SshConfig::default().parse(&mut reader, ParseRule::STRICT).expect("Failed to parse configuration");
324    /// ```
325    pub fn parse(mut self, reader: &mut impl BufRead, rules: ParseRule) -> SshParserResult<Self> {
326        let home = self.home_dir.clone().or_else(dirs::home_dir);
327        parser::SshConfigParser::parse(
328            &mut self,
329            reader,
330            rules,
331            None,
332            &mut parser::IncludeContext::new(home),
333        )
334        .map(|_| self)
335    }
336
337    /// Parse `~/.ssh/config`` file and return parsed configuration [`SshConfig`] or parser error
338    ///
339    /// The file and every `~` or relative `Include` path in it are resolved against
340    /// [`dirs::home_dir`]. To parse the configuration of another home, open `<home>/.ssh/config`
341    /// and parse it with [`SshConfig::home_dir`] set, as shown below.
342    ///
343    /// # Examples
344    ///
345    /// ```rust,no_run
346    /// use std::fs::File;
347    /// use std::io::BufReader;
348    /// use std::path::Path;
349    ///
350    /// use ssh2_config::{ParseRule, SshConfig};
351    ///
352    /// let home = Path::new("/home/alice");
353    /// let mut reader = BufReader::new(
354    ///     File::open(home.join(".ssh").join("config")).expect("Could not open configuration file"),
355    /// );
356    /// let config = SshConfig::default()
357    ///     .home_dir(home)
358    ///     .parse(&mut reader, ParseRule::STRICT)
359    ///     .expect("Failed to parse configuration");
360    /// ```
361    pub fn parse_default_file(rules: ParseRule) -> SshParserResult<Self> {
362        let ssh_folder = dirs::home_dir()
363            .ok_or_else(|| {
364                SshParserError::Io(io::Error::new(
365                    io::ErrorKind::NotFound,
366                    "Home folder not found",
367                ))
368            })?
369            .join(".ssh");
370
371        let mut reader =
372            BufReader::new(File::open(ssh_folder.join("config")).map_err(SshParserError::Io)?);
373
374        Self::default().parse(&mut reader, rules)
375    }
376
377    /// Get list of [`Host`]s in the configuration
378    pub fn get_hosts(&self) -> &Vec<Host> {
379        &self.hosts
380    }
381}
382
383#[cfg(test)]
384fn test_log() {
385    use std::sync::Once;
386
387    static INIT: Once = Once::new();
388
389    INIT.call_once(|| {
390        let _ = env_logger::builder()
391            .filter_level(log::LevelFilter::Trace)
392            .is_test(true)
393            .try_init();
394    });
395}
396
397#[cfg(test)]
398mod tests {
399
400    use pretty_assertions::assert_eq;
401
402    use super::*;
403
404    #[test]
405    fn should_init_ssh_config() {
406        test_log();
407
408        let config = SshConfig::default();
409        assert_eq!(config.hosts.len(), 0);
410        assert_eq!(
411            config.query("192.168.1.2"),
412            HostParams::new(&DefaultAlgorithms::default())
413        );
414    }
415
416    #[test]
417    fn should_parse_default_config() -> Result<(), parser::SshParserError> {
418        test_log();
419
420        let _config = SshConfig::parse_default_file(ParseRule::ALLOW_UNKNOWN_FIELDS)?;
421        Ok(())
422    }
423
424    #[test]
425    fn should_parse_config() -> Result<(), parser::SshParserError> {
426        test_log();
427
428        use std::fs::File;
429        use std::io::BufReader;
430        use std::path::Path;
431
432        let mut reader = BufReader::new(
433            File::open(Path::new("./assets/ssh.config"))
434                .expect("Could not open configuration file"),
435        );
436
437        SshConfig::default().parse(&mut reader, ParseRule::STRICT)?;
438
439        Ok(())
440    }
441
442    #[test]
443    fn should_ignore_home_dir_when_comparing() -> Result<(), parser::SshParserError> {
444        test_log();
445
446        let input = "Host a\n  User x\n";
447        let with_home = SshConfig::default()
448            .home_dir("/home/alice")
449            .parse(&mut input.as_bytes(), ParseRule::STRICT)?;
450        let without_home = SshConfig::default().parse(&mut input.as_bytes(), ParseRule::STRICT)?;
451        assert_eq!(with_home, without_home);
452
453        Ok(())
454    }
455
456    #[test]
457    fn should_query_ssh_config() {
458        test_log();
459
460        let mut config = SshConfig::default();
461        // add config
462        let mut params1 = HostParams::new(&DefaultAlgorithms::default());
463        params1.bind_address = Some("0.0.0.0".to_string());
464        config.hosts.push(Host::new(
465            vec![HostClause::new(String::from("192.168.*.*"), false)],
466            params1.clone(),
467        ));
468        let mut params2 = HostParams::new(&DefaultAlgorithms::default());
469        params2.bind_interface = Some(String::from("tun0"));
470        config.hosts.push(Host::new(
471            vec![HostClause::new(String::from("192.168.10.*"), false)],
472            params2.clone(),
473        ));
474
475        let mut params3 = HostParams::new(&DefaultAlgorithms::default());
476        params3.host_name = Some("172.26.104.4".to_string());
477        config.hosts.push(Host::new(
478            vec![
479                HostClause::new(String::from("172.26.*.*"), false),
480                HostClause::new(String::from("172.26.104.4"), true),
481            ],
482            params3.clone(),
483        ));
484        // Query
485        assert_eq!(config.query("192.168.1.32"), params1);
486        // merged case
487        params1.overwrite_if_none(&params2);
488        assert_eq!(config.query("192.168.10.1"), params1);
489        // Negated case
490        assert_eq!(config.query("172.26.254.1"), params3);
491        assert_eq!(
492            config.query("172.26.104.4"),
493            HostParams::new(&DefaultAlgorithms::default())
494        );
495    }
496
497    #[test]
498    fn roundtrip() {
499        test_log();
500
501        // Root host
502        let mut default_host_params = HostParams::new(&DefaultAlgorithms::default());
503        default_host_params.add_keys_to_agent = Some(true);
504        let root_host_config = Host::new(
505            vec![HostClause::new(String::from("*"), false)],
506            default_host_params,
507        );
508
509        // A host using proxy jumps
510        let mut host_params = HostParams::new(&DefaultAlgorithms::default());
511        host_params.host_name = Some(String::from("192.168.10.1"));
512        host_params.proxy_jump = Some(vec![String::from("jump.example.com")]);
513        let host_config = Host::new(
514            vec![HostClause::new(String::from("server"), false)],
515            host_params,
516        );
517
518        // Create the overall config and serialise it
519        let config = SshConfig::from_hosts(vec![root_host_config, host_config]);
520        let config_string = config.to_string();
521
522        // Parse the serialised string
523        let mut reader = std::io::BufReader::new(config_string.as_bytes());
524        let config_parsed = SshConfig::default()
525            .parse(&mut reader, ParseRule::STRICT)
526            .expect("Could not parse config.");
527
528        assert_eq!(config, config_parsed);
529    }
530
531    #[test]
532    fn should_get_intersecting_hosts() {
533        test_log();
534
535        let mut config = SshConfig::default();
536        let mut params1 = HostParams::new(&DefaultAlgorithms::default());
537        params1.bind_address = Some("0.0.0.0".to_string());
538        config.hosts.push(Host::new(
539            vec![HostClause::new(String::from("192.168.*.*"), false)],
540            params1,
541        ));
542        let mut params2 = HostParams::new(&DefaultAlgorithms::default());
543        params2.bind_interface = Some(String::from("tun0"));
544        config.hosts.push(Host::new(
545            vec![HostClause::new(String::from("192.168.10.*"), false)],
546            params2,
547        ));
548        let mut params3 = HostParams::new(&DefaultAlgorithms::default());
549        params3.host_name = Some("172.26.104.4".to_string());
550        config.hosts.push(Host::new(
551            vec![HostClause::new(String::from("172.26.*.*"), false)],
552            params3,
553        ));
554
555        // Test intersecting_hosts returns correct hosts
556        let matching: Vec<_> = config.intersecting_hosts("192.168.10.1").collect();
557        assert_eq!(matching.len(), 2);
558
559        let matching: Vec<_> = config.intersecting_hosts("192.168.1.1").collect();
560        assert_eq!(matching.len(), 1);
561
562        let matching: Vec<_> = config.intersecting_hosts("172.26.0.1").collect();
563        assert_eq!(matching.len(), 1);
564
565        // No matches
566        let matching: Vec<_> = config.intersecting_hosts("10.0.0.1").collect();
567        assert_eq!(matching.len(), 0);
568    }
569
570    #[test]
571    fn should_set_default_algorithms() {
572        test_log();
573
574        let custom_algos = DefaultAlgorithms {
575            ca_signature_algorithms: vec!["custom-algo".to_string()],
576            ciphers: vec!["custom-cipher".to_string()],
577            host_key_algorithms: vec!["custom-hostkey".to_string()],
578            kex_algorithms: vec!["custom-kex".to_string()],
579            mac: vec!["custom-mac".to_string()],
580            pubkey_accepted_algorithms: vec!["custom-pubkey".to_string()],
581        };
582
583        let config = SshConfig::default().default_algorithms(custom_algos.clone());
584
585        assert_eq!(config.default_algorithms, custom_algos);
586    }
587
588    #[test]
589    fn should_create_config_from_hosts() {
590        test_log();
591
592        let mut params = HostParams::new(&DefaultAlgorithms::default());
593        params.host_name = Some("example.com".to_string());
594        let host = Host::new(
595            vec![HostClause::new(String::from("example"), false)],
596            params,
597        );
598
599        let config = SshConfig::from_hosts(vec![host.clone()]);
600        assert_eq!(config.get_hosts().len(), 1);
601        assert_eq!(config.get_hosts()[0], host);
602    }
603
604    #[test]
605    fn should_query_empty_config() {
606        test_log();
607
608        let config = SshConfig::default();
609        let params = config.query("any-host");
610
611        // Should return default params
612        assert!(params.host_name.is_none());
613        assert!(params.port.is_none());
614    }
615
616    #[test]
617    fn should_display_empty_config() {
618        test_log();
619
620        let config = SshConfig::default();
621        let output = config.to_string();
622        assert!(output.is_empty());
623    }
624}