Skip to main content

ssh_browser/ssh_config/
mod.rs

1//! The hosts this machine's ssh already knows how to reach.
2//!
3//! Everything here reads what OpenSSH reads, and nothing here decides how to connect.
4//! The transport is `ssh <host> -s sftp`, so authentication, `ProxyJump`, `User`, `Port`
5//! and the rest are OpenSSH's to resolve and this daemon's only job is to name the host.
6//! Reimplementing any of that would produce a second, worse copy of a file the user has
7//! already got right.
8//!
9//! Two separate things live here for that reason. Parsing the file answers "which hosts
10//! exist", which is a question about names and is worth doing locally. Answering "what
11//! does this host resolve to" is `ssh -G`'s job, because the resolution rules — first
12//! match wins, `Match` blocks, `Include`, canonicalisation — are not something to
13//! reimplement for the sake of a line in a popup.
14
15use std::path::{Path, PathBuf};
16
17use anyhow::{Context, Result};
18
19use crate::origin::guard;
20
21/// How deep `Include` is followed before giving up, matching OpenSSH's own limit.
22const MAX_INCLUDE_DEPTH: usize = 16;
23
24/// A host named in ssh_config, and the alias it would be served under.
25#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
26pub struct Host {
27    /// The name as written in the file, which is what `ssh` is invoked with.
28    ///
29    /// Kept exactly as written. `Host Panza` is reached as `Panza`, and lowercasing what
30    /// gets passed to ssh would be this daemon second-guessing a file it does not own.
31    pub host: String,
32    /// The same name as a hostname label, which is what an alias has to be.
33    pub alias: String,
34}
35
36/// A host that exists but cannot be served, and why.
37///
38/// Carried rather than dropped. A host missing from the list with no explanation reads
39/// as this daemon having failed to find it, which is a different problem with a
40/// different fix.
41#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
42pub struct Unusable {
43    pub host: String,
44    pub why: String,
45}
46
47/// What a parse found: the hosts that can be served, and the ones that cannot.
48#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
49pub struct Found {
50    pub hosts: Vec<Host>,
51    pub unusable: Vec<Unusable>,
52}
53
54/// `~/.ssh/config`, wherever this user's home is.
55pub fn default_path() -> Option<PathBuf> {
56    let home = std::env::var_os("HOME")
57        .or_else(|| std::env::var_os("USERPROFILE"))
58        .filter(|h| !h.is_empty())?;
59    Some(PathBuf::from(home).join(".ssh").join("config"))
60}
61
62/// Read the user's ssh_config and every file it includes.
63///
64/// A missing file is an empty answer rather than an error: not having an ssh_config is
65/// an ordinary state, and it is the same answer as having one that names no hosts.
66pub fn read() -> Result<Found> {
67    let Some(path) = default_path() else {
68        return Ok(Found::default());
69    };
70    read_from(&path)
71}
72
73/// The same, from a named file. Split out so tests have a way in.
74pub fn read_from(path: &Path) -> Result<Found> {
75    if !path.exists() {
76        return Ok(Found::default());
77    }
78    // `Include` is resolved against the directory the top-level file lives in, which is
79    // `~/.ssh` for the user config. OpenSSH resolves relative includes against that
80    // directory rather than against the including file, so nesting does not shift it.
81    let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
82    let mut text = String::new();
83    gather(path, &root, 0, &mut text)?;
84    Ok(parse(&text))
85}
86
87/// Append a file's lines, following `Include` as it goes.
88fn gather(path: &Path, root: &Path, depth: usize, out: &mut String) -> Result<()> {
89    if depth > MAX_INCLUDE_DEPTH {
90        // Refused rather than silently truncated, because an include loop that quietly
91        // stopped producing hosts would look exactly like a config that did not name
92        // them.
93        anyhow::bail!(
94            "ssh_config includes nest more than {MAX_INCLUDE_DEPTH} deep at {}",
95            path.display()
96        );
97    }
98    let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
99    for line in text.lines() {
100        match include_target(line) {
101            Some(pattern) => {
102                for file in expand(pattern, root) {
103                    // A named include that is not there is skipped, as OpenSSH skips it.
104                    // `Include config.d/*` matching nothing is the ordinary state of a
105                    // machine that has not made one yet.
106                    if file.is_file() {
107                        gather(&file, root, depth + 1, out)?;
108                    }
109                }
110            }
111            None => {
112                out.push_str(line);
113                out.push('\n');
114            }
115        }
116    }
117    Ok(())
118}
119
120/// The argument of an `Include` line, if this is one.
121fn include_target(line: &str) -> Option<&str> {
122    let (keyword, rest) = keyword_and_rest(line)?;
123    keyword.eq_ignore_ascii_case("include").then_some(rest)
124}
125
126/// Split a config line into its keyword and the rest.
127///
128/// ssh_config accepts `Key value`, `Key=value` and any amount of surrounding space, so
129/// the split has to handle all three. Comments and blank lines answer `None`.
130fn keyword_and_rest(line: &str) -> Option<(&str, &str)> {
131    let line = line.trim();
132    if line.is_empty() || line.starts_with('#') {
133        return None;
134    }
135    let end = line
136        .find(|c: char| c.is_ascii_whitespace() || c == '=')
137        .unwrap_or(line.len());
138    let (keyword, rest) = line.split_at(end);
139    Some((keyword, rest.trim_start_matches(['=', ' ', '\t']).trim()))
140}
141
142/// Expand one `Include` argument into the files it names.
143///
144/// Only the final component may contain a wildcard, which is what every real config
145/// does: `Include config.d/*`.
146fn expand(pattern: &str, root: &Path) -> Vec<PathBuf> {
147    let mut out = Vec::new();
148    for word in pattern.split_ascii_whitespace() {
149        let word = word.trim_matches('"');
150        let resolved = if let Some(rest) = word.strip_prefix("~/") {
151            match std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
152                Some(home) => PathBuf::from(home).join(rest),
153                None => continue,
154            }
155        } else if Path::new(word).is_absolute() {
156            PathBuf::from(word)
157        } else {
158            root.join(word)
159        };
160
161        let Some(last) = resolved.file_name().and_then(|n| n.to_str()) else {
162            continue;
163        };
164        if !last.contains(['*', '?']) {
165            out.push(resolved);
166            continue;
167        }
168        let Some(dir) = resolved.parent() else {
169            continue;
170        };
171        let Ok(entries) = std::fs::read_dir(dir) else {
172            continue;
173        };
174        // Sorted, so that a config split across several files produces the same ordering
175        // every run. Directory order is not defined and OpenSSH sorts for the same reason.
176        let mut matched: Vec<PathBuf> = entries
177            .flatten()
178            .filter(|e| {
179                e.file_name()
180                    .to_str()
181                    .is_some_and(|name| glob_matches(last, name))
182            })
183            .map(|e| e.path())
184            .collect();
185        matched.sort();
186        out.extend(matched);
187    }
188    out
189}
190
191/// `*` and `?` against one filename component, which is all ssh_config's includes use.
192fn glob_matches(pattern: &str, name: &str) -> bool {
193    let (p, n): (Vec<char>, Vec<char>) = (pattern.chars().collect(), name.chars().collect());
194    // The two-index walk with a remembered star, which is linear rather than the
195    // exponential the obvious recursion gives on a pattern full of stars.
196    let (mut pi, mut ni) = (0, 0);
197    let (mut star, mut resume) = (None, 0);
198    while ni < n.len() {
199        if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
200            pi += 1;
201            ni += 1;
202        } else if pi < p.len() && p[pi] == '*' {
203            star = Some(pi);
204            resume = ni;
205            pi += 1;
206        } else if let Some(s) = star {
207            pi = s + 1;
208            resume += 1;
209            ni = resume;
210        } else {
211            return false;
212        }
213    }
214    p[pi..].iter().all(|&c| c == '*')
215}
216
217/// The hosts named by an ssh_config's text.
218///
219/// Only concrete names. A pattern is a rule for matching hosts, not a host: `Host *`
220/// sets defaults for everything and names nothing, and serving an alias called `*` is
221/// not a thing that could work. Negations are patterns too.
222///
223/// Order is the file's order, and the first spelling of a duplicate wins, because that
224/// is the one OpenSSH's first-match-wins resolution will use.
225pub fn parse(text: &str) -> Found {
226    let mut found = Found::default();
227    let mut seen: Vec<String> = Vec::new();
228    for line in text.lines() {
229        let Some((keyword, rest)) = keyword_and_rest(line) else {
230            continue;
231        };
232        if !keyword.eq_ignore_ascii_case("host") {
233            continue;
234        }
235        for name in rest.split_ascii_whitespace() {
236            let name = name.trim_matches('"');
237            if name.is_empty() || name.contains(['*', '?']) || name.starts_with('!') {
238                continue;
239            }
240            // The alias becomes a hostname label and hostnames are case-insensitive, so
241            // `Host Panza` is served as `panza`. Lowercasing is the whole transformation:
242            // anything more would be this daemon inventing a name for a host the user has
243            // already named.
244            let alias = name.to_ascii_lowercase();
245            if seen.iter().any(|s| s == &alias) {
246                continue;
247            }
248            seen.push(alias.clone());
249            if guard::is_label(&alias) {
250                found.hosts.push(Host {
251                    host: name.to_string(),
252                    alias,
253                });
254            } else {
255                found.unusable.push(Unusable {
256                    host: name.to_string(),
257                    why: "not usable as a hostname label: give it an alias in the config file"
258                        .to_string(),
259                });
260            }
261        }
262    }
263    found
264}
265
266/// What OpenSSH resolves a host to, for showing beside it.
267///
268/// Every field is whatever `ssh -G` said, which is the only answer that matches what the
269/// transport will actually do. Parsing the config for these would mean reimplementing
270/// first-match-wins, `Match` blocks and canonicalisation, and being subtly wrong about a
271/// host the user can see is configured correctly.
272#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
273pub struct Settings {
274    pub user: Option<String>,
275    pub hostname: Option<String>,
276    pub port: Option<u16>,
277    #[serde(rename = "proxyJump")]
278    pub proxy_jump: Option<String>,
279}
280
281/// Read `ssh -G` output into the handful of fields worth showing.
282///
283/// Unknown keys are ignored rather than refused: `ssh -G` prints every option OpenSSH
284/// has, the list grows with each release, and none of the rest is this daemon's business.
285pub fn parse_settings(text: &str) -> Settings {
286    let mut s = Settings::default();
287    for line in text.lines() {
288        let Some((key, value)) = line.trim().split_once(' ') else {
289            continue;
290        };
291        let value = value.trim();
292        match key.to_ascii_lowercase().as_str() {
293            "user" => s.user = Some(value.to_string()),
294            "hostname" => s.hostname = Some(value.to_string()),
295            "port" => s.port = value.parse().ok(),
296            // `ssh -G` prints the literal word for "no jump host", and showing that
297            // beside a host would suggest a jump host called "none".
298            "proxyjump" if !value.eq_ignore_ascii_case("none") => {
299                s.proxy_jump = Some(value.to_string());
300            }
301            _ => {}
302        }
303    }
304    s
305}
306
307/// Ask OpenSSH what a host resolves to.
308pub async fn describe(host: &str) -> Result<Settings> {
309    let out = tokio::process::Command::new("ssh")
310        .arg("-G")
311        .arg(host)
312        .output()
313        .await
314        .with_context(|| format!("run ssh -G {host}"))?;
315    // stdout is read even on a non-zero exit. `ssh -G` reports what it could resolve and
316    // then complains, and the partial answer is more useful than none.
317    Ok(parse_settings(&String::from_utf8_lossy(&out.stdout)))
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn the_hosts_of_a_real_looking_config_are_found_in_order() {
326        let found = parse(
327            "Host Panza\n  HostName panza.example\n\nHost yukawa-front\n  ProxyJump yukawa-mercury\n",
328        );
329        assert_eq!(
330            found.hosts,
331            vec![
332                Host {
333                    host: "Panza".to_string(),
334                    alias: "panza".to_string()
335                },
336                Host {
337                    host: "yukawa-front".to_string(),
338                    alias: "yukawa-front".to_string()
339                },
340            ]
341        );
342        assert!(found.unusable.is_empty());
343    }
344
345    /// A pattern is a rule for matching hosts, not a host. `Host *` is how nearly every
346    /// config sets its defaults, so serving it would put an alias called `*` in front of
347    /// whatever the first stanza happened to be.
348    #[test]
349    fn patterns_are_not_hosts() {
350        let found = parse("Host *\n  ForwardAgent yes\nHost *.example.com\nHost !bad ok\n");
351        assert_eq!(
352            found.hosts,
353            vec![Host {
354                host: "ok".to_string(),
355                alias: "ok".to_string()
356            }]
357        );
358    }
359
360    /// One stanza can name several hosts, and they are separate hosts.
361    #[test]
362    fn one_line_can_name_several_hosts() {
363        let found = parse("Host alpha beta gamma\n");
364        let aliases: Vec<&str> = found.hosts.iter().map(|h| h.alias.as_str()).collect();
365        assert_eq!(aliases, ["alpha", "beta", "gamma"]);
366    }
367
368    /// ssh_config's own spelling latitude: `Key=value`, leading space, comments.
369    #[test]
370    fn the_odd_spellings_ssh_config_allows_are_understood() {
371        let found = parse("# a comment\n\n   host=Odd\n\tHOST   Other\n");
372        let aliases: Vec<&str> = found.hosts.iter().map(|h| h.alias.as_str()).collect();
373        assert_eq!(aliases, ["odd", "other"]);
374    }
375
376    /// Hostnames are case-insensitive, so these are one host, and the name ssh is
377    /// invoked with is the first spelling rather than the last.
378    #[test]
379    fn a_host_named_twice_in_different_cases_is_one_alias() {
380        let found = parse("Host Panza\nHost panza\n");
381        assert_eq!(found.hosts.len(), 1);
382        assert_eq!(found.hosts[0].host, "Panza");
383    }
384
385    /// Named rather than dropped, so the absence has a reason attached to it.
386    #[test]
387    fn a_host_that_cannot_be_a_label_is_reported_rather_than_dropped() {
388        let found = parse("Host build.example.com\nHost fine\n");
389        let aliases: Vec<&str> = found.hosts.iter().map(|h| h.alias.as_str()).collect();
390        assert_eq!(aliases, ["fine"]);
391        assert_eq!(found.unusable.len(), 1);
392        assert_eq!(found.unusable[0].host, "build.example.com");
393    }
394
395    #[test]
396    fn ssh_dash_g_output_is_read_for_the_fields_worth_showing() {
397        let s = parse_settings(
398            "user souta\nhostname 10.0.0.2\nport 2222\nproxyjump bastion\nforwardagent yes\n",
399        );
400        assert_eq!(s.user.as_deref(), Some("souta"));
401        assert_eq!(s.hostname.as_deref(), Some("10.0.0.2"));
402        assert_eq!(s.port, Some(2222));
403        assert_eq!(s.proxy_jump.as_deref(), Some("bastion"));
404    }
405
406    /// `ssh -G` prints the word rather than omitting the key, and showing it would
407    /// suggest a jump host called "none".
408    #[test]
409    fn proxyjump_none_is_no_proxy_jump() {
410        assert_eq!(parse_settings("proxyjump none\n").proxy_jump, None);
411    }
412
413    #[test]
414    fn globs_match_the_way_include_needs() {
415        assert!(glob_matches("*", "anything"));
416        assert!(glob_matches("*.conf", "work.conf"));
417        assert!(glob_matches("a?c", "abc"));
418        assert!(!glob_matches("a?c", "ac"));
419        assert!(!glob_matches("*.conf", "conf.bak"));
420        assert!(glob_matches("*a*b*", "xxayybzz"));
421    }
422
423    #[test]
424    fn include_pulls_in_another_file() {
425        let dir = std::env::temp_dir().join(format!("ssh-browser-inc-{}", std::process::id()));
426        let sub = dir.join("config.d");
427        std::fs::create_dir_all(&sub).expect("temp dirs");
428        std::fs::write(sub.join("10-work.conf"), "Host from-include\n").expect("write include");
429        std::fs::write(dir.join("config"), "Host direct\nInclude config.d/*\n").expect("write");
430
431        let found = read_from(&dir.join("config")).expect("reads");
432        let aliases: Vec<&str> = found.hosts.iter().map(|h| h.alias.as_str()).collect();
433        assert_eq!(aliases, ["direct", "from-include"]);
434
435        std::fs::remove_dir_all(&dir).ok();
436    }
437}