anvilsign in

collin/browser-terminal-extension

main / daemon / src / ssh.rs
1//! Hosts the omnibar can offer to connect to, and the gate on the one it sends
2//! back.
3//!
4//! The daemon runs tmux on *this* machine, and that does not change here: an
5//! ssh connection is an ordinary local window that happens to be running ssh.
6//! Nothing about the session model, the tabs or the status frames knows the
7//! difference, which is the whole reason this is cheap.
8//!
9//! What the daemon contributes is the list. The names worth offering are the
10//! ones ssh itself already knows — `~/.ssh/config` and, failing that,
11//! `~/.ssh/known_hosts` — so the panel offers hosts you have actually
12//! connected to rather than asking you to remember them a second time.
13
14use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16
17/// The most hosts the panel is told about.
18///
19/// `known_hosts` on an old account runs to hundreds of lines, and the omnibar
20/// shows eight rows. The cap is about not sending a list nobody will scroll,
21/// not about safety — every name in it went through [`valid_ssh_host`].
22const MAX_HOSTS: usize = 100;
23
24/// The most an ssh destination may be. Longer than any real `user@host` and
25/// far short of anything that would look like a command line.
26const MAX_HOST: usize = 128;
27
28/// An ssh destination, as `[user@]host`.
29///
30/// The hazard here is not shell metacharacters — the destination reaches ssh
31/// as one argv element, by way of a generated script — but *ssh's own options*.
32/// `ssh -oProxyCommand=…` runs a shell, and ssh accepts an option anywhere on
33/// its command line, so a destination beginning with a dash is the one thing
34/// that turns "connect somewhere" into "run something". Both halves are checked
35/// for it, and the charset on top of that leaves nothing that could become a
36/// second argument.
37///
38/// Deliberately narrower than what ssh accepts: no `ssh://` URLs, no IPv6
39/// brackets, no `:port`. Those are all reachable by typing `!ssh …` into the
40/// same box, which is where the general case belongs. This is for names.
41pub fn valid_ssh_host(raw: &str) -> Option<String> {
42 let s = raw.trim();
43 if s.is_empty() || s.len() > MAX_HOST {
44 return None;
45 }
46 // At most one `@`, so a host cannot smuggle a second destination.
47 let (user, host) = match s.split_once('@') {
48 Some((u, h)) => {
49 if h.contains('@') {
50 return None;
51 }
52 (Some(u), h)
53 }
54 None => (None, s),
55 };
56 let plain = |part: &str| {
57 !part.is_empty()
58 && !part.starts_with('-')
59 && part
60 .chars()
61 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
62 };
63 if !plain(host) {
64 return None;
65 }
66 if user.is_some_and(|u| !plain(u)) {
67 return None;
68 }
69 Some(s.to_string())
70}
71
72/// The tmux window name for a connection: the host, without the user.
73///
74/// Three windows on the same box are told apart by what is in them, not by the
75/// login, and `collin@mini` in a tab is mostly the part that is the same every
76/// time. Falls back to the whole destination, which [`valid_ssh_host`] has
77/// already restricted to characters a tmux window name is happy with.
78pub fn window_name(host: &str) -> &str {
79 match host.split_once('@') {
80 Some((_, h)) if !h.is_empty() => h,
81 _ => host,
82 }
83}
84
85/// Every host worth offering, best source first and deduplicated.
86///
87/// Config aliases lead because they are what a person types and what they
88/// named themselves; `known_hosts` fills in behind them for the machines
89/// reached without ever writing a config entry.
90pub fn hosts() -> Vec<String> {
91 let Some(dir) = ssh_dir() else {
92 return Vec::new();
93 };
94 let mut out = Vec::new();
95 let mut seen = HashSet::new();
96 let mut push = |name: String| {
97 if out.len() < MAX_HOSTS && seen.insert(name.clone()) {
98 out.push(name);
99 }
100 };
101
102 let (names, includes) = parse_config(&read(dir.join("config")));
103 for name in names {
104 push(name);
105 }
106 // One level. Included files may include further files, and following that
107 // is a graph walk with a cycle check for the sake of a list of suggestions
108 // — the first level is where every real config puts its hosts.
109 for include in includes {
110 for path in expand_include(&include, &dir) {
111 let (names, _) = parse_config(&read(path));
112 for name in names {
113 push(name);
114 }
115 }
116 }
117 for name in parse_known_hosts(&read(dir.join("known_hosts"))) {
118 push(name);
119 }
120 out
121}
122
123fn ssh_dir() -> Option<PathBuf> {
124 let home = std::env::var_os("HOME")?;
125 Some(PathBuf::from(home).join(".ssh"))
126}
127
128/// Missing and unreadable are the same answer: no hosts from here. An
129/// unreadable ssh config is not this daemon's problem to report.
130fn read(path: impl AsRef<Path>) -> String {
131 std::fs::read_to_string(path).unwrap_or_default()
132}
133
134/// Host aliases and `Include` paths from an ssh config.
135///
136/// Aliases, not `HostName`s: the alias is the thing you type at ssh, and the
137/// whole point of having written one is that it is shorter and more memorable
138/// than the address behind it.
139///
140/// Patterns are skipped. `Host *` is settings for everything rather than a
141/// machine, and offering it as somewhere to connect would be offering ssh's
142/// own defaults block as a destination. Negations (`!host`) are skipped for
143/// the same reason: they name where a block does *not* apply.
144pub fn parse_config(text: &str) -> (Vec<String>, Vec<String>) {
145 let mut hosts = Vec::new();
146 let mut includes = Vec::new();
147 for line in text.lines() {
148 let line = line.trim();
149 if line.is_empty() || line.starts_with('#') {
150 continue;
151 }
152 // ssh accepts `Host foo` and `Host=foo` alike, and is case-insensitive
153 // about the keyword.
154 let mut parts = line.split(|c: char| c.is_whitespace() || c == '=');
155 let Some(keyword) = parts.next() else {
156 continue;
157 };
158 let values = parts.filter(|v| !v.is_empty());
159 if keyword.eq_ignore_ascii_case("host") {
160 hosts.extend(
161 values
162 .filter(|v| !v.contains(['*', '?', '!']))
163 .filter_map(valid_ssh_host),
164 );
165 } else if keyword.eq_ignore_ascii_case("include") {
166 includes.extend(values.map(str::to_string));
167 }
168 }
169 (hosts, includes)
170}
171
172/// Hostnames from a `known_hosts` file.
173///
174/// Most of what is in one is unusable here and is dropped: hashed entries
175/// (`|1|…`, which is the default on many distributions) cannot be reversed,
176/// addresses are not names anybody types, and `[host]:port` entries belong to
177/// the general case that `!ssh` covers. What is left is the plain hostnames,
178/// which is exactly the set that is useful when there is no config file.
179pub fn parse_known_hosts(text: &str) -> Vec<String> {
180 let mut out = Vec::new();
181 for line in text.lines() {
182 let line = line.trim();
183 if line.is_empty() || line.starts_with('#') || line.starts_with('|') {
184 continue;
185 }
186 // A marker line (`@cert-authority`, `@revoked`) shifts every field
187 // along by one.
188 let mut fields = line.split_whitespace();
189 let Some(first) = fields.next() else { continue };
190 let patterns = if first.starts_with('@') {
191 match fields.next() {
192 Some(p) => p,
193 None => continue,
194 }
195 } else {
196 first
197 };
198 // One entry can list a name and its address, comma separated.
199 for name in patterns.split(',') {
200 // An address is not a name worth suggesting: you would not have
201 // typed it, and if you would, `!ssh` takes it.
202 if name.chars().all(|c| c.is_ascii_digit() || c == '.') {
203 continue;
204 }
205 if let Some(host) = valid_ssh_host(name) {
206 out.push(host);
207 }
208 }
209 }
210 out
211}
212
213/// The files an `Include` names.
214///
215/// Relative paths are relative to `~/.ssh`, per ssh_config(5). The only glob
216/// handled is a `*` in the final component, which is the shape every real
217/// config uses (`Include config.d/*`) — anything more elaborate silently
218/// contributes nothing, which costs a suggestion and no more.
219fn expand_include(pattern: &str, ssh_dir: &Path) -> Vec<PathBuf> {
220 let expanded = match pattern.strip_prefix("~/") {
221 Some(rest) => match std::env::var_os("HOME") {
222 Some(home) => PathBuf::from(home).join(rest),
223 None => return Vec::new(),
224 },
225 None => {
226 let p = Path::new(pattern);
227 if p.is_absolute() {
228 p.to_path_buf()
229 } else {
230 ssh_dir.join(p)
231 }
232 }
233 };
234 let Some(name) = expanded.file_name().and_then(|n| n.to_str()) else {
235 return Vec::new();
236 };
237 if !name.contains('*') {
238 return vec![expanded];
239 }
240 let (prefix, suffix) = name.split_once('*').unwrap_or((name, ""));
241 // A second `*` would need real glob matching; the prefix and suffix of the
242 // first one are as far as this goes.
243 if suffix.contains('*') {
244 return Vec::new();
245 }
246 let dir = expanded.parent().unwrap_or(ssh_dir).to_path_buf();
247 let Ok(entries) = std::fs::read_dir(&dir) else {
248 return Vec::new();
249 };
250 let mut out: Vec<PathBuf> = entries
251 .flatten()
252 .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
253 .filter(|e| match e.file_name().to_str() {
254 Some(n) => {
255 n.starts_with(prefix)
256 && n.ends_with(suffix)
257 && n.len() >= prefix.len() + suffix.len()
258 }
259 None => false,
260 })
261 .map(|e| e.path())
262 .collect();
263 // Directory order is whatever the filesystem feels like, and the panel
264 // shows the first few of whatever it is sent.
265 out.sort();
266 out
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn accepts_plain_destinations() {
275 assert_eq!(valid_ssh_host("mini").as_deref(), Some("mini"));
276 assert_eq!(
277 valid_ssh_host(" collin@mini ").as_deref(),
278 Some("collin@mini")
279 );
280 assert_eq!(
281 valid_ssh_host("build-01.example.com").as_deref(),
282 Some("build-01.example.com")
283 );
284 }
285
286 #[test]
287 fn rejects_anything_ssh_would_read_as_an_option() {
288 // The one that matters: a leading dash is an ssh flag, and
289 // `-oProxyCommand=` runs a shell.
290 assert_eq!(valid_ssh_host("-oProxyCommand=sh"), None);
291 assert_eq!(valid_ssh_host("root@-oProxyCommand=sh"), None);
292 assert_eq!(valid_ssh_host("-mini"), None);
293 }
294
295 #[test]
296 fn rejects_anything_that_is_not_one_name() {
297 assert_eq!(valid_ssh_host(""), None);
298 assert_eq!(valid_ssh_host(" "), None);
299 assert_eq!(valid_ssh_host("mini ; kill-server"), None);
300 assert_eq!(valid_ssh_host("mini' ; kill-server ; '"), None);
301 assert_eq!(valid_ssh_host("a@b@c"), None);
302 assert_eq!(valid_ssh_host("@mini"), None);
303 assert_eq!(valid_ssh_host("collin@"), None);
304 assert_eq!(valid_ssh_host("ssh://mini"), None);
305 assert_eq!(valid_ssh_host("mini:22"), None);
306 assert_eq!(valid_ssh_host(&"a".repeat(MAX_HOST + 1)), None);
307 }
308
309 #[test]
310 fn window_name_drops_the_user() {
311 assert_eq!(window_name("collin@mini"), "mini");
312 assert_eq!(window_name("mini"), "mini");
313 }
314
315 #[test]
316 fn config_yields_aliases_and_includes() {
317 let (hosts, includes) = parse_config(
318 "# comment\n\
319 Include ~/.ssh/config.d/*\n\
320 Host mini nas\n\
321 \tHostName 10.0.0.4\n\
322 Host=work\n\
323 Host *\n\
324 Host *.internal\n\
325 Host !badhost\n\
326 Match host anything\n",
327 );
328 assert_eq!(hosts, ["mini", "nas", "work"]);
329 assert_eq!(includes, ["~/.ssh/config.d/*"]);
330 }
331
332 #[test]
333 fn known_hosts_drops_what_cannot_be_typed() {
334 let hosts = parse_known_hosts(
335 "|1|aGFzaGVk|aGFzaGVk ssh-ed25519 AAAA\n\
336 mini,10.0.0.4 ssh-ed25519 AAAA\n\
337 [mini]:2222 ssh-ed25519 AAAA\n\
338 @cert-authority anvil.example.com ssh-rsa AAAA\n\
339 192.168.1.9 ssh-rsa AAAA\n\
340 # comment\n",
341 );
342 assert_eq!(hosts, ["mini", "anvil.example.com"]);
343 }
344}