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