#![crate_name = "ssh2_config"]
#![crate_type = "lib"]
#![doc(html_playground_url = "https://play.rust-lang.org")]
#[macro_use]
extern crate log;
use std::fmt;
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use std::path::PathBuf;
use std::time::Duration;
mod default_algorithms;
mod host;
mod params;
mod parser;
mod serializer;
pub use self::default_algorithms::{
DefaultAlgorithms, default_algorithms as default_openssh_algorithms,
};
pub use self::host::{Host, HostClause};
pub use self::params::{Algorithms, HostParams};
pub use self::parser::{ParseRule, SshParserError, SshParserResult};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SshConfig {
default_algorithms: DefaultAlgorithms,
hosts: Vec<Host>,
}
impl fmt::Display for SshConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
serializer::SshConfigSerializer::from(self).serialize(f)
}
}
impl SshConfig {
pub fn from_hosts(hosts: Vec<Host>) -> Self {
Self {
default_algorithms: DefaultAlgorithms::default(),
hosts,
}
}
pub fn query<S: AsRef<str>>(&self, pattern: S) -> HostParams {
let mut params = HostParams::new(&self.default_algorithms);
for host in self.hosts.iter() {
if host.intersects(pattern.as_ref()) {
debug!(
"Merging params for host: {:?} into params {params:?}",
host.pattern
);
params.overwrite_if_none(&host.params);
trace!("Params after merge: {params:?}");
}
}
params
}
pub fn intersecting_hosts(&self, pattern: &str) -> impl Iterator<Item = &'_ Host> {
self.hosts.iter().filter(|host| host.intersects(pattern))
}
pub fn default_algorithms(mut self, algos: DefaultAlgorithms) -> Self {
self.default_algorithms = algos;
self
}
pub fn parse(mut self, reader: &mut impl BufRead, rules: ParseRule) -> SshParserResult<Self> {
parser::SshConfigParser::parse(&mut self, reader, rules, None).map(|_| self)
}
pub fn parse_default_file(rules: ParseRule) -> SshParserResult<Self> {
let ssh_folder = dirs::home_dir()
.ok_or_else(|| {
SshParserError::Io(io::Error::new(
io::ErrorKind::NotFound,
"Home folder not found",
))
})?
.join(".ssh");
let mut reader =
BufReader::new(File::open(ssh_folder.join("config")).map_err(SshParserError::Io)?);
Self::default().parse(&mut reader, rules)
}
pub fn get_hosts(&self) -> &Vec<Host> {
&self.hosts
}
}
#[cfg(test)]
fn test_log() {
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
let _ = env_logger::builder()
.filter_level(log::LevelFilter::Trace)
.is_test(true)
.try_init();
});
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn should_init_ssh_config() {
test_log();
let config = SshConfig::default();
assert_eq!(config.hosts.len(), 0);
assert_eq!(
config.query("192.168.1.2"),
HostParams::new(&DefaultAlgorithms::default())
);
}
#[test]
fn should_parse_default_config() -> Result<(), parser::SshParserError> {
test_log();
let _config = SshConfig::parse_default_file(ParseRule::ALLOW_UNKNOWN_FIELDS)?;
Ok(())
}
#[test]
fn should_parse_config() -> Result<(), parser::SshParserError> {
test_log();
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
let mut reader = BufReader::new(
File::open(Path::new("./assets/ssh.config"))
.expect("Could not open configuration file"),
);
SshConfig::default().parse(&mut reader, ParseRule::STRICT)?;
Ok(())
}
#[test]
fn should_query_ssh_config() {
test_log();
let mut config = SshConfig::default();
let mut params1 = HostParams::new(&DefaultAlgorithms::default());
params1.bind_address = Some("0.0.0.0".to_string());
config.hosts.push(Host::new(
vec![HostClause::new(String::from("192.168.*.*"), false)],
params1.clone(),
));
let mut params2 = HostParams::new(&DefaultAlgorithms::default());
params2.bind_interface = Some(String::from("tun0"));
config.hosts.push(Host::new(
vec![HostClause::new(String::from("192.168.10.*"), false)],
params2.clone(),
));
let mut params3 = HostParams::new(&DefaultAlgorithms::default());
params3.host_name = Some("172.26.104.4".to_string());
config.hosts.push(Host::new(
vec![
HostClause::new(String::from("172.26.*.*"), false),
HostClause::new(String::from("172.26.104.4"), true),
],
params3.clone(),
));
assert_eq!(config.query("192.168.1.32"), params1);
params1.overwrite_if_none(¶ms2);
assert_eq!(config.query("192.168.10.1"), params1);
assert_eq!(config.query("172.26.254.1"), params3);
assert_eq!(
config.query("172.26.104.4"),
HostParams::new(&DefaultAlgorithms::default())
);
}
#[test]
fn roundtrip() {
test_log();
let mut default_host_params = HostParams::new(&DefaultAlgorithms::default());
default_host_params.add_keys_to_agent = Some(true);
let root_host_config = Host::new(
vec![HostClause::new(String::from("*"), false)],
default_host_params,
);
let mut host_params = HostParams::new(&DefaultAlgorithms::default());
host_params.host_name = Some(String::from("192.168.10.1"));
host_params.proxy_jump = Some(vec![String::from("jump.example.com")]);
let host_config = Host::new(
vec![HostClause::new(String::from("server"), false)],
host_params,
);
let config = SshConfig::from_hosts(vec![root_host_config, host_config]);
let config_string = config.to_string();
let mut reader = std::io::BufReader::new(config_string.as_bytes());
let config_parsed = SshConfig::default()
.parse(&mut reader, ParseRule::STRICT)
.expect("Could not parse config.");
assert_eq!(config, config_parsed);
}
#[test]
fn should_get_intersecting_hosts() {
test_log();
let mut config = SshConfig::default();
let mut params1 = HostParams::new(&DefaultAlgorithms::default());
params1.bind_address = Some("0.0.0.0".to_string());
config.hosts.push(Host::new(
vec![HostClause::new(String::from("192.168.*.*"), false)],
params1,
));
let mut params2 = HostParams::new(&DefaultAlgorithms::default());
params2.bind_interface = Some(String::from("tun0"));
config.hosts.push(Host::new(
vec![HostClause::new(String::from("192.168.10.*"), false)],
params2,
));
let mut params3 = HostParams::new(&DefaultAlgorithms::default());
params3.host_name = Some("172.26.104.4".to_string());
config.hosts.push(Host::new(
vec![HostClause::new(String::from("172.26.*.*"), false)],
params3,
));
let matching: Vec<_> = config.intersecting_hosts("192.168.10.1").collect();
assert_eq!(matching.len(), 2);
let matching: Vec<_> = config.intersecting_hosts("192.168.1.1").collect();
assert_eq!(matching.len(), 1);
let matching: Vec<_> = config.intersecting_hosts("172.26.0.1").collect();
assert_eq!(matching.len(), 1);
let matching: Vec<_> = config.intersecting_hosts("10.0.0.1").collect();
assert_eq!(matching.len(), 0);
}
#[test]
fn should_set_default_algorithms() {
test_log();
let custom_algos = DefaultAlgorithms {
ca_signature_algorithms: vec!["custom-algo".to_string()],
ciphers: vec!["custom-cipher".to_string()],
host_key_algorithms: vec!["custom-hostkey".to_string()],
kex_algorithms: vec!["custom-kex".to_string()],
mac: vec!["custom-mac".to_string()],
pubkey_accepted_algorithms: vec!["custom-pubkey".to_string()],
};
let config = SshConfig::default().default_algorithms(custom_algos.clone());
assert_eq!(config.default_algorithms, custom_algos);
}
#[test]
fn should_create_config_from_hosts() {
test_log();
let mut params = HostParams::new(&DefaultAlgorithms::default());
params.host_name = Some("example.com".to_string());
let host = Host::new(
vec![HostClause::new(String::from("example"), false)],
params,
);
let config = SshConfig::from_hosts(vec![host.clone()]);
assert_eq!(config.get_hosts().len(), 1);
assert_eq!(config.get_hosts()[0], host);
}
#[test]
fn should_query_empty_config() {
test_log();
let config = SshConfig::default();
let params = config.query("any-host");
assert!(params.host_name.is_none());
assert!(params.port.is_none());
}
#[test]
fn should_display_empty_config() {
test_log();
let config = SshConfig::default();
let output = config.to_string();
assert!(output.is_empty());
}
}