opencode_cloud_core/host/
ssh_config.rs1use 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#[derive(Debug, Clone, Default)]
15pub struct SshConfigMatch {
16 pub user: Option<String>,
18 pub port: Option<u16>,
20 pub identity_file: Option<String>,
22 pub proxy_jump: Option<String>,
24 pub matched: bool,
26}
27
28impl SshConfigMatch {
29 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 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
58pub fn get_ssh_config_path() -> Option<PathBuf> {
60 dirs::home_dir().map(|home| home.join(".ssh").join("config"))
61}
62
63pub 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 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 let params = config.query(hostname);
89
90 let mut result = SshConfigMatch {
91 matched: true,
92 ..Default::default()
93 };
94
95 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 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 if !jump.is_empty() {
111 result.proxy_jump = Some(jump.join(","));
112 }
113 }
114
115 if !result.has_settings() {
117 result.matched = false;
118 }
119
120 Ok(result)
121}
122
123pub 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 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 #[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 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 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 #[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
220pub 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 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}