//! SSH config file support (OpenSSH ~/.ssh/config). //! //! Parse SSH config file and provide host-specific settings. //! Reference: https://linux.die.net/man/5/ssh_config use anyhow::{Result, anyhow}; use std::path::PathBuf; use std::collections::HashMap; use log::info; /// SSH host configuration entry. #[derive(Debug, Clone, Default)] pub struct SshHostConfig { /// Host alias (from "Host" line) pub host: String, /// Actual hostname to connect to (HostName) pub hostname: Option, /// Username for connection (User) pub user: Option, /// Port number (Port) pub port: Option, /// Identity file path (IdentityFile) pub identity_file: Option, /// Preferred authentication methods (PreferredAuthentications) pub preferred_authentications: Option, /// Ciphers (Ciphers) pub ciphers: Option, /// MACs (MACs) pub macs: Option, /// KEX algorithms (KexAlgorithms) pub kex_algorithms: Option, /// Compression (Compression) pub compression: Option, /// Connection timeout (ConnectTimeout) pub connect_timeout: Option, /// Server alive interval (ServerAliveInterval) pub server_alive_interval: Option, /// Server alive count max (ServerAliveCountMax) pub server_alive_count_max: Option, /// Strict host key checking (StrictHostKeyChecking) pub strict_host_key_checking: Option, /// User known hosts file (UserKnownHostsFile) pub user_known_hosts_file: Option, /// Proxy command (ProxyCommand) pub proxy_command: Option, /// Proxy jump (ProxyJump) pub proxy_jump: Option, } /// SSH config file parser. pub struct SshConfigParser { /// Host configurations (keyed by host alias) hosts: HashMap, /// Default configuration (for "*" host) default_config: SshHostConfig, } impl SshConfigParser { /// Create new SSH config parser. pub fn new() -> Self { Self { hosts: HashMap::new(), default_config: SshHostConfig::default(), } } /// Parse SSH config file. pub fn parse(config_path: &PathBuf) -> Result { let mut parser = Self::new(); if !config_path.exists() { info!("SSH config file not found: {}", config_path.display()); return Ok(parser); } let content = std::fs::read_to_string(config_path)?; parser.parse_content(&content)?; info!("Parsed SSH config: {} hosts", parser.hosts.len()); Ok(parser) } /// Parse default SSH config (~/.ssh/config). pub fn parse_default() -> Result { let home = std::env::var("HOME") .map_err(|_| anyhow!("HOME environment variable not set"))?; let config_path = PathBuf::from(home).join(".ssh/config"); Self::parse(&config_path) } /// Parse config content. fn parse_content(&mut self, content: &str) -> Result<()> { let mut current_host: Option = None; let mut current_config: SshHostConfig = SshHostConfig::default(); for line in content.lines() { // Skip empty lines and comments let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } // Split into key and value let parts: Vec<&str> = line.splitn(2, ' ').collect(); if parts.len() != 2 { continue; } let key = parts[0].trim(); let value = parts[1].trim(); // Handle Host directive (starts new block) if key == "Host" { // Save previous host config if let Some(host) = current_host.take() { if host == "*" { self.default_config = current_config.clone(); } else { current_config.host = host.clone(); self.hosts.insert(host, current_config.clone()); } } // Start new host config current_host = Some(value.to_string()); current_config = SshHostConfig::default(); continue; } // Parse other directives match key { "HostName" => current_config.hostname = Some(value.to_string()), "User" => current_config.user = Some(value.to_string()), "Port" => { current_config.port = value.parse::().ok(); } "IdentityFile" => { // Expand ~ to HOME let path = if value.starts_with('~') { let home = std::env::var("HOME").unwrap_or_else(|_| "/".to_string()); PathBuf::from(value.replace('~', &home)) } else { PathBuf::from(value) }; current_config.identity_file = Some(path); } "PreferredAuthentications" => { current_config.preferred_authentications = Some(value.to_string()); } "Ciphers" => current_config.ciphers = Some(value.to_string()), "MACs" => current_config.macs = Some(value.to_string()), "KexAlgorithms" => { current_config.kex_algorithms = Some(value.to_string()); } "Compression" => { current_config.compression = Some(value == "yes"); } "ConnectTimeout" => { current_config.connect_timeout = value.parse::().ok(); } "ServerAliveInterval" => { current_config.server_alive_interval = value.parse::().ok(); } "ServerAliveCountMax" => { current_config.server_alive_count_max = value.parse::().ok(); } "StrictHostKeyChecking" => { current_config.strict_host_key_checking = Some(value.to_string()); } "UserKnownHostsFile" => { let path = PathBuf::from(value); current_config.user_known_hosts_file = Some(path); } "ProxyCommand" => { current_config.proxy_command = Some(value.to_string()); } "ProxyJump" => { current_config.proxy_jump = Some(value.to_string()); } _ => { // Ignore unknown directives info!("Unknown SSH config directive: {}", key); } } } // Save last host config if let Some(host) = current_host { if host == "*" { self.default_config = current_config.clone(); } else { current_config.host = host.clone(); self.hosts.insert(host, current_config); } } Ok(()) } /// Get config for a specific host. /// Returns merged config (default + host-specific). pub fn get_config(&self, host: &str) -> SshHostConfig { // Start with default config let mut config = self.default_config.clone(); // Merge with host-specific config if let Some(host_config) = self.hosts.get(host) { if host_config.hostname.is_some() { config.hostname = host_config.hostname.clone(); } if host_config.user.is_some() { config.user = host_config.user.clone(); } if host_config.port.is_some() { config.port = host_config.port; } if host_config.identity_file.is_some() { config.identity_file = host_config.identity_file.clone(); } if host_config.preferred_authentications.is_some() { config.preferred_authentications = host_config.preferred_authentications.clone(); } if host_config.ciphers.is_some() { config.ciphers = host_config.ciphers.clone(); } if host_config.macs.is_some() { config.macs = host_config.macs.clone(); } if host_config.kex_algorithms.is_some() { config.kex_algorithms = host_config.kex_algorithms.clone(); } if host_config.compression.is_some() { config.compression = host_config.compression; } if host_config.connect_timeout.is_some() { config.connect_timeout = host_config.connect_timeout; } if host_config.server_alive_interval.is_some() { config.server_alive_interval = host_config.server_alive_interval; } if host_config.server_alive_count_max.is_some() { config.server_alive_count_max = host_config.server_alive_count_max; } if host_config.strict_host_key_checking.is_some() { config.strict_host_key_checking = host_config.strict_host_key_checking.clone(); } if host_config.user_known_hosts_file.is_some() { config.user_known_hosts_file = host_config.user_known_hosts_file.clone(); } if host_config.proxy_command.is_some() { config.proxy_command = host_config.proxy_command.clone(); } if host_config.proxy_jump.is_some() { config.proxy_jump = host_config.proxy_jump.clone(); } } // Set host alias config.host = host.to_string(); config } /// List all configured hosts. pub fn list_hosts(&self) -> Vec { self.hosts.keys().cloned().collect() } /// Check if host exists in config. pub fn has_host(&self, host: &str) -> bool { self.hosts.contains_key(host) } } impl Default for SshConfigParser { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_simple_config() { let content = " Host myhost User myuser Port 2222 HostName myhost.example.com "; let mut parser = SshConfigParser::new(); parser.parse_content(content).unwrap(); assert!(parser.has_host("myhost")); let config = parser.get_config("myhost"); assert_eq!(config.user, Some("myuser".to_string())); assert_eq!(config.port, Some(2222)); assert_eq!(config.hostname, Some("myhost.example.com".to_string())); } #[test] fn test_parse_default_config() { let content = " Host * User defaultuser Port 22 "; let mut parser = SshConfigParser::new(); parser.parse_content(content).unwrap(); let config = parser.get_config("unknownhost"); assert_eq!(config.user, Some("defaultuser".to_string())); assert_eq!(config.port, Some(22)); } #[test] fn test_parse_identity_file() { let content = " Host myhost IdentityFile ~/.ssh/id_rsa "; let mut parser = SshConfigParser::new(); parser.parse_content(content).unwrap(); let config = parser.get_config("myhost"); assert!(config.identity_file.is_some()); } #[test] fn test_parser_new() { let parser = SshConfigParser::new(); assert_eq!(parser.hosts.len(), 0); } #[test] fn test_list_hosts() { let content = " Host host1 User user1 Host host2 User user2 "; let mut parser = SshConfigParser::new(); parser.parse_content(content).unwrap(); let hosts = parser.list_hosts(); assert_eq!(hosts.len(), 2); assert!(hosts.contains(&"host1".to_string())); assert!(hosts.contains(&"host2".to_string())); } }