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