Skip to main content

ssh_browser/autostart/
mod.rs

1//! Start the daemon when you log in, so nobody has to remember to.
2//!
3//! A reader who must run `ssh-browser serve` before their bookmarks resolve does not have a
4//! product, they have a program they run. The daemon is not a thing anyone wants to think
5//! about — it is what makes a URL work — so it should already be there.
6//!
7//! Unlike [`crate::tls`], which prints a command and executes nothing, this does the thing.
8//! The difference is about consent rather than effort: trusting a certificate authority changes
9//! what the whole machine believes, so it is a decision to make with your own hands. Starting a
10//! program of your own at login is what was asked for, and printing a command to copy would be
11//! the same failure in a politer form.
12//!
13//! The three platforms are a parameter rather than a `cfg!`, for the reason written out in
14//! `tls::Store`: a platform-specific string only its own platform can run is a string nobody
15//! tests. Here the plan is worked out as data, checked on any machine, and only [`apply`]
16//! touches anything.
17
18use std::path::{Path, PathBuf};
19
20use anyhow::{Context, Result, bail};
21
22/// How a platform starts something at login.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Kind {
25    /// Task Scheduler, a task for this account.
26    Windows,
27    /// A launchd agent in the user's own `LaunchAgents`.
28    MacOs,
29    /// A systemd user unit.
30    Systemd,
31}
32
33impl Kind {
34    /// The one this is running on.
35    pub fn here() -> Self {
36        if cfg!(windows) {
37            Self::Windows
38        } else if cfg!(target_os = "macos") {
39            Self::MacOs
40        } else {
41            Self::Systemd
42        }
43    }
44}
45
46/// Everything a platform needs done, worked out without doing any of it.
47///
48/// Files first and commands second, always: each of these registers a path that has to exist
49/// by the time the command referring to it runs.
50#[derive(Debug, PartialEq, Eq)]
51pub struct Plan {
52    pub files: Vec<(PathBuf, String)>,
53    pub commands: Vec<Vec<String>>,
54    /// Paths to delete, after the commands. Removal fills this; installing leaves it empty.
55    pub remove: Vec<PathBuf>,
56    /// What to tell the reader, including how to undo it.
57    pub note: String,
58}
59
60/// The reverse-DNS label launchd wants, and the name the unit goes by elsewhere.
61const LABEL: &str = "com.qatlashub.ssh-browser";
62
63/// The folder Windows runs the contents of at login.
64///
65/// Chosen over a Task Scheduler entry, and not for simplicity. `schtasks /Create /SC ONLOGON`
66/// writes to the machine's task store, so it wants administrator rights — measured, on the
67/// machine this was written for, with the task never created:
68///
69/// ```text
70/// Error: schtasks failed: エラー: アクセスが拒否されました。
71/// ```
72///
73/// Asking somebody to open an elevated prompt in order to start a program of their own is the
74/// thing this command exists to avoid. This folder needs nothing, is where Windows itself
75/// documents that login programs go, and is undone by deleting a file you can see.
76fn startup_dir(home: &Path) -> PathBuf {
77    std::env::var_os("APPDATA")
78        .map(PathBuf::from)
79        .unwrap_or_else(|| home.join("AppData").join("Roaming"))
80        .join("Microsoft")
81        .join("Windows")
82        .join("Start Menu")
83        .join("Programs")
84        .join("Startup")
85}
86
87/// Where the login entry lives, per platform.
88///
89/// Two of the three are fixed by convention; the third is ours to choose, so it goes beside
90/// everything else the daemon remembers between runs.
91fn entry_path(kind: Kind, home: &Path, state: &Path) -> PathBuf {
92    // Kept in the signature though only the other two read it: a state directory is where the
93    // Windows entry lived before `schtasks` turned out to need elevation, and a caller should
94    // not have to know which platforms happen to want which directory today.
95    let _ = state;
96    match kind {
97        Kind::Windows => startup_dir(home).join("ssh-browser.vbs"),
98        Kind::MacOs => home
99            .join("Library")
100            .join("LaunchAgents")
101            .join(format!("{LABEL}.plist")),
102        Kind::Systemd => home
103            .join(".config")
104            .join("systemd")
105            .join("user")
106            .join("ssh-browser.service"),
107    }
108}
109
110/// What installing looks like on `kind`, for a daemon at `exe`.
111pub fn install_plan(kind: Kind, exe: &Path, home: &Path, state: &Path) -> Plan {
112    let entry = entry_path(kind, home, state);
113    let exe = exe.display().to_string();
114    match kind {
115        Kind::Windows => Plan {
116            // A one-line script, because Task Scheduler runs a console program in a console
117            // window. Left visible that window sits there for the session, and the first thing
118            // anybody does with a window they did not ask for is close it -- which kills the
119            // daemon. `Run(..., 0, False)` is the documented way to start something with no
120            // window at all, and one line of VBScript is a thing a suspicious reader can read.
121            files: vec![(
122                entry.clone(),
123                format!("CreateObject(\"WScript.Shell\").Run \"\"\"{exe}\"\" serve\", 0, False\n"),
124            )],
125            // Nothing to run. Writing the file is the whole of it, which is also what makes
126            // installing twice the same as installing once.
127            commands: Vec::new(),
128            remove: Vec::new(),
129            note: format!(
130                "ssh-browser will start when you log in.\n\
131                 \x20 {}\n\n\
132                 Undo with `ssh-browser autostart --off`, or delete that file.\n",
133                entry.display()
134            ),
135        },
136        Kind::MacOs => Plan {
137            files: vec![(entry.clone(), launch_agent(&exe))],
138            // `bootstrap` rather than the deprecated `load`, and into this user's own GUI
139            // domain, so it asks for no password.
140            commands: vec![vec![
141                "launchctl".into(),
142                "bootstrap".into(),
143                format!("gui/{}", users_uid()),
144                entry.display().to_string(),
145            ]],
146            remove: Vec::new(),
147            note: format!(
148                "ssh-browser will start when you log in.\n\
149                 \x20 agent {}\n\n\
150                 Undo with `ssh-browser autostart --off`.\n",
151                entry.display()
152            ),
153        },
154        Kind::Systemd => Plan {
155            files: vec![(entry.clone(), user_unit(&exe))],
156            commands: vec![
157                vec!["systemctl".into(), "--user".into(), "daemon-reload".into()],
158                vec![
159                    "systemctl".into(),
160                    "--user".into(),
161                    "enable".into(),
162                    "--now".into(),
163                    "ssh-browser.service".into(),
164                ],
165            ],
166            remove: Vec::new(),
167            note: format!(
168                "ssh-browser will start when you log in.\n\
169                 \x20 unit {}\n\n\
170                 On a machine you reach over ssh rather than log into, a user unit stops when\n\
171                 your last session ends. `loginctl enable-linger` is what keeps it running.\n\n\
172                 Undo with `ssh-browser autostart --off`.\n",
173                entry.display()
174            ),
175        },
176    }
177}
178
179/// What removing looks like.
180///
181/// A separate function rather than a flag on the first, because the commands are not the
182/// install commands backwards.
183pub fn remove_plan(kind: Kind, home: &Path, state: &Path) -> Plan {
184    let entry = entry_path(kind, home, state);
185    let note = "ssh-browser will no longer start when you log in. One running now keeps\n\
186                running; stop it however you started it.\n"
187        .to_string();
188    match kind {
189        Kind::Windows => Plan {
190            files: Vec::new(),
191            commands: Vec::new(),
192            remove: vec![entry],
193            note,
194        },
195        Kind::MacOs => Plan {
196            files: Vec::new(),
197            commands: vec![vec![
198                "launchctl".into(),
199                "bootout".into(),
200                format!("gui/{}/{LABEL}", users_uid()),
201            ]],
202            remove: vec![entry],
203            note,
204        },
205        Kind::Systemd => Plan {
206            files: Vec::new(),
207            commands: vec![vec![
208                "systemctl".into(),
209                "--user".into(),
210                "disable".into(),
211                "--now".into(),
212                "ssh-browser.service".into(),
213            ]],
214            remove: vec![entry],
215            note,
216        },
217    }
218}
219
220/// This account's user id, which launchd wants as part of the domain it is bootstrapped into.
221///
222/// Asked of `id -u` rather than through a libc binding, because that is one dependency for one
223/// number. A wrong answer fails loudly at `launchctl` rather than quietly at login.
224fn users_uid() -> String {
225    std::process::Command::new("id")
226        .arg("-u")
227        .output()
228        .ok()
229        .filter(|o| o.status.success())
230        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
231        .filter(|s| !s.is_empty())
232        .unwrap_or_else(|| "501".to_string())
233}
234
235fn launch_agent(exe: &str) -> String {
236    format!(
237        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
238         <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
239         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
240         <plist version=\"1.0\">\n\
241         <dict>\n\
242         \x20 <key>Label</key>\n\
243         \x20 <string>{LABEL}</string>\n\
244         \x20 <key>ProgramArguments</key>\n\
245         \x20 <array>\n\
246         \x20   <string>{exe}</string>\n\
247         \x20   <string>serve</string>\n\
248         \x20 </array>\n\
249         \x20 <key>RunAtLoad</key>\n\
250         \x20 <true/>\n\
251         \x20 <key>KeepAlive</key>\n\
252         \x20 <true/>\n\
253         </dict>\n\
254         </plist>\n"
255    )
256}
257
258fn user_unit(exe: &str) -> String {
259    format!(
260        "[Unit]\n\
261         Description=ssh-browser, serving SSH hosts as browser origins\n\
262         \n\
263         [Service]\n\
264         ExecStart={exe} serve\n\
265         Restart=on-failure\n\
266         \n\
267         [Install]\n\
268         WantedBy=default.target\n"
269    )
270}
271
272/// Do it, saying what was done as it happens.
273///
274/// Reported step by step rather than summarised at the end, because the step that fails is the
275/// one worth naming and a summary printed afterwards never arrives.
276pub fn apply(plan: &Plan) -> Result<()> {
277    for (path, body) in &plan.files {
278        if let Some(parent) = path.parent() {
279            std::fs::create_dir_all(parent)
280                .with_context(|| format!("making {}", parent.display()))?;
281        }
282        std::fs::write(path, body).with_context(|| format!("writing {}", path.display()))?;
283        eprintln!("  wrote {}", path.display());
284    }
285
286    for command in &plan.commands {
287        let (program, args) = command.split_first().expect("a command has a program");
288        eprintln!("  {}", command.join(" "));
289        let out = std::process::Command::new(program)
290            .args(args)
291            .output()
292            .with_context(|| format!("running {program}"))?;
293        if !out.status.success() {
294            // Both streams: `schtasks` reports on stdout and `systemctl` on stderr, and a
295            // failure that prints only the empty one is a failure with no reason attached.
296            let said = [out.stdout, out.stderr]
297                .iter()
298                .map(|s| String::from_utf8_lossy(s).trim().to_string())
299                .filter(|s| !s.is_empty())
300                .collect::<Vec<_>>()
301                .join("\n");
302            bail!("{program} failed: {said}");
303        }
304    }
305
306    for path in &plan.remove {
307        match std::fs::remove_file(path) {
308            Ok(()) => eprintln!("  removed {}", path.display()),
309            // Already gone is the state that was wanted.
310            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
311            Err(e) => return Err(e).with_context(|| format!("removing {}", path.display())),
312        }
313    }
314    Ok(())
315}
316
317/// The home directory, which two of the three platforms put their login entry under.
318pub fn home() -> Option<PathBuf> {
319    std::env::var_os("HOME")
320        .or_else(|| std::env::var_os("USERPROFILE"))
321        .filter(|h| !h.is_empty())
322        .map(PathBuf::from)
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn dirs() -> (PathBuf, PathBuf) {
330        (PathBuf::from("/home/you"), PathBuf::from("/state"))
331    }
332
333    const EVERY: [Kind; 3] = [Kind::Windows, Kind::MacOs, Kind::Systemd];
334
335    /// Every platform, on whichever one happens to be running this. The whole reason `Kind` is
336    /// an argument: two of the three are otherwise checked by nobody until somebody on that
337    /// platform tries them, which is the wrong moment to find out.
338    ///
339    /// Not "and runs a command", which is what this said until Windows stopped needing one.
340    /// What every platform has in common is the entry, and that it names the daemon by path.
341    #[test]
342    fn each_platform_writes_an_entry_naming_the_daemon() {
343        let (home, state) = dirs();
344        let exe = PathBuf::from("/bin/ssh-browser");
345        for kind in EVERY {
346            let plan = install_plan(kind, &exe, &home, &state);
347            assert_eq!(plan.files.len(), 1, "{kind:?}");
348            assert!(
349                plan.remove.is_empty(),
350                "{kind:?} installs, it does not delete"
351            );
352            // The daemon's own path, not a bare name: a login session's PATH is not a shell's,
353            // and an entry that starts whatever `ssh-browser` it finds may find none.
354            let (_, body) = &plan.files[0];
355            assert!(body.contains("/bin/ssh-browser"), "{kind:?}: {body}");
356            assert!(body.contains("serve"), "{kind:?}: {body}");
357            assert!(plan.note.contains("autostart --off"), "{kind:?}");
358        }
359    }
360
361    /// Removing undoes exactly what installing wrote.
362    ///
363    /// Compared as paths rather than by reading both functions, because the failure this
364    /// prevents is silent: an uninstall that deletes a file nobody wrote leaves the login entry
365    /// in place and reports success.
366    #[test]
367    fn removing_touches_what_installing_wrote() {
368        let (home, state) = dirs();
369        let exe = PathBuf::from("/bin/ssh-browser");
370        for kind in EVERY {
371            let installed = install_plan(kind, &exe, &home, &state);
372            let removed = remove_plan(kind, &home, &state);
373            assert_eq!(
374                removed.remove,
375                vec![installed.files[0].0.clone()],
376                "{kind:?}"
377            );
378            assert!(removed.files.is_empty(), "{kind:?}");
379        }
380    }
381
382    /// The Windows launcher hides its window, and that is load-bearing rather than tidy.
383    #[test]
384    fn the_windows_launcher_asks_for_no_window() {
385        let (home, state) = dirs();
386        let plan = install_plan(
387            Kind::Windows,
388            &PathBuf::from("C:/bin/ssh-browser.exe"),
389            &home,
390            &state,
391        );
392        let (path, body) = &plan.files[0];
393        // The tail rather than the whole path: `APPDATA` is a real environment variable on the
394        // machine running this and a roaming profile moves it, so asserting the prefix would be
395        // asserting something about the test runner.
396        assert!(
397            path.ends_with("Start Menu/Programs/Startup/ssh-browser.vbs")
398                || path.ends_with(r"Start Menu\Programs\Startup\ssh-browser.vbs"),
399            "{path:?}"
400        );
401        // Nothing to run at all, which is the point of this location: no elevation, and
402        // writing the file twice is the same as writing it once.
403        assert!(plan.commands.is_empty(), "{:?}", plan.commands);
404        assert!(
405            body.contains(", 0, False"),
406            "the window style must be hidden: {body}"
407        );
408        // Quoted, because a path with a space in it is the ordinary case on Windows and an
409        // unquoted one runs the wrong program or none.
410        assert!(body.contains("\"\"\"C:/bin/ssh-browser.exe\"\""), "{body}");
411    }
412
413    /// The launchd agent asks to be started at login, and is a plist launchd will accept.
414    #[test]
415    fn the_launch_agent_runs_at_load() {
416        let (home, state) = dirs();
417        let plan = install_plan(
418            Kind::MacOs,
419            &PathBuf::from("/bin/ssh-browser"),
420            &home,
421            &state,
422        );
423        let (path, body) = &plan.files[0];
424        assert!(
425            path.starts_with("/home/you/Library/LaunchAgents"),
426            "{path:?}"
427        );
428        assert!(body.starts_with("<?xml"), "{body}");
429        assert!(body.contains("<key>RunAtLoad</key>\n  <true/>"), "{body}");
430        assert!(body.contains(LABEL), "{body}");
431    }
432
433    /// The systemd unit is wanted by the user's default target, which is what starts it.
434    #[test]
435    fn the_user_unit_is_wanted_by_default_target() {
436        let (home, state) = dirs();
437        let plan = install_plan(
438            Kind::Systemd,
439            &PathBuf::from("/bin/ssh-browser"),
440            &home,
441            &state,
442        );
443        let (path, body) = &plan.files[0];
444        assert!(
445            path.starts_with("/home/you/.config/systemd/user"),
446            "{path:?}"
447        );
448        assert!(body.contains("WantedBy=default.target"), "{body}");
449        assert!(body.contains("ExecStart=/bin/ssh-browser serve"), "{body}");
450    }
451
452    /// Installing twice is installing once, everywhere.
453    ///
454    /// Somebody unsure whether they did it already will do it again, and the answer has to be
455    /// "you have it" rather than an error. Writing a file is idempotent on its own; the two
456    /// platforms that also run something have to have chosen a command that is.
457    #[test]
458    fn installing_again_is_not_an_error() {
459        let (home, state) = dirs();
460        for kind in EVERY {
461            let plan = install_plan(kind, &PathBuf::from("/bin/x"), &home, &state);
462            for command in &plan.commands {
463                let line = command.join(" ");
464                let forgiving = line.contains("daemon-reload")
465                    || line.contains("enable")
466                    || line.contains("bootstrap");
467                assert!(
468                    forgiving,
469                    "{kind:?} runs something that may refuse twice: {line}"
470                );
471            }
472        }
473    }
474}