Skip to main content

isb_server/server/
system_service.rs

1//! Installing `isb serve` as a systemd system unit that runs as the
2//! operator: `isb serve install --system`.
3//!
4//! A user unit lives in the user's slice (`user-UID.slice`), with everything
5//! else that user runs. On a host where agents run as that same user, the
6//! slice's limits (MemoryHigh, MemoryMax, TasksMax) are theirs, and an agent
7//! that fills the slice stalls the daemon with it. A system unit runs in
8//! `system.slice` instead, with its own limits, while keeping the user, home,
9//! runtime directory and state the CLI expects, so nothing else changes: the
10//! CLI finds the daemon at `$XDG_RUNTIME_DIR/isb/serve.sock` as before.
11//!
12//! The installer runs as that user and uses sudo for what needs root (the
13//! unit, the system credential, systemctl, lingering).
14
15use std::io::Write;
16use std::path::{Path, PathBuf};
17use std::process::{Command, Stdio};
18use std::time::Duration;
19
20use super::service::{
21    self, CREDENTIAL_FILE, ServiceInstall, ServiceOptions, UNIT_NAME, escape_env_path, quote,
22};
23use crate::error::{Error, Result};
24
25/// Where the system unit is written.
26pub const UNIT_PATH: &str = "/etc/systemd/system/isb.service";
27/// The system credential store `LoadCredentialEncrypted=` searches by name.
28pub const CREDSTORE: &str = "/etc/credstore.encrypted";
29/// The unit's own ceilings. Far above what the daemon uses (hundreds of MB,
30/// a few hundred tasks), so only a leak in isb itself reaches them. Raise
31/// them with a drop-in (`sudo systemctl edit isb`).
32pub const MEMORY_MAX: &str = "8G";
33pub const TASKS_MAX: u32 = 4096;
34
35/// Who the daemon runs as.
36#[derive(Debug, Clone)]
37pub struct Operator {
38    pub user: String,
39    pub uid: u32,
40    pub home: PathBuf,
41}
42
43/// Is the daemon installed as a system unit on this host?
44pub fn installed() -> bool {
45    Path::new(UNIT_PATH).exists()
46}
47
48/// Write the system unit and its credential, retire a user unit, (re)start
49/// the service and wait until it is healthy. Run as the daemon's user, not
50/// root. Safe to run again: it updates an existing installation.
51pub fn install_system_service(opts: &ServiceOptions) -> Result<ServiceInstall> {
52    if !cfg!(target_os = "linux") {
53        return Err(Error::invalid("--system needs Linux with systemd"));
54    }
55    if rustix::process::geteuid().is_root() {
56        return Err(Error::invalid(
57            "run `isb serve install --system` as the user the daemon runs as, not root: it uses \
58             sudo for the parts that need root",
59        ));
60    }
61    let op = operator()?;
62    let config = service::config_dir()?;
63    let env_path = config.join("isb/serve.env");
64    let exe = std::env::current_exe()?.canonicalize()?;
65    let listen = service::prepare_env(opts, &env_path)?;
66    let mut notes = Vec::new();
67
68    if !Path::new("/var/lib/systemd/linger").join(&op.user).exists() {
69        sudo(&["loginctl", "enable-linger", &op.user], None)?;
70        notes.push(format!(
71            "turned on lingering for {}: the daemon's runtime directory /run/user/{} must exist \
72             from boot, without a login",
73            op.user, op.uid
74        ));
75    }
76    let credential = setup_system_key(&config, &mut notes)?;
77
78    // Retire a user unit first: both would serve the same socket and port.
79    let user_unit = config.join("systemd/user").join(UNIT_NAME);
80    if user_unit.exists() {
81        let _ = Command::new("systemctl")
82            .args(["--user", "disable", "--now", UNIT_NAME])
83            .stdin(Stdio::null())
84            .output();
85        std::fs::remove_file(&user_unit)?;
86        let _ = Command::new("systemctl")
87            .args(["--user", "daemon-reload"])
88            .stdin(Stdio::null())
89            .output();
90        notes.push(format!(
91            "stopped and removed the user unit {}",
92            user_unit.display()
93        ));
94    }
95
96    let unit = render_system_unit(&op, &exe, &env_path, credential.is_some());
97    sudo_write(Path::new(UNIT_PATH), unit.as_bytes(), "0644")?;
98    for args in [
99        &["systemctl", "daemon-reload"][..],
100        &["systemctl", "enable", "--quiet", UNIT_NAME],
101        &["systemctl", "restart", UNIT_NAME],
102    ] {
103        sudo(args, None)?;
104    }
105
106    let health_url = format!("http://{listen}/healthz");
107    service::wait_healthy(
108        &listen,
109        opts.health_timeout.unwrap_or(Duration::from_secs(30)),
110    )
111    .map_err(|e| Error::OperationFailed {
112        step: format!("start {UNIT_NAME}"),
113        message: format!(
114            "{health_url} did not answer 200: {e}; inspect with `sudo journalctl -u {UNIT_NAME}`"
115        ),
116    })?;
117    Ok(ServiceInstall {
118        exe,
119        unit_path: PathBuf::from(UNIT_PATH),
120        env_path,
121        listen,
122        health_url,
123        key_credential: credential,
124        notes,
125    })
126}
127
128fn operator() -> Result<Operator> {
129    let user = std::env::var("USER")
130        .ok()
131        .or_else(|| std::env::var("LOGNAME").ok())
132        .filter(|u| !u.is_empty())
133        .ok_or_else(|| Error::invalid("cannot tell who the daemon runs as ($USER is unset)"))?;
134    let home = std::env::var_os("HOME")
135        .filter(|h| !h.is_empty())
136        .map(PathBuf::from)
137        .ok_or_else(|| Error::invalid("HOME is not set"))?;
138    Ok(Operator {
139        user,
140        uid: rustix::process::getuid().as_raw(),
141        home,
142    })
143}
144
145/// Put the daemon's age key in the system credential store, encrypted with
146/// the host key, so `LoadCredentialEncrypted=isb-age-key` finds it. The key
147/// comes from the plaintext file, else the user credential an earlier
148/// `isb serve install` made; one is generated only when neither exists and
149/// no system credential does either.
150fn setup_system_key(config: &Path, notes: &mut Vec<String>) -> Result<Option<PathBuf>> {
151    use crate::secrets::keys;
152    let target = Path::new(CREDSTORE).join(keys::CREDENTIAL_NAME);
153    let sources = keys::KeySources::from_env();
154    if Command::new("systemd-creds")
155        .arg("--version")
156        .stdin(Stdio::null())
157        .output()
158        .map_or(true, |o| !o.status.success())
159    {
160        let k = keys::load_identity(&sources)?;
161        notes.extend(k.notes);
162        notes.push(format!(
163            "systemd-creds is not available, so the daemon reads its secrets key from {}: keep \
164             that file out of unencrypted backups",
165            sources.default_file.display()
166        ));
167        return Ok(None);
168    }
169    let user_cred = config.join(CREDENTIAL_FILE);
170    let text = match keys::find_identity(&sources) {
171        Ok(k) => keys::identity_file_text(&k.identity),
172        Err(_) if sudo_exists(&target) => {
173            notes.push(format!(
174                "kept the daemon's secrets key in the system credential {}",
175                target.display()
176            ));
177            return Ok(Some(target));
178        }
179        Err(_) if user_cred.exists() => decrypt_user_credential(&user_cred)?,
180        Err(_) => keys::identity_file_text(&keys::load_identity(&sources)?.identity),
181    };
182    let public = keys::parse_identity(&text)?.to_public();
183    sudo(&["install", "-d", "-m", "0700", CREDSTORE], None)?;
184    let tmp = Path::new(CREDSTORE).join(format!(".isb-age-key.{}.tmp", std::process::id()));
185    let tmp_s = tmp.to_string_lossy();
186    let name = format!("--name={}", keys::CREDENTIAL_NAME);
187    let r = sudo(
188        &["systemd-creds", "encrypt", &name, "-", &tmp_s],
189        Some(text.as_bytes()),
190    )
191    .and_then(|()| sudo(&["chmod", "0600", &tmp_s], None))
192    .and_then(|()| sudo(&["mv", "-f", &tmp_s, &target.to_string_lossy()], None));
193    if r.is_err() {
194        let _ = sudo(&["rm", "-f", &tmp_s], None);
195    }
196    r?;
197    notes.push(format!(
198        "the daemon's secrets key ({public}) is an encrypted system credential, {}, bound to this \
199         host",
200        target.display()
201    ));
202    if user_cred.exists() {
203        notes.push(format!(
204            "the user credential {} is no longer read; remove it once the daemon is healthy",
205            user_cred.display()
206        ));
207    }
208    Ok(Some(target))
209}
210
211/// The key text from a user credential (`systemd-creds encrypt --user`),
212/// which only this user can decrypt.
213fn decrypt_user_credential(path: &Path) -> Result<String> {
214    let out = Command::new("systemd-creds")
215        .args(["decrypt", "--user"])
216        .arg(format!("--name={}", crate::secrets::keys::CREDENTIAL_NAME))
217        .arg(path)
218        .arg("-")
219        .stdin(Stdio::null())
220        .output()?;
221    if !out.status.success() {
222        return Err(Error::OperationFailed {
223            step: format!("systemd-creds decrypt --user {}", path.display()),
224            message: String::from_utf8_lossy(&out.stderr).trim().to_string(),
225        });
226    }
227    String::from_utf8(out.stdout).map_err(|_| Error::invalid("the user credential is not text"))
228}
229
230/// The unit. It runs as the operator with their runtime directory, so the
231/// socket, state and config are where the CLI looks; it is ordered after the
232/// user manager, which creates that directory (lingering starts it at boot).
233pub fn render_system_unit(op: &Operator, exe: &Path, env_path: &Path, credential: bool) -> String {
234    let uid = op.uid;
235    let bin = escape_env_path(&op.home.join(".local/bin").to_string_lossy());
236    let cred = if credential {
237        format!(
238            "LoadCredentialEncrypted={}\n",
239            crate::secrets::keys::CREDENTIAL_NAME
240        )
241    } else {
242        String::new()
243    };
244    format!(
245        "[Unit]
246Description=isb serve: incus app stacks and MCP server
247Wants=network-online.target user@{uid}.service
248After=network-online.target incus.service user@{uid}.service
249
250[Service]
251Type=simple
252User={user}
253WorkingDirectory=~
254Environment=XDG_RUNTIME_DIR=/run/user/{uid}
255Environment=PATH={bin}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
256EnvironmentFile=-{env}
257{cred}ExecStart={exe} serve
258Restart=always
259RestartSec=2
260MemoryMax={MEMORY_MAX}
261TasksMax={TASKS_MAX}
262
263[Install]
264WantedBy=multi-user.target
265",
266        user = op.user,
267        env = escape_env_path(&env_path.to_string_lossy()),
268        exe = quote(&exe.to_string_lossy()),
269    )
270}
271
272/// `sudo -- args`, with `input` on stdin (never in argv: it may be a key).
273fn sudo(args: &[&str], input: Option<&[u8]>) -> Result<()> {
274    let mut child = Command::new("sudo")
275        .arg("--")
276        .args(args)
277        .stdin(if input.is_some() {
278            Stdio::piped()
279        } else {
280            Stdio::inherit()
281        })
282        .stdout(Stdio::null())
283        .stderr(Stdio::piped())
284        .spawn()
285        .map_err(|e| Error::OperationFailed {
286            step: format!("sudo {}", args.join(" ")),
287            message: format!("could not run sudo: {e}"),
288        })?;
289    if let (Some(data), Some(mut stdin)) = (input, child.stdin.take()) {
290        stdin.write_all(data)?;
291    }
292    let out = child.wait_with_output()?;
293    if !out.status.success() {
294        return Err(Error::OperationFailed {
295            step: format!("sudo {}", args.join(" ")),
296            message: String::from_utf8_lossy(&out.stderr).trim().to_string(),
297        });
298    }
299    Ok(())
300}
301
302/// Does `path` exist, looked at as root (the credential store is 0700)?
303fn sudo_exists(path: &Path) -> bool {
304    Command::new("sudo")
305        .args(["--", "test", "-e"])
306        .arg(path)
307        .stdin(Stdio::inherit())
308        .stdout(Stdio::null())
309        .stderr(Stdio::null())
310        .status()
311        .is_ok_and(|s| s.success())
312}
313
314/// Write a root-owned file through sudo: next to it, then renamed.
315fn sudo_write(path: &Path, content: &[u8], mode: &str) -> Result<()> {
316    if std::fs::read(path).is_ok_and(|c| c == content) {
317        return Ok(());
318    }
319    let dir = path
320        .parent()
321        .ok_or_else(|| Error::invalid(format!("{} has no parent", path.display())))?;
322    let tmp = dir.join(format!(".isb.service.{}.tmp", std::process::id()));
323    let tmp_s = tmp.to_string_lossy();
324    let r = sudo(&["sh", "-c", "cat > \"$1\"", "sh", &tmp_s], Some(content))
325        .and_then(|()| sudo(&["chmod", mode, &tmp_s], None))
326        .and_then(|()| sudo(&["mv", "-f", &tmp_s, &path.to_string_lossy()], None));
327    if r.is_err() {
328        let _ = sudo(&["rm", "-f", &tmp_s], None);
329    }
330    r
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    fn op() -> Operator {
338        Operator {
339            user: "me".into(),
340            uid: 1000,
341            home: PathBuf::from("/home/me"),
342        }
343    }
344
345    #[test]
346    fn system_unit_runs_as_the_operator_outside_their_slice() {
347        let u = render_system_unit(
348            &op(),
349            Path::new("/home/me/.local/bin/isb"),
350            Path::new("/home/me/.config/isb/serve.env"),
351            true,
352        );
353        assert!(u.contains("\nUser=me\n"), "{u}");
354        assert!(u.contains("\nWorkingDirectory=~\n"), "{u}");
355        assert!(
356            u.contains("\nEnvironment=XDG_RUNTIME_DIR=/run/user/1000\n"),
357            "the CLI's socket path: {u}"
358        );
359        assert!(u.contains("Environment=PATH=/home/me/.local/bin:/usr/local/sbin:"));
360        assert!(u.contains("\nWants=network-online.target user@1000.service\n"));
361        assert!(u.contains("\nAfter=network-online.target incus.service user@1000.service\n"));
362        assert!(u.contains(
363            "\nEnvironmentFile=-/home/me/.config/isb/serve.env\nLoadCredentialEncrypted=isb-age-key\nExecStart=\"/home/me/.local/bin/isb\" serve\n"
364        ), "{u}");
365        assert!(u.contains("\nMemoryMax=8G\nTasksMax=4096\n"), "{u}");
366        assert!(u.contains("\nWantedBy=multi-user.target\n"), "{u}");
367        assert!(!u.contains("Slice="), "system.slice, the default");
368    }
369
370    #[test]
371    fn system_unit_without_a_credential() {
372        let u = render_system_unit(
373            &op(),
374            Path::new("/opt/my isb/isb"),
375            Path::new("/home/me/.config/isb/serve.env"),
376            false,
377        );
378        assert!(!u.contains("Credential"), "{u}");
379        assert!(u.contains("ExecStart=\"/opt/my isb/isb\" serve"), "{u}");
380    }
381}