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}