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