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(¶ms2);
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}