Skip to main content

wire/
service.rs

1//! Install + manage OS service units that run wire components
2//! automatically across reboots.
3//!
4//! Today's onboarding tells operators "run `wire daemon &` in a tmux
5//! pane or write a launchd plist yourself" — friction that gets skipped,
6//! leading to the "daemon dies on reboot, peer sends evaporate" silent
7//! class. Bake the unit install into `wire service install` so it's one
8//! command, idempotent, cross-platform.
9//!
10//! ## Service kinds (v0.5.22)
11//!
12//! - **Daemon** (`wire service install`) — runs `wire daemon --interval 5`.
13//!   Pulls/pushes the operator's own inbox/outbox. ONE per identity.
14//!   Label: `sh.slancha.wire.daemon`.
15//!
16//! - **LocalRelay** (`wire service install --local-relay`) — runs
17//!   `wire relay-server --bind 127.0.0.1:8771 --local-only`. The
18//!   loopback transport for sister-agents on the same box (v0.5.17
19//!   dual-slot). ONE per machine. Label: `sh.slancha.wire.local-relay`.
20//!
21//! ## Unit paths
22//!
23//! - macOS: `~/Library/LaunchAgents/<label>.plist`
24//! - linux: `~/.config/systemd/user/wire-<kind>.service`
25//!
26//! Units auto-start on login + restart on crash. Pair with
27//! `wire upgrade` (P0.5) for atomic version swaps without unit churn.
28
29use std::path::PathBuf;
30use std::process::Command;
31
32use anyhow::{Context, Result, anyhow, bail};
33
34/// Which wire service is being managed. Each kind has its own launchd
35/// label / systemd unit name / log path so the two kinds can coexist
36/// on the same machine without colliding.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ServiceKind {
39    /// `wire daemon --interval 5`. One per identity. The default.
40    Daemon,
41    /// `wire relay-server --bind 127.0.0.1:8771 --local-only`. One
42    /// per machine — provides the loopback transport that sister
43    /// agents' sessions route through (v0.5.17 dual-slot).
44    LocalRelay,
45}
46
47impl ServiceKind {
48    /// launchd Label / systemd unit base name (without `.service`).
49    fn label(self) -> &'static str {
50        match self {
51            ServiceKind::Daemon => "sh.slancha.wire.daemon",
52            ServiceKind::LocalRelay => "sh.slancha.wire.local-relay",
53        }
54    }
55
56    /// systemd unit filename (`wire-daemon.service` etc.).
57    fn systemd_unit_name(self) -> &'static str {
58        match self {
59            ServiceKind::Daemon => "wire-daemon.service",
60            ServiceKind::LocalRelay => "wire-local-relay.service",
61        }
62    }
63
64    /// Human-readable name for `Description=` / log messages.
65    fn description(self) -> &'static str {
66        match self {
67            ServiceKind::Daemon => "wire — daemon (push/pull sync)",
68            ServiceKind::LocalRelay => "wire — local-only relay (127.0.0.1:8771)",
69        }
70    }
71
72    /// Arguments to pass to the `wire` binary in the ProgramArguments
73    /// / ExecStart line. The first element of the wider arg vector is
74    /// the binary itself, supplied separately by callers.
75    fn binary_args(self) -> &'static [&'static str] {
76        match self {
77            ServiceKind::Daemon => &["daemon", "--interval", "5"],
78            ServiceKind::LocalRelay => {
79                &["relay-server", "--bind", "127.0.0.1:8771", "--local-only"]
80            }
81        }
82    }
83
84    /// Per-kind log file basename. macOS-only — launchd's
85    /// `StandardOutPath` directive redirects daemon stdout/stderr to a
86    /// real file under `~/Library/Logs/`. On Linux the systemd unit
87    /// has no equivalent file redirect (it logs to journald instead,
88    /// which is the idiomatic Linux pattern; `journalctl --user -u
89    /// <unit>` reads it). v0.5.23: stopped reporting a log-file path
90    /// to Linux operators since no file was ever written there —
91    /// previously the install detail message named a phantom location
92    /// in `~/.cache/wire/` that confused anyone who went looking for
93    /// the actual log.
94    fn log_basename(self) -> &'static str {
95        match self {
96            ServiceKind::Daemon => "wire-daemon.log",
97            ServiceKind::LocalRelay => "wire-local-relay.log",
98        }
99    }
100}
101
102/// Outcome of `wire service install` etc., suitable for both human + JSON
103/// rendering.
104#[derive(Debug, Clone, serde::Serialize)]
105pub struct ServiceReport {
106    pub action: String,
107    pub platform: String,
108    pub unit_path: String,
109    pub status: String,
110    pub detail: String,
111    /// v0.5.22: which service kind this report is about ("daemon" or
112    /// "local-relay"). Lets JSON consumers distinguish multiple reports.
113    #[serde(default)]
114    pub kind: String,
115}
116
117/// Back-compat shim — `wire service install` with no flags installs
118/// the daemon, matching pre-v0.5.22 behavior.
119pub fn install() -> Result<ServiceReport> {
120    install_kind(ServiceKind::Daemon)
121}
122pub fn uninstall() -> Result<ServiceReport> {
123    uninstall_kind(ServiceKind::Daemon)
124}
125pub fn status() -> Result<ServiceReport> {
126    status_kind(ServiceKind::Daemon)
127}
128
129/// Install a user-scope service unit for the given kind.
130pub fn install_kind(kind: ServiceKind) -> Result<ServiceReport> {
131    let exe = std::env::current_exe()?;
132    let exe_str = exe.to_string_lossy().to_string();
133
134    // v0.5.23: log path is macOS-only — launchd's StandardOutPath
135    // directive redirects to a file; systemd defaults to journald
136    // and we don't add an explicit file-redirect directive (let
137    // operators use `journalctl --user -u <unit>` which is the
138    // idiomatic Linux read path).
139    let log_str = if cfg!(target_os = "macos") {
140        ensure_macos_log_path(kind)?.to_string_lossy().to_string()
141    } else {
142        String::new()
143    };
144
145    if cfg!(target_os = "macos") {
146        let plist_path = launchd_plist_path(kind)?;
147        if let Some(parent) = plist_path.parent() {
148            std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
149        }
150        let plist = launchd_plist_xml(kind, &exe_str, &log_str);
151        std::fs::write(&plist_path, plist).with_context(|| format!("writing {plist_path:?}"))?;
152
153        // launchctl bootstrap is idempotent if we bootout first.
154        let _ = Command::new("launchctl")
155            .args(["bootout", &launchctl_target_for(kind)])
156            .status();
157        let load = Command::new("launchctl")
158            .args([
159                "bootstrap",
160                &launchctl_user_target(),
161                plist_path.to_str().unwrap_or(""),
162            ])
163            .status();
164        let loaded = load.map(|s| s.success()).unwrap_or(false);
165
166        return Ok(ServiceReport {
167            action: "install".into(),
168            platform: "macos-launchd".into(),
169            unit_path: plist_path.to_string_lossy().to_string(),
170            status: if loaded {
171                "loaded".into()
172            } else {
173                "written".into()
174            },
175            detail: if loaded {
176                format!("plist written + bootstrapped; logs at {log_str}")
177            } else {
178                format!(
179                    "plist written; `launchctl bootstrap` failed — try `launchctl bootstrap {} {}` manually",
180                    launchctl_user_target(),
181                    plist_path.display()
182                )
183            },
184            kind: kind_label(kind).into(),
185        });
186    }
187    if cfg!(target_os = "linux") {
188        let unit_path = systemd_unit_path(kind)?;
189        if let Some(parent) = unit_path.parent() {
190            std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
191        }
192        let unit = systemd_unit_text(kind, &exe_str);
193        std::fs::write(&unit_path, unit).with_context(|| format!("writing {unit_path:?}"))?;
194
195        // Reload + enable + start. Each is idempotent on linux.
196        let _ = Command::new("systemctl")
197            .args(["--user", "daemon-reload"])
198            .status();
199        let enabled = Command::new("systemctl")
200            .args(["--user", "enable", "--now", kind.systemd_unit_name()])
201            .status()
202            .map(|s| s.success())
203            .unwrap_or(false);
204
205        // v0.5.23: surface the "user-scope unit only starts after first
206        // login" footgun. systemd user units require `loginctl enable-
207        // linger <user>` to start at boot without a console login
208        // session. Operators logging in via SSH frequently miss this
209        // and discover the service is "down at boot" only later.
210        // Check the current state and only nag if linger is OFF.
211        let linger_note = if enabled && !linger_enabled() {
212            let user = std::env::var("USER").unwrap_or_else(|_| "$USER".into());
213            format!(
214                " NOTE: linger is OFF — service starts at *first login*, \
215                 not at boot. For boot-time start (e.g. headless SSH boxes), \
216                 run `sudo loginctl enable-linger {user}` once."
217            )
218        } else {
219            String::new()
220        };
221
222        return Ok(ServiceReport {
223            action: "install".into(),
224            platform: "linux-systemd-user".into(),
225            unit_path: unit_path.to_string_lossy().to_string(),
226            status: if enabled {
227                "enabled".into()
228            } else {
229                "written".into()
230            },
231            detail: if enabled {
232                format!(
233                    "unit written + enable --now succeeded; logs via \
234                     `journalctl --user -u {}`{linger_note}",
235                    kind.systemd_unit_name()
236                )
237            } else {
238                format!(
239                    "unit written; `systemctl --user enable --now {}` failed — try manually",
240                    kind.systemd_unit_name()
241                )
242            },
243            kind: kind_label(kind).into(),
244        });
245    }
246    bail!("wire service install: unsupported platform")
247}
248
249pub fn uninstall_kind(kind: ServiceKind) -> Result<ServiceReport> {
250    if cfg!(target_os = "macos") {
251        let plist_path = launchd_plist_path(kind)?;
252        let _ = Command::new("launchctl")
253            .args(["bootout", &launchctl_target_for(kind)])
254            .status();
255        let removed = if plist_path.exists() {
256            std::fs::remove_file(&plist_path).ok();
257            true
258        } else {
259            false
260        };
261        return Ok(ServiceReport {
262            action: "uninstall".into(),
263            platform: "macos-launchd".into(),
264            unit_path: plist_path.to_string_lossy().to_string(),
265            status: if removed {
266                "removed".into()
267            } else {
268                "absent".into()
269            },
270            detail: "launchctl bootout + plist file removed".into(),
271            kind: kind_label(kind).into(),
272        });
273    }
274    if cfg!(target_os = "linux") {
275        let unit_path = systemd_unit_path(kind)?;
276        let _ = Command::new("systemctl")
277            .args(["--user", "disable", "--now", kind.systemd_unit_name()])
278            .status();
279        let removed = if unit_path.exists() {
280            std::fs::remove_file(&unit_path).ok();
281            true
282        } else {
283            false
284        };
285        let _ = Command::new("systemctl")
286            .args(["--user", "daemon-reload"])
287            .status();
288        return Ok(ServiceReport {
289            action: "uninstall".into(),
290            platform: "linux-systemd-user".into(),
291            unit_path: unit_path.to_string_lossy().to_string(),
292            status: if removed {
293                "removed".into()
294            } else {
295                "absent".into()
296            },
297            detail: "systemctl disable --now + unit file removed".into(),
298            kind: kind_label(kind).into(),
299        });
300    }
301    bail!("wire service uninstall: unsupported platform")
302}
303
304pub fn status_kind(kind: ServiceKind) -> Result<ServiceReport> {
305    if cfg!(target_os = "macos") {
306        let plist_path = launchd_plist_path(kind)?;
307        let exists = plist_path.exists();
308        let listed = Command::new("launchctl")
309            .args(["list", kind.label()])
310            .output()
311            .map(|o| o.status.success())
312            .unwrap_or(false);
313        return Ok(ServiceReport {
314            action: "status".into(),
315            platform: "macos-launchd".into(),
316            unit_path: plist_path.to_string_lossy().to_string(),
317            status: if listed {
318                "loaded".into()
319            } else if exists {
320                "installed (not loaded)".into()
321            } else {
322                "absent".into()
323            },
324            detail: format!("plist exists={exists}, launchctl-list-success={listed}"),
325            kind: kind_label(kind).into(),
326        });
327    }
328    if cfg!(target_os = "linux") {
329        let unit_path = systemd_unit_path(kind)?;
330        let exists = unit_path.exists();
331        let active = Command::new("systemctl")
332            .args(["--user", "is-active", kind.systemd_unit_name()])
333            .output()
334            .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
335            .unwrap_or(false);
336        return Ok(ServiceReport {
337            action: "status".into(),
338            platform: "linux-systemd-user".into(),
339            unit_path: unit_path.to_string_lossy().to_string(),
340            status: if active {
341                "active".into()
342            } else if exists {
343                "installed (inactive)".into()
344            } else {
345                "absent".into()
346            },
347            detail: format!("unit exists={exists}, is-active={active}"),
348            kind: kind_label(kind).into(),
349        });
350    }
351    bail!("wire service status: unsupported platform")
352}
353
354/// v0.5.23 (linux only): true iff `loginctl show-user --property=Linger`
355/// returns `Linger=yes`. Used to suppress the install-time linger nag
356/// when the operator has already enabled it. Best-effort: returns false
357/// on any error (missing `loginctl`, $USER unset, command failure) so
358/// the nag fires by default rather than silently going missing.
359#[cfg(target_os = "linux")]
360fn linger_enabled() -> bool {
361    let user = match std::env::var("USER") {
362        Ok(u) if !u.is_empty() => u,
363        _ => return false,
364    };
365    Command::new("loginctl")
366        .args(["show-user", &user, "--property=Linger"])
367        .output()
368        .ok()
369        .and_then(|o| {
370            if o.status.success() {
371                Some(String::from_utf8_lossy(&o.stdout).into_owned())
372            } else {
373                None
374            }
375        })
376        .map(|s| s.trim().eq_ignore_ascii_case("Linger=yes"))
377        .unwrap_or(false)
378}
379
380#[cfg(not(target_os = "linux"))]
381fn linger_enabled() -> bool {
382    // Non-linux platforms don't have systemd's linger concept.
383    // Compiled but never called from the macOS / Windows / BSD
384    // branches; provided so cross-target unit tests compile.
385    false
386}
387
388fn kind_label(kind: ServiceKind) -> &'static str {
389    match kind {
390        ServiceKind::Daemon => "daemon",
391        ServiceKind::LocalRelay => "local-relay",
392    }
393}
394
395fn launchd_plist_path(kind: ServiceKind) -> Result<PathBuf> {
396    let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
397    Ok(PathBuf::from(home)
398        .join("Library")
399        .join("LaunchAgents")
400        .join(format!("{}.plist", kind.label())))
401}
402
403fn launchctl_user_target() -> String {
404    let uid = Command::new("id")
405        .args(["-u"])
406        .output()
407        .ok()
408        .and_then(|o| {
409            if o.status.success() {
410                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
411            } else {
412                None
413            }
414        })
415        .unwrap_or_else(|| "0".to_string());
416    format!("gui/{uid}")
417}
418
419fn launchctl_target_for(kind: ServiceKind) -> String {
420    format!("{}/{}", launchctl_user_target(), kind.label())
421}
422
423/// Resolve the macOS log destination for a service kind and ensure
424/// the parent directory exists. Returns the absolute path that
425/// launchd's `StandardOutPath` will redirect the service's stdout/
426/// stderr to (`~/Library/Logs/wire-<kind>.log`).
427///
428/// v0.5.23: macOS-only. The previous version had a Linux branch that
429/// computed a path nothing would ever write to, because the Linux
430/// systemd unit logs to journald rather than a file. Caused a
431/// confusing "logs at ~/.cache/wire/..." message on `wire service
432/// install` when no such file ever appeared.
433#[cfg(target_os = "macos")]
434fn ensure_macos_log_path(kind: ServiceKind) -> Result<PathBuf> {
435    let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
436    let dir = PathBuf::from(&home).join("Library").join("Logs");
437    std::fs::create_dir_all(&dir).with_context(|| format!("creating log dir {dir:?}"))?;
438    Ok(dir.join(kind.log_basename()))
439}
440
441/// Stub for non-macOS targets so the macOS branch in `install_kind`
442/// type-checks under cross-platform builds. Never called in practice
443/// because the corresponding `cfg!(target_os = "macos")` guard skips
444/// it. Returns an empty path; if you ever see this in a non-macOS
445/// log message, it's a bug.
446#[cfg(not(target_os = "macos"))]
447fn ensure_macos_log_path(_kind: ServiceKind) -> Result<PathBuf> {
448    Ok(PathBuf::new())
449}
450
451fn launchd_plist_xml(kind: ServiceKind, exe: &str, log_path: &str) -> String {
452    let args_xml = kind
453        .binary_args()
454        .iter()
455        .map(|a| format!("        <string>{a}</string>"))
456        .collect::<Vec<_>>()
457        .join("\n");
458    let label = kind.label();
459    format!(
460        r#"<?xml version="1.0" encoding="UTF-8"?>
461<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
462<plist version="1.0">
463<dict>
464    <key>Label</key>
465    <string>{label}</string>
466    <key>ProgramArguments</key>
467    <array>
468        <string>{exe}</string>
469{args_xml}
470    </array>
471    <key>RunAtLoad</key>
472    <true/>
473    <key>KeepAlive</key>
474    <true/>
475    <key>ProcessType</key>
476    <string>Background</string>
477    <key>StandardOutPath</key>
478    <string>{log_path}</string>
479    <key>StandardErrorPath</key>
480    <string>{log_path}</string>
481</dict>
482</plist>
483"#
484    )
485}
486
487fn systemd_unit_path(kind: ServiceKind) -> Result<PathBuf> {
488    let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
489    Ok(PathBuf::from(home)
490        .join(".config")
491        .join("systemd")
492        .join("user")
493        .join(kind.systemd_unit_name()))
494}
495
496fn systemd_unit_text(kind: ServiceKind, exe: &str) -> String {
497    let args = kind.binary_args().join(" ");
498    let desc = kind.description();
499    format!(
500        r#"[Unit]
501Description={desc}
502After=network-online.target
503Wants=network-online.target
504
505[Service]
506Type=simple
507ExecStart={exe} {args}
508Restart=on-failure
509RestartSec=5
510
511[Install]
512WantedBy=default.target
513"#
514    )
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn launchd_plist_xml_for_daemon_contains_required_keys() {
523        let xml = launchd_plist_xml(
524            ServiceKind::Daemon,
525            "/usr/local/bin/wire",
526            "/tmp/wire-daemon.log",
527        );
528        assert!(xml.contains("<key>Label</key>"));
529        assert!(xml.contains(ServiceKind::Daemon.label()));
530        assert!(xml.contains("/usr/local/bin/wire"));
531        assert!(xml.contains("<string>daemon</string>"));
532        assert!(xml.contains("<string>--interval</string>"));
533        assert!(xml.contains("<key>KeepAlive</key>"));
534        assert!(xml.contains("<key>RunAtLoad</key>"));
535        assert!(xml.contains("<true/>"));
536        // v0.5.22: log path is honored, not /dev/null.
537        assert!(xml.contains("/tmp/wire-daemon.log"));
538        assert!(!xml.contains("/dev/null"));
539    }
540
541    #[test]
542    fn launchd_plist_xml_for_local_relay_uses_correct_args() {
543        let xml = launchd_plist_xml(
544            ServiceKind::LocalRelay,
545            "/usr/local/bin/wire",
546            "/tmp/wire-local-relay.log",
547        );
548        assert!(xml.contains(ServiceKind::LocalRelay.label()));
549        assert!(xml.contains("<string>relay-server</string>"));
550        assert!(xml.contains("<string>--bind</string>"));
551        assert!(xml.contains("<string>127.0.0.1:8771</string>"));
552        assert!(xml.contains("<string>--local-only</string>"));
553        // Must NOT include daemon args.
554        assert!(!xml.contains("<string>daemon</string>"));
555    }
556
557    #[test]
558    fn systemd_unit_text_for_daemon_contains_required_directives() {
559        let unit = systemd_unit_text(ServiceKind::Daemon, "/usr/local/bin/wire");
560        assert!(unit.contains("[Unit]"));
561        assert!(unit.contains("[Service]"));
562        assert!(unit.contains("[Install]"));
563        assert!(unit.contains("/usr/local/bin/wire daemon --interval 5"));
564        assert!(unit.contains("Restart=on-failure"));
565        assert!(unit.contains("WantedBy=default.target"));
566    }
567
568    #[test]
569    fn systemd_unit_text_for_local_relay_uses_correct_exec() {
570        let unit = systemd_unit_text(ServiceKind::LocalRelay, "/usr/local/bin/wire");
571        assert!(
572            unit.contains("/usr/local/bin/wire relay-server --bind 127.0.0.1:8771 --local-only")
573        );
574        assert!(!unit.contains("daemon --interval"));
575    }
576
577    #[test]
578    fn label_and_unit_name_distinct_per_kind() {
579        // Both kinds MUST have distinct identifiers so they can coexist
580        // on the same machine.
581        assert_ne!(ServiceKind::Daemon.label(), ServiceKind::LocalRelay.label());
582        assert_ne!(
583            ServiceKind::Daemon.systemd_unit_name(),
584            ServiceKind::LocalRelay.systemd_unit_name()
585        );
586        assert_ne!(
587            ServiceKind::Daemon.log_basename(),
588            ServiceKind::LocalRelay.log_basename()
589        );
590    }
591}