diff --git a/markbase-core/src/ssh_server/mod.rs b/markbase-core/src/ssh_server/mod.rs index 302abae..07dba3e 100644 --- a/markbase-core/src/ssh_server/mod.rs +++ b/markbase-core/src/ssh_server/mod.rs @@ -17,6 +17,7 @@ pub mod rsync_handler; pub mod scp_handler; pub mod server; pub mod sftp_handler; +pub mod ssh_config; // SSH config file support (~/.ssh/config) pub mod ssh_security_config; pub mod sshbuf; pub mod upload_hook; @@ -26,6 +27,7 @@ pub mod x11_forward; // SSH X11 forwarding (RFC 4254 §7.2) pub use packet::{PacketType, SshPacket}; pub use server::SshServer; +pub use ssh_config::SshConfigParser; // Phase 1: Export SSH config parser pub use ssh_security_config::SshSecurityConfig; // Phase 13.1: 导出安全配置 pub use sshbuf::SshBuf; pub use version::VersionExchange; // Phase 15: 导出 SSH Buffer diff --git a/markbase-core/src/ssh_server/ssh_config.rs b/markbase-core/src/ssh_server/ssh_config.rs new file mode 100644 index 0000000..e1de63b --- /dev/null +++ b/markbase-core/src/ssh_server/ssh_config.rs @@ -0,0 +1,353 @@ +//! 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())); + } +} \ No newline at end of file