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 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}