1use 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
25pub const UNIT_PATH: &str = "/etc/systemd/system/isb.service";
27pub const CREDSTORE: &str = "/etc/credstore.encrypted";
29pub const MEMORY_MAX: &str = "8G";
33pub const TASKS_MAX: u32 = 4096;
34
35#[derive(Debug, Clone)]
37pub struct Operator {
38 pub user: String,
39 pub uid: u32,
40 pub home: PathBuf,
41}
42
43pub fn installed() -> bool {
45 Path::new(UNIT_PATH).exists()
46}
47
48pub 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 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
145fn 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
211fn 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
230pub 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
272fn 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
302fn 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
314fn 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}