Skip to main content

second_brain_sync/
ssh.rs

1use std::collections::HashSet;
2use std::ffi::OsString;
3use std::fs;
4use std::io::Write;
5use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9use second_brain_core::machine::MachineIdentity;
10
11pub fn known_hosts_path_in(config_dir: &Path) -> PathBuf {
12    config_dir.join("known_hosts")
13}
14
15pub fn known_hosts_path() -> Result<PathBuf> {
16    Ok(known_hosts_path_in(&MachineIdentity::config_dir()?))
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct KeyscanLine {
21    pub host: String,
22    pub key_type: String,
23    pub public_key: String,
24}
25
26pub fn parse_keyscan_output(raw: &str) -> Vec<KeyscanLine> {
27    raw.lines()
28        .filter_map(|line| {
29            let line = line.trim();
30            if line.is_empty() || line.starts_with('#') {
31                return None;
32            }
33            let mut parts = line.split_whitespace();
34            let host = parts.next()?;
35            let key_type = parts.next()?;
36            let public_key = parts.next()?;
37            Some(KeyscanLine {
38                host: host.to_string(),
39                key_type: key_type.to_string(),
40                public_key: public_key.to_string(),
41            })
42        })
43        .collect()
44}
45
46pub fn compose_known_host_entry(line: &KeyscanLine) -> String {
47    format!("{} {} {}\n", line.host, line.key_type, line.public_key)
48}
49
50pub fn append_known_host(path: &Path, lines: &[KeyscanLine]) -> Result<()> {
51    if let Some(parent) = path.parent()
52        && !parent.as_os_str().is_empty()
53    {
54        fs::create_dir_all(parent).context("creating known_hosts parent dir")?;
55    }
56
57    let existing = match fs::read_to_string(path) {
58        Ok(s) => s,
59        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
60        Err(e) => return Err(e).context("reading known_hosts"),
61    };
62    let already: HashSet<&str> = existing
63        .lines()
64        .map(str::trim)
65        .filter(|l| !l.is_empty())
66        .collect();
67
68    let mut file = fs::OpenOptions::new()
69        .create(true)
70        .append(true)
71        .mode(0o600)
72        .open(path)
73        .context("opening known_hosts for append")?;
74
75    for line in lines {
76        let entry = compose_known_host_entry(line);
77        if already.contains(entry.trim_end()) {
78            continue;
79        }
80        file.write_all(entry.as_bytes())
81            .context("writing known_hosts entry")?;
82    }
83
84    // because OpenOptions::mode only applies on file creation, tighten unconditionally
85    // so an existing permissive file is corrected on first append.
86    fs::set_permissions(path, fs::Permissions::from_mode(0o600))
87        .context("setting known_hosts mode 0600")?;
88
89    Ok(())
90}
91
92pub fn untrusted_host_hint(host: &str) -> String {
93    format!(
94        "if ssh reported a host-key error, the host is not in ~/.second-brain/known_hosts yet — \
95         run `sb sync trust {host}` to verify and pin its fingerprint."
96    )
97}
98
99pub fn ssh_args(known_hosts: &Path) -> Vec<OsString> {
100    let mut user_kh = OsString::from("UserKnownHostsFile=");
101    user_kh.push(known_hosts);
102
103    vec![
104        OsString::from("-o"),
105        OsString::from("StrictHostKeyChecking=yes"),
106        OsString::from("-o"),
107        user_kh,
108        OsString::from("-o"),
109        OsString::from("GlobalKnownHostsFile=/dev/null"),
110        OsString::from("-o"),
111        OsString::from("ServerAliveInterval=30"),
112    ]
113}