Skip to main content

ssh_browser/config/
mod.rs

1//! A configuration file, so that six hosts are not six command lines.
2//!
3//! Unknown keys are refused rather than ignored. A configuration file is exactly where a typo
4//! is invisible: `suffixx = "dev"` that is quietly dropped leaves the daemon running on a suffix
5//! nobody chose, and looking no different from one that was configured. Refusing costs one
6//! confusing start and saves an hour of confusion later.
7//!
8//! Aliases from a file are built through [`Alias::new`], the same constructor the command line
9//! uses. Two entry points and one set of rules is fine; two entry points and two copies of the
10//! rules is how the looser copy becomes the one that matters.
11
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result, bail, ensure};
15use serde::Deserialize;
16
17use crate::origin::Alias;
18
19/// The `[server]` table. Every key is optional: a file listing only aliases is a perfectly
20/// good file, and the defaults belong to the command line that owns them.
21#[derive(Debug, Default, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct Server {
24    pub port: Option<u16>,
25    pub suffix: Option<String>,
26    /// What directory listings look like. See `crate::theme`.
27    ///
28    /// The starting value only: a theme chosen later from the dashboard is remembered
29    /// beside the token rather than written back here, because this file is hand-written
30    /// and a daemon that rewrote it would eventually lose somebody's comment.
31    pub theme: Option<String>,
32    /// `http` or `https`.
33    ///
34    /// `https` terminates TLS behind a `CONNECT`, using a local authority constrained to the
35    /// suffix — see `crate::tls`. It needs that authority trusted once, which `ssh-browser
36    /// trust` prints the command for, so it is opt-in rather than the default.
37    pub scheme: Option<String>,
38}
39
40#[derive(Debug, Deserialize)]
41#[serde(deny_unknown_fields)]
42struct AliasEntry {
43    name: String,
44    host: String,
45    /// Omitted means the remote's home directory.
46    ///
47    /// The default that makes an alias worth writing at all: a host name and nothing
48    /// else. Resolved by asking the remote, in `Origin::bind`.
49    #[serde(default)]
50    base: Option<String>,
51}
52
53/// A host from `ssh_config` that is reachable at its URL without being opened first.
54///
55/// The difference from `[[alias]]` is when the connection happens. An alias is connected while
56/// the daemon starts, so a host that is down stops it starting; one of these is connected the
57/// first time somebody navigates to it, so naming ten costs nothing until one is used.
58///
59/// **Only the name is written here.** Which account, which port, which jump host — all of that
60/// is already in `ssh_config`, and copying any of it into a second file would mean two answers
61/// to one question and a new place for the interesting ones to sit.
62#[derive(Debug, Deserialize)]
63#[serde(deny_unknown_fields)]
64struct HostEntry {
65    /// A `Host` from `ssh_config`. Also the label in the URL.
66    name: String,
67    /// Omitted means the remote's home directory, as for an alias.
68    #[serde(default)]
69    base: Option<String>,
70    /// Written out so a host can be turned off without deleting the line that says where it
71    /// is rooted. Absent means on: a host somebody bothered to write down is one they want.
72    #[serde(default = "yes")]
73    enabled: bool,
74}
75
76fn yes() -> bool {
77    true
78}
79
80#[derive(Debug, Default, Deserialize)]
81#[serde(deny_unknown_fields)]
82struct Document {
83    #[serde(default)]
84    server: Server,
85    /// `[[alias]]` in the file, because each table is one alias; `aliases` here, because this
86    /// is all of them.
87    #[serde(default, rename = "alias")]
88    aliases: Vec<AliasEntry>,
89    #[serde(default, rename = "host")]
90    hosts: Vec<HostEntry>,
91}
92
93/// One `[[host]]`, checked.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct Reachable {
96    pub name: String,
97    pub base: Option<String>,
98    pub enabled: bool,
99}
100
101#[derive(Debug)]
102pub struct Config {
103    pub server: Server,
104    pub aliases: Vec<Alias>,
105    pub hosts: Vec<Reachable>,
106}
107
108/// Parse the text of a configuration file.
109///
110/// Separate from reading one so that every rule below is testable without a filesystem.
111pub fn parse(text: &str) -> Result<Config> {
112    let doc: Document = toml::from_str(text).context("reading the configuration")?;
113
114    if let Some(scheme) = doc.server.scheme.as_deref() {
115        ensure!(
116            scheme == "http" || scheme == "https",
117            "scheme = {scheme:?} is not one this daemon serves; use \"http\" or \"https\""
118        );
119    }
120
121    let mut aliases = Vec::with_capacity(doc.aliases.len());
122    for entry in &doc.aliases {
123        aliases.push(Alias::new(&entry.name, &entry.host, entry.base.as_deref())?);
124    }
125
126    let mut hosts = Vec::with_capacity(doc.hosts.len());
127    for entry in &doc.hosts {
128        // Built through the same constructor an alias uses, and then thrown away. The name has
129        // to be a usable label — it becomes a hostname — and the base has to survive the same
130        // checks, and there is no reason for a second copy of either rule that could drift
131        // looser than this one.
132        Alias::new(&entry.name, &entry.name, entry.base.as_deref())?;
133        ensure!(
134            !hosts.iter().any(|h: &Reachable| h.name == entry.name),
135            "host {:?} is listed twice",
136            entry.name
137        );
138        ensure!(
139            !aliases.iter().any(|a| a.name() == entry.name),
140            "{:?} is both an alias and a host; one of them would decide what that URL means and it is not obvious which",
141            entry.name
142        );
143        hosts.push(Reachable {
144            name: entry.name.clone(),
145            base: entry.base.clone(),
146            enabled: entry.enabled,
147        });
148    }
149
150    Ok(Config {
151        server: doc.server,
152        aliases,
153        hosts,
154    })
155}
156
157/// Read a configuration file.
158pub fn load(path: &Path) -> Result<Config> {
159    let text =
160        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
161    parse(&text).with_context(|| format!("in {}", path.display()))
162}
163
164/// Where a configuration file is looked for when none was named.
165///
166/// Resolved at runtime rather than compiled in. The configuration directory and not the
167/// runtime one the control token uses: a token should disappear when the session does, and a
168/// configuration should not, so the two resolve differently on purpose.
169pub fn default_path() -> Option<PathBuf> {
170    let base = std::env::var_os("XDG_CONFIG_HOME")
171        .or_else(|| std::env::var_os("APPDATA"))
172        .or_else(|| std::env::var_os("LOCALAPPDATA"))
173        .map(PathBuf::from)
174        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
175    Some(base.join("ssh-browser").join("config.toml"))
176}
177
178pub const DEFAULT_PORT: u16 = 7391;
179pub const DEFAULT_SUFFIX: &str = "ssh-browser";
180
181/// `http`, because `https` needs a root trusted once by hand and a default that silently
182/// required that would fail for everybody who had not done it.
183pub const DEFAULT_SCHEME: &str = "http";
184
185/// What the command line said, all of it optional because anything it leaves out the file may
186/// supply and anything neither supplies has a default.
187#[derive(Debug, Default)]
188pub struct Overrides {
189    pub port: Option<u16>,
190    pub suffix: Option<String>,
191    pub scheme: Option<String>,
192    pub aliases: Vec<Alias>,
193}
194
195/// What the daemon will actually run with.
196#[derive(Debug)]
197pub struct Resolved {
198    pub port: u16,
199    pub suffix: String,
200    pub scheme: String,
201    pub aliases: Vec<Alias>,
202    /// Hosts reachable on demand. Carried through rather than merged with anything: there is
203    /// no command-line half of this, because a host worth reaching every day is worth writing
204    /// down once.
205    pub hosts: Vec<Reachable>,
206}
207
208/// Fold the command line over the file.
209///
210/// Lives here rather than inside `main` so that it can be tested at all: the precedence is
211/// three `or`s and an `extend`, any one of which could be turned around without a single test
212/// noticing, and the result decides which host a URL reaches.
213///
214/// The command line wins, because it is what was typed for this run. Aliases are the
215/// exception and are added rather than replacing: naming one host on the command line should
216/// not silently drop the six in the file.
217pub fn merge(cli: Overrides, file: Config) -> Result<Resolved> {
218    let mut aliases = file.aliases;
219    aliases.extend(cli.aliases);
220    ensure_distinct(&aliases)?;
221
222    Ok(Resolved {
223        port: cli.port.or(file.server.port).unwrap_or(DEFAULT_PORT),
224        suffix: cli
225            .suffix
226            .or(file.server.suffix)
227            .unwrap_or_else(|| DEFAULT_SUFFIX.to_string()),
228        scheme: cli
229            .scheme
230            .or(file.server.scheme)
231            .unwrap_or_else(|| DEFAULT_SCHEME.to_string()),
232        aliases,
233        hosts: file.hosts,
234    })
235}
236
237/// Refuse two aliases with the same name.
238///
239/// One would shadow the other in the session map, and which one survived would depend on the
240/// order they happened to be added in. A URL quietly pointing at a different host than the one
241/// configured is not something to settle by precedence.
242pub fn ensure_distinct(aliases: &[Alias]) -> Result<()> {
243    for (i, a) in aliases.iter().enumerate() {
244        if let Some(other) = aliases[..i].iter().find(|b| b.name() == a.name()) {
245            bail!(
246                "alias {:?} is defined twice: {} and {}",
247                a.name(),
248                other.host(),
249                a.host()
250            );
251        }
252    }
253    Ok(())
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    const FULL: &str = r#"
261[server]
262port = 7391
263suffix = "ssh-browser"
264
265[[alias]]
266name = "docs"
267host = "myhost"
268base = "/srv/docs"
269
270[[alias]]
271name = "cluster"
272host = "login-node"
273base = "/home/me/public_html"
274"#;
275
276    #[test]
277    fn a_full_file_parses() {
278        let c = parse(FULL).expect("parses");
279        assert_eq!(c.server.port, Some(7391));
280        assert_eq!(c.server.suffix.as_deref(), Some("ssh-browser"));
281        assert_eq!(c.aliases.len(), 2);
282        assert_eq!(c.aliases[0].name(), "docs");
283        assert_eq!(c.aliases[1].base(), Some("/home/me/public_html"));
284    }
285
286    #[test]
287    fn a_file_of_only_aliases_is_fine() {
288        let c =
289            parse("[[alias]]\nname = \"docs\"\nhost = \"h\"\nbase = \"/srv\"\n").expect("parses");
290        assert!(c.server.port.is_none());
291        assert_eq!(c.aliases.len(), 1);
292    }
293
294    #[test]
295    fn an_empty_file_is_fine() {
296        assert!(parse("").expect("parses").aliases.is_empty());
297    }
298
299    /// Why `deny_unknown_fields` is on. A silently dropped key leaves the daemon running on
300    /// something nobody chose, indistinguishable from something somebody did.
301    #[test]
302    fn a_misspelled_key_is_refused_rather_than_ignored() {
303        let e = parse("[server]\nsuffixx = \"dev\"\n").expect_err("refused");
304        assert!(
305            format!("{e:#}").contains("suffixx"),
306            "the error has to name the key: {e:#}"
307        );
308        assert!(
309            parse("[[alias]]\nname = \"a\"\nhost = \"h\"\nbase = \"/b\"\nextra = 1\n").is_err()
310        );
311        assert!(parse("[serverr]\nport = 1\n").is_err());
312    }
313
314    /// Both schemes are served, and nothing else is.
315    ///
316    /// The third case is the one worth a test: a scheme this daemon does not speak has to be
317    /// refused where it is written rather than quietly falling back, because serving http to a
318    /// file that asked for something else is the one outcome that looks like success.
319    #[test]
320    fn the_two_schemes_are_accepted_and_a_third_is_refused() {
321        assert!(parse("[server]\nscheme = \"http\"\n").is_ok());
322        assert!(parse("[server]\nscheme = \"https\"\n").is_ok());
323        for bad in ["HTTPS", "ftp", "wss", "", "http:"] {
324            let e = parse(&format!("[server]\nscheme = \"{bad}\"\n"))
325                .expect_err(&format!("{bad:?} should have been refused"));
326            assert!(format!("{e:#}").contains("scheme"), "{e:#}");
327        }
328    }
329
330    /// http unless something says otherwise.
331    ///
332    /// https needs a root trusted once by hand, so a default that silently required that would
333    /// fail for everybody who had not done it — and fail at the TLS layer, where the reason is
334    /// least visible.
335    #[test]
336    fn the_default_scheme_is_http() {
337        let r = merge(Overrides::default(), parse("").expect("empty parses")).expect("merges");
338        assert_eq!(r.scheme, "http");
339    }
340
341    /// And `--scheme` wins over the file, like the rest of the command line.
342    #[test]
343    fn the_command_line_scheme_wins() {
344        let file = parse("[server]\nscheme = \"http\"\n").expect("parses");
345        let cli = Overrides {
346            scheme: Some("https".to_string()),
347            ..Overrides::default()
348        };
349        assert_eq!(merge(cli, file).expect("merges").scheme, "https");
350    }
351
352    /// The command line's rules, reached through the same constructor rather than written out
353    /// again here.
354    #[test]
355    fn an_alias_from_a_file_is_checked_like_one_from_the_command_line() {
356        for bad in [
357            "[[alias]]\nname = \"Docs\"\nhost = \"h\"\nbase = \"/srv\"\n",
358            "[[alias]]\nname = \"a.b\"\nhost = \"h\"\nbase = \"/srv\"\n",
359            "[[alias]]\nname = \"docs\"\nhost = \"h\"\nbase = \"relative\"\n",
360            "[[alias]]\nname = \"docs\"\nhost = \"\"\nbase = \"/srv\"\n",
361            // A leading or trailing hyphen is a label `guard::classify` refuses on every
362            // request. The constructor used to accept both, so the daemon connected over ssh,
363            // printed the route, listed it as a link — and then served a 403 to anyone who
364            // followed it.
365            "[[alias]]\nname = \"-docs\"\nhost = \"h\"\nbase = \"/srv\"\n",
366            "[[alias]]\nname = \"docs-\"\nhost = \"h\"\nbase = \"/srv\"\n",
367        ] {
368            assert!(parse(bad).is_err(), "should have been refused:\n{bad}");
369        }
370    }
371
372    #[test]
373    fn a_missing_alias_field_is_refused() {
374        for bad in [
375            "[[alias]]\nhost = \"h\"\nbase = \"/srv\"\n",
376            "[[alias]]\nname = \"docs\"\nbase = \"/srv\"\n",
377        ] {
378            assert!(parse(bad).is_err(), "should have been refused:\n{bad}");
379        }
380    }
381
382    /// The short form, and the one worth typing: a name and a host, nothing else.
383    ///
384    /// `None` rather than a path, because where the home directory is lives on the remote.
385    /// Filling it in here would mean this machine's home directory, which belongs to a
386    /// different computer.
387    #[test]
388    fn an_alias_without_a_base_means_the_home_directory() {
389        let c = parse("[[alias]]\nname = \"docs\"\nhost = \"h\"\n").expect("parses");
390        assert_eq!(c.aliases[0].base(), None);
391    }
392
393    #[test]
394    fn a_host_needs_only_a_name_and_is_on_by_default() {
395        let c = parse(
396            "[[host]]
397name = \"login-node\"
398",
399        )
400        .expect("parses");
401        assert_eq!(c.hosts.len(), 1);
402        assert_eq!(c.hosts[0].name, "login-node");
403        assert_eq!(c.hosts[0].base, None);
404        assert!(
405            c.hosts[0].enabled,
406            "a host somebody wrote down is one they want"
407        );
408    }
409
410    #[test]
411    fn a_host_can_be_turned_off_without_deleting_where_it_is_rooted() {
412        let c = parse(
413            "[[host]]
414name = \"n\"
415base = \"~/w\"
416enabled = false
417",
418        )
419        .expect("parses");
420        assert!(!c.hosts[0].enabled);
421        assert_eq!(c.hosts[0].base.as_deref(), Some("~/w"));
422    }
423
424    /// Nothing about *how* to reach a host belongs here — that is `ssh_config`'s job, and two
425    /// answers to one question is how they come to disagree. An unknown key is refused rather
426    /// than ignored, so writing one is a failed start and not a silently different connection.
427    #[test]
428    fn a_host_may_not_carry_ssh_details() {
429        for line in [
430            "user = \"me\"",
431            "port = 22",
432            "hostname = \"h\"",
433            "proxyJump = \"j\"",
434        ] {
435            assert!(
436                parse(&format!(
437                    "[[host]]
438name = \"n\"
439{line}
440"
441                ))
442                .is_err(),
443                "{line} should have been refused"
444            );
445        }
446    }
447
448    /// Two rows for the same name, or a name that is also an alias, would each make one URL
449    /// mean two things — and which one won would depend on the order they were read in.
450    #[test]
451    fn a_name_may_not_mean_two_things() {
452        assert!(
453            parse(
454                "[[host]]
455name = \"n\"
456[[host]]
457name = \"n\"
458"
459            )
460            .is_err()
461        );
462        assert!(
463            parse(
464                "[[alias]]
465name = \"n\"
466host = \"h\"
467[[host]]
468name = \"n\"
469"
470            )
471            .is_err()
472        );
473    }
474
475    /// The name becomes a hostname label, so it is held to the same rule an alias name is —
476    /// here, where it is written, rather than on every request after the daemon has already
477    /// connected and announced the route.
478    #[test]
479    fn a_host_name_that_cannot_be_a_label_is_refused_where_it_is_written() {
480        assert!(
481            parse(
482                "[[host]]
483name = \"-nope\"
484"
485            )
486            .is_err()
487        );
488        assert!(
489            parse(
490                "[[host]]
491name = \"\"
492"
493            )
494            .is_err()
495        );
496    }
497
498    fn alias(name: &str, host: &str) -> Alias {
499        Alias::new(name, host, Some("/srv")).expect("a valid alias")
500    }
501
502    fn file_with(server: Server, aliases: Vec<Alias>) -> Config {
503        Config {
504            server,
505            aliases,
506            hosts: Vec::new(),
507        }
508    }
509
510    /// What was typed for this run wins over what was written down for every run.
511    #[test]
512    fn the_command_line_wins_over_the_file() {
513        let file = file_with(
514            Server {
515                port: Some(1111),
516                suffix: Some("from-file".to_string()),
517                theme: None,
518                scheme: None,
519            },
520            vec![],
521        );
522        let cli = Overrides {
523            port: Some(2222),
524            suffix: Some("from-cli".to_string()),
525            scheme: None,
526            aliases: vec![],
527        };
528
529        let r = merge(cli, file).expect("merges");
530        assert_eq!(r.port, 2222);
531        assert_eq!(r.suffix, "from-cli");
532    }
533
534    #[test]
535    fn the_file_supplies_what_the_command_line_does_not() {
536        let file = file_with(
537            Server {
538                port: Some(1111),
539                suffix: Some("from-file".to_string()),
540                theme: None,
541                scheme: None,
542            },
543            vec![],
544        );
545
546        let r = merge(Overrides::default(), file).expect("merges");
547        assert_eq!(r.port, 1111);
548        assert_eq!(r.suffix, "from-file");
549        // Neither said, so the default stands.
550    }
551
552    #[test]
553    fn what_neither_supplies_falls_back() {
554        let r = merge(Overrides::default(), file_with(Server::default(), vec![])).expect("merges");
555        assert_eq!(r.port, DEFAULT_PORT);
556        assert_eq!(r.suffix, DEFAULT_SUFFIX);
557    }
558
559    /// Added, not replaced. Naming one host on the command line must not drop the ones in the
560    /// file, which is the difference between an override and an amendment.
561    #[test]
562    fn aliases_from_both_places_are_kept() {
563        let r = merge(
564            Overrides {
565                aliases: vec![alias("cli", "h")],
566                ..Overrides::default()
567            },
568            file_with(Server::default(), vec![alias("file", "h")]),
569        )
570        .expect("merges");
571
572        let names: Vec<&str> = r.aliases.iter().map(Alias::name).collect();
573        assert_eq!(names, ["file", "cli"]);
574    }
575
576    /// And a name in both places is a collision, because whichever won would depend on the
577    /// order they happened to be added in.
578    #[test]
579    fn a_name_given_in_both_places_is_refused() {
580        let e = merge(
581            Overrides {
582                aliases: vec![alias("docs", "from-cli")],
583                ..Overrides::default()
584            },
585            file_with(Server::default(), vec![alias("docs", "from-file")]),
586        )
587        .expect_err("refused");
588        assert!(format!("{e:#}").contains("docs"), "{e:#}");
589    }
590
591    #[test]
592    fn two_aliases_with_one_name_are_refused() {
593        let docs = |host: &str| Alias::new("docs", host, Some("/srv")).expect("valid");
594        assert!(ensure_distinct(&[docs("a"), docs("b")]).is_err());
595        let other = Alias::new("other", "b", Some("/srv")).expect("valid");
596        assert!(ensure_distinct(&[docs("a"), other]).is_ok());
597    }
598
599    #[test]
600    fn the_default_path_is_resolved_at_runtime() {
601        // Whichever variable the platform offers, the tail is the same and nothing is
602        // compiled in.
603        if let Some(p) = default_path() {
604            assert!(p.ends_with(Path::new("ssh-browser").join("config.toml")));
605        }
606    }
607}