Skip to main content

isb_server/server/
service.rs

1//! Installing `isb serve` as a systemd user service.
2//!
3//! The unit runs the isb binary that installed it, by its canonical path. A
4//! version manager that keeps each version in its own directory (mise does)
5//! therefore pins that version: after an upgrade, install again to point the
6//! unit at the new binary. Settings live in an environment file that is
7//! created once and then left to the user.
8
9use std::net::ToSocketAddrs;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use std::time::{Duration, Instant};
13
14use crate::error::{Error, Result};
15
16pub const UNIT_NAME: &str = "isb.service";
17pub const DEFAULT_LISTEN: &str = "127.0.0.1:8092";
18pub const LISTEN_ENV: &str = "ISB_SERVE_LISTEN";
19
20#[derive(Debug, Clone, Default)]
21pub struct ServiceOptions {
22    /// Loopback address to serve on. Default: the env file's
23    /// `ISB_SERVE_LISTEN`, else [`DEFAULT_LISTEN`]. Given explicitly, it is
24    /// written to the env file.
25    pub listen: Option<String>,
26    /// How long to wait for `/healthz` to answer 200. Default 30s.
27    pub health_timeout: Option<Duration>,
28}
29
30#[derive(Debug, Clone, serde::Serialize)]
31pub struct ServiceInstall {
32    pub exe: PathBuf,
33    pub unit_path: PathBuf,
34    pub env_path: PathBuf,
35    pub listen: String,
36    pub health_url: String,
37    /// The daemon's secrets key as an encrypted systemd credential, when
38    /// this systemd can make one.
39    pub key_credential: Option<PathBuf>,
40    /// Things the user should know, one sentence each (linger, version pinning).
41    pub notes: Vec<String>,
42}
43
44/// Write the unit and env file, (re)start the service, and wait until it is
45/// healthy. Safe to run again: it updates an existing installation.
46pub fn install_user_service(opts: &ServiceOptions) -> Result<ServiceInstall> {
47    if cfg!(target_os = "macos") {
48        return Err(Error::invalid(
49            "on macOS isb serve runs in the isb machine; install the LaunchAgent that starts \
50             it with isb::machine::install_launch_agent (`isb serve install`)",
51        ));
52    }
53    if !cfg!(target_os = "linux") {
54        return Err(Error::invalid(
55            "installing the service needs Linux with systemd user services",
56        ));
57    }
58    let config = config_dir()?;
59    let env_path = config.join("isb/serve.env");
60    let unit_path = config.join("systemd/user").join(UNIT_NAME);
61    let exe = std::env::current_exe()?.canonicalize()?;
62    let key = setup_key(&config)?;
63
64    let existing = match std::fs::read_to_string(&env_path) {
65        Ok(s) => Some(s),
66        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
67        Err(e) => return Err(e.into()),
68    };
69    let listen = opts
70        .listen
71        .clone()
72        .or_else(|| existing.as_deref().and_then(|s| env_value(s, LISTEN_ENV)))
73        .unwrap_or_else(|| DEFAULT_LISTEN.to_string());
74    check_loopback(&listen)?;
75
76    let env_text = match &existing {
77        None => Some(render_env(&listen)),
78        Some(s) if opts.listen.is_some() && env_value(s, LISTEN_ENV).as_ref() != Some(&listen) => {
79            Some(set_env_value(s, LISTEN_ENV, &listen))
80        }
81        Some(_) => None,
82    };
83    if let Some(text) = env_text {
84        // The env file may come to hold credentials: keep it private.
85        if let Some(dir) = env_path.parent() {
86            use std::os::unix::fs::DirBuilderExt;
87            std::fs::DirBuilder::new()
88                .recursive(true)
89                .mode(0o700)
90                .create(dir)?;
91        }
92        write_atomic(&env_path, text.as_bytes(), 0o600)?;
93    }
94    let home = std::env::var_os("HOME").map(PathBuf::from);
95    let cred = key
96        .credential
97        .as_deref()
98        .map(|p| credential_path(p, home.as_deref()));
99    write_atomic(
100        &unit_path,
101        render_unit(&exe, &env_path, cred.as_deref()).as_bytes(),
102        0o644,
103    )?;
104
105    for args in [
106        &["daemon-reload"][..],
107        &["enable", UNIT_NAME],
108        &["restart", UNIT_NAME],
109    ] {
110        systemctl(args)?;
111    }
112
113    let health_url = format!("http://{listen}/healthz");
114    wait_healthy(
115        &listen,
116        opts.health_timeout.unwrap_or(Duration::from_secs(30)),
117    )
118    .map_err(|e| Error::OperationFailed {
119        step: format!("start {UNIT_NAME}"),
120        message: format!(
121            "{health_url} did not answer 200: {e}; inspect with `journalctl --user -u {UNIT_NAME}`"
122        ),
123    })?;
124
125    let mut notes = key.notes;
126    if let Some(user) = std::env::var("USER")
127        .ok()
128        .or_else(|| std::env::var("LOGNAME").ok())
129        .filter(|u| !u.is_empty() && !Path::new("/var/lib/systemd/linger").join(u).exists())
130    {
131        notes.push(format!(
132            "lingering is off for {user}, so the service stops when you log out; \
133             run `loginctl enable-linger {user}` to keep it running"
134        ));
135    }
136    if exe.to_string_lossy().contains("/mise/installs/") {
137        notes.push(format!(
138            "the unit runs {} directly; install the service again after upgrading isb",
139            exe.display()
140        ));
141    }
142    Ok(ServiceInstall {
143        exe,
144        unit_path,
145        env_path,
146        listen,
147        health_url,
148        key_credential: key.credential,
149        notes,
150    })
151}
152
153/// The daemon's secrets key as the install left it.
154struct KeySetup {
155    /// The encrypted credential, when systemd-creds could make one.
156    credential: Option<PathBuf>,
157    notes: Vec<String>,
158}
159
160/// The credential file `isb serve install` writes, under the config dir.
161pub const CREDENTIAL_FILE: &str = "isb/isb-age-key.cred";
162
163/// Put the daemon's age key in an encrypted systemd credential where
164/// systemd-creds can make user credentials (systemd 256+); else leave it in
165/// the key file. The key is generated if there is none, but never when a
166/// credential already holds it.
167fn setup_key(config: &Path) -> Result<KeySetup> {
168    use crate::secrets::keys;
169    let sources = keys::KeySources::from_env();
170    let cred = config.join(CREDENTIAL_FILE);
171    let key_file = sources.default_file.clone();
172    let version = systemd_version();
173    if version.is_none_or(|v| v < 256) {
174        // Make sure there is a key, so the daemon does not generate one at
175        // first start unnoticed.
176        let k = keys::load_identity(&sources)?;
177        let mut notes = k.notes;
178        notes.push(format!(
179            "systemd-creds here cannot encrypt user credentials ({}; needs systemd 256+), so the daemon reads its secrets key from {}: keep that file out of unencrypted backups, and add a break-glass recipient (docs/guides/secrets.md)",
180            version.map_or("not found".to_string(), |v| format!("systemd {v}")),
181            key_file.display()
182        ));
183        return Ok(KeySetup {
184            credential: None,
185            notes,
186        });
187    }
188    let mut notes = Vec::new();
189    let k = match keys::find_identity(&sources) {
190        Ok(k) => k,
191        // The plaintext was removed after an earlier install: the
192        // credential is the key now. Keep it; never mint a new one.
193        Err(_) if cred.exists() => {
194            notes.push(format!(
195                "kept the daemon's secrets key in the systemd credential {}",
196                cred.display()
197            ));
198            return Ok(KeySetup {
199                credential: Some(cred),
200                notes,
201            });
202        }
203        Err(_) => keys::load_identity(&sources)?,
204    };
205    notes.extend(k.notes);
206    encrypt_credential(&keys::identity_file_text(&k.identity), &cred)?;
207    notes.push(format!(
208        "the daemon's secrets key ({}) is now an encrypted systemd credential, {}, bound to this machine and user",
209        k.identity.to_public(),
210        cred.display()
211    ));
212    if key_file.exists() {
213        notes.push(format!(
214            "the daemon no longer needs the plaintext key; to remove it: `shred -u {}`. Before you do, add a break-glass recipient (recipients = [...] in {}) and `isb secret reencrypt --all`: the credential cannot be decrypted on another machine, so without one, losing this host loses every secret. `isb up` without a running daemon also reads that file",
215            key_file.display(),
216            keys::SecretsConfig::default_path().display()
217        ));
218    }
219    Ok(KeySetup {
220        credential: Some(cred),
221        notes,
222    })
223}
224
225/// `systemd-creds --version`'s major version.
226fn systemd_version() -> Option<u32> {
227    let out = Command::new("systemd-creds")
228        .arg("--version")
229        .stdin(std::process::Stdio::null())
230        .output()
231        .ok()?;
232    if !out.status.success() {
233        return None;
234    }
235    parse_systemd_version(&String::from_utf8_lossy(&out.stdout))
236}
237
238/// `systemd 259 (259.5-0ubuntu3.4)` -> 259.
239fn parse_systemd_version(text: &str) -> Option<u32> {
240    let first = text.lines().next()?;
241    let mut words = first.split_whitespace();
242    if words.next()? != "systemd" {
243        return None;
244    }
245    words.next()?.parse().ok()
246}
247
248/// Encrypt `text` as the user credential `isb-age-key` into `path` (0600):
249/// written next to it, then renamed, so a failure leaves the old one.
250fn encrypt_credential(text: &str, path: &Path) -> Result<()> {
251    use std::io::Write;
252    use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
253    let dir = path
254        .parent()
255        .ok_or_else(|| Error::invalid(format!("{} has no parent", path.display())))?;
256    std::fs::DirBuilder::new()
257        .recursive(true)
258        .mode(0o700)
259        .create(dir)?;
260    let tmp = dir.join(format!(".isb-age-key.cred.{}.tmp", std::process::id()));
261    let _ = std::fs::remove_file(&tmp);
262    let mut child = Command::new("systemd-creds")
263        .args(["encrypt", "--user"])
264        .arg(format!("--name={}", crate::secrets::keys::CREDENTIAL_NAME))
265        .arg("-")
266        .arg(&tmp)
267        .stdin(std::process::Stdio::piped())
268        .stdout(std::process::Stdio::null())
269        .stderr(std::process::Stdio::piped())
270        .spawn()?;
271    if let Some(mut stdin) = child.stdin.take() {
272        stdin.write_all(text.as_bytes())?;
273    }
274    let out = child.wait_with_output()?;
275    let r = (|| -> Result<()> {
276        if !out.status.success() {
277            return Err(Error::OperationFailed {
278                step: "systemd-creds encrypt --user".into(),
279                message: String::from_utf8_lossy(&out.stderr).trim().to_string(),
280            });
281        }
282        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
283        std::fs::rename(&tmp, path)?;
284        Ok(())
285    })();
286    if r.is_err() {
287        let _ = std::fs::remove_file(&tmp);
288    }
289    r
290}
291
292/// The credential's path as the unit names it: `%h/...` under the home
293/// directory, else absolute; escaped for a unit file either way.
294pub fn credential_path(path: &Path, home: Option<&Path>) -> String {
295    match home.and_then(|h| path.strip_prefix(h).ok()) {
296        Some(rest) => format!("%h/{}", escape_env_path(&rest.to_string_lossy())),
297        None => escape_env_path(&path.to_string_lossy()),
298    }
299}
300
301fn config_dir() -> Result<PathBuf> {
302    if let Some(d) = std::env::var_os("XDG_CONFIG_HOME").filter(|s| !s.is_empty()) {
303        return Ok(PathBuf::from(d));
304    }
305    std::env::var_os("HOME")
306        .filter(|s| !s.is_empty())
307        .map(|h| PathBuf::from(h).join(".config"))
308        .ok_or_else(|| Error::invalid("HOME is not set"))
309}
310
311fn check_loopback(listen: &str) -> Result<()> {
312    let addrs: Vec<_> = listen
313        .to_socket_addrs()
314        .map_err(|e| Error::invalid(format!("listen address {listen:?}: {e}")))?
315        .collect();
316    if addrs.is_empty() || addrs.iter().any(|a| !a.ip().is_loopback()) {
317        return Err(Error::invalid(format!(
318            "listen address {listen:?} is not loopback; expose it through a Cloudflare Tunnel instead"
319        )));
320    }
321    Ok(())
322}
323
324fn systemctl(args: &[&str]) -> Result<()> {
325    let out = Command::new("systemctl")
326        .arg("--user")
327        .args(args)
328        .stdin(std::process::Stdio::null())
329        .output()?;
330    if !out.status.success() {
331        return Err(Error::OperationFailed {
332            step: format!("systemctl --user {}", args.join(" ")),
333            message: String::from_utf8_lossy(&out.stderr).trim().to_string(),
334        });
335    }
336    Ok(())
337}
338
339fn wait_healthy(listen: &str, timeout: Duration) -> std::result::Result<(), String> {
340    let started = Instant::now();
341    let mut last = "no answer".to_string();
342    while started.elapsed() < timeout {
343        match super::client::healthz(listen, Duration::from_secs(2)) {
344            Ok((200, _)) => return Ok(()),
345            Ok((s, _)) => last = format!("HTTP {s}"),
346            Err(e) => last = e.to_string(),
347        }
348        std::thread::sleep(Duration::from_millis(200));
349    }
350    Err(last)
351}
352
353/// The unit file. No sandboxing directives: the service drives incusd and
354/// reads the user's projects, and most of them need a system manager anyway.
355/// `credential` (from [`credential_path`]) loads the encrypted secrets key.
356pub fn render_unit(exe: &Path, env_path: &Path, credential: Option<&str>) -> String {
357    let cred = credential
358        .map(|c| {
359            format!(
360                "LoadCredentialEncrypted={}:{c}\n",
361                crate::secrets::keys::CREDENTIAL_NAME
362            )
363        })
364        .unwrap_or_default();
365    format!(
366        "[Unit]
367Description=isb serve: incus app stacks and MCP server
368After=network-online.target
369Wants=network-online.target
370
371[Service]
372Type=simple
373EnvironmentFile=-{}
374{cred}ExecStart={} serve
375Restart=always
376RestartSec=2
377
378[Install]
379WantedBy=default.target
380",
381        escape_env_path(&env_path.to_string_lossy()),
382        quote(&exe.to_string_lossy()),
383    )
384}
385
386/// The env file written on first install.
387pub fn render_env(listen: &str) -> String {
388    format!(
389        "# isb serve settings, read by the {UNIT_NAME} user unit.
390
391# Loopback address for /mcp and /healthz; point cloudflared here.
392{LISTEN_ENV}={listen}
393
394# The Cloudflare Access application in front of the tunnel hostname. Every
395# /mcp request on {LISTEN_ENV} must then carry a valid Access assertion.
396#CF_ACCESS_TEAM_DOMAIN=yourteam.cloudflareaccess.com
397#CF_ACCESS_AUD=
398"
399    )
400}
401
402/// `KEY=value` from env-file text: comments skipped, `export ` and quotes
403/// tolerated, the last assignment wins as it does for systemd.
404fn env_value(text: &str, key: &str) -> Option<String> {
405    let mut found = None;
406    for line in text.lines() {
407        let line = line.trim();
408        if line.starts_with('#') {
409            continue;
410        }
411        let line = line.strip_prefix("export ").unwrap_or(line);
412        if let Some((_, v)) = line.split_once('=').filter(|(k, _)| k.trim() == key) {
413            found = Some(v.trim().trim_matches(['"', '\'']).to_string());
414        }
415    }
416    found
417}
418
419/// Replace every `key=` assignment with `key=value`, or append one.
420fn set_env_value(text: &str, key: &str, value: &str) -> String {
421    let mut done = false;
422    let mut out: Vec<String> = text
423        .lines()
424        .map(|l| {
425            let t = l.trim();
426            let t = t.strip_prefix("export ").unwrap_or(t);
427            if !t.starts_with('#') && t.split_once('=').is_some_and(|(k, _)| k.trim() == key) {
428                done = true;
429                format!("{key}={value}")
430            } else {
431                l.to_string()
432            }
433        })
434        .collect();
435    if !done {
436        out.push(format!("{key}={value}"));
437    }
438    out.join("\n") + "\n"
439}
440
441/// An ExecStart argument: quoted, with systemd's `%` and `$` expansion escaped.
442fn quote(s: &str) -> String {
443    let s = s
444        .replace('\\', "\\\\")
445        .replace('"', "\\\"")
446        .replace('%', "%%")
447        .replace('$', "$$");
448    format!("\"{s}\"")
449}
450
451fn escape_env_path(s: &str) -> String {
452    s.replace('\\', "\\x5c")
453        .replace(' ', "\\x20")
454        .replace('\t', "\\x09")
455        .replace('%', "%%")
456}
457
458fn write_atomic(path: &Path, content: &[u8], mode: u32) -> Result<()> {
459    use std::os::unix::fs::PermissionsExt;
460    if std::fs::read(path).is_ok_and(|c| c == content) {
461        return Ok(());
462    }
463    let dir = path
464        .parent()
465        .ok_or_else(|| Error::invalid(format!("{} has no parent", path.display())))?;
466    std::fs::create_dir_all(dir)?;
467    let tmp = dir.join(format!(
468        ".{}.{}.tmp",
469        path.file_name().unwrap_or_default().to_string_lossy(),
470        std::process::id()
471    ));
472    std::fs::write(&tmp, content)?;
473    std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
474    std::fs::rename(&tmp, path).inspect_err(|_| {
475        let _ = std::fs::remove_file(&tmp);
476    })?;
477    Ok(())
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn unit_rendering() {
486        let u = render_unit(
487            Path::new("/home/me/.local/bin/isb"),
488            Path::new("/home/me/.config/isb/serve.env"),
489            None,
490        );
491        assert!(!u.contains("Credential"), "{u}");
492        assert!(
493            u.contains("\nExecStart=\"/home/me/.local/bin/isb\" serve\n"),
494            "{u}"
495        );
496        assert!(u.contains("\nEnvironmentFile=-/home/me/.config/isb/serve.env\n"));
497        assert!(u.contains("\nRestart=always\nRestartSec=2\n"));
498        assert!(u.contains("After=network-online.target"));
499        assert!(u.contains("WantedBy=default.target"));
500        assert!(!u.contains("Protect"), "no sandboxing directives");
501        let odd = render_unit(
502            Path::new("/opt/my isb/100%$x\"/isb"),
503            Path::new("/a b/%e"),
504            None,
505        );
506        assert!(
507            odd.contains("ExecStart=\"/opt/my isb/100%%$$x\\\"/isb\" serve"),
508            "{odd}"
509        );
510        assert!(odd.contains("EnvironmentFile=-/a\\x20b/%%e"), "{odd}");
511    }
512
513    #[test]
514    fn unit_loads_the_encrypted_key() {
515        let home = Path::new("/home/me");
516        let c = credential_path(
517            Path::new("/home/me/.config/isb/isb-age-key.cred"),
518            Some(home),
519        );
520        assert_eq!(c, "%h/.config/isb/isb-age-key.cred");
521        let u = render_unit(
522            Path::new("/home/me/.local/bin/isb"),
523            Path::new("/home/me/.config/isb/serve.env"),
524            Some(&c),
525        );
526        assert!(
527            u.contains(
528                "\nEnvironmentFile=-/home/me/.config/isb/serve.env\nLoadCredentialEncrypted=isb-age-key:%h/.config/isb/isb-age-key.cred\nExecStart="
529            ),
530            "{u}"
531        );
532        // Outside the home directory: absolute, escaped.
533        assert_eq!(
534            credential_path(Path::new("/srv/cfg 1/k%.cred"), Some(home)),
535            "/srv/cfg\\x201/k%%.cred"
536        );
537        assert_eq!(
538            credential_path(Path::new("/srv/k.cred"), None),
539            "/srv/k.cred"
540        );
541    }
542
543    #[test]
544    fn systemd_versions() {
545        assert_eq!(
546            parse_systemd_version("systemd 259 (259.5-0ubuntu3.4)\n+PAM +AUDIT"),
547            Some(259)
548        );
549        assert_eq!(
550            parse_systemd_version("systemd 255 (255.4-1ubuntu8)"),
551            Some(255)
552        );
553        assert_eq!(parse_systemd_version("something else"), None);
554        assert_eq!(parse_systemd_version(""), None);
555    }
556
557    #[test]
558    fn env_rendering_and_editing() {
559        let e = render_env("127.0.0.1:9000");
560        assert_eq!(env_value(&e, LISTEN_ENV).as_deref(), Some("127.0.0.1:9000"));
561        assert!(e.contains("#CF_ACCESS_TEAM_DOMAIN="));
562        assert!(e.contains("#CF_ACCESS_AUD="));
563        assert_eq!(env_value(&e, "CF_ACCESS_AUD"), None, "commented out");
564
565        let edited = set_env_value(&e, LISTEN_ENV, "127.0.0.1:9001");
566        assert_eq!(
567            env_value(&edited, LISTEN_ENV).as_deref(),
568            Some("127.0.0.1:9001")
569        );
570        assert!(edited.contains("#CF_ACCESS_AUD="), "rest untouched");
571        let appended = set_env_value("A=1", LISTEN_ENV, "[::1]:1");
572        assert_eq!(appended, "A=1\nISB_SERVE_LISTEN=[::1]:1\n");
573        assert_eq!(
574            env_value("export ISB_SERVE_LISTEN=\"localhost:1\"\n", LISTEN_ENV).as_deref(),
575            Some("localhost:1")
576        );
577    }
578
579    #[test]
580    fn listen_must_be_loopback() {
581        assert!(check_loopback("127.0.0.1:8092").is_ok());
582        assert!(check_loopback("[::1]:8092").is_ok());
583        assert!(check_loopback("0.0.0.0:8092").is_err());
584        assert!(check_loopback("nonsense").is_err());
585    }
586
587    #[test]
588    fn atomic_write_is_idempotent_and_sets_mode() {
589        use std::os::unix::fs::PermissionsExt;
590        let d = tempfile::tempdir().unwrap();
591        let p = d.path().join("x/serve.env");
592        write_atomic(&p, b"a", 0o600).unwrap();
593        write_atomic(&p, b"a", 0o600).unwrap();
594        assert_eq!(std::fs::read(&p).unwrap(), b"a");
595        assert_eq!(
596            std::fs::metadata(&p).unwrap().permissions().mode() & 0o777,
597            0o600
598        );
599        assert_eq!(std::fs::read_dir(p.parent().unwrap()).unwrap().count(), 1);
600    }
601}