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