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