1use serde_json::{json, Value};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5struct SshConfigEntry {
6 host_alias: String,
7 hostname: Option<String>,
8 user: Option<String>,
9 port: Option<u16>,
10 identity_file: Option<String>,
11 proxy_jump: Option<String>,
12}
13
14struct SshKeyFile {
15 name: String,
16 algorithm: String,
17 has_public: bool,
18 is_pem: bool,
19}
20
21pub async fn ssh_migrate_analyze(
32 _params: &Value,
33) -> omegon_extension::Result<Value> {
34 let ssh_dir = dirs::home_dir()
35 .map(|h| h.join(".ssh"))
36 .unwrap_or_else(|| PathBuf::from("/nonexistent"));
37
38 let mut report = json!({
39 "ssh_dir_exists": ssh_dir.is_dir(),
40 });
41
42 if !ssh_dir.is_dir() {
43 report["summary"] = json!("No ~/.ssh directory found. Nothing to migrate.");
44 return Ok(report);
45 }
46
47 let config_entries = parse_ssh_config(&ssh_dir.join("config"));
48 let key_files = scan_key_files(&ssh_dir);
49 let known_hosts_count = count_known_hosts(&ssh_dir.join("known_hosts"));
50
51 let keys: Vec<Value> = key_files
52 .iter()
53 .map(|k| {
54 json!({
55 "filename": k.name,
56 "algorithm": k.algorithm,
57 "has_public_key": k.has_public,
58 "is_pem": k.is_pem,
59 })
60 })
61 .collect();
62
63 let hosts: Vec<Value> = config_entries
66 .iter()
67 .map(|e| {
68 let mut entry = json!({
69 "alias": sanitize_for_display(&e.host_alias),
70 });
71 if let Some(ref h) = e.hostname {
72 entry["hostname"] = json!(h);
73 }
74 if let Some(ref u) = e.user {
75 entry["user"] = json!(u);
76 }
77 if let Some(p) = e.port {
78 entry["port"] = json!(p);
79 }
80 if let Some(ref f) = e.identity_file {
81 let basename = Path::new(f)
82 .file_name()
83 .and_then(|n| n.to_str())
84 .unwrap_or("(key)");
85 entry["identity_file_name"] = json!(basename);
86 }
87 if e.proxy_jump.is_some() {
88 entry["has_proxy_jump"] = json!(true);
89 entry["note"] = json!(
90 "ProxyJump is not directly supported by shuttle. \
91 Use ssh_tunnel_open through the bastion instead."
92 );
93 }
94 entry
95 })
96 .collect();
97
98 let mut label_groups: HashMap<String, Vec<String>> = HashMap::new();
100 for entry in &config_entries {
101 let key = entry
102 .identity_file
103 .as_deref()
104 .and_then(|f| Path::new(f).file_name())
105 .and_then(|n| n.to_str())
106 .unwrap_or("(default)")
107 .to_string();
108 label_groups
109 .entry(key)
110 .or_default()
111 .push(entry.host_alias.clone());
112 }
113
114 let suggested_labels: Vec<Value> = label_groups
115 .iter()
116 .map(|(key_file, hosts)| {
117 let label_suggestion = suggest_label(key_file, hosts);
118 json!({
119 "current_key_name": key_file,
120 "hosts": hosts.iter().map(|h| sanitize_for_display(h)).collect::<Vec<_>>(),
121 "suggested_label": label_suggestion,
122 })
123 })
124 .collect();
125
126 let mut hosts_toml_lines = vec![
128 "# Draft hosts.toml generated by ssh_migrate_analyze".to_string(),
129 "# Review and adjust identity_label names to match your trust domains.".to_string(),
130 String::new(),
131 ];
132
133 for entry in &config_entries {
134 if entry.host_alias == "*" || entry.hostname.is_none() {
135 continue;
136 }
137 let alias = sanitize_toml_key(&entry.host_alias);
138 let hostname = toml_escape(entry.hostname.as_deref().unwrap());
139 let user = toml_escape(entry.user.as_deref().unwrap_or("TODO_SET_USER"));
140 let port = entry.port.unwrap_or(22);
141 let label = entry
142 .identity_file
143 .as_deref()
144 .and_then(|f| Path::new(f).file_name())
145 .and_then(|n| n.to_str())
146 .map(|f| suggest_label(f, &[entry.host_alias.clone()]))
147 .unwrap_or_else(|| "default".to_string());
148
149 hosts_toml_lines.push(format!("[{alias}]"));
150 hosts_toml_lines.push(format!("address = \"{hostname}\""));
151 hosts_toml_lines.push(format!("user = \"{user}\""));
152 if port != 22 {
153 hosts_toml_lines.push(format!("port = {port}"));
154 }
155 hosts_toml_lines.push(format!("identity_label = \"{}\"", toml_escape(&label)));
156 hosts_toml_lines.push("trust_on_first_use = true".to_string());
157 hosts_toml_lines.push(String::new());
158 }
159
160 let labels: Vec<String> = label_groups
162 .iter()
163 .map(|(key_file, hosts)| suggest_label(key_file, hosts))
164 .collect::<std::collections::HashSet<_>>()
165 .into_iter()
166 .collect();
167
168 let keygen_commands: Vec<String> = labels
169 .iter()
170 .map(|label| {
171 format!(
172 "shuttle-keygen ~/.config/styrene/identity.key {label}"
173 )
174 })
175 .collect();
176
177 report["key_files"] = json!(keys);
178 report["ssh_config_hosts"] = json!(hosts);
179 report["known_hosts_count"] = json!(known_hosts_count);
180 report["suggested_label_groups"] = json!(suggested_labels);
181 report["draft_hosts_toml"] = json!(hosts_toml_lines.join("\n"));
182 report["keygen_commands"] = json!(keygen_commands);
183 report["migration_steps"] = json!([
184 "1. Review the draft hosts.toml — adjust identity_label names to match your trust domains",
185 "2. Run each keygen command to export the SSH public keys",
186 "3. Add each public key to the corresponding remote server's ~/.ssh/authorized_keys",
187 "4. Copy the hosts.toml to ~/.omegon/shuttle/hosts.toml",
188 "5. Test each host with ssh_ping",
189 "6. Once verified, remove old key entries from ~/.ssh/config for migrated hosts",
190 ]);
191
192 Ok(report)
193}
194
195fn parse_ssh_config(path: &Path) -> Vec<SshConfigEntry> {
196 let content = match std::fs::read_to_string(path) {
197 Ok(c) => c,
198 Err(_) => return Vec::new(),
199 };
200
201 let mut entries = Vec::new();
202 let mut current: Option<SshConfigEntry> = None;
203
204 for line in content.lines() {
205 let line = line.trim();
206 if line.is_empty() || line.starts_with('#') {
207 continue;
208 }
209
210 let (key, value) = match line.split_once(char::is_whitespace) {
211 Some((k, v)) => (k.to_lowercase(), v.trim().to_string()),
212 None => continue,
213 };
214
215 match key.as_str() {
216 "host" => {
217 if let Some(entry) = current.take() {
218 entries.push(entry);
219 }
220 current = Some(SshConfigEntry {
221 host_alias: value,
222 hostname: None,
223 user: None,
224 port: None,
225 identity_file: None,
226 proxy_jump: None,
227 });
228 }
229 "hostname" => {
230 if let Some(ref mut e) = current {
231 e.hostname = Some(value);
232 }
233 }
234 "user" => {
235 if let Some(ref mut e) = current {
236 e.user = Some(value);
237 }
238 }
239 "port" => {
240 if let Some(ref mut e) = current {
241 e.port = value.parse().ok();
242 }
243 }
244 "identityfile" => {
245 if let Some(ref mut e) = current {
246 e.identity_file = Some(value);
247 }
248 }
249 "proxyjump" | "proxycommand" => {
250 if let Some(ref mut e) = current {
251 e.proxy_jump = Some(value);
252 }
253 }
254 _ => {}
255 }
256 }
257
258 if let Some(entry) = current {
259 entries.push(entry);
260 }
261
262 entries
263}
264
265fn scan_key_files(ssh_dir: &Path) -> Vec<SshKeyFile> {
266 let entries = match std::fs::read_dir(ssh_dir) {
267 Ok(e) => e,
268 Err(_) => return Vec::new(),
269 };
270
271 let mut keys = Vec::new();
272
273 for entry in entries.flatten() {
274 let name = entry.file_name().to_string_lossy().to_string();
275
276 if name == "config"
277 || name == "known_hosts"
278 || name == "known_hosts.old"
279 || name == "authorized_keys"
280 || name == "agent"
281 || name == "environment"
282 || name.starts_with('.')
283 || name.ends_with(".pub")
284 {
285 continue;
286 }
287
288 let path = entry.path();
289 if !path.is_file() {
290 continue;
291 }
292
293 let algorithm = detect_key_algorithm(&name);
294 let has_public = ssh_dir.join(format!("{name}.pub")).exists();
295 let is_pem = name.ends_with(".pem");
296
297 keys.push(SshKeyFile {
298 name,
299 algorithm,
300 has_public,
301 is_pem,
302 });
303 }
304
305 keys.sort_by(|a, b| a.name.cmp(&b.name));
306 keys
307}
308
309fn detect_key_algorithm(filename: &str) -> String {
310 if filename.contains("ed25519") {
311 "ed25519".to_string()
312 } else if filename.contains("ecdsa") {
313 "ecdsa".to_string()
314 } else if filename.contains("rsa") || filename.ends_with(".pem") {
315 "rsa (likely)".to_string()
316 } else if filename.contains("dsa") {
317 "dsa (legacy)".to_string()
318 } else {
319 "unknown".to_string()
320 }
321}
322
323fn count_known_hosts(path: &Path) -> usize {
325 let content = match std::fs::read_to_string(path) {
326 Ok(c) => c,
327 Err(_) => return 0,
328 };
329
330 content
331 .lines()
332 .filter(|l| {
333 let l = l.trim();
334 !l.is_empty() && !l.starts_with('#')
335 })
336 .count()
337}
338
339fn sanitize_toml_key(s: &str) -> String {
342 let sanitized: String = s
343 .chars()
344 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
345 .collect();
346 if sanitized.is_empty() {
347 "unnamed-host".to_string()
348 } else {
349 sanitized
350 }
351}
352
353fn toml_escape(s: &str) -> String {
355 s.replace('\\', "\\\\")
356 .replace('"', "\\\"")
357 .replace('\n', "\\n")
358 .replace('\r', "\\r")
359 .replace('\t', "\\t")
360}
361
362fn sanitize_for_display(s: &str) -> String {
364 s.chars()
365 .filter(|c| !c.is_control())
366 .collect()
367}
368
369fn suggest_label(key_file: &str, hosts: &[String]) -> String {
370 let basename = Path::new(key_file)
371 .file_stem()
372 .and_then(|s| s.to_str())
373 .unwrap_or("default");
374
375 if basename.contains("prod") {
376 return "prod".to_string();
377 }
378 if basename.contains("staging") || basename.contains("stag") {
379 return "staging".to_string();
380 }
381 if basename.contains("dev") || basename.contains("personal") {
382 return "dev".to_string();
383 }
384 if basename.contains("deploy") || basename.contains("ci") {
385 return "deploy".to_string();
386 }
387 if basename.contains("admin") {
388 return "admin".to_string();
389 }
390
391 if hosts.len() == 1 {
392 let h = &hosts[0];
393 if h != "*" {
394 return sanitize_toml_key(h).to_lowercase();
395 }
396 }
397
398 sanitize_toml_key(
399 basename
400 .trim_start_matches("id_")
401 ).to_lowercase()
402}