1use 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 pub listen: Option<String>,
28 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 pub key_credential: Option<PathBuf>,
42 pub notes: Vec<String>,
44}
45
46pub 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
126pub(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 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
162fn 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
176struct KeySetup {
178 credential: Option<PathBuf>,
180 notes: Vec<String>,
181}
182
183pub const CREDENTIAL_FILE: &str = "isb/isb-age-key.cred";
185
186fn 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 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 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
248fn 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
261fn 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
271fn 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
315pub 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
376pub 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
409pub 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
425fn 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
442fn 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
464pub(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 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}