1use 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 pub listen: Option<String>,
27 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 pub key_credential: Option<PathBuf>,
41 pub notes: Vec<String>,
43}
44
45pub 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 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
149fn 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
163struct KeySetup {
165 credential: Option<PathBuf>,
167 notes: Vec<String>,
168}
169
170pub const CREDENTIAL_FILE: &str = "isb/isb-age-key.cred";
172
173fn 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 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 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
235fn 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
248fn 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
258fn 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
302pub 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
363pub 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
396pub 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
412fn 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
429fn 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
451fn 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 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}