Skip to main content

isb_server/server/
service.rs

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