Skip to main content

opencode_cloud_core/host/
ssh_config.rs

1//! SSH config file parsing and writing
2//!
3//! Parses ~/.ssh/config to auto-fill host settings and can write new entries.
4
5use std::fs::{self, File, OpenOptions};
6use std::io::{BufReader, Write};
7use std::path::PathBuf;
8
9use ssh2_config::{ParseRule, SshConfig};
10
11use super::error::HostError;
12
13/// Settings found in user's SSH config for a host
14#[derive(Debug, Clone, Default)]
15pub struct SshConfigMatch {
16    /// User from SSH config
17    pub user: Option<String>,
18    /// Port from SSH config
19    pub port: Option<u16>,
20    /// Identity file path from SSH config
21    pub identity_file: Option<String>,
22    /// ProxyJump (jump host) from SSH config
23    pub proxy_jump: Option<String>,
24    /// Whether any match was found
25    pub matched: bool,
26}
27
28impl SshConfigMatch {
29    /// Check if any useful settings were found
30    pub fn has_settings(&self) -> bool {
31        self.user.is_some()
32            || self.port.is_some()
33            || self.identity_file.is_some()
34            || self.proxy_jump.is_some()
35    }
36
37    /// Format found settings for display
38    pub fn display_settings(&self) -> String {
39        let mut parts = Vec::new();
40
41        if let Some(user) = &self.user {
42            parts.push(format!("User={user}"));
43        }
44        if let Some(port) = self.port {
45            parts.push(format!("Port={port}"));
46        }
47        if let Some(key) = &self.identity_file {
48            parts.push(format!("IdentityFile={key}"));
49        }
50        if let Some(jump) = &self.proxy_jump {
51            parts.push(format!("ProxyJump={jump}"));
52        }
53
54        parts.join(", ")
55    }
56}
57
58/// Get the path to the user's SSH config file
59pub fn get_ssh_config_path() -> Option<PathBuf> {
60    dirs::home_dir().map(|home| home.join(".ssh").join("config"))
61}
62
63/// Parse user's SSH config and query for a hostname
64///
65/// Returns settings found for the given hostname, applying SSH config
66/// precedence rules (first match wins).
67pub fn query_ssh_config(hostname: &str) -> Result<SshConfigMatch, HostError> {
68    let config_path = match get_ssh_config_path() {
69        Some(path) if path.exists() => path,
70        _ => {
71            tracing::debug!("No SSH config file found");
72            return Ok(SshConfigMatch::default());
73        }
74    };
75
76    let file = File::open(&config_path).map_err(|e| {
77        HostError::SshConfigRead(format!("Failed to open {}: {}", config_path.display(), e))
78    })?;
79
80    let mut reader = BufReader::new(file);
81
82    // Use ALLOW_UNKNOWN_FIELDS to be lenient with SSH config options we don't support
83    let config = SshConfig::default()
84        .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
85        .map_err(|e| HostError::SshConfigRead(format!("Failed to parse SSH config: {e}")))?;
86
87    // Query for the hostname
88    let params = config.query(hostname);
89
90    let mut result = SshConfigMatch {
91        matched: true,
92        ..Default::default()
93    };
94
95    // Extract relevant fields
96    if let Some(user) = params.user {
97        result.user = Some(user);
98    }
99    if let Some(port) = params.port {
100        result.port = Some(port);
101    }
102    if let Some(files) = params.identity_file {
103        // SSH config can have multiple identity files; take the first
104        if let Some(first) = files.first() {
105            result.identity_file = Some(first.to_string_lossy().to_string());
106        }
107    }
108    if let Some(jump) = params.proxy_jump {
109        // SSH config can have multiple jump hosts chained; join them
110        if !jump.is_empty() {
111            result.proxy_jump = Some(jump.join(","));
112        }
113    }
114
115    // Check if we actually found anything useful
116    if !result.has_settings() {
117        result.matched = false;
118    }
119
120    Ok(result)
121}
122
123/// Write a new host entry to the user's SSH config file
124///
125/// Appends a Host block to ~/.ssh/config with the provided settings.
126/// Creates the file and directory if they don't exist.
127pub fn write_ssh_config_entry(
128    alias: &str,
129    hostname: &str,
130    user: Option<&str>,
131    port: Option<u16>,
132    identity_file: Option<&str>,
133    jump_host: Option<&str>,
134) -> Result<PathBuf, HostError> {
135    let config_path = get_ssh_config_path().ok_or_else(|| {
136        HostError::SshConfigWrite("Could not determine home directory".to_string())
137    })?;
138
139    // Ensure .ssh directory exists with proper permissions
140    if let Some(ssh_dir) = config_path.parent() {
141        if !ssh_dir.exists() {
142            fs::create_dir_all(ssh_dir).map_err(|e| {
143                HostError::SshConfigWrite(format!("Failed to create .ssh directory: {e}"))
144            })?;
145
146            // Set directory permissions to 700 on Unix
147            #[cfg(unix)]
148            {
149                use std::os::unix::fs::PermissionsExt;
150                let perms = fs::Permissions::from_mode(0o700);
151                fs::set_permissions(ssh_dir, perms).map_err(|e| {
152                    HostError::SshConfigWrite(format!("Failed to set .ssh permissions: {e}"))
153                })?;
154            }
155        }
156    }
157
158    // Build the config entry
159    let mut entry = String::new();
160    entry.push_str(&format!("\n# Added by opencode-cloud for host '{alias}'\n"));
161    entry.push_str(&format!("Host {alias}\n"));
162    entry.push_str(&format!("    HostName {hostname}\n"));
163
164    if let Some(u) = user {
165        entry.push_str(&format!("    User {u}\n"));
166    }
167    if let Some(p) = port {
168        if p != 22 {
169            entry.push_str(&format!("    Port {p}\n"));
170        }
171    }
172    if let Some(key) = identity_file {
173        entry.push_str(&format!("    IdentityFile {key}\n"));
174    }
175    if let Some(jump) = jump_host {
176        entry.push_str(&format!("    ProxyJump {jump}\n"));
177    }
178
179    // Append to config file (create if doesn't exist)
180    let mut file = OpenOptions::new()
181        .create(true)
182        .append(true)
183        .open(&config_path)
184        .map_err(|e| {
185            HostError::SshConfigWrite(format!("Failed to open {}: {}", config_path.display(), e))
186        })?;
187
188    // Set file permissions to 600 on Unix if we just created it
189    #[cfg(unix)]
190    {
191        use std::os::unix::fs::PermissionsExt;
192        let metadata = file
193            .metadata()
194            .map_err(|e| HostError::SshConfigWrite(format!("Failed to get file metadata: {e}")))?;
195        if metadata.len() == 0 {
196            let perms = fs::Permissions::from_mode(0o600);
197            fs::set_permissions(&config_path, perms).map_err(|e| {
198                HostError::SshConfigWrite(format!("Failed to set config permissions: {e}"))
199            })?;
200        }
201    }
202
203    file.write_all(entry.as_bytes()).map_err(|e| {
204        HostError::SshConfigWrite(format!(
205            "Failed to write to {}: {}",
206            config_path.display(),
207            e
208        ))
209    })?;
210
211    tracing::info!(
212        "Added host '{}' to SSH config at {}",
213        alias,
214        config_path.display()
215    );
216
217    Ok(config_path)
218}
219
220/// Check if a host alias already exists in SSH config
221pub fn host_exists_in_ssh_config(alias: &str) -> bool {
222    let config_path = match get_ssh_config_path() {
223        Some(path) if path.exists() => path,
224        _ => return false,
225    };
226
227    let Ok(file) = File::open(&config_path) else {
228        return false;
229    };
230
231    let mut reader = BufReader::new(file);
232
233    let Ok(config) = SshConfig::default().parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
234    else {
235        return false;
236    };
237
238    // Query returns default params if not found, so we check if hostname is set
239    let params = config.query(alias);
240    params.host_name.is_some()
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn test_ssh_config_match_display() {
249        let m = SshConfigMatch {
250            user: Some("ubuntu".to_string()),
251            port: Some(2222),
252            identity_file: Some("~/.ssh/mykey.pem".to_string()),
253            proxy_jump: None,
254            matched: true,
255        };
256
257        let display = m.display_settings();
258        assert!(display.contains("User=ubuntu"));
259        assert!(display.contains("Port=2222"));
260        assert!(display.contains("IdentityFile=~/.ssh/mykey.pem"));
261    }
262
263    #[test]
264    fn test_ssh_config_match_has_settings() {
265        let empty = SshConfigMatch::default();
266        assert!(!empty.has_settings());
267
268        let with_user = SshConfigMatch {
269            user: Some("test".to_string()),
270            ..Default::default()
271        };
272        assert!(with_user.has_settings());
273    }
274
275    #[test]
276    fn test_get_ssh_config_path() {
277        let path = get_ssh_config_path();
278        assert!(path.is_some());
279        let path = path.unwrap();
280        assert!(path.ends_with(".ssh/config"));
281    }
282}