isb_server/server/
ssh_config.rs1use std::path::Path;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct HostEntry {
11 pub org: String,
12 pub instance: String,
13 pub user: String,
15 pub host_keys: Vec<String>,
17}
18
19impl HostEntry {
20 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct ProxyOptions {
33 pub isb: String,
35 pub url: Option<String>,
37 pub token_file: Option<String>,
39 pub keys_of: Option<String>,
41 pub identity: Option<String>,
43 pub known_hosts: String,
44}
45
46fn 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
59pub 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
102pub 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
112pub 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
136pub 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 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}