Skip to main content

ssh_browser/reachable/
mod.rs

1//! Which hosts are opened without being asked for, every run.
2//!
3//! Without this, a host from `ssh_config` is reachable only after somebody opens it from the
4//! dashboard, and they have to do that again after every restart. Enabled means the daemon
5//! opens it for them, so `http://<name>.<suffix>/` simply works.
6//!
7//! **Enabled means open, not "openable on request".** The tempting version is to connect when
8//! a request for an unopened host arrives — but every request to an alias origin arrives
9//! through the proxy, and a page can cause one of those by writing `<img src>`. So that version
10//! hands any web page the ability to start ssh sessions, and the timing difference between
11//! connecting and refusing tells it which hosts you have.
12//!
13//! The obvious defence does not exist: `Sec-Fetch-Dest` would separate a navigation from a
14//! subresource, and **Chromium sends no `Sec-Fetch-*` header at all on a proxied request**.
15//! Measured, not assumed — every request through the PAC arrives with none of them, while the
16//! same browser sends them to `127.0.0.1` directly. So the daemon cannot tell the two apart,
17//! and the rule stays what it already was: a session is opened by the daemon itself at startup
18//! or by a control call carrying the token, and by nothing else.
19//!
20//! **Nothing here records how to reach a host.** Only its name, which is a `Host` in
21//! `ssh_config`; the account, the port and the jump host stay there. That keeps the interesting
22//! details in one file rather than two, and it is also what makes this set uninteresting to
23//! leak: the name is already in the URL you typed.
24
25use std::collections::BTreeMap;
26use std::path::PathBuf;
27
28use anyhow::{Result, bail};
29
30/// One host opened without being asked for, and where it is rooted.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Host {
33    pub name: String,
34    /// `None` for the remote's home directory, resolved when it is first connected.
35    pub base: Option<String>,
36    pub enabled: bool,
37}
38
39/// Every host anybody has an opinion about, by name.
40///
41/// A map rather than a list because the question asked of it is always about one name, and
42/// because the order it iterates in is then the order the startup banner prints.
43#[derive(Debug, Default, Clone)]
44pub struct Set {
45    hosts: BTreeMap<String, Host>,
46}
47
48impl Set {
49    /// The configured hosts, with any remembered changes of mind applied over them.
50    pub fn new(configured: Vec<Host>) -> Self {
51        let mut hosts: BTreeMap<String, Host> = configured
52            .into_iter()
53            .map(|h| (h.name.clone(), h))
54            .collect();
55        // Applied over the file rather than replacing it. The file says where a host is
56        // rooted; this only ever says whether it is on. So a host turned off from the
57        // dashboard and turned back on later is still rooted where the file put it.
58        for (name, enabled) in remembered() {
59            if let Some(host) = hosts.get_mut(&name) {
60                host.enabled = enabled;
61            } else if enabled {
62                // Enabled from the dashboard for a host the file never mentioned, which is the
63                // ordinary way to turn one on: pick it out of the list and click.
64                hosts.insert(
65                    name.clone(),
66                    Host {
67                        name,
68                        base: None,
69                        enabled: true,
70                    },
71                );
72            }
73        }
74        Self { hosts }
75    }
76
77    /// This host, if it is enabled.
78    ///
79    /// Disabled reads the same as absent, which is what lets a caller treat "turned off" and
80    /// "never mentioned" alike without writing the distinction down anywhere.
81    pub fn get(&self, name: &str) -> Option<&Host> {
82        self.hosts.get(name).filter(|h| h.enabled)
83    }
84
85    /// Every enabled host, for the daemon to open at startup.
86    pub fn enabled(&self) -> impl Iterator<Item = &Host> {
87        self.hosts.values().filter(|h| h.enabled)
88    }
89
90    /// Turn one on or off.
91    ///
92    /// Remembering it is a separate call: a state directory that cannot be written must not
93    /// undo a change the reader can already see working.
94    pub fn set(&mut self, name: &str, enabled: bool, base: Option<String>) {
95        self.hosts
96            .entry(name.to_string())
97            .and_modify(|h| h.enabled = enabled)
98            .or_insert_with(|| Host {
99                name: name.to_string(),
100                base,
101                enabled,
102            });
103    }
104
105    /// What to write down, so the next run starts where this one left off.
106    fn to_text(&self) -> String {
107        let mut out = String::new();
108        for host in self.hosts.values() {
109            out.push(if host.enabled { '+' } else { '-' });
110            out.push_str(&host.name);
111            out.push('\n');
112        }
113        out
114    }
115}
116
117/// Where the enabled set is remembered between runs.
118///
119/// Beside the token, not in the user's `config.toml`. That file is hand-written and carries
120/// their comments, and a daemon that rewrote it would eventually lose one. The config file
121/// still sets the starting value; this records a later change of mind — the same arrangement
122/// the theme uses, for the same reason.
123fn stored_path() -> Option<PathBuf> {
124    Some(crate::control::state_dir()?.join("enabled"))
125}
126
127/// One host per line: `+name` on, `-name` off.
128///
129/// Off is written out rather than left implicit, because a host the config file turns on has to
130/// be turnable off again — and "absent" cannot say that.
131fn remembered() -> Vec<(String, bool)> {
132    let Some(path) = stored_path() else {
133        return Vec::new();
134    };
135    let Ok(text) = std::fs::read_to_string(path) else {
136        return Vec::new();
137    };
138    text.lines().filter_map(parse_line).collect()
139}
140
141fn parse_line(line: &str) -> Option<(String, bool)> {
142    let line = line.trim();
143    let enabled = match line.chars().next()? {
144        '+' => true,
145        '-' => false,
146        // Neither form means a future version or a damaged file. Skipped rather than guessed
147        // at: a guess here turns a host on, or off, without being asked.
148        _ => return None,
149    };
150    let name = line[1..].trim();
151    (!name.is_empty()).then(|| (name.to_string(), enabled))
152}
153
154/// Remember the set, and say where it went.
155pub fn remember(set: &Set) -> Result<PathBuf> {
156    let path = match stored_path() {
157        Some(path) => path,
158        None => bail!("no directory to remember which hosts are reachable in"),
159    };
160    if let Some(dir) = path.parent() {
161        std::fs::create_dir_all(dir)?;
162    }
163    std::fs::write(&path, set.to_text())?;
164    Ok(path)
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn a_disabled_host_reads_exactly_like_one_that_is_not_there() {
173        let mut set = Set::default();
174        set.set("off", false, None);
175        assert!(set.get("off").is_none());
176        assert!(set.get("never-configured").is_none());
177    }
178
179    /// The whole reason `-name` is written down rather than left out: a host the config file
180    /// turns on has to be turnable off, and absence cannot say that.
181    #[test]
182    fn off_survives_a_round_trip_through_the_file_format() {
183        let mut set = Set::default();
184        set.set("on", true, None);
185        set.set("off", false, None);
186        let lines: Vec<(String, bool)> = set.to_text().lines().filter_map(parse_line).collect();
187        assert_eq!(
188            lines,
189            vec![("off".to_string(), false), ("on".to_string(), true)]
190        );
191    }
192
193    #[test]
194    fn a_line_in_neither_form_is_skipped_rather_than_guessed_at() {
195        assert_eq!(parse_line("+yes"), Some(("yes".to_string(), true)));
196        assert_eq!(parse_line("-no"), Some(("no".to_string(), false)));
197        assert_eq!(parse_line("bare"), None);
198        assert_eq!(parse_line("+"), None);
199        assert_eq!(parse_line(""), None);
200    }
201
202    /// Turning a host off and on again must not lose where the file said it was rooted. The
203    /// remembered set carries no base at all, so a merge that replaced rather than overlaid
204    /// would silently re-root it at the remote's home.
205    #[test]
206    fn turning_one_off_and_on_keeps_the_base_the_file_gave_it() {
207        let mut set = Set::new(vec![Host {
208            name: "panza".to_string(),
209            base: Some("~/work".to_string()),
210            enabled: true,
211        }]);
212        set.set("panza", false, None);
213        set.set("panza", true, None);
214        assert_eq!(
215            set.get("panza").and_then(|h| h.base.as_deref()),
216            Some("~/work")
217        );
218    }
219}