1use std::path::{Path, PathBuf};
19
20use anyhow::{Context, Result, bail};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Kind {
25 Windows,
27 MacOs,
29 Systemd,
31}
32
33impl Kind {
34 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#[derive(Debug, PartialEq, Eq)]
51pub struct Plan {
52 pub files: Vec<(PathBuf, String)>,
53 pub commands: Vec<Vec<String>>,
54 pub remove: Vec<PathBuf>,
56 pub note: String,
58}
59
60const LABEL: &str = "com.qatlashub.ssh-browser";
62
63fn 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
87fn entry_path(kind: Kind, home: &Path, state: &Path) -> PathBuf {
92 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
110pub 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 files: vec![(
122 entry.clone(),
123 format!("CreateObject(\"WScript.Shell\").Run \"\"\"{exe}\"\" serve\", 0, False\n"),
124 )],
125 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 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
179pub 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
220fn 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
272pub 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 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 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
317pub 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 #[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 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 #[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 #[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 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 assert!(plan.commands.is_empty(), "{:?}", plan.commands);
404 assert!(
405 body.contains(", 0, False"),
406 "the window style must be hidden: {body}"
407 );
408 assert!(body.contains("\"\"\"C:/bin/ssh-browser.exe\"\""), "{body}");
411 }
412
413 #[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 #[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 #[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}