Implement SSH config file support Phase 1
- 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:
@@ -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
|
||||
|
||||
353
markbase-core/src/ssh_server/ssh_config.rs
Normal file
353
markbase-core/src/ssh_server/ssh_config.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user