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.6"
29//! ```
30//!
31//! ## Example
32//!
33//! Here is a basic example:
34//!
35//! ```rust
36//!
37//! use ssh2::Session;
38//! use ssh2_config::{HostParams, ParseRule, SshConfig};
39//! use std::fs::File;
40//! use std::io::BufReader;
41//! use std::path::Path;
42//!
43//! let mut reader = BufReader::new(
44//!     File::open(Path::new("./assets/ssh.config"))
45//!         .expect("Could not open configuration file")
46//! );
47//!
48//! let config = SshConfig::default().parse(&mut reader, ParseRule::STRICT).expect("Failed to parse configuration");
49//!
50//! // Query parameters for your host
51//! // If there's no rule for your host, default params are returned
52//! let params = config.query("192.168.1.2");
53//!
54//! // ...
55//!
56//! // serialize configuration to string
57//! let s = config.to_string();
58//!
59//! ```
60//!
61//! ---
62//!
63//! ## How host parameters are resolved
64//!
65//! 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>.
66//!
67//! > 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).
68//! >
69//! > 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.
70//!
71//! This means that:
72//!
73//! 1. The first obtained value parsing the configuration top-down will be used
74//! 2. Host specific rules ARE not overriding default ones if they are not the first obtained value
75//! 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 *`.
76//! 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>.
77//!
78//! ### Resolvers examples
79//!
80//! ```ssh
81//! Compression yes
82//!
83//! Host 192.168.1.1
84//!     Compression no
85//! ```
86//!
87//! If we get rules for `192.168.1.1`, compression will be `yes`, because it's the first obtained value.
88//!
89//! ```ssh
90//! Host 192.168.1.1
91//!     Compression no
92//!
93//! Host *
94//!     Compression yes
95//! ```
96//!
97//! If we get rules for `192.168.1.1`, compression will be `no`, because it's the first obtained value.
98//!
99//! If we get rules for `172.168.1.1`, compression will be `yes`, because it's the first obtained value MATCHING the host rule.
100//!
101//! ```ssh
102//!
103//! Host 192.168.1.1
104//!     Ciphers +c
105//! ```
106//!
107//! 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.
108//!
109//! ## Configuring default algorithms
110//!
111//! When you invoke [`SshConfig::default`], the default algorithms are set from openssh source code,
112//! which can be seen in the [`default_openssh_algorithms`] function documentation.
113//!
114//! If you want you can use a custom constructor [`SshConfig::default_algorithms`] to set your own default algorithms.
115
116#![doc(html_playground_url = "https://play.rust-lang.org")]
117
118#[macro_use]
119extern crate log;
120
121use std::fmt;
122use std::fs::File;
123use std::io::{self, BufRead, BufReader};
124use std::path::PathBuf;
125use std::time::Duration;
126// -- modules
127mod default_algorithms;
128mod host;
129mod params;
130mod parser;
131mod serializer;
132
133// -- export
134pub use self::default_algorithms::{
135    DefaultAlgorithms, default_algorithms as default_openssh_algorithms,
136};
137pub use self::host::{Host, HostClause};
138pub use self::params::{Algorithms, HostParams};
139pub use self::parser::{ParseRule, SshParserError, SshParserResult};
140
141/// Describes the ssh configuration.
142/// Configuration is described in this document: <http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5>
143#[derive(Debug, Clone, PartialEq, Eq, Default)]
144pub struct SshConfig {
145    /// Default algorithms for ssh.
146    default_algorithms: DefaultAlgorithms,
147    /// Rulesets for hosts.
148    /// Default config will be stored with key `*`
149    hosts: Vec<Host>,
150}
151
152impl fmt::Display for SshConfig {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        serializer::SshConfigSerializer::from(self).serialize(f)
155    }
156}
157
158impl SshConfig {
159    /// Constructs a new [`SshConfig`] from a list of [`Host`]s.
160    ///
161    /// You can later also set the [`DefaultAlgorithms`] using [`SshConfig::default_algorithms`].
162    ///
163    /// ```rust
164    /// use ssh2_config::{DefaultAlgorithms, Host, SshConfig};
165    ///
166    /// let config = SshConfig::from_hosts(vec![/* put your hosts here */]).default_algorithms(DefaultAlgorithms::default());
167    /// ```
168    pub fn from_hosts(hosts: Vec<Host>) -> Self {
169        Self {
170            default_algorithms: DefaultAlgorithms::default(),
171            hosts,
172        }
173    }
174
175    /// Query params for a certain host. Returns [`HostParams`] for the host.
176    pub fn query<S: AsRef<str>>(&self, pattern: S) -> HostParams {
177        let mut params = HostParams::new(&self.default_algorithms);
178        // iter keys, overwrite if None top-down
179        for host in self.hosts.iter() {
180            if host.intersects(pattern.as_ref()) {
181                debug!(
182                    "Merging params for host: {:?} into params {params:?}",
183                    host.pattern
184                );
185                params.overwrite_if_none(&host.params);
186                trace!("Params after merge: {params:?}");
187            }
188        }
189        // return calculated params
190        params
191    }
192
193    /// Get an iterator over the [`Host`]s which intersect with the given host pattern
194    pub fn intersecting_hosts(&self, pattern: &str) -> impl Iterator<Item = &'_ Host> {
195        self.hosts.iter().filter(|host| host.intersects(pattern))
196    }
197
198    /// Set default algorithms for ssh.
199    ///
200    /// If you want to use the default algorithms from the system, you can use the `Default::default()` method.
201    pub fn default_algorithms(mut self, algos: DefaultAlgorithms) -> Self {
202        self.default_algorithms = algos;
203
204        self
205    }
206
207    /// Parse [`SshConfig`] from stream which implements [`BufRead`] and return parsed configuration or parser error
208    ///
209    /// ## Example
210    ///
211    /// ```rust,ignore
212    /// let mut reader = BufReader::new(
213    ///    File::open(Path::new("./assets/ssh.config"))
214    ///       .expect("Could not open configuration file")
215    /// );
216    ///
217    /// let config = SshConfig::default().parse(&mut reader, ParseRule::STRICT).expect("Failed to parse configuration");
218    /// ```
219    pub fn parse(mut self, reader: &mut impl BufRead, rules: ParseRule) -> SshParserResult<Self> {
220        parser::SshConfigParser::parse(&mut self, reader, rules, None).map(|_| self)
221    }
222
223    /// Parse `~/.ssh/config`` file and return parsed configuration [`SshConfig`] or parser error
224    pub fn parse_default_file(rules: ParseRule) -> SshParserResult<Self> {
225        let ssh_folder = dirs::home_dir()
226            .ok_or_else(|| {
227                SshParserError::Io(io::Error::new(
228                    io::ErrorKind::NotFound,
229                    "Home folder not found",
230                ))
231            })?
232            .join(".ssh");
233
234        let mut reader =
235            BufReader::new(File::open(ssh_folder.join("config")).map_err(SshParserError::Io)?);
236
237        Self::default().parse(&mut reader, rules)
238    }
239
240    /// Get list of [`Host`]s in the configuration
241    pub fn get_hosts(&self) -> &Vec<Host> {
242        &self.hosts
243    }
244}
245
246#[cfg(test)]
247fn test_log() {
248    use std::sync::Once;
249
250    static INIT: Once = Once::new();
251
252    INIT.call_once(|| {
253        let _ = env_logger::builder()
254            .filter_level(log::LevelFilter::Trace)
255            .is_test(true)
256            .try_init();
257    });
258}
259
260#[cfg(test)]
261mod tests {
262
263    use pretty_assertions::assert_eq;
264
265    use super::*;
266
267    #[test]
268    fn should_init_ssh_config() {
269        test_log();
270
271        let config = SshConfig::default();
272        assert_eq!(config.hosts.len(), 0);
273        assert_eq!(
274            config.query("192.168.1.2"),
275            HostParams::new(&DefaultAlgorithms::default())
276        );
277    }
278
279    #[test]
280    fn should_parse_default_config() -> Result<(), parser::SshParserError> {
281        test_log();
282
283        let _config = SshConfig::parse_default_file(ParseRule::ALLOW_UNKNOWN_FIELDS)?;
284        Ok(())
285    }
286
287    #[test]
288    fn should_parse_config() -> Result<(), parser::SshParserError> {
289        test_log();
290
291        use std::fs::File;
292        use std::io::BufReader;
293        use std::path::Path;
294
295        let mut reader = BufReader::new(
296            File::open(Path::new("./assets/ssh.config"))
297                .expect("Could not open configuration file"),
298        );
299
300        SshConfig::default().parse(&mut reader, ParseRule::STRICT)?;
301
302        Ok(())
303    }
304
305    #[test]
306    fn should_query_ssh_config() {
307        test_log();
308
309        let mut config = SshConfig::default();
310        // add config
311        let mut params1 = HostParams::new(&DefaultAlgorithms::default());
312        params1.bind_address = Some("0.0.0.0".to_string());
313        config.hosts.push(Host::new(
314            vec![HostClause::new(String::from("192.168.*.*"), false)],
315            params1.clone(),
316        ));
317        let mut params2 = HostParams::new(&DefaultAlgorithms::default());
318        params2.bind_interface = Some(String::from("tun0"));
319        config.hosts.push(Host::new(
320            vec![HostClause::new(String::from("192.168.10.*"), false)],
321            params2.clone(),
322        ));
323
324        let mut params3 = HostParams::new(&DefaultAlgorithms::default());
325        params3.host_name = Some("172.26.104.4".to_string());
326        config.hosts.push(Host::new(
327            vec![
328                HostClause::new(String::from("172.26.*.*"), false),
329                HostClause::new(String::from("172.26.104.4"), true),
330            ],
331            params3.clone(),
332        ));
333        // Query
334        assert_eq!(config.query("192.168.1.32"), params1);
335        // merged case
336        params1.overwrite_if_none(&params2);
337        assert_eq!(config.query("192.168.10.1"), params1);
338        // Negated case
339        assert_eq!(config.query("172.26.254.1"), params3);
340        assert_eq!(
341            config.query("172.26.104.4"),
342            HostParams::new(&DefaultAlgorithms::default())
343        );
344    }
345
346    #[test]
347    fn roundtrip() {
348        test_log();
349
350        // Root host
351        let mut default_host_params = HostParams::new(&DefaultAlgorithms::default());
352        default_host_params.add_keys_to_agent = Some(true);
353        let root_host_config = Host::new(
354            vec![HostClause::new(String::from("*"), false)],
355            default_host_params,
356        );
357
358        // A host using proxy jumps
359        let mut host_params = HostParams::new(&DefaultAlgorithms::default());
360        host_params.host_name = Some(String::from("192.168.10.1"));
361        host_params.proxy_jump = Some(vec![String::from("jump.example.com")]);
362        let host_config = Host::new(
363            vec![HostClause::new(String::from("server"), false)],
364            host_params,
365        );
366
367        // Create the overall config and serialise it
368        let config = SshConfig::from_hosts(vec![root_host_config, host_config]);
369        let config_string = config.to_string();
370
371        // Parse the serialised string
372        let mut reader = std::io::BufReader::new(config_string.as_bytes());
373        let config_parsed = SshConfig::default()
374            .parse(&mut reader, ParseRule::STRICT)
375            .expect("Could not parse config.");
376
377        assert_eq!(config, config_parsed);
378    }
379
380    #[test]
381    fn should_get_intersecting_hosts() {
382        test_log();
383
384        let mut config = SshConfig::default();
385        let mut params1 = HostParams::new(&DefaultAlgorithms::default());
386        params1.bind_address = Some("0.0.0.0".to_string());
387        config.hosts.push(Host::new(
388            vec![HostClause::new(String::from("192.168.*.*"), false)],
389            params1,
390        ));
391        let mut params2 = HostParams::new(&DefaultAlgorithms::default());
392        params2.bind_interface = Some(String::from("tun0"));
393        config.hosts.push(Host::new(
394            vec![HostClause::new(String::from("192.168.10.*"), false)],
395            params2,
396        ));
397        let mut params3 = HostParams::new(&DefaultAlgorithms::default());
398        params3.host_name = Some("172.26.104.4".to_string());
399        config.hosts.push(Host::new(
400            vec![HostClause::new(String::from("172.26.*.*"), false)],
401            params3,
402        ));
403
404        // Test intersecting_hosts returns correct hosts
405        let matching: Vec<_> = config.intersecting_hosts("192.168.10.1").collect();
406        assert_eq!(matching.len(), 2);
407
408        let matching: Vec<_> = config.intersecting_hosts("192.168.1.1").collect();
409        assert_eq!(matching.len(), 1);
410
411        let matching: Vec<_> = config.intersecting_hosts("172.26.0.1").collect();
412        assert_eq!(matching.len(), 1);
413
414        // No matches
415        let matching: Vec<_> = config.intersecting_hosts("10.0.0.1").collect();
416        assert_eq!(matching.len(), 0);
417    }
418
419    #[test]
420    fn should_set_default_algorithms() {
421        test_log();
422
423        let custom_algos = DefaultAlgorithms {
424            ca_signature_algorithms: vec!["custom-algo".to_string()],
425            ciphers: vec!["custom-cipher".to_string()],
426            host_key_algorithms: vec!["custom-hostkey".to_string()],
427            kex_algorithms: vec!["custom-kex".to_string()],
428            mac: vec!["custom-mac".to_string()],
429            pubkey_accepted_algorithms: vec!["custom-pubkey".to_string()],
430        };
431
432        let config = SshConfig::default().default_algorithms(custom_algos.clone());
433
434        assert_eq!(config.default_algorithms, custom_algos);
435    }
436
437    #[test]
438    fn should_create_config_from_hosts() {
439        test_log();
440
441        let mut params = HostParams::new(&DefaultAlgorithms::default());
442        params.host_name = Some("example.com".to_string());
443        let host = Host::new(
444            vec![HostClause::new(String::from("example"), false)],
445            params,
446        );
447
448        let config = SshConfig::from_hosts(vec![host.clone()]);
449        assert_eq!(config.get_hosts().len(), 1);
450        assert_eq!(config.get_hosts()[0], host);
451    }
452
453    #[test]
454    fn should_query_empty_config() {
455        test_log();
456
457        let config = SshConfig::default();
458        let params = config.query("any-host");
459
460        // Should return default params
461        assert!(params.host_name.is_none());
462        assert!(params.port.is_none());
463    }
464
465    #[test]
466    fn should_display_empty_config() {
467        test_log();
468
469        let config = SshConfig::default();
470        let output = config.to_string();
471        assert!(output.is_empty());
472    }
473}