Skip to main content

isb_server/server/
ssh_config.rs

1//! What `isb ssh-config` writes: `Host` blocks whose `ProxyCommand` is
2//! `isb ssh-proxy`, and a known_hosts file of the instances' host keys
3//! under each block's `HostKeyAlias`, so plain `ssh`, `scp`, editors and
4//! `herdr machine add` pin the right key without ever trusting on first use.
5
6use std::path::Path;
7
8/// One instance's block.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct HostEntry {
11    pub org: String,
12    pub instance: String,
13    /// The guest user to log in as.
14    pub user: String,
15    /// `algorithm base64` host keys; empty when the instance has none yet.
16    pub host_keys: Vec<String>,
17}
18
19impl HostEntry {
20    /// `<instance>.<org>.isb`: the `Host` name, and the key alias.
21    pub fn alias(&self) -> String {
22        alias(&self.org, &self.instance)
23    }
24}
25
26pub fn alias(org: &str, instance: &str) -> String {
27    format!("{instance}.{org}.isb")
28}
29
30/// How the blocks reach `isb ssh-proxy`.
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct ProxyOptions {
33    /// The isb binary.
34    pub isb: String,
35    /// `--url` for a remote daemon; none for the local socket.
36    pub url: Option<String>,
37    /// `--token-file`, when the token is in a file rather than `ISB_TOKEN`.
38    pub token_file: Option<String>,
39    /// `--as`: whose keys, for a caller with no isb account (the socket).
40    pub keys_of: Option<String>,
41    /// `IdentityFile` (and `IdentitiesOnly yes`).
42    pub identity: Option<String>,
43    pub known_hosts: String,
44}
45
46/// A word for ssh_config: quoted when it has spaces or quotes.
47fn word(s: &str) -> String {
48    if !s.is_empty()
49        && !s
50            .chars()
51            .any(|c| c.is_whitespace() || matches!(c, '"' | '\'' | '\\' | '#'))
52    {
53        s.to_string()
54    } else {
55        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
56    }
57}
58
59/// The `Host` block for one instance.
60pub fn render(e: &HostEntry, o: &ProxyOptions) -> String {
61    let a = e.alias();
62    let mut proxy = format!("{} ssh-proxy {}/{}", word(&o.isb), e.org, e.instance);
63    if let Some(u) = &o.url {
64        proxy.push_str(&format!(" --url {}", word(u)));
65    }
66    if let Some(t) = &o.token_file {
67        proxy.push_str(&format!(" --token-file {}", word(t)));
68    }
69    if let Some(k) = &o.keys_of {
70        proxy.push_str(&format!(" --as {}", word(k)));
71    }
72    let mut s = format!(
73        "# {org}/{inst}: isb ssh-proxy over isb serve's websocket (isb ssh-config)\n\
74         Host {a}\n\
75         \x20 HostName {a}\n\
76         \x20 User {user}\n\
77         \x20 ProxyCommand {proxy}\n\
78         \x20 HostKeyAlias {a}\n\
79         \x20 UserKnownHostsFile {kh}\n\
80         \x20 StrictHostKeyChecking {strict}\n\
81         \x20 ServerAliveInterval 30\n\
82         \x20 ServerAliveCountMax 4\n",
83        org = e.org,
84        inst = e.instance,
85        user = word(&e.user),
86        kh = word(&o.known_hosts),
87        strict = if e.host_keys.is_empty() {
88            "accept-new"
89        } else {
90            "yes"
91        },
92    );
93    if let Some(i) = &o.identity {
94        s.push_str(&format!(
95            "  IdentityFile {}\n  IdentitiesOnly yes\n",
96            word(i)
97        ));
98    }
99    s
100}
101
102/// The `herdr machine add` line for an entry.
103pub fn herdr_line(e: &HostEntry) -> String {
104    format!(
105        "herdr machine add {} --label {}/{}",
106        e.alias(),
107        e.org,
108        e.instance
109    )
110}
111
112/// `existing` known_hosts with every line for these entries' aliases
113/// replaced by their current keys. Other lines are kept as they were.
114pub fn update_known_hosts(existing: &str, entries: &[HostEntry]) -> String {
115    let aliases: Vec<String> = entries.iter().map(HostEntry::alias).collect();
116    let mut out: Vec<String> = existing
117        .lines()
118        .filter(|l| {
119            let host = l.split_whitespace().next().unwrap_or("");
120            !host.split(',').any(|h| aliases.iter().any(|a| a == h))
121        })
122        .map(String::from)
123        .collect();
124    for e in entries {
125        for k in &e.host_keys {
126            out.push(format!("{} {k}", e.alias()));
127        }
128    }
129    let mut s = out.join("\n");
130    if !s.is_empty() {
131        s.push('\n');
132    }
133    s
134}
135
136/// Rewrite `path` with [`update_known_hosts`], creating it (and its
137/// directory) as needed. Written whole, then renamed into place.
138pub fn write_known_hosts(path: &Path, entries: &[HostEntry]) -> std::io::Result<()> {
139    let existing = match std::fs::read_to_string(path) {
140        Ok(s) => s,
141        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
142        Err(e) => return Err(e),
143    };
144    if let Some(dir) = path.parent() {
145        std::fs::create_dir_all(dir)?;
146    }
147    let tmp = path.with_extension("isb-tmp");
148    std::fs::write(&tmp, update_known_hosts(&existing, entries))?;
149    std::fs::rename(&tmp, path)
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn entry(keys: &[&str]) -> HostEntry {
157        HostEntry {
158            org: "acme".into(),
159            instance: "box".into(),
160            user: "dev".into(),
161            host_keys: keys.iter().map(|s| s.to_string()).collect(),
162        }
163    }
164
165    #[test]
166    fn renders_a_pinned_host_block() {
167        let o = ProxyOptions {
168            isb: "/usr/local/bin/isb".into(),
169            url: Some("https://isb.example.com".into()),
170            token_file: Some("/home/me/.config/isb/my token".into()),
171            keys_of: None,
172            identity: None,
173            known_hosts: "/home/me/.config/isb/known_hosts".into(),
174        };
175        let s = render(&entry(&["ssh-ed25519 AAAA"]), &o);
176        assert_eq!(
177            s,
178            "# acme/box: isb ssh-proxy over isb serve's websocket (isb ssh-config)\n\
179             Host box.acme.isb\n\
180             \x20 HostName box.acme.isb\n\
181             \x20 User dev\n\
182             \x20 ProxyCommand /usr/local/bin/isb ssh-proxy acme/box --url https://isb.example.com --token-file \"/home/me/.config/isb/my token\"\n\
183             \x20 HostKeyAlias box.acme.isb\n\
184             \x20 UserKnownHostsFile /home/me/.config/isb/known_hosts\n\
185             \x20 StrictHostKeyChecking yes\n\
186             \x20 ServerAliveInterval 30\n\
187             \x20 ServerAliveCountMax 4\n"
188        );
189        assert_eq!(
190            herdr_line(&entry(&[])),
191            "herdr machine add box.acme.isb --label acme/box"
192        );
193    }
194
195    #[test]
196    fn no_host_key_yet_accepts_the_first_and_identity_is_pinned() {
197        let o = ProxyOptions {
198            isb: "isb".into(),
199            keys_of: Some("me@example.com".into()),
200            identity: Some("/tmp/k".into()),
201            known_hosts: "/kh".into(),
202            ..Default::default()
203        };
204        let s = render(&entry(&[]), &o);
205        assert!(s.contains("StrictHostKeyChecking accept-new\n"), "{s}");
206        assert!(s.contains("ProxyCommand isb ssh-proxy acme/box --as me@example.com\n"));
207        assert!(s.contains("  IdentityFile /tmp/k\n  IdentitiesOnly yes\n"));
208    }
209
210    #[test]
211    fn known_hosts_replaces_only_its_own_lines() {
212        let old = "github.com ssh-ed25519 GH\nbox.acme.isb ssh-ed25519 OLD\nother.acme.isb ssh-rsa KEEP\n";
213        let new = update_known_hosts(old, &[entry(&["ssh-ed25519 NEW", "ssh-rsa NEW2"])]);
214        assert_eq!(
215            new,
216            "github.com ssh-ed25519 GH\nother.acme.isb ssh-rsa KEEP\nbox.acme.isb ssh-ed25519 NEW\nbox.acme.isb ssh-rsa NEW2\n"
217        );
218        // Idempotent.
219        assert_eq!(
220            update_known_hosts(&new, &[entry(&["ssh-ed25519 NEW", "ssh-rsa NEW2"])]),
221            new
222        );
223        let dir = tempfile::tempdir().unwrap();
224        let p = dir.path().join("isb/known_hosts");
225        write_known_hosts(&p, &[entry(&["ssh-ed25519 K"])]).unwrap();
226        assert_eq!(
227            std::fs::read_to_string(&p).unwrap(),
228            "box.acme.isb ssh-ed25519 K\n"
229        );
230    }
231}