Skip to main content

shuttle/
migrate.rs

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
21/// Scan ~/.ssh/ to produce a migration plan for shuttle.
22///
23/// SECURITY: This tool intentionally reads ~/.ssh/config, directory listings,
24/// and known_hosts line counts. This is an explicit exception to the
25/// transfer.rs blocklist which blocks ~/.ssh for scp/sftp operations.
26/// The exception is justified because:
27/// - No private key file content is ever read (only filenames)
28/// - The output is a migration plan, not executable config
29/// - Identity file paths are exposed as basenames only, not full paths
30/// - known_hosts content is counted, not returned
31pub 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    // Build host inventory — redact identity_file paths (only expose the
64    // basename, not the full path, to avoid leaking directory structure)
65    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    // Group hosts by identity file basename to suggest label assignments
99    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    // Generate draft hosts.toml with proper TOML escaping
127    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    // Collect unique labels for keygen commands
161    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
323/// Count known_hosts entries without parsing or returning hostname content.
324fn 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
339/// Sanitize a string for TOML table key use — strip characters that could
340/// break TOML syntax or inject new sections.
341fn 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
353/// Escape a string for use inside TOML double-quoted values.
354fn toml_escape(s: &str) -> String {
355    s.replace('\\', "\\\\")
356        .replace('"', "\\\"")
357        .replace('\n', "\\n")
358        .replace('\r', "\\r")
359        .replace('\t', "\\t")
360}
361
362/// Sanitize a string for display in JSON output — prevent control characters.
363fn 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}