1mod render;
6
7use std::collections::BTreeMap;
8use std::fs::{self, File, OpenOptions};
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use std::time::Duration;
13
14use crate::node_log;
15use crate::windows_process::{hide_console, hide_console_detached, free_console, ignore_console_close_events};
16
17pub use render::{
18 render_launchd_plist, render_schtasks_xml, render_systemd_unit, LaunchPlist, SchtasksXml,
19 SystemdUnit,
20};
21
22const ENV_PREFIXES: &[&str] = &["GATE4AGENT_", "HATCHERY_", "RUST_LOG"];
24
25#[derive(Clone, Debug)]
26pub struct ServiceSpec {
27 pub system: bool,
28 pub exe: PathBuf,
29 pub args: Vec<String>,
30 pub env: Vec<(String, String)>,
31 pub enroll_state: Option<PathBuf>,
32}
33
34#[derive(Clone, Debug)]
35pub struct ServiceStatus {
36 pub installed: bool,
37 pub running: bool,
38 pub scope: &'static str,
39 pub unit_name: String,
40 pub detail: String,
41}
42
43impl ServiceSpec {
44 pub fn from_install_args(system: bool, node_args: &[String]) -> Result<Self, String> {
45 let exe = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?;
46 let enroll_state = extract_enroll_state(node_args);
47 let env = capture_env();
48 Ok(Self {
49 system,
50 exe,
51 args: node_args.to_vec(),
52 env,
53 enroll_state,
54 })
55 }
56
57 pub fn unit_name(&self) -> String {
58 match &self.enroll_state {
59 Some(p) => {
60 let key = p.to_string_lossy();
61 let digest = short_digest(&key);
62 format!("gate4agent-node-{digest}")
63 }
64 None => "gate4agent-node".to_owned(),
65 }
66 }
67
68 pub fn launchd_label(&self) -> String {
69 format!("com.gate4agent.node.{}", self.unit_name())
70 }
71}
72
73fn extract_enroll_state(args: &[String]) -> Option<PathBuf> {
74 args.windows(2)
75 .find(|w| w[0] == "--enroll-state")
76 .map(|w| PathBuf::from(&w[1]))
77}
78
79fn capture_env() -> Vec<(String, String)> {
80 let mut out = BTreeMap::new();
81 for (k, v) in std::env::vars() {
82 if ENV_PREFIXES.iter().any(|p| k.starts_with(p)) || k == "RUST_LOG" {
83 out.insert(k, v);
84 }
85 }
86 out.into_iter().collect()
87}
88
89fn windows_task_exe(exe: &Path) -> PathBuf {
90 #[cfg(windows)]
91 {
92 let w = exe.with_file_name("gate4agent-nodew.exe");
93 if w.is_file() {
94 return w;
95 }
96 }
97 let _ = exe;
98 exe.to_path_buf()
99}
100
101fn short_digest(s: &str) -> String {
102 use sha2::{Digest, Sha256};
103 let h = Sha256::digest(s.as_bytes());
104 hex::encode(&h[..6])
105}
106
107fn home_dir() -> Result<PathBuf, String> {
108 std::env::var_os("HOME")
109 .or_else(|| std::env::var_os("USERPROFILE"))
110 .map(PathBuf::from)
111 .ok_or_else(|| "HOME/USERPROFILE is unset".to_owned())
112}
113
114pub fn install(spec: &ServiceSpec) -> Result<String, String> {
116 require_admin_if_system(spec.system)?;
117 #[cfg(target_os = "linux")]
118 {
119 return install_linux(spec);
120 }
121 #[cfg(target_os = "macos")]
122 {
123 return install_macos(spec);
124 }
125 #[cfg(windows)]
126 {
127 return install_windows(spec);
128 }
129 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
130 {
131 let _ = spec;
132 Err("service install is unsupported on this OS".into())
133 }
134}
135
136pub fn uninstall(system: bool) -> Result<String, String> {
137 require_admin_if_system(system)?;
138 let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
140 #[cfg(target_os = "linux")]
141 {
142 return uninstall_linux(system, &name);
143 }
144 #[cfg(target_os = "macos")]
145 {
146 return uninstall_macos(system, &name);
147 }
148 #[cfg(windows)]
149 {
150 return uninstall_windows(system, &name);
151 }
152 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
153 {
154 let _ = (system, name);
155 Err("service uninstall is unsupported on this OS".into())
156 }
157}
158
159pub fn status(system: bool) -> Result<ServiceStatus, String> {
160 let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
161 #[cfg(target_os = "linux")]
162 {
163 return status_linux(system, &name);
164 }
165 #[cfg(target_os = "macos")]
166 {
167 return status_macos(system, &name);
168 }
169 #[cfg(windows)]
170 {
171 return status_windows(system, &name);
172 }
173 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
174 {
175 let _ = (system, name);
176 Err("service-status is unsupported on this OS".into())
177 }
178}
179
180pub fn restart(system: bool) -> Result<String, String> {
181 require_admin_if_system(system)?;
182 let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
183 #[cfg(target_os = "linux")]
184 {
185 return restart_linux(system, &name);
186 }
187 #[cfg(target_os = "macos")]
188 {
189 return restart_macos(system, &name);
190 }
191 #[cfg(windows)]
192 {
193 return restart_windows(system, &name);
194 }
195 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
196 {
197 let _ = (system, name);
198 Err("service-restart is unsupported on this OS".into())
199 }
200}
201
202fn require_admin_if_system(system: bool) -> Result<(), String> {
203 if !system {
204 return Ok(());
205 }
206 if is_elevated() {
207 Ok(())
208 } else {
209 Err("--system requires administrator / root privileges".into())
210 }
211}
212
213fn is_elevated() -> bool {
214 #[cfg(unix)]
215 {
216 unsafe { libc::geteuid() == 0 }
217 }
218 #[cfg(windows)]
219 {
220 true
223 }
224 #[cfg(all(not(unix), not(windows)))]
225 {
226 false
227 }
228}
229
230fn marker_path(system: bool) -> Result<PathBuf, String> {
231 let base = if system {
232 #[cfg(unix)]
233 {
234 PathBuf::from("/var/lib/gate4agent-node")
235 }
236 #[cfg(not(unix))]
237 {
238 std::env::var_os("ProgramData")
239 .map(PathBuf::from)
240 .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
241 .join("gate4agent-node")
242 }
243 } else {
244 home_dir()?.join(".local").join("state").join("gate4agent-node")
245 };
246 Ok(base.join("service-unit-name"))
247}
248
249fn save_unit_name(system: bool, name: &str) -> Result<(), String> {
250 let path = marker_path(system)?;
251 if let Some(parent) = path.parent() {
252 fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
253 }
254 fs::write(&path, name).map_err(|e| format!("write {}: {e}", path.display()))
255}
256
257fn saved_unit_name(system: bool) -> Option<String> {
258 let path = marker_path(system).ok()?;
259 fs::read_to_string(path).ok().map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
260}
261
262fn clear_unit_name(system: bool) {
263 if let Ok(path) = marker_path(system) {
264 let _ = fs::remove_file(path);
265 }
266}
267
268#[cfg(target_os = "linux")]
271fn unit_dir(system: bool) -> Result<PathBuf, String> {
272 if system {
273 Ok(PathBuf::from("/etc/systemd/system"))
274 } else {
275 Ok(home_dir()?.join(".config").join("systemd").join("user"))
276 }
277}
278
279#[cfg(target_os = "linux")]
280fn install_linux(spec: &ServiceSpec) -> Result<String, String> {
281 let name = spec.unit_name();
282 let dir = unit_dir(spec.system)?;
283 fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
284 let path = dir.join(format!("{name}.service"));
285 let body = render_systemd_unit(&SystemdUnit {
287 description: "gate4agent-node",
288 exe: &spec.exe.to_string_lossy(),
289 args: &spec.args,
290 env: &spec.env,
291 supervised: false,
292 });
293 fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?;
294 save_unit_name(spec.system, &name)?;
295
296 let mut notes = Vec::new();
297 if !spec.system {
298 match Command::new("loginctl")
299 .args(["enable-linger"])
300 .status()
301 {
302 Ok(s) if s.success() => notes.push("linger enabled".into()),
303 Ok(_) | Err(_) => notes.push(
304 "warning: loginctl enable-linger failed or unavailable; user units may stop at logout"
305 .into(),
306 ),
307 }
308 }
309 match run_ok(&mut systemctl_cmd(spec.system).args(["daemon-reload"]))
310 .and_then(|_| run_ok(&mut systemctl_cmd(spec.system).args(["enable", "--now", &name])))
311 {
312 Ok(()) => notes.push(format!("installed and started {name} ({})", path.display())),
313 Err(e) => notes.push(format!(
314 "wrote {} but systemctl failed ({e}); start later when the user bus is up",
315 path.display()
316 )),
317 }
318 Ok(notes.join("; "))
319}
320
321#[cfg(target_os = "linux")]
322fn uninstall_linux(system: bool, name: &str) -> Result<String, String> {
323 let _ = systemctl_cmd(system).args(["disable", "--now", name]).status();
324 let path = unit_dir(system)?.join(format!("{name}.service"));
325 let _ = fs::remove_file(&path);
326 let _ = systemctl_cmd(system).args(["daemon-reload"]).status();
327 clear_unit_name(system);
328 Ok(format!("removed {name}"))
329}
330
331#[cfg(target_os = "linux")]
332fn status_linux(system: bool, name: &str) -> Result<ServiceStatus, String> {
333 let path = unit_dir(system)?.join(format!("{name}.service"));
334 let installed = path.is_file();
335 let out = systemctl_cmd(system).args(["is-active", name]).output();
336 let (running, detail) = match out {
337 Ok(o) => {
338 let s = String::from_utf8_lossy(&o.stdout).trim().to_owned();
339 (s == "active", s)
340 }
341 Err(e) => (false, e.to_string()),
342 };
343 Ok(ServiceStatus {
344 installed,
345 running,
346 scope: if system { "system" } else { "user" },
347 unit_name: name.to_owned(),
348 detail,
349 })
350}
351
352#[cfg(target_os = "linux")]
353fn restart_linux(system: bool, name: &str) -> Result<String, String> {
354 run_ok(&mut systemctl_cmd(system).args(["restart", name]))?;
355 Ok(format!("restarted {name}"))
356}
357
358#[cfg(target_os = "linux")]
359fn systemctl_cmd(system: bool) -> Command {
360 let mut c = Command::new("systemctl");
361 if !system {
362 c.arg("--user");
363 }
364 c
365}
366
367#[cfg(target_os = "macos")]
370fn plist_path(system: bool, label: &str) -> Result<PathBuf, String> {
371 if system {
372 Ok(PathBuf::from("/Library/LaunchDaemons").join(format!("{label}.plist")))
373 } else {
374 Ok(home_dir()?
375 .join("Library")
376 .join("LaunchAgents")
377 .join(format!("{label}.plist")))
378 }
379}
380
381#[cfg(target_os = "macos")]
382fn install_macos(spec: &ServiceSpec) -> Result<String, String> {
383 let label = spec.launchd_label();
384 let path = plist_path(spec.system, &label)?;
385 if let Some(parent) = path.parent() {
386 fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
387 }
388 let body = render_launchd_plist(&LaunchPlist {
389 label: &label,
390 exe: &spec.exe.to_string_lossy(),
391 args: &spec.args,
392 env: &spec.env,
393 supervised: false, });
395 fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?;
396 save_unit_name(spec.system, &spec.unit_name())?;
397 let domain = if spec.system {
398 "system".to_owned()
399 } else {
400 format!("gui/{}", unsafe { libc::getuid() })
401 };
402 let _ = Command::new("launchctl")
403 .args(["bootout", &domain, &path.to_string_lossy()])
404 .status();
405 run_ok(
406 &mut Command::new("launchctl").args(["bootstrap", &domain, &path.to_string_lossy()]),
407 )?;
408 let _ = Command::new("launchctl")
409 .args(["enable", &format!("{domain}/{label}")])
410 .status();
411 Ok(format!("installed {label} ({})", path.display()))
412}
413
414#[cfg(target_os = "macos")]
415fn uninstall_macos(system: bool, name: &str) -> Result<String, String> {
416 let label = format!("com.gate4agent.node.{name}");
417 let path = plist_path(system, &label)?;
418 let domain = if system {
419 "system".to_owned()
420 } else {
421 format!("gui/{}", unsafe { libc::getuid() })
422 };
423 let _ = Command::new("launchctl")
424 .args(["bootout", &domain, &path.to_string_lossy()])
425 .status();
426 let _ = fs::remove_file(&path);
427 clear_unit_name(system);
428 Ok(format!("removed {label}"))
429}
430
431#[cfg(target_os = "macos")]
432fn status_macos(system: bool, name: &str) -> Result<ServiceStatus, String> {
433 let label = format!("com.gate4agent.node.{name}");
434 let path = plist_path(system, &label)?;
435 let installed = path.is_file();
436 let out = Command::new("launchctl")
437 .args(["print", &format!("system/{label}")])
438 .output();
439 let running = match out {
441 Ok(o) if o.status.success() => true,
442 _ => Command::new("launchctl")
443 .args(["list", &label])
444 .status()
445 .map(|s| s.success())
446 .unwrap_or(false),
447 };
448 Ok(ServiceStatus {
449 installed,
450 running,
451 scope: if system { "system" } else { "user" },
452 unit_name: name.to_owned(),
453 detail: label,
454 })
455}
456
457#[cfg(target_os = "macos")]
458fn restart_macos(system: bool, name: &str) -> Result<String, String> {
459 let label = format!("com.gate4agent.node.{name}");
460 let path = plist_path(system, &label)?;
461 let domain = if system {
462 "system".to_owned()
463 } else {
464 format!("gui/{}", unsafe { libc::getuid() })
465 };
466 let _ = Command::new("launchctl")
467 .args(["kickstart", "-k", &format!("{domain}/{label}")])
468 .status();
469 let _ = path;
470 Ok(format!("restarted {label}"))
471}
472
473#[cfg(windows)]
476fn install_windows(spec: &ServiceSpec) -> Result<String, String> {
477 let name = spec.unit_name();
478 if spec.system {
479 return install_windows_system(spec, &name);
484 }
485 let dir = home_dir()?
486 .join("AppData")
487 .join("Local")
488 .join("gate4agent-node");
489 fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
490 let xml_path = dir.join(format!("{name}.xml"));
491 let cwd = home_dir()?;
492 let task_exe = windows_task_exe(&spec.exe);
493 let use_nodew = task_exe
494 .file_name()
495 .and_then(|s| s.to_str())
496 == Some("gate4agent-nodew.exe");
497 let xml = render_schtasks_xml(&SchtasksXml {
498 task_name: &name,
499 exe: &task_exe.to_string_lossy(),
500 args: &spec.args,
501 working_dir: &cwd.to_string_lossy(),
502 author: "gate4agent-node",
503 supervised: !use_nodew,
504 });
505 let mut encoded: Vec<u8> = vec![0xFF, 0xFE];
507 for u in xml.encode_utf16() {
508 encoded.extend_from_slice(&u.to_le_bytes());
509 }
510 fs::write(&xml_path, encoded).map_err(|e| format!("write {}: {e}", xml_path.display()))?;
511 write_env_file(&dir.join(format!("{name}.env")), &spec.env)?;
514 let _ = Command::new("schtasks")
515 .args(["/Delete", "/TN", &name, "/F"])
516 .status();
517 run_ok(&mut Command::new("schtasks").args([
518 "/Create",
519 "/TN",
520 &name,
521 "/XML",
522 &xml_path.to_string_lossy(),
523 "/F",
524 ]))?;
525 let _ = Command::new("schtasks")
526 .args(["/Run", "/TN", &name])
527 .status();
528 save_unit_name(false, &name)?;
529 Ok(format!(
530 "installed scheduled task {name}; upgrade: gate4agent-node service-restart then cargo install then service-restart"
531 ))
532}
533
534#[cfg(windows)]
535fn install_windows_system(spec: &ServiceSpec, name: &str) -> Result<String, String> {
536 let dir = PathBuf::from(
539 std::env::var_os("ProgramData")
540 .map(PathBuf::from)
541 .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")),
542 )
543 .join("gate4agent-node");
544 fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
545 let xml_path = dir.join(format!("{name}.xml"));
546 let task_exe = windows_task_exe(&spec.exe);
547 let use_nodew = task_exe
548 .file_name()
549 .and_then(|s| s.to_str())
550 == Some("gate4agent-nodew.exe");
551 let mut xml = render_schtasks_xml(&SchtasksXml {
552 task_name: name,
553 exe: &task_exe.to_string_lossy(),
554 args: &spec.args,
555 working_dir: &dir.to_string_lossy(),
556 author: "gate4agent-node",
557 supervised: !use_nodew,
558 });
559 xml = xml.replace(
560 "<RunLevel>LeastPrivilege</RunLevel>",
561 "<RunLevel>HighestAvailable</RunLevel>",
562 );
563 xml = xml.replace(
564 "<LogonType>InteractiveToken</LogonType>",
565 "<LogonType>S4U</LogonType>",
566 );
567 let mut encoded: Vec<u8> = vec![0xFF, 0xFE];
568 for u in xml.encode_utf16() {
569 encoded.extend_from_slice(&u.to_le_bytes());
570 }
571 fs::write(&xml_path, encoded).map_err(|e| e.to_string())?;
572 write_env_file(&dir.join(format!("{name}.env")), &spec.env)?;
573 let _ = Command::new("schtasks")
574 .args(["/Delete", "/TN", &name, "/F"])
575 .status();
576 let status = Command::new("schtasks")
577 .args([
578 "/Create",
579 "/TN",
580 name,
581 "/XML",
582 &xml_path.to_string_lossy(),
583 "/RU",
584 "SYSTEM",
585 "/F",
586 ])
587 .output()
588 .map_err(|e| e.to_string())?;
589 if !status.status.success() {
590 return Err(format!(
591 "--system install failed (need Administrator): {}",
592 String::from_utf8_lossy(&status.stderr)
593 ));
594 }
595 let _ = Command::new("schtasks")
596 .args(["/Run", "/TN", name])
597 .status();
598 save_unit_name(true, name)?;
599 Ok(format!("installed SYSTEM task {name}"))
600}
601
602#[cfg(windows)]
603fn uninstall_windows(system: bool, name: &str) -> Result<String, String> {
604 let _ = Command::new("schtasks")
605 .args(["/End", "/TN", name])
606 .status();
607 let st = Command::new("schtasks")
608 .args(["/Delete", "/TN", name, "/F"])
609 .output()
610 .map_err(|e| e.to_string())?;
611 clear_unit_name(system);
612 if st.status.success() {
613 Ok(format!("removed task {name}"))
614 } else {
615 Err(format!(
616 "schtasks delete failed: {}",
617 String::from_utf8_lossy(&st.stderr)
618 ))
619 }
620}
621
622#[cfg(windows)]
623fn status_windows(system: bool, name: &str) -> Result<ServiceStatus, String> {
624 let out = Command::new("schtasks")
625 .args(["/Query", "/TN", name, "/V", "/FO", "LIST"])
626 .output()
627 .map_err(|e| e.to_string())?;
628 let text = String::from_utf8_lossy(&out.stdout);
629 let installed = out.status.success();
630 let running = text.to_ascii_lowercase().contains("running");
631 Ok(ServiceStatus {
632 installed,
633 running,
634 scope: if system { "system" } else { "user" },
635 unit_name: name.to_owned(),
636 detail: text.lines().take(8).collect::<Vec<_>>().join(" | "),
637 })
638}
639
640#[cfg(windows)]
641fn restart_windows(system: bool, name: &str) -> Result<String, String> {
642 let _ = system;
643 let _ = Command::new("schtasks")
644 .args(["/End", "/TN", name])
645 .status();
646 std::thread::sleep(Duration::from_secs(2));
647 run_ok(&mut Command::new("schtasks").args(["/Run", "/TN", name]))?;
648 Ok(format!(
649 "restarted {name}; Windows locks a running exe — stop before cargo install, then service-restart"
650 ))
651}
652
653#[cfg(windows)]
654fn write_env_file(path: &Path, env: &[(String, String)]) -> Result<(), String> {
655 let mut f = File::create(path).map_err(|e| e.to_string())?;
656 for (k, v) in env {
657 writeln!(f, "{k}={v}").map_err(|e| e.to_string())?;
658 }
659 Ok(())
660}
661
662fn run_ok(cmd: &mut Command) -> Result<(), String> {
663 hide_console(cmd);
664 let out = cmd.output().map_err(|e| e.to_string())?;
665 if out.status.success() {
666 Ok(())
667 } else {
668 Err(format!(
669 "{:?} failed: {} {}",
670 cmd,
671 String::from_utf8_lossy(&out.stdout),
672 String::from_utf8_lossy(&out.stderr)
673 ))
674 }
675}
676
677pub struct InstanceLock {
681 _file: File,
682 path: PathBuf,
683}
684
685pub fn acquire_instance_lock(enroll_state: &Path) -> Result<InstanceLock, String> {
686 fs::create_dir_all(enroll_state).map_err(|e| e.to_string())?;
687 let path = enroll_state.join("node.instance.lock");
688 #[cfg(windows)]
689 let file = {
690 use std::os::windows::fs::OpenOptionsExt;
691 OpenOptions::new()
692 .create(true)
693 .write(true)
694 .share_mode(0) .open(&path)
696 .map_err(|e| {
697 format!(
698 "another gate4agent-node already holds {} ({e})",
699 path.display()
700 )
701 })?
702 };
703 #[cfg(not(windows))]
704 let file = OpenOptions::new()
705 .create(true)
706 .write(true)
707 .open(&path)
708 .map_err(|e| format!("open {}: {e}", path.display()))?;
709 #[cfg(unix)]
710 {
711 use std::os::unix::io::AsRawFd;
712 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
713 if rc != 0 {
714 return Err(format!(
715 "another gate4agent-node already holds {}",
716 path.display()
717 ));
718 }
719 }
720 Ok(InstanceLock { _file: file, path })
721}
722
723pub fn install_panic_hook() {
724 let prev = std::panic::take_hook();
725 std::panic::set_hook(Box::new(move |info| {
726 let msg = if let Some(s) = info.payload().downcast_ref::<&str>() {
727 (*s).to_owned()
728 } else if let Some(s) = info.payload().downcast_ref::<String>() {
729 s.clone()
730 } else {
731 "panic".into()
732 };
733 let loc = info
734 .location()
735 .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
736 .unwrap_or_else(|| "unknown".into());
737 let _ = node_log::init_from_env();
738 node_log::error(
739 "process_panic",
740 &[("message", msg), ("location", loc)],
741 );
742 prev(info);
743 }));
744}
745
746pub fn run_supervised(child_args: &[String]) -> ! {
752 let _ = node_log::init_from_env();
753 install_panic_hook();
754 free_console();
757 ignore_console_close_events();
758 let exe = std::env::current_exe().expect("current_exe");
759 let mut n: u64 = 0;
760 let mut backoff = Duration::from_secs(1);
761 loop {
762 n = n.saturating_add(1);
763 let mut cmd = Command::new(&exe);
764 cmd.args(child_args).stdin(Stdio::null());
765 #[cfg(windows)]
766 {
767 cmd.stdout(Stdio::null()).stderr(Stdio::null());
768 hide_console_detached(&mut cmd);
769 }
770 #[cfg(not(windows))]
771 {
772 cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
773 }
774 cmd.env("GATE4AGENT_SUPERVISED_CHILD", "1");
776 node_log::info(
777 "supervised_start",
778 &[("n", n.to_string()), ("args_len", child_args.len().to_string())],
779 );
780 let status = match cmd.status() {
781 Ok(s) => s,
782 Err(e) => {
783 node_log::error(
784 "process_exit",
785 &[
786 ("reason", format!("spawn: {e}")),
787 ("code", "spawn_error".into()),
788 ("supervised_n", n.to_string()),
789 ],
790 );
791 std::thread::sleep(backoff);
792 backoff = (backoff * 2).min(Duration::from_secs(30));
793 continue;
794 }
795 };
796 let code = status
797 .code()
798 .map(|c| c.to_string())
799 .unwrap_or_else(|| "signal".into());
800 node_log::warn(
801 "process_exit",
802 &[
803 ("reason", "child_exit".into()),
804 ("code", code.clone()),
805 ("supervised_n", n.to_string()),
806 ],
807 );
808 node_log::warn(
809 "supervised_restart",
810 &[("n", n.to_string()), ("backoff_secs", backoff.as_secs().to_string())],
811 );
812 std::thread::sleep(backoff);
813 backoff = (backoff * 2).min(Duration::from_secs(30));
814 if status.success() {
815 backoff = Duration::from_secs(1);
817 }
818 }
819}
820
821pub fn dispatch(raw_args: &[String]) -> Result<bool, String> {
824 let Some(cmd) = raw_args.first().map(String::as_str) else {
825 return Ok(false);
826 };
827 match cmd {
828 "install" => {
829 let (system, rest) = split_system_and_args(&raw_args[1..]);
830 let node_args = strip_leading_dd(&rest);
832 if node_args.is_empty() {
833 return Err("install requires node arguments after optional --system (and optional --)".into());
834 }
835 let spec = ServiceSpec::from_install_args(system, &node_args)?;
836 let msg = install(&spec)?;
837 println!("{msg}");
838 Ok(true)
839 }
840 "uninstall" => {
841 let (system, _) = split_system_and_args(&raw_args[1..]);
842 println!("{}", uninstall(system)?);
843 Ok(true)
844 }
845 "service-status" => {
846 let (system, _) = split_system_and_args(&raw_args[1..]);
847 let st = status(system)?;
848 println!(
849 "scope={} unit={} installed={} running={} detail={}",
850 st.scope, st.unit_name, st.installed, st.running, st.detail
851 );
852 Ok(true)
853 }
854 "service-restart" => {
855 let (system, _) = split_system_and_args(&raw_args[1..]);
856 println!("{}", restart(system)?);
857 Ok(true)
858 }
859 "run-supervised" => {
860 let rest = strip_leading_dd(&raw_args[1..]);
861 if rest.is_empty() {
862 return Err("run-supervised requires `--` and node arguments".into());
863 }
864 run_supervised(&rest);
865 }
866 _ => Ok(false),
867 }
868}
869
870fn split_system_and_args(args: &[String]) -> (bool, Vec<String>) {
871 let mut system = false;
872 let mut rest = Vec::new();
873 for a in args {
874 if a == "--system" {
875 system = true;
876 } else {
877 rest.push(a.clone());
878 }
879 }
880 (system, rest)
881}
882
883fn strip_leading_dd(args: &[String]) -> Vec<String> {
884 match args.first().map(String::as_str) {
885 Some("--") => args[1..].to_vec(),
886 _ => args.to_vec(),
887 }
888}
889
890#[cfg(test)]
891mod spec_tests {
892 use super::*;
893
894 #[test]
895 fn unit_name_stable_for_enroll_path() {
896 let spec = ServiceSpec {
897 system: false,
898 exe: PathBuf::from("/bin/gate4agent-node"),
899 args: vec![
900 "--enrolled".into(),
901 "--enroll-state".into(),
902 "/tmp/enroll-a".into(),
903 ],
904 env: vec![],
905 enroll_state: Some(PathBuf::from("/tmp/enroll-a")),
906 };
907 let n = spec.unit_name();
908 assert!(n.starts_with("gate4agent-node-"));
909 assert_eq!(n, spec.unit_name());
910 assert!(!n.contains('/'));
911 }
912
913 #[test]
914 fn extract_enroll_state_from_args() {
915 let args = vec![
916 "--enrolled".into(),
917 "--enroll-state".into(),
918 "/var/lib/x".into(),
919 ];
920 assert_eq!(
921 extract_enroll_state(&args),
922 Some(PathBuf::from("/var/lib/x"))
923 );
924 }
925}