use std::fs::{self, File, OpenOptions};
use std::io::{BufReader, Write};
use std::path::PathBuf;
use ssh2_config_rs::{ParseRule, SshConfig};
use super::error::HostError;
#[derive(Debug, Clone, Default)]
pub struct SshConfigMatch {
pub user: Option<String>,
pub port: Option<u16>,
pub identity_file: Option<String>,
pub proxy_jump: Option<String>,
pub matched: bool,
}
impl SshConfigMatch {
pub fn has_settings(&self) -> bool {
self.user.is_some()
|| self.port.is_some()
|| self.identity_file.is_some()
|| self.proxy_jump.is_some()
}
pub fn display_settings(&self) -> String {
let mut parts = Vec::new();
if let Some(user) = &self.user {
parts.push(format!("User={user}"));
}
if let Some(port) = self.port {
parts.push(format!("Port={port}"));
}
if let Some(key) = &self.identity_file {
parts.push(format!("IdentityFile={key}"));
}
if let Some(jump) = &self.proxy_jump {
parts.push(format!("ProxyJump={jump}"));
}
parts.join(", ")
}
}
pub fn get_ssh_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".ssh").join("config"))
}
pub fn query_ssh_config(hostname: &str) -> Result<SshConfigMatch, HostError> {
let config_path = match get_ssh_config_path() {
Some(path) if path.exists() => path,
_ => {
tracing::debug!("No SSH config file found");
return Ok(SshConfigMatch::default());
}
};
let file = File::open(&config_path).map_err(|e| {
HostError::SshConfigRead(format!("Failed to open {}: {}", config_path.display(), e))
})?;
let mut reader = BufReader::new(file);
let config = SshConfig::default()
.parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
.map_err(|e| HostError::SshConfigRead(format!("Failed to parse SSH config: {e}")))?;
let params = config.query(hostname);
let mut result = SshConfigMatch {
matched: true,
..Default::default()
};
if let Some(user) = params.user {
result.user = Some(user);
}
if let Some(port) = params.port {
result.port = Some(port);
}
if let Some(files) = params.identity_file {
if let Some(first) = files.first() {
result.identity_file = Some(first.to_string_lossy().to_string());
}
}
if let Some(jump) = params.proxy_jump {
if !jump.is_empty() {
result.proxy_jump = Some(jump.join(","));
}
}
if !result.has_settings() {
result.matched = false;
}
Ok(result)
}
pub fn write_ssh_config_entry(
alias: &str,
hostname: &str,
user: Option<&str>,
port: Option<u16>,
identity_file: Option<&str>,
jump_host: Option<&str>,
) -> Result<PathBuf, HostError> {
let config_path = get_ssh_config_path().ok_or_else(|| {
HostError::SshConfigWrite("Could not determine home directory".to_string())
})?;
if let Some(ssh_dir) = config_path.parent()
&& !ssh_dir.exists()
{
fs::create_dir_all(ssh_dir).map_err(|e| {
HostError::SshConfigWrite(format!("Failed to create .ssh directory: {e}"))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o700);
fs::set_permissions(ssh_dir, perms).map_err(|e| {
HostError::SshConfigWrite(format!("Failed to set .ssh permissions: {e}"))
})?;
}
}
let mut entry = String::new();
entry.push_str(&format!("\n# Added by opencode-cloud for host '{alias}'\n"));
entry.push_str(&format!("Host {alias}\n"));
entry.push_str(&format!(" HostName {hostname}\n"));
if let Some(u) = user {
entry.push_str(&format!(" User {u}\n"));
}
if let Some(p) = port
&& p != 22
{
entry.push_str(&format!(" Port {p}\n"));
}
if let Some(key) = identity_file {
entry.push_str(&format!(" IdentityFile {key}\n"));
}
if let Some(jump) = jump_host {
entry.push_str(&format!(" ProxyJump {jump}\n"));
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&config_path)
.map_err(|e| {
HostError::SshConfigWrite(format!("Failed to open {}: {}", config_path.display(), e))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = file
.metadata()
.map_err(|e| HostError::SshConfigWrite(format!("Failed to get file metadata: {e}")))?;
if metadata.len() == 0 {
let perms = fs::Permissions::from_mode(0o600);
fs::set_permissions(&config_path, perms).map_err(|e| {
HostError::SshConfigWrite(format!("Failed to set config permissions: {e}"))
})?;
}
}
file.write_all(entry.as_bytes()).map_err(|e| {
HostError::SshConfigWrite(format!(
"Failed to write to {}: {}",
config_path.display(),
e
))
})?;
tracing::info!(
"Added host '{}' to SSH config at {}",
alias,
config_path.display()
);
Ok(config_path)
}
pub fn host_exists_in_ssh_config(alias: &str) -> bool {
let config_path = match get_ssh_config_path() {
Some(path) if path.exists() => path,
_ => return false,
};
let Ok(file) = File::open(&config_path) else {
return false;
};
let mut reader = BufReader::new(file);
let Ok(config) = SshConfig::default().parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
else {
return false;
};
let params = config.query(alias);
params.host_name.is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ssh_config_match_display() {
let m = SshConfigMatch {
user: Some("ubuntu".to_string()),
port: Some(2222),
identity_file: Some("~/.ssh/mykey.pem".to_string()),
proxy_jump: None,
matched: true,
};
let display = m.display_settings();
assert!(display.contains("User=ubuntu"));
assert!(display.contains("Port=2222"));
assert!(display.contains("IdentityFile=~/.ssh/mykey.pem"));
}
#[test]
fn test_ssh_config_match_has_settings() {
let empty = SshConfigMatch::default();
assert!(!empty.has_settings());
let with_user = SshConfigMatch {
user: Some("test".to_string()),
..Default::default()
};
assert!(with_user.has_settings());
}
#[test]
fn test_get_ssh_config_path() {
let path = get_ssh_config_path();
assert!(path.is_some());
let path = path.unwrap();
assert!(path.ends_with(".ssh/config"));
}
}