Implement SSH config file support Phase 1
Some checks failed
Test / build (push) Has been cancelled
Test / test (push) Has been cancelled

- ssh_config.rs module with SshConfigParser
- Parse ~/.ssh/config format (OpenSSH standard)
- SshHostConfig struct with common options:
  HostName, User, Port, IdentityFile
  PreferredAuthentications, Ciphers, MACs, KexAlgorithms
  Compression, ConnectTimeout, ServerAliveInterval
  StrictHostKeyChecking, ProxyCommand, ProxyJump
- Merge default config (*) with host-specific config
- Unit tests: 5 tests (parse_simple, parse_default, identity_file, list_hosts)

All 187 tests pass.
This commit is contained in:
Warren
2026-06-21 02:36:32 +08:00
parent b24e4f727b
commit bb886449d7
2 changed files with 355 additions and 0 deletions

View File

@@ -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

View File

@@ -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<String>,
/// Username for connection (User)
pub user: Option<String>,
/// Port number (Port)
pub port: Option<u16>,
/// Identity file path (IdentityFile)
pub identity_file: Option<PathBuf>,
/// Preferred authentication methods (PreferredAuthentications)
pub preferred_authentications: Option<String>,
/// Ciphers (Ciphers)
pub ciphers: Option<String>,
/// MACs (MACs)
pub macs: Option<String>,
/// KEX algorithms (KexAlgorithms)
pub kex_algorithms: Option<String>,
/// Compression (Compression)
pub compression: Option<bool>,
/// Connection timeout (ConnectTimeout)
pub connect_timeout: Option<u32>,
/// Server alive interval (ServerAliveInterval)
pub server_alive_interval: Option<u32>,
/// Server alive count max (ServerAliveCountMax)
pub server_alive_count_max: Option<u32>,
/// Strict host key checking (StrictHostKeyChecking)
pub strict_host_key_checking: Option<String>,
/// User known hosts file (UserKnownHostsFile)
pub user_known_hosts_file: Option<PathBuf>,
/// Proxy command (ProxyCommand)
pub proxy_command: Option<String>,
/// Proxy jump (ProxyJump)
pub proxy_jump: Option<String>,
}
/// SSH config file parser.
pub struct SshConfigParser {
/// Host configurations (keyed by host alias)
hosts: HashMap<String, SshHostConfig>,
/// 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<Self> {
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<Self> {
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<String> = 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::<u16>().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::<u32>().ok();
}
"ServerAliveInterval" => {
current_config.server_alive_interval = value.parse::<u32>().ok();
}
"ServerAliveCountMax" => {
current_config.server_alive_count_max = value.parse::<u32>().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<String> {
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()));
}
}