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.8"
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, PartialEq, Eq, Default)]
156pub struct SshConfig {
157    /// Default algorithms for ssh.
158    default_algorithms: DefaultAlgorithms,
159    /// Rulesets for hosts.
160    /// Default config will be stored with key `*`
161    hosts: Vec<Host>,
162    /// Inherited scopes for each host rule.
163    host_scopes: Vec<HostScope>,
164}
165
166pub(crate) type HostScope = Vec<Vec<HostClause>>;
167
168impl fmt::Display for SshConfig {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        serializer::SshConfigSerializer::from(self).serialize(f)
171    }
172}
173
174impl SshConfig {
175    /// Constructs a new [`SshConfig`] from a list of [`Host`]s.
176    ///
177    /// You can later also set the [`DefaultAlgorithms`] using [`SshConfig::default_algorithms`].
178    ///
179    /// ```rust
180    /// use ssh2_config::{DefaultAlgorithms, Host, SshConfig};
181    ///
182    /// let config = SshConfig::from_hosts(vec![/* put your hosts here */]).default_algorithms(DefaultAlgorithms::default());
183    /// ```
184    pub fn from_hosts(hosts: Vec<Host>) -> Self {
185        Self {
186            default_algorithms: DefaultAlgorithms::default(),
187            host_scopes: vec![Vec::new(); hosts.len()],
188            hosts,
189        }
190    }
191
192    /// Appends a host rule and its inherited scope.
193    pub(crate) fn append_host(&mut self, host: Host, scope: HostScope) -> usize {
194        self.hosts.push(host);
195        self.host_scopes.push(scope);
196        self.hosts.len() - 1
197    }
198
199    /// Returns whether a host rule and all its inherited scopes match a query.
200    pub(crate) fn host_matches(&self, index: usize, pattern: &str) -> bool {
201        let Some(host) = self.hosts.get(index) else {
202            return false;
203        };
204        if !host.intersects(pattern) {
205            return false;
206        }
207
208        self.host_scopes.get(index).is_none_or(|scope| {
209            scope
210                .iter()
211                .all(|clauses| clauses_intersect(clauses, pattern))
212        })
213    }
214
215    /// Query params for a certain host. Returns [`HostParams`] for the host.
216    pub fn query<S: AsRef<str>>(&self, pattern: S) -> HostParams {
217        let mut params = HostParams::new(&self.default_algorithms);
218        // iter keys, overwrite if None top-down
219        for (index, host) in self.hosts.iter().enumerate() {
220            if self.host_matches(index, pattern.as_ref()) {
221                debug!(
222                    "Merging params for host: {:?} into params {params:?}",
223                    host.pattern
224                );
225                params.overwrite_if_none(&host.params);
226                trace!("Params after merge: {params:?}");
227            }
228        }
229        // return calculated params
230        params
231    }
232
233    /// Get an iterator over the [`Host`]s which intersect with the given host pattern
234    pub fn intersecting_hosts(&self, pattern: &str) -> impl Iterator<Item = &'_ Host> {
235        self.hosts
236            .iter()
237            .enumerate()
238            .filter(move |(index, _)| self.host_matches(*index, pattern))
239            .map(|(_, host)| host)
240    }
241
242    /// Set default algorithms for ssh.
243    ///
244    /// If you want to use the default algorithms from the system, you can use the `Default::default()` method.
245    pub fn default_algorithms(mut self, algos: DefaultAlgorithms) -> Self {
246        self.default_algorithms = algos;
247
248        self
249    }
250
251    /// Parse [`SshConfig`] from stream which implements [`BufRead`] and return parsed configuration or parser error
252    ///
253    /// ## Example
254    ///
255    /// ```rust,ignore
256    /// let mut reader = BufReader::new(
257    ///    File::open(Path::new("./assets/ssh.config"))
258    ///       .expect("Could not open configuration file")
259    /// );
260    ///
261    /// let config = SshConfig::default().parse(&mut reader, ParseRule::STRICT).expect("Failed to parse configuration");
262    /// ```
263    pub fn parse(mut self, reader: &mut impl BufRead, rules: ParseRule) -> SshParserResult<Self> {
264        parser::SshConfigParser::parse(&mut self, reader, rules, None).map(|_| self)
265    }
266
267    /// Parse `~/.ssh/config`` file and return parsed configuration [`SshConfig`] or parser error
268    pub fn parse_default_file(rules: ParseRule) -> SshParserResult<Self> {
269        let ssh_folder = dirs::home_dir()
270            .ok_or_else(|| {
271                SshParserError::Io(io::Error::new(
272                    io::ErrorKind::NotFound,
273                    "Home folder not found",
274                ))
275            })?
276            .join(".ssh");
277
278        let mut reader =
279            BufReader::new(File::open(ssh_folder.join("config")).map_err(SshParserError::Io)?);
280
281        Self::default().parse(&mut reader, rules)
282    }
283
284    /// Get list of [`Host`]s in the configuration
285    pub fn get_hosts(&self) -> &Vec<Host> {
286        &self.hosts
287    }
288}
289
290#[cfg(test)]
291fn test_log() {
292    use std::sync::Once;
293
294    static INIT: Once = Once::new();
295
296    INIT.call_once(|| {
297        let _ = env_logger::builder()
298            .filter_level(log::LevelFilter::Trace)
299            .is_test(true)
300            .try_init();
301    });
302}
303
304#[cfg(test)]
305mod tests {
306
307    use pretty_assertions::assert_eq;
308
309    use super::*;
310
311    #[test]
312    fn should_init_ssh_config() {
313        test_log();
314
315        let config = SshConfig::default();
316        assert_eq!(config.hosts.len(), 0);
317        assert_eq!(
318            config.query("192.168.1.2"),
319            HostParams::new(&DefaultAlgorithms::default())
320        );
321    }
322
323    #[test]
324    fn should_parse_default_config() -> Result<(), parser::SshParserError> {
325        test_log();
326
327        let _config = SshConfig::parse_default_file(ParseRule::ALLOW_UNKNOWN_FIELDS)?;
328        Ok(())
329    }
330
331    #[test]
332    fn should_parse_config() -> Result<(), parser::SshParserError> {
333        test_log();
334
335        use std::fs::File;
336        use std::io::BufReader;
337        use std::path::Path;
338
339        let mut reader = BufReader::new(
340            File::open(Path::new("./assets/ssh.config"))
341                .expect("Could not open configuration file"),
342        );
343
344        SshConfig::default().parse(&mut reader, ParseRule::STRICT)?;
345
346        Ok(())
347    }
348
349    #[test]
350    fn should_query_ssh_config() {
351        test_log();
352
353        let mut config = SshConfig::default();
354        // add config
355        let mut params1 = HostParams::new(&DefaultAlgorithms::default());
356        params1.bind_address = Some("0.0.0.0".to_string());
357        config.hosts.push(Host::new(
358            vec![HostClause::new(String::from("192.168.*.*"), false)],
359            params1.clone(),
360        ));
361        let mut params2 = HostParams::new(&DefaultAlgorithms::default());
362        params2.bind_interface = Some(String::from("tun0"));
363        config.hosts.push(Host::new(
364            vec![HostClause::new(String::from("192.168.10.*"), false)],
365            params2.clone(),
366        ));
367
368        let mut params3 = HostParams::new(&DefaultAlgorithms::default());
369        params3.host_name = Some("172.26.104.4".to_string());
370        config.hosts.push(Host::new(
371            vec![
372                HostClause::new(String::from("172.26.*.*"), false),
373                HostClause::new(String::from("172.26.104.4"), true),
374            ],
375            params3.clone(),
376        ));
377        // Query
378        assert_eq!(config.query("192.168.1.32"), params1);
379        // merged case
380        params1.overwrite_if_none(&params2);
381        assert_eq!(config.query("192.168.10.1"), params1);
382        // Negated case
383        assert_eq!(config.query("172.26.254.1"), params3);
384        assert_eq!(
385            config.query("172.26.104.4"),
386            HostParams::new(&DefaultAlgorithms::default())
387        );
388    }
389
390    #[test]
391    fn roundtrip() {
392        test_log();
393
394        // Root host
395        let mut default_host_params = HostParams::new(&DefaultAlgorithms::default());
396        default_host_params.add_keys_to_agent = Some(true);
397        let root_host_config = Host::new(
398            vec![HostClause::new(String::from("*"), false)],
399            default_host_params,
400        );
401
402        // A host using proxy jumps
403        let mut host_params = HostParams::new(&DefaultAlgorithms::default());
404        host_params.host_name = Some(String::from("192.168.10.1"));
405        host_params.proxy_jump = Some(vec![String::from("jump.example.com")]);
406        let host_config = Host::new(
407            vec![HostClause::new(String::from("server"), false)],
408            host_params,
409        );
410
411        // Create the overall config and serialise it
412        let config = SshConfig::from_hosts(vec![root_host_config, host_config]);
413        let config_string = config.to_string();
414
415        // Parse the serialised string
416        let mut reader = std::io::BufReader::new(config_string.as_bytes());
417        let config_parsed = SshConfig::default()
418            .parse(&mut reader, ParseRule::STRICT)
419            .expect("Could not parse config.");
420
421        assert_eq!(config, config_parsed);
422    }
423
424    #[test]
425    fn should_get_intersecting_hosts() {
426        test_log();
427
428        let mut config = SshConfig::default();
429        let mut params1 = HostParams::new(&DefaultAlgorithms::default());
430        params1.bind_address = Some("0.0.0.0".to_string());
431        config.hosts.push(Host::new(
432            vec![HostClause::new(String::from("192.168.*.*"), false)],
433            params1,
434        ));
435        let mut params2 = HostParams::new(&DefaultAlgorithms::default());
436        params2.bind_interface = Some(String::from("tun0"));
437        config.hosts.push(Host::new(
438            vec![HostClause::new(String::from("192.168.10.*"), false)],
439            params2,
440        ));
441        let mut params3 = HostParams::new(&DefaultAlgorithms::default());
442        params3.host_name = Some("172.26.104.4".to_string());
443        config.hosts.push(Host::new(
444            vec![HostClause::new(String::from("172.26.*.*"), false)],
445            params3,
446        ));
447
448        // Test intersecting_hosts returns correct hosts
449        let matching: Vec<_> = config.intersecting_hosts("192.168.10.1").collect();
450        assert_eq!(matching.len(), 2);
451
452        let matching: Vec<_> = config.intersecting_hosts("192.168.1.1").collect();
453        assert_eq!(matching.len(), 1);
454
455        let matching: Vec<_> = config.intersecting_hosts("172.26.0.1").collect();
456        assert_eq!(matching.len(), 1);
457
458        // No matches
459        let matching: Vec<_> = config.intersecting_hosts("10.0.0.1").collect();
460        assert_eq!(matching.len(), 0);
461    }
462
463    #[test]
464    fn should_set_default_algorithms() {
465        test_log();
466
467        let custom_algos = DefaultAlgorithms {
468            ca_signature_algorithms: vec!["custom-algo".to_string()],
469            ciphers: vec!["custom-cipher".to_string()],
470            host_key_algorithms: vec!["custom-hostkey".to_string()],
471            kex_algorithms: vec!["custom-kex".to_string()],
472            mac: vec!["custom-mac".to_string()],
473            pubkey_accepted_algorithms: vec!["custom-pubkey".to_string()],
474        };
475
476        let config = SshConfig::default().default_algorithms(custom_algos.clone());
477
478        assert_eq!(config.default_algorithms, custom_algos);
479    }
480
481    #[test]
482    fn should_create_config_from_hosts() {
483        test_log();
484
485        let mut params = HostParams::new(&DefaultAlgorithms::default());
486        params.host_name = Some("example.com".to_string());
487        let host = Host::new(
488            vec![HostClause::new(String::from("example"), false)],
489            params,
490        );
491
492        let config = SshConfig::from_hosts(vec![host.clone()]);
493        assert_eq!(config.get_hosts().len(), 1);
494        assert_eq!(config.get_hosts()[0], host);
495    }
496
497    #[test]
498    fn should_query_empty_config() {
499        test_log();
500
501        let config = SshConfig::default();
502        let params = config.query("any-host");
503
504        // Should return default params
505        assert!(params.host_name.is_none());
506        assert!(params.port.is_none());
507    }
508
509    #[test]
510    fn should_display_empty_config() {
511        test_log();
512
513        let config = SshConfig::default();
514        let output = config.to_string();
515        assert!(output.is_empty());
516    }
517}