use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
mod env_cache;
mod include;
#[cfg(test)]
mod integration_tests;
mod match_directive;
mod parser;
mod path;
mod pattern;
mod resolver;
#[cfg(test)]
mod resolver_tests;
mod security;
#[cfg(test)]
mod security_fix_tests;
mod types;
pub use types::SshHostConfig;
#[derive(Debug, Clone, Default)]
pub struct SshConfig {
pub hosts: Vec<SshHostConfig>,
}
impl SshConfig {
pub fn new() -> Self {
Self::default()
}
pub async fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
let content = tokio::fs::read_to_string(path)
.await
.with_context(|| format!("Failed to read SSH config file: {}", path.display()))?;
Self::parse_from_file_with_content(path, &content)
.await
.with_context(|| format!("Failed to parse SSH config file: {}", path.display()))
}
pub async fn load_from_file_cached<P: AsRef<Path>>(path: P) -> Result<Self> {
crate::ssh::GLOBAL_CACHE.get_or_load(path).await
}
pub async fn load_default() -> Result<Self> {
if let Some(home_dir) = dirs::home_dir() {
let user_config = home_dir.join(".ssh").join("config");
if tokio::fs::try_exists(&user_config).await.unwrap_or(false) {
return Self::load_from_file(&user_config).await;
}
}
let system_config = Path::new("/etc/ssh/ssh_config");
if tokio::fs::try_exists(system_config).await.unwrap_or(false) {
return Self::load_from_file(system_config).await;
}
Ok(Self::new())
}
pub async fn load_default_cached() -> Result<Self> {
crate::ssh::GLOBAL_CACHE.load_default().await
}
pub fn parse(content: &str) -> Result<Self> {
let hosts = parser::parse(content)?;
Ok(Self { hosts })
}
pub async fn parse_from_file_with_content(path: &Path, content: &str) -> Result<Self> {
let hosts = parser::parse_from_file(path, content).await?;
Ok(Self { hosts })
}
pub fn find_host_config(&self, hostname: &str) -> SshHostConfig {
resolver::find_host_config(&self.hosts, hostname)
}
pub fn get_effective_hostname(&self, hostname: &str) -> String {
resolver::get_effective_hostname(&self.hosts, hostname)
}
pub fn get_effective_user(&self, hostname: &str, cli_user: Option<&str>) -> Option<String> {
resolver::get_effective_user(&self.hosts, hostname, cli_user)
}
pub fn get_effective_port(&self, hostname: &str, cli_port: Option<u16>) -> u16 {
resolver::get_effective_port(&self.hosts, hostname, cli_port)
}
pub fn get_identity_files(&self, hostname: &str) -> Vec<PathBuf> {
resolver::get_identity_files(&self.hosts, hostname)
}
pub fn get_strict_host_key_checking(&self, hostname: &str) -> Option<String> {
resolver::get_strict_host_key_checking(&self.hosts, hostname)
}
pub fn get_proxy_jump(&self, hostname: &str) -> Option<String> {
resolver::get_proxy_jump(&self.hosts, hostname)
}
pub fn get_int_option(&self, hostname: Option<&str>, option: &str) -> Option<i64> {
let hostname = hostname.unwrap_or("*");
let config = self.find_host_config(hostname);
match option.to_lowercase().as_str() {
"serveraliveinterval" => config.server_alive_interval.map(|v| v as i64),
"serveralivecountmax" => config.server_alive_count_max.map(|v| v as i64),
_ => None,
}
}
pub fn get_all_configs(&self) -> &[SshHostConfig] {
&self.hosts
}
#[allow(clippy::type_complexity)]
pub fn resolve_jump_host(
&self,
host_alias: &str,
) -> Option<(String, Option<String>, Option<u16>, Option<String>)> {
let config = self.find_host_config(host_alias);
let hostname = config
.hostname
.clone()
.unwrap_or_else(|| host_alias.to_string());
if config.hostname.is_none()
&& config.user.is_none()
&& config.port.is_none()
&& config.identity_files.is_empty()
{
let has_matching_pattern = self
.hosts
.iter()
.any(|h| h.host_patterns.iter().any(|p| p == host_alias || p == "*"));
if !has_matching_pattern {
return None;
}
}
let identity_file = config
.identity_files
.first()
.map(|p| p.to_string_lossy().to_string());
Some((hostname, config.user, config.port, identity_file))
}
pub fn resolve_jump_host_connection(
&self,
host_alias: &str,
) -> Option<(String, Option<String>)> {
let (hostname, user, port, identity_file) = self.resolve_jump_host(host_alias)?;
let mut conn_str = String::new();
if let Some(u) = user {
conn_str.push_str(&u);
conn_str.push('@');
}
conn_str.push_str(&hostname);
if let Some(p) = port {
conn_str.push(':');
conn_str.push_str(&p.to_string());
}
Some((conn_str, identity_file))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_parse_basic_host_config() {
let config_content = r#"
Host example.com
User testuser
Port 2222
IdentityFile ~/.ssh/test_key
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(config.hosts.len(), 1);
let host = &config.hosts[0];
assert_eq!(host.host_patterns, vec!["example.com"]);
assert_eq!(host.user, Some("testuser".to_string()));
assert_eq!(host.port, Some(2222));
assert_eq!(host.identity_files.len(), 1);
}
#[test]
fn test_parse_multiple_hosts() {
let config_content = r#"
Host web*.example.com
User webuser
Port 22
Host db*.example.com
User dbuser
Port 5432
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(config.hosts.len(), 2);
let web_host = &config.hosts[0];
assert_eq!(web_host.host_patterns, vec!["web*.example.com"]);
assert_eq!(web_host.user, Some("webuser".to_string()));
assert_eq!(web_host.port, Some(22));
let db_host = &config.hosts[1];
assert_eq!(db_host.host_patterns, vec!["db*.example.com"]);
assert_eq!(db_host.user, Some("dbuser".to_string()));
assert_eq!(db_host.port, Some(5432));
}
#[test]
fn test_find_host_config() {
let config_content = r#"
Host *.example.com
User defaultuser
Port 22
Host web*.example.com
User webuser
Port 8080
Host web1.example.com
Port 9090
"#;
let config = SshConfig::parse(config_content).unwrap();
let host_config = config.find_host_config("web1.example.com");
assert_eq!(host_config.user, Some("webuser".to_string())); assert_eq!(host_config.port, Some(9090));
let host_config = config.find_host_config("web2.example.com");
assert_eq!(host_config.user, Some("webuser".to_string())); assert_eq!(host_config.port, Some(8080));
let host_config = config.find_host_config("db1.example.com");
assert_eq!(host_config.user, Some("defaultuser".to_string())); assert_eq!(host_config.port, Some(22)); }
#[test]
fn test_load_from_file() {
let temp_dir = TempDir::new().unwrap();
let config_file = temp_dir.path().join("ssh_config");
let config_content = r#"
Host test.example.com
User testuser
Port 2222
"#;
std::fs::write(&config_file, config_content).unwrap();
let config = tokio_test::block_on(SshConfig::load_from_file(&config_file)).unwrap();
assert_eq!(config.hosts.len(), 1);
assert_eq!(config.hosts[0].host_patterns, vec!["test.example.com"]);
assert_eq!(config.hosts[0].user, Some("testuser".to_string()));
assert_eq!(config.hosts[0].port, Some(2222));
}
#[test]
fn test_parse_certificate_and_forwarding_options() {
let config_content = r#"
Host *.secure.example.com
CertificateFile ~/.ssh/id_rsa-cert.pub
CASignatureAlgorithms ssh-ed25519,rsa-sha2-512
GatewayPorts yes
ExitOnForwardFailure yes
HostbasedAuthentication yes
HostbasedAcceptedAlgorithms ssh-ed25519,rsa-sha2-512
Host web1.secure.example.com
CertificateFile /etc/ssh/host-cert.pub
PermitRemoteOpen localhost:8080 db.internal:5432
GatewayPorts clientspecified
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(config.hosts.len(), 2);
let host1 = &config.hosts[0];
assert_eq!(host1.certificate_files.len(), 1);
assert!(host1.certificate_files[0]
.to_string_lossy()
.contains("id_rsa-cert.pub"));
assert_eq!(host1.ca_signature_algorithms.len(), 2);
assert_eq!(host1.ca_signature_algorithms[0], "ssh-ed25519");
assert_eq!(host1.ca_signature_algorithms[1], "rsa-sha2-512");
assert_eq!(host1.gateway_ports, Some("yes".to_string()));
assert_eq!(host1.exit_on_forward_failure, Some(true));
assert_eq!(host1.hostbased_authentication, Some(true));
assert_eq!(host1.hostbased_accepted_algorithms.len(), 2);
let host2 = &config.hosts[1];
assert_eq!(host2.certificate_files.len(), 1);
assert!(host2.certificate_files[0]
.to_string_lossy()
.contains("host-cert.pub"));
assert_eq!(host2.permit_remote_open.len(), 2);
assert_eq!(host2.permit_remote_open[0], "localhost:8080");
assert_eq!(host2.permit_remote_open[1], "db.internal:5432");
assert_eq!(host2.gateway_ports, Some("clientspecified".to_string()));
}
#[test]
fn test_merge_certificate_and_forwarding_options() {
let config_content = r#"
Host *.example.com
CertificateFile ~/.ssh/default-cert.pub
CASignatureAlgorithms ssh-ed25519
GatewayPorts no
HostbasedAuthentication no
Host web*.example.com
CertificateFile ~/.ssh/web-cert.pub
GatewayPorts yes
PermitRemoteOpen localhost:8080
Host web1.example.com
CASignatureAlgorithms rsa-sha2-512,rsa-sha2-256
ExitOnForwardFailure yes
"#;
let config = SshConfig::parse(config_content).unwrap();
let host_config = config.find_host_config("web1.example.com");
assert_eq!(host_config.certificate_files.len(), 2);
assert_eq!(host_config.ca_signature_algorithms.len(), 2);
assert_eq!(host_config.ca_signature_algorithms[0], "rsa-sha2-512");
assert_eq!(host_config.ca_signature_algorithms[1], "rsa-sha2-256");
assert_eq!(host_config.gateway_ports, Some("yes".to_string()));
assert_eq!(host_config.exit_on_forward_failure, Some(true));
assert_eq!(host_config.permit_remote_open.len(), 1);
assert_eq!(host_config.permit_remote_open[0], "localhost:8080");
assert_eq!(host_config.hostbased_authentication, Some(false));
}
#[test]
fn test_parse_host_key_verification_options() {
let config_content = r#"
Host localhost 127.0.0.1
NoHostAuthenticationForLocalhost yes
HashKnownHosts yes
Host *.example.com
CheckHostIP no
VisualHostKey yes
HostKeyAlias shared-key.example.com
VerifyHostKeyDNS ask
UpdateHostKeys yes
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(config.hosts.len(), 2);
let host1 = &config.hosts[0];
assert_eq!(host1.no_host_authentication_for_localhost, Some(true));
assert_eq!(host1.hash_known_hosts, Some(true));
let host2 = &config.hosts[1];
assert_eq!(host2.check_host_ip, Some(false));
assert_eq!(host2.visual_host_key, Some(true));
assert_eq!(
host2.host_key_alias,
Some("shared-key.example.com".to_string())
);
assert_eq!(host2.verify_host_key_dns, Some("ask".to_string()));
assert_eq!(host2.update_host_keys, Some("yes".to_string()));
}
#[test]
fn test_parse_authentication_options() {
let config_content = r#"
Host automated-server
NumberOfPasswordPrompts 1
EnableSSHKeysign yes
Host secure-server
NumberOfPasswordPrompts 5
EnableSSHKeysign no
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(config.hosts.len(), 2);
let host1 = &config.hosts[0];
assert_eq!(host1.number_of_password_prompts, Some(1));
assert_eq!(host1.enable_ssh_keysign, Some(true));
let host2 = &config.hosts[1];
assert_eq!(host2.number_of_password_prompts, Some(5));
assert_eq!(host2.enable_ssh_keysign, Some(false));
}
#[test]
fn test_parse_network_options() {
let config_content = r#"
Host vpn-server
BindInterface tun0
IPQoS lowdelay throughput
RekeyLimit 1G 1h
Host backup-server
BindInterface eth1
IPQoS af21
RekeyLimit default none
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(config.hosts.len(), 2);
let host1 = &config.hosts[0];
assert_eq!(host1.bind_interface, Some("tun0".to_string()));
assert_eq!(host1.ipqos, Some("lowdelay throughput".to_string()));
assert_eq!(host1.rekey_limit, Some("1G 1h".to_string()));
let host2 = &config.hosts[1];
assert_eq!(host2.bind_interface, Some("eth1".to_string()));
assert_eq!(host2.ipqos, Some("af21".to_string()));
assert_eq!(host2.rekey_limit, Some("default none".to_string()));
}
#[test]
fn test_parse_x11_forwarding_options() {
let config_content = r#"
Host gui-server
ForwardX11 yes
ForwardX11Timeout 1h
ForwardX11Trusted yes
Host desktop-server
ForwardX11 yes
ForwardX11Timeout 0
ForwardX11Trusted no
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(config.hosts.len(), 2);
let host1 = &config.hosts[0];
assert_eq!(host1.forward_x11, Some(true));
assert_eq!(host1.forward_x11_timeout, Some("1h".to_string()));
assert_eq!(host1.forward_x11_trusted, Some(true));
let host2 = &config.hosts[1];
assert_eq!(host2.forward_x11, Some(true));
assert_eq!(host2.forward_x11_timeout, Some("0".to_string()));
assert_eq!(host2.forward_x11_trusted, Some(false));
}
#[test]
fn test_merge_host_key_and_network_options() {
let config_content = r#"
Host *
HashKnownHosts yes
NumberOfPasswordPrompts 3
BindInterface eth0
ForwardX11Trusted no
Host *.example.com
VisualHostKey yes
EnableSSHKeysign yes
IPQoS lowdelay
ForwardX11Timeout 30m
Host web1.example.com
HostKeyAlias shared.example.com
NumberOfPasswordPrompts 1
RekeyLimit 1G 2h
ForwardX11Trusted yes
"#;
let config = SshConfig::parse(config_content).unwrap();
let host_config = config.find_host_config("web1.example.com");
assert_eq!(host_config.hash_known_hosts, Some(true));
assert_eq!(host_config.visual_host_key, Some(true));
assert_eq!(
host_config.host_key_alias,
Some("shared.example.com".to_string())
);
assert_eq!(host_config.number_of_password_prompts, Some(1));
assert_eq!(host_config.enable_ssh_keysign, Some(true));
assert_eq!(host_config.bind_interface, Some("eth0".to_string()));
assert_eq!(host_config.ipqos, Some("lowdelay".to_string()));
assert_eq!(host_config.rekey_limit, Some("1G 2h".to_string()));
assert_eq!(host_config.forward_x11_timeout, Some("30m".to_string()));
assert_eq!(host_config.forward_x11_trusted, Some(true));
}
#[test]
fn test_host_key_and_network_validation_errors() {
let config_content = r#"
Host test
VerifyHostKeyDNS invalid
"#;
assert!(SshConfig::parse(config_content).is_err());
let config_content = r#"
Host test
UpdateHostKeys invalid
"#;
assert!(SshConfig::parse(config_content).is_err());
let config_content = r#"
Host test
NumberOfPasswordPrompts abc
"#;
assert!(SshConfig::parse(config_content).is_err());
}
#[test]
fn test_host_key_and_network_option_value_syntax() {
let config_content = r#"
Host test
NoHostAuthenticationForLocalhost=yes
HashKnownHosts=yes
NumberOfPasswordPrompts=2
BindInterface=eth0
ForwardX11Trusted=yes
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(config.hosts.len(), 1);
let host = &config.hosts[0];
assert_eq!(host.no_host_authentication_for_localhost, Some(true));
assert_eq!(host.hash_known_hosts, Some(true));
assert_eq!(host.number_of_password_prompts, Some(2));
assert_eq!(host.bind_interface, Some("eth0".to_string()));
assert_eq!(host.forward_x11_trusted, Some(true));
}
#[test]
fn test_host_key_and_network_security_validations() {
let config_content = r#"
Host test
HostKeyAlias "bad;command"
"#;
assert!(
SshConfig::parse(config_content).is_err(),
"Should reject shell metacharacters in HostKeyAlias"
);
let config_content = r#"
Host test
HostKeyAlias "../etc/passwd"
"#;
assert!(
SshConfig::parse(config_content).is_err(),
"Should reject path traversal in HostKeyAlias"
);
let config_content = r#"
Host test
HostKeyAlias lb-1.example.com
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(
config.hosts[0].host_key_alias,
Some("lb-1.example.com".to_string())
);
let config_content = r#"
Host test
BindInterface "eth0;rm -rf /"
"#;
assert!(
SshConfig::parse(config_content).is_err(),
"Should reject shell metacharacters in BindInterface"
);
let config_content = r#"
Host test
BindInterface "verylonginterfacename123456789"
"#;
assert!(
SshConfig::parse(config_content).is_err(),
"Should reject too long interface names"
);
let config_content = r#"
Host test
BindInterface eth0
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(config.hosts[0].bind_interface, Some("eth0".to_string()));
let config_content = r#"
Host test
BindInterface tun0
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(config.hosts[0].bind_interface, Some("tun0".to_string()));
let config_content = r#"
Host test
IPQoS invalid-value
"#;
assert!(
SshConfig::parse(config_content).is_err(),
"Should reject invalid IPQoS values"
);
let config_content = r#"
Host test
IPQoS af11 af12 af13
"#;
assert!(
SshConfig::parse(config_content).is_err(),
"Should reject more than 2 IPQoS values"
);
let config_content = r#"
Host test
IPQoS lowdelay throughput
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(
config.hosts[0].ipqos,
Some("lowdelay throughput".to_string())
);
let config_content = r#"
Host test
RekeyLimit invalid
"#;
assert!(
SshConfig::parse(config_content).is_err(),
"Should reject invalid RekeyLimit format"
);
let config_content = r#"
Host test
RekeyLimit 1G 1h
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(config.hosts[0].rekey_limit, Some("1G 1h".to_string()));
let config_content = r#"
Host test
ForwardX11Timeout "../../etc/passwd"
"#;
assert!(
SshConfig::parse(config_content).is_err(),
"Should reject path traversal in ForwardX11Timeout"
);
let config_content = r#"
Host test
ForwardX11Timeout 1h
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(config.hosts[0].forward_x11_timeout, Some("1h".to_string()));
let config_content = r#"
Host test
NumberOfPasswordPrompts 0
"#;
assert!(
SshConfig::parse(config_content).is_err(),
"Should reject 0 for NumberOfPasswordPrompts"
);
let config_content = r#"
Host test
NumberOfPasswordPrompts 101
"#;
assert!(
SshConfig::parse(config_content).is_err(),
"Should reject values > 100 for NumberOfPasswordPrompts"
);
let config_content = r#"
Host test
NumberOfPasswordPrompts 3
"#;
let config = SshConfig::parse(config_content).unwrap();
assert_eq!(config.hosts[0].number_of_password_prompts, Some(3));
}
}