1#![crate_name = "ssh2_config"]
2#![crate_type = "lib"]
3
4#![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;
134mod default_algorithms;
136mod host;
137mod params;
138mod parser;
139mod serializer;
140
141pub 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
156pub struct SshConfig {
157 default_algorithms: DefaultAlgorithms,
159 hosts: Vec<Host>,
162 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 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 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 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 pub fn query<S: AsRef<str>>(&self, pattern: S) -> HostParams {
217 let mut params = HostParams::new(&self.default_algorithms);
218 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 params
231 }
232
233 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 pub fn default_algorithms(mut self, algos: DefaultAlgorithms) -> Self {
246 self.default_algorithms = algos;
247
248 self
249 }
250
251 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 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 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 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 assert_eq!(config.query("192.168.1.32"), params1);
379 params1.overwrite_if_none(¶ms2);
381 assert_eq!(config.query("192.168.10.1"), params1);
382 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 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 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 let config = SshConfig::from_hosts(vec![root_host_config, host_config]);
413 let config_string = config.to_string();
414
415 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 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 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 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}