Skip to main content

deaddrop_app/
daemon_service.rs

1//! Background daemon spawn and OS service install (systemd user, LaunchAgent, logon task).
2
3use anyhow::{Context, Result};
4use std::net::SocketAddr;
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7
8pub const WINDOWS_TASK: &str = "DeadDropDaemon";
9pub const SYSTEMD_UNIT: &str = "deaddrop.service";
10pub const LAUNCH_LABEL: &str = "com.deaddrop.daemon";
11
12pub fn daemon_executable() -> Result<PathBuf> {
13    let me = std::env::current_exe().context("current executable")?;
14    let sibling = me.with_file_name(if cfg!(windows) {
15        "dd-daemon.exe"
16    } else {
17        "dd-daemon"
18    });
19    if sibling.is_file() {
20        Ok(sibling)
21    } else {
22        Ok(me)
23    }
24}
25
26pub fn spawn_background(data_dir: &Path, listen: SocketAddr, peers: &[SocketAddr]) -> Result<u32> {
27    std::fs::create_dir_all(data_dir)?;
28    let log_path = data_dir.join("daemon.log");
29    let log = std::fs::OpenOptions::new()
30        .create(true)
31        .append(true)
32        .open(&log_path)
33        .with_context(|| format!("open {}", log_path.display()))?;
34    let log_err = log.try_clone()?;
35    let bin = daemon_executable()?;
36    let mut cmd = Command::new(&bin);
37    let stem = bin.file_stem().and_then(|s| s.to_str()).unwrap_or("");
38    if stem == "dd" {
39        cmd.arg("--data-dir")
40            .arg(data_dir)
41            .arg("daemon")
42            .arg("start")
43            .arg("--foreground")
44            .arg("--listen")
45            .arg(listen.to_string());
46        for p in peers {
47            cmd.arg("--peer").arg(p.to_string());
48        }
49    } else {
50        cmd.arg("--data-dir")
51            .arg(data_dir)
52            .arg("--listen")
53            .arg(listen.to_string());
54        for p in peers {
55            cmd.arg("--peer").arg(p.to_string());
56        }
57    }
58    cmd.stdin(Stdio::null())
59        .stdout(Stdio::from(log))
60        .stderr(Stdio::from(log_err));
61    #[cfg(windows)]
62    {
63        use std::os::windows::process::CommandExt;
64        const DETACHED_PROCESS: u32 = 0x00000008;
65        const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
66        const CREATE_NO_WINDOW: u32 = 0x08000000;
67        cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW);
68    }
69    #[cfg(unix)]
70    {
71        std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
72    }
73    let child = cmd.spawn().context("spawn daemon")?;
74    Ok(child.id())
75}
76
77pub fn daemon_command_line(exec: &Path, data_dir: &Path, listen: SocketAddr) -> Vec<String> {
78    let stem = exec.file_stem().and_then(|s| s.to_str()).unwrap_or("");
79    let mut args = vec![exec.display().to_string()];
80    if stem == "dd" {
81        args.extend([
82            "--data-dir".into(),
83            data_dir.display().to_string(),
84            "daemon".into(),
85            "start".into(),
86            "--foreground".into(),
87            "--listen".into(),
88            listen.to_string(),
89        ]);
90    } else {
91        args.extend([
92            "--data-dir".into(),
93            data_dir.display().to_string(),
94            "--listen".into(),
95            listen.to_string(),
96        ]);
97    }
98    args
99}
100
101pub fn systemd_unit(exec: &Path, data_dir: &Path, listen: SocketAddr) -> String {
102    let line = daemon_command_line(exec, data_dir, listen).join(" ");
103    format!(
104        "[Unit]\nDescription=DeadDrop DDP/2 node\nAfter=network.target\n\n[Service]\nType=simple\nExecStart={line}\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=default.target\n"
105    )
106}
107
108pub fn launchd_plist(exec: &Path, data_dir: &Path, listen: SocketAddr) -> String {
109    let log = data_dir.join("daemon.log");
110    let args = daemon_command_line(exec, data_dir, listen);
111    let args_xml: String = args
112        .iter()
113        .map(|a| format!("    <string>{}</string>\n", a))
114        .collect();
115    format!(
116        r#"<?xml version="1.0" encoding="UTF-8"?>
117<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
118<plist version="1.0">
119<dict>
120  <key>Label</key><string>{LAUNCH_LABEL}</string>
121  <key>ProgramArguments</key>
122  <array>
123{args_xml}  </array>
124  <key>RunAtLoad</key><true/>
125  <key>KeepAlive</key><true/>
126  <key>StandardOutPath</key><string>{}</string>
127  <key>StandardErrorPath</key><string>{}</string>
128</dict>
129</plist>
130"#,
131        log.display(),
132        log.display()
133    )
134}
135
136pub fn install(data_dir: &Path, listen: SocketAddr) -> Result<String> {
137    let exec = daemon_executable()?;
138    let abs_dir = std::fs::canonicalize(data_dir).unwrap_or_else(|_| data_dir.to_path_buf());
139    install_os(&exec, &abs_dir, listen)
140}
141
142#[cfg(target_os = "linux")]
143fn install_os(exec: &Path, abs_dir: &Path, listen: SocketAddr) -> Result<String> {
144    let unit_dir = dirs_config()?.join("systemd/user");
145    std::fs::create_dir_all(&unit_dir)?;
146    let path = unit_dir.join(SYSTEMD_UNIT);
147    std::fs::write(&path, systemd_unit(exec, abs_dir, listen))?;
148    let _ = Command::new("systemctl")
149        .args(["--user", "daemon-reload"])
150        .status();
151    let _ = Command::new("systemctl")
152        .args(["--user", "enable", "--now", SYSTEMD_UNIT])
153        .status();
154    Ok(format!(
155        "installed systemd user unit {}\nstart/stop: systemctl --user start|stop {SYSTEMD_UNIT}",
156        path.display()
157    ))
158}
159
160#[cfg(target_os = "macos")]
161fn install_os(exec: &Path, abs_dir: &Path, listen: SocketAddr) -> Result<String> {
162    let dir = home_dir()?.join("Library/LaunchAgents");
163    std::fs::create_dir_all(&dir)?;
164    let path = dir.join(format!("{LAUNCH_LABEL}.plist"));
165    std::fs::write(&path, launchd_plist(exec, abs_dir, listen))?;
166    let _ = Command::new("launchctl")
167        .args(["unload", &path.to_string_lossy()])
168        .status();
169    let st = Command::new("launchctl")
170        .args(["load", &path.to_string_lossy()])
171        .status();
172    let extra = if st.map(|s| s.success()).unwrap_or(false) {
173        "loaded"
174    } else {
175        "wrote plist; run: launchctl load ~/Library/LaunchAgents/com.deaddrop.daemon.plist"
176    };
177    Ok(format!(
178        "installed LaunchAgent {} ({extra})",
179        path.display()
180    ))
181}
182
183#[cfg(windows)]
184fn install_os(exec: &Path, abs_dir: &Path, listen: SocketAddr) -> Result<String> {
185    let parts = daemon_command_line(exec, abs_dir, listen);
186    let tr = format!(
187        "\"{}\" {}",
188        parts[0],
189        parts[1..]
190            .iter()
191            .map(|a| format!("\"{a}\""))
192            .collect::<Vec<_>>()
193            .join(" ")
194    );
195    let status = Command::new("schtasks")
196        .args([
197            "/Create",
198            "/F",
199            "/SC",
200            "ONLOGON",
201            "/RL",
202            "LIMITED",
203            "/TN",
204            WINDOWS_TASK,
205            "/TR",
206            &tr,
207        ])
208        .status()
209        .context("schtasks")?;
210    if !status.success() {
211        anyhow::bail!(
212            "schtasks failed (try from an elevated prompt). Task command would be:\n  {tr}"
213        );
214    }
215    let _ = Command::new("schtasks")
216        .args(["/Run", "/TN", WINDOWS_TASK])
217        .status();
218    Ok(format!(
219        "installed Windows logon task {WINDOWS_TASK} (Task Scheduler, no admin service)"
220    ))
221}
222
223#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
224fn install_os(_exec: &Path, _abs_dir: &Path, _listen: SocketAddr) -> Result<String> {
225    anyhow::bail!("service install is supported on Linux, macOS, and Windows")
226}
227
228pub fn uninstall() -> Result<String> {
229    uninstall_os()
230}
231
232#[cfg(target_os = "linux")]
233fn uninstall_os() -> Result<String> {
234    let _ = Command::new("systemctl")
235        .args(["--user", "disable", "--now", SYSTEMD_UNIT])
236        .status();
237    if let Ok(dir) = dirs_config() {
238        let path = dir.join("systemd/user").join(SYSTEMD_UNIT);
239        let _ = std::fs::remove_file(&path);
240        return Ok(format!("removed {}", path.display()));
241    }
242    Ok("disabled deaddrop.service".into())
243}
244
245#[cfg(target_os = "macos")]
246fn uninstall_os() -> Result<String> {
247    if let Ok(home) = home_dir() {
248        let path = home
249            .join("Library/LaunchAgents")
250            .join(format!("{LAUNCH_LABEL}.plist"));
251        let _ = Command::new("launchctl")
252            .args(["unload", &path.to_string_lossy()])
253            .status();
254        let _ = std::fs::remove_file(&path);
255        return Ok(format!("removed {}", path.display()));
256    }
257    Ok("unloaded LaunchAgent".into())
258}
259
260#[cfg(windows)]
261fn uninstall_os() -> Result<String> {
262    let _ = Command::new("schtasks")
263        .args(["/End", "/TN", WINDOWS_TASK])
264        .status();
265    let status = Command::new("schtasks")
266        .args(["/Delete", "/F", "/TN", WINDOWS_TASK])
267        .status()
268        .context("schtasks delete")?;
269    if !status.success() {
270        anyhow::bail!("no task {WINDOWS_TASK} (already uninstalled?)");
271    }
272    Ok(format!("removed Windows task {WINDOWS_TASK}"))
273}
274
275#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
276fn uninstall_os() -> Result<String> {
277    anyhow::bail!("service uninstall is supported on Linux, macOS, and Windows")
278}
279
280#[cfg(unix)]
281fn home_dir() -> Result<PathBuf> {
282    std::env::var_os("HOME")
283        .map(PathBuf::from)
284        .context("HOME is unset")
285}
286
287#[cfg(target_os = "linux")]
288fn dirs_config() -> Result<PathBuf> {
289    if let Some(x) = std::env::var_os("XDG_CONFIG_HOME") {
290        return Ok(PathBuf::from(x));
291    }
292    Ok(home_dir()?.join(".config"))
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use std::path::Path;
299
300    #[test]
301    fn systemd_unit_mentions_listen() {
302        let u = systemd_unit(
303            Path::new("/usr/bin/dd-daemon"),
304            Path::new("/var/lib/deaddrop"),
305            "0.0.0.0:7947".parse().unwrap(),
306        );
307        assert!(u.contains("0.0.0.0:7947"));
308        assert!(u.contains("/var/lib/deaddrop"));
309    }
310
311    #[test]
312    fn launchd_plist_is_xml() {
313        let p = launchd_plist(
314            Path::new("/usr/local/bin/dd-daemon"),
315            Path::new("/tmp/dd"),
316            "127.0.0.1:7947".parse().unwrap(),
317        );
318        assert!(p.contains(LAUNCH_LABEL));
319        assert!(p.contains("127.0.0.1:7947"));
320    }
321}