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::{
16 current_user_identity, free_console, hide_console, ignore_console_close_events,
17 relaunch_elevated, set_creation_flags, CREATE_NO_WINDOW,
18};
19
20pub use render::{
21 render_launchd_plist, render_schtasks_xml, render_systemd_unit, LaunchPlist, SchtasksXml,
22 SystemdUnit,
23};
24
25const ENV_PREFIXES: &[&str] = &["GATE4AGENT_", "HATCHERY_", "RUST_LOG"];
27
28#[derive(Clone, Debug)]
29pub struct ServiceSpec {
30 pub system: bool,
31 pub exe: PathBuf,
32 pub args: Vec<String>,
33 pub env: Vec<(String, String)>,
34 pub enroll_state: Option<PathBuf>,
35}
36
37#[derive(Clone, Debug)]
38pub struct ServiceStatus {
39 pub installed: bool,
40 pub running: bool,
41 pub scope: &'static str,
42 pub unit_name: String,
43 pub detail: String,
44 pub node_health: String,
46}
47
48impl ServiceSpec {
49 pub fn from_install_args(system: bool, node_args: &[String]) -> Result<Self, String> {
50 let exe = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?;
51 let enroll_state = extract_enroll_state(node_args);
52 let env = capture_env();
53 Ok(Self {
54 system,
55 exe,
56 args: node_args.to_vec(),
57 env,
58 enroll_state,
59 })
60 }
61
62 pub fn unit_name(&self) -> String {
63 match &self.enroll_state {
64 Some(p) => {
65 let key = p.to_string_lossy();
66 let digest = short_digest(&key);
67 format!("gate4agent-node-{digest}")
68 }
69 None => "gate4agent-node".to_owned(),
70 }
71 }
72
73 pub fn launchd_label(&self) -> String {
74 format!("com.gate4agent.node.{}", self.unit_name())
75 }
76}
77
78fn extract_enroll_state(args: &[String]) -> Option<PathBuf> {
79 args.windows(2)
80 .find(|w| w[0] == "--enroll-state")
81 .map(|w| PathBuf::from(&w[1]))
82}
83
84fn capture_env() -> Vec<(String, String)> {
85 let mut out = BTreeMap::new();
86 for (k, v) in std::env::vars() {
87 if ENV_PREFIXES.iter().any(|p| k.starts_with(p)) || k == "RUST_LOG" {
88 out.insert(k, v);
89 }
90 }
91 out.into_iter().collect()
92}
93
94fn windows_task_exe(exe: &Path) -> PathBuf {
95 #[cfg(windows)]
96 {
97 let w = exe.with_file_name("gate4agent-nodew.exe");
98 if w.is_file() {
99 return w;
100 }
101 }
102 let _ = exe;
103 exe.to_path_buf()
104}
105
106fn short_digest(s: &str) -> String {
107 use sha2::{Digest, Sha256};
108 let h = Sha256::digest(s.as_bytes());
109 hex::encode(&h[..6])
110}
111
112fn home_dir() -> Result<PathBuf, String> {
113 std::env::var_os("HOME")
114 .or_else(|| std::env::var_os("USERPROFILE"))
115 .map(PathBuf::from)
116 .ok_or_else(|| "HOME/USERPROFILE is unset".to_owned())
117}
118
119pub fn install(spec: &ServiceSpec) -> Result<String, String> {
121 require_admin_if_system(spec.system)?;
122 #[cfg(target_os = "linux")]
123 {
124 return install_linux(spec);
125 }
126 #[cfg(target_os = "macos")]
127 {
128 return install_macos(spec);
129 }
130 #[cfg(windows)]
131 {
132 return install_windows(spec);
133 }
134 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
135 {
136 let _ = spec;
137 Err("service install is unsupported on this OS".into())
138 }
139}
140
141pub fn uninstall(system: bool) -> Result<String, String> {
142 require_admin_if_system(system)?;
143 let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
145 #[cfg(target_os = "linux")]
146 {
147 return uninstall_linux(system, &name);
148 }
149 #[cfg(target_os = "macos")]
150 {
151 return uninstall_macos(system, &name);
152 }
153 #[cfg(windows)]
154 {
155 return uninstall_windows(system, &name);
156 }
157 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
158 {
159 let _ = (system, name);
160 Err("service uninstall is unsupported on this OS".into())
161 }
162}
163
164pub fn status(system: bool) -> Result<ServiceStatus, String> {
165 let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
166 #[cfg(target_os = "linux")]
167 {
168 return status_linux(system, &name);
169 }
170 #[cfg(target_os = "macos")]
171 {
172 return status_macos(system, &name);
173 }
174 #[cfg(windows)]
175 {
176 return status_windows(system, &name);
177 }
178 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
179 {
180 let _ = (system, name);
181 Err("service-status is unsupported on this OS".into())
182 }
183}
184
185pub fn restart(system: bool) -> Result<String, String> {
186 require_admin_if_system(system)?;
187 let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
188 #[cfg(target_os = "linux")]
189 {
190 return restart_linux(system, &name);
191 }
192 #[cfg(target_os = "macos")]
193 {
194 return restart_macos(system, &name);
195 }
196 #[cfg(windows)]
197 {
198 return restart_windows(system, &name);
199 }
200 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
201 {
202 let _ = (system, name);
203 Err("service-restart is unsupported on this OS".into())
204 }
205}
206
207fn require_admin_if_system(system: bool) -> Result<(), String> {
208 if !system {
209 return Ok(());
210 }
211 if is_elevated() {
212 Ok(())
213 } else {
214 Err("--system requires administrator / root privileges".into())
215 }
216}
217
218#[cfg(windows)]
219fn is_elevated_windows() -> bool {
220 use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
221 use windows_sys::Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY};
222 use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
223 unsafe {
224 let mut token: HANDLE = std::ptr::null_mut();
225 if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
226 return false;
227 }
228 let mut elev = TOKEN_ELEVATION { TokenIsElevated: 0 };
229 let mut needed = 0u32;
230 let ok = GetTokenInformation(
231 token,
232 TokenElevation,
233 &mut elev as *mut _ as *mut _,
234 std::mem::size_of::<TOKEN_ELEVATION>() as u32,
235 &mut needed,
236 );
237 CloseHandle(token);
238 ok != 0 && elev.TokenIsElevated != 0
239 }
240}
241
242fn is_elevated() -> bool {
243 #[cfg(unix)]
244 {
245 unsafe { libc::geteuid() == 0 }
246 }
247 #[cfg(windows)]
248 {
249 is_elevated_windows()
250 }
251 #[cfg(all(not(unix), not(windows)))]
252 {
253 false
254 }
255}
256
257fn marker_path(system: bool) -> Result<PathBuf, String> {
258 let base = if system {
259 #[cfg(unix)]
260 {
261 PathBuf::from("/var/lib/gate4agent-node")
262 }
263 #[cfg(not(unix))]
264 {
265 std::env::var_os("ProgramData")
266 .map(PathBuf::from)
267 .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
268 .join("gate4agent-node")
269 }
270 } else {
271 home_dir()?.join(".local").join("state").join("gate4agent-node")
272 };
273 Ok(base.join("service-unit-name"))
274}
275
276fn save_unit_name(system: bool, name: &str) -> Result<(), String> {
277 let path = marker_path(system)?;
278 if let Some(parent) = path.parent() {
279 fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
280 }
281 fs::write(&path, name).map_err(|e| format!("write {}: {e}", path.display()))
282}
283
284fn saved_unit_name(system: bool) -> Option<String> {
285 let path = marker_path(system).ok()?;
286 fs::read_to_string(path).ok().map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
287}
288
289fn clear_unit_name(system: bool) {
290 if let Ok(path) = marker_path(system) {
291 let _ = fs::remove_file(path);
292 }
293}
294
295#[cfg(target_os = "linux")]
298fn unit_dir(system: bool) -> Result<PathBuf, String> {
299 if system {
300 Ok(PathBuf::from("/etc/systemd/system"))
301 } else {
302 Ok(home_dir()?.join(".config").join("systemd").join("user"))
303 }
304}
305
306#[cfg(target_os = "linux")]
307fn install_linux(spec: &ServiceSpec) -> Result<String, String> {
308 let name = spec.unit_name();
309 let dir = unit_dir(spec.system)?;
310 fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
311 let path = dir.join(format!("{name}.service"));
312 let body = render_systemd_unit(&SystemdUnit {
314 description: "gate4agent-node",
315 exe: &spec.exe.to_string_lossy(),
316 args: &spec.args,
317 env: &spec.env,
318 supervised: false,
319 });
320 fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?;
321 save_unit_name(spec.system, &name)?;
322
323 let mut notes = Vec::new();
324 if !spec.system {
325 match Command::new("loginctl")
326 .args(["enable-linger"])
327 .status()
328 {
329 Ok(s) if s.success() => notes.push("linger enabled".into()),
330 Ok(_) | Err(_) => notes.push(
331 "warning: loginctl enable-linger failed or unavailable; user units may stop at logout"
332 .into(),
333 ),
334 }
335 }
336 match run_ok(&mut systemctl_cmd(spec.system).args(["daemon-reload"]))
337 .and_then(|_| run_ok(&mut systemctl_cmd(spec.system).args(["enable", "--now", &name])))
338 {
339 Ok(()) => notes.push(format!("installed and started {name} ({})", path.display())),
340 Err(e) => notes.push(format!(
341 "wrote {} but systemctl failed ({e}); start later when the user bus is up",
342 path.display()
343 )),
344 }
345 Ok(notes.join("; "))
346}
347
348#[cfg(target_os = "linux")]
349fn uninstall_linux(system: bool, name: &str) -> Result<String, String> {
350 let _ = systemctl_cmd(system).args(["disable", "--now", name]).status();
351 let path = unit_dir(system)?.join(format!("{name}.service"));
352 let _ = fs::remove_file(&path);
353 let _ = systemctl_cmd(system).args(["daemon-reload"]).status();
354 clear_unit_name(system);
355 Ok(format!("removed {name}"))
356}
357
358#[cfg(target_os = "linux")]
359fn status_linux(system: bool, name: &str) -> Result<ServiceStatus, String> {
360 let path = unit_dir(system)?.join(format!("{name}.service"));
361 let installed = path.is_file();
362 let out = systemctl_cmd(system).args(["is-active", name]).output();
363 let (running, detail) = match out {
364 Ok(o) => {
365 let s = String::from_utf8_lossy(&o.stdout).trim().to_owned();
366 (s == "active", s)
367 }
368 Err(e) => (false, e.to_string()),
369 };
370 Ok(ServiceStatus {
371 installed,
372 running,
373 scope: if system { "system" } else { "user" },
374 unit_name: name.to_owned(),
375 detail,
376 node_health: probe_node_health(),
377 })
378}
379
380#[cfg(target_os = "linux")]
381fn restart_linux(system: bool, name: &str) -> Result<String, String> {
382 run_ok(&mut systemctl_cmd(system).args(["restart", name]))?;
383 Ok(format!("restarted {name}"))
384}
385
386#[cfg(target_os = "linux")]
387fn systemctl_cmd(system: bool) -> Command {
388 let mut c = Command::new("systemctl");
389 if !system {
390 c.arg("--user");
391 }
392 c
393}
394
395#[cfg(target_os = "macos")]
398fn plist_path(system: bool, label: &str) -> Result<PathBuf, String> {
399 if system {
400 Ok(PathBuf::from("/Library/LaunchDaemons").join(format!("{label}.plist")))
401 } else {
402 Ok(home_dir()?
403 .join("Library")
404 .join("LaunchAgents")
405 .join(format!("{label}.plist")))
406 }
407}
408
409#[cfg(target_os = "macos")]
410fn install_macos(spec: &ServiceSpec) -> Result<String, String> {
411 let label = spec.launchd_label();
412 let path = plist_path(spec.system, &label)?;
413 if let Some(parent) = path.parent() {
414 fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
415 }
416 let body = render_launchd_plist(&LaunchPlist {
417 label: &label,
418 exe: &spec.exe.to_string_lossy(),
419 args: &spec.args,
420 env: &spec.env,
421 supervised: false, });
423 fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?;
424 save_unit_name(spec.system, &spec.unit_name())?;
425 let domain = if spec.system {
426 "system".to_owned()
427 } else {
428 format!("gui/{}", unsafe { libc::getuid() })
429 };
430 let _ = Command::new("launchctl")
431 .args(["bootout", &domain, &path.to_string_lossy()])
432 .status();
433 run_ok(
434 &mut Command::new("launchctl").args(["bootstrap", &domain, &path.to_string_lossy()]),
435 )?;
436 let _ = Command::new("launchctl")
437 .args(["enable", &format!("{domain}/{label}")])
438 .status();
439 Ok(format!("installed {label} ({})", path.display()))
440}
441
442#[cfg(target_os = "macos")]
443fn uninstall_macos(system: bool, name: &str) -> Result<String, String> {
444 let label = format!("com.gate4agent.node.{name}");
445 let path = plist_path(system, &label)?;
446 let domain = if system {
447 "system".to_owned()
448 } else {
449 format!("gui/{}", unsafe { libc::getuid() })
450 };
451 let _ = Command::new("launchctl")
452 .args(["bootout", &domain, &path.to_string_lossy()])
453 .status();
454 let _ = fs::remove_file(&path);
455 clear_unit_name(system);
456 Ok(format!("removed {label}"))
457}
458
459#[cfg(target_os = "macos")]
460fn status_macos(system: bool, name: &str) -> Result<ServiceStatus, String> {
461 let label = format!("com.gate4agent.node.{name}");
462 let path = plist_path(system, &label)?;
463 let installed = path.is_file();
464 let out = Command::new("launchctl")
465 .args(["print", &format!("system/{label}")])
466 .output();
467 let running = match out {
469 Ok(o) if o.status.success() => true,
470 _ => Command::new("launchctl")
471 .args(["list", &label])
472 .status()
473 .map(|s| s.success())
474 .unwrap_or(false),
475 };
476 Ok(ServiceStatus {
477 installed,
478 running,
479 scope: if system { "system" } else { "user" },
480 unit_name: name.to_owned(),
481 detail: label,
482 node_health: probe_node_health(),
483 })
484}
485
486#[cfg(target_os = "macos")]
487fn restart_macos(system: bool, name: &str) -> Result<String, String> {
488 let label = format!("com.gate4agent.node.{name}");
489 let path = plist_path(system, &label)?;
490 let domain = if system {
491 "system".to_owned()
492 } else {
493 format!("gui/{}", unsafe { libc::getuid() })
494 };
495 let _ = Command::new("launchctl")
496 .args(["kickstart", "-k", &format!("{domain}/{label}")])
497 .status();
498 let _ = path;
499 Ok(format!("restarted {label}"))
500}
501
502#[cfg(windows)]
505fn install_windows(spec: &ServiceSpec) -> Result<String, String> {
506 let name = spec.unit_name();
507 let (user_sid, user_logon) = current_user_identity()
508 .map_err(|e| format!("cannot resolve current user SID for Task Scheduler: {e}"))?;
509 if spec.system {
510 return install_windows_system(spec, &name);
515 }
516 let dir = home_dir()?
517 .join("AppData")
518 .join("Local")
519 .join("gate4agent-node");
520 fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
521 let xml_path = dir.join(format!("{name}.xml"));
522 let cwd = home_dir()?;
523 let task_exe = windows_task_exe(&spec.exe);
524 let use_nodew = task_exe
525 .file_name()
526 .and_then(|s| s.to_str())
527 == Some("gate4agent-nodew.exe");
528 let xml = render_schtasks_xml(&SchtasksXml {
529 task_name: &name,
530 exe: &task_exe.to_string_lossy(),
531 args: &spec.args,
532 working_dir: &cwd.to_string_lossy(),
533 author: "gate4agent-node",
534 supervised: !use_nodew,
535 user_sid: &user_sid,
536 user_logon: &user_logon,
537 });
538 let mut encoded: Vec<u8> = vec![0xFF, 0xFE];
540 for u in xml.encode_utf16() {
541 encoded.extend_from_slice(&u.to_le_bytes());
542 }
543 fs::write(&xml_path, encoded).map_err(|e| format!("write {}: {e}", xml_path.display()))?;
544 write_env_file(&dir.join(format!("{name}.env")), &spec.env)?;
547 let _ = Command::new("schtasks")
548 .args(["/Delete", "/TN", &name, "/F"])
549 .status();
550 run_ok(&mut Command::new("schtasks").args([
551 "/Create",
552 "/TN",
553 &name,
554 "/XML",
555 &xml_path.to_string_lossy(),
556 "/F",
557 ]))?;
558 let _ = Command::new("schtasks")
559 .args(["/Run", "/TN", &name])
560 .status();
561 save_unit_name(false, &name)?;
562 Ok(format!(
563 "installed scheduled task {name}; upgrade: gate4agent-node service-restart then cargo install then service-restart"
564 ))
565}
566
567#[cfg(windows)]
568fn install_windows_system(spec: &ServiceSpec, name: &str) -> Result<String, String> {
569 let (user_sid, user_logon) = current_user_identity()
570 .map_err(|e| format!("cannot resolve current user SID: {e}"))?;
571 let dir = PathBuf::from(
574 std::env::var_os("ProgramData")
575 .map(PathBuf::from)
576 .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")),
577 )
578 .join("gate4agent-node");
579 fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
580 let xml_path = dir.join(format!("{name}.xml"));
581 let task_exe = windows_task_exe(&spec.exe);
582 let use_nodew = task_exe
583 .file_name()
584 .and_then(|s| s.to_str())
585 == Some("gate4agent-nodew.exe");
586 let mut xml = render_schtasks_xml(&SchtasksXml {
587 task_name: name,
588 exe: &task_exe.to_string_lossy(),
589 args: &spec.args,
590 working_dir: &dir.to_string_lossy(),
591 author: "gate4agent-node",
592 supervised: !use_nodew,
593 user_sid: &user_sid,
594 user_logon: &user_logon,
595 });
596 xml = xml.replace(
597 "<RunLevel>LeastPrivilege</RunLevel>",
598 "<RunLevel>HighestAvailable</RunLevel>",
599 );
600 xml = xml.replace(
601 "<LogonType>InteractiveToken</LogonType>",
602 "<LogonType>S4U</LogonType>",
603 );
604 let mut encoded: Vec<u8> = vec![0xFF, 0xFE];
605 for u in xml.encode_utf16() {
606 encoded.extend_from_slice(&u.to_le_bytes());
607 }
608 fs::write(&xml_path, encoded).map_err(|e| e.to_string())?;
609 write_env_file(&dir.join(format!("{name}.env")), &spec.env)?;
610 let _ = Command::new("schtasks")
611 .args(["/Delete", "/TN", &name, "/F"])
612 .status();
613 let status = Command::new("schtasks")
614 .args([
615 "/Create",
616 "/TN",
617 name,
618 "/XML",
619 &xml_path.to_string_lossy(),
620 "/RU",
621 "SYSTEM",
622 "/F",
623 ])
624 .output()
625 .map_err(|e| e.to_string())?;
626 if !status.status.success() {
627 return Err(format!(
628 "--system install failed (need Administrator): {}",
629 String::from_utf8_lossy(&status.stderr)
630 ));
631 }
632 let _ = Command::new("schtasks")
633 .args(["/Run", "/TN", name])
634 .status();
635 save_unit_name(true, name)?;
636 Ok(format!("installed SYSTEM task {name}"))
637}
638
639#[cfg(windows)]
640fn uninstall_windows(system: bool, name: &str) -> Result<String, String> {
641 let _ = Command::new("schtasks")
642 .args(["/End", "/TN", name])
643 .status();
644 let st = Command::new("schtasks")
645 .args(["/Delete", "/TN", name, "/F"])
646 .output()
647 .map_err(|e| e.to_string())?;
648 clear_unit_name(system);
649 if st.status.success() {
650 Ok(format!("removed task {name}"))
651 } else {
652 Err(format!(
653 "schtasks delete failed: {}",
654 String::from_utf8_lossy(&st.stderr)
655 ))
656 }
657}
658
659#[cfg(windows)]
660fn status_windows(system: bool, name: &str) -> Result<ServiceStatus, String> {
661 let out = Command::new("schtasks")
662 .args(["/Query", "/TN", name, "/V", "/FO", "LIST"])
663 .output()
664 .map_err(|e| e.to_string())?;
665 let text = String::from_utf8_lossy(&out.stdout);
666 let installed = out.status.success();
667 let running = text.to_ascii_lowercase().contains("running");
668 Ok(ServiceStatus {
669 installed,
670 running,
671 scope: if system { "system" } else { "user" },
672 unit_name: name.to_owned(),
673 detail: text.lines().take(8).collect::<Vec<_>>().join(" | "),
674 node_health: probe_node_health(),
675 })
676}
677
678#[cfg(windows)]
679fn restart_windows(system: bool, name: &str) -> Result<String, String> {
680 let _ = system;
681 let _ = Command::new("schtasks")
682 .args(["/End", "/TN", name])
683 .status();
684 std::thread::sleep(Duration::from_secs(2));
685 run_ok(&mut Command::new("schtasks").args(["/Run", "/TN", name]))?;
686 Ok(format!(
687 "restarted {name}; Windows locks a running exe — stop before cargo install, then service-restart"
688 ))
689}
690
691#[cfg(windows)]
692fn write_env_file(path: &Path, env: &[(String, String)]) -> Result<(), String> {
693 let mut f = File::create(path).map_err(|e| e.to_string())?;
694 for (k, v) in env {
695 writeln!(f, "{k}={v}").map_err(|e| e.to_string())?;
696 }
697 Ok(())
698}
699
700fn probe_node_health() -> String {
701 let addr = std::env::var("GATE4AGENT_HEALTH_URL")
702 .or_else(|_| {
703 std::env::var("GATE4AGENT_API_LISTEN").map(|a| format!("http://{a}/health"))
704 })
705 .unwrap_or_else(|_| "http://127.0.0.1:18330/health".into());
706 let hostport = addr
707 .trim_start_matches("http://")
708 .trim_start_matches("https://")
709 .split('/')
710 .next()
711 .unwrap_or("127.0.0.1:18330");
712 use std::io::{Read, Write};
713 use std::net::ToSocketAddrs;
714 let sock = match hostport.to_socket_addrs() {
715 Ok(mut i) => match i.next() {
716 Some(a) => a,
717 None => return "bad_addr".into(),
718 },
719 Err(_) => return "bad_addr".into(),
720 };
721 let mut stream = match std::net::TcpStream::connect_timeout(&sock, Duration::from_secs(2)) {
722 Ok(s) => s,
723 Err(_) => return "down".into(),
724 };
725 let req = format!("GET /health HTTP/1.1
726Host: {hostport}
727Connection: close
728
729");
730 if stream.write_all(req.as_bytes()).is_err() {
731 return "unreachable".into();
732 }
733 let mut buf = vec![0u8; 4096];
734 let n = stream.read(&mut buf).unwrap_or(0);
735 let body = String::from_utf8_lossy(&buf[..n]);
736 if body.contains("\"ok\":true") || body.contains("\"ok\": true") {
737 "ok".into()
738 } else if n == 0 {
739 "no_response".into()
740 } else {
741 "responded_not_ok".into()
742 }
743}
744
745fn run_ok(cmd: &mut Command) -> Result<(), String> {
746 hide_console(cmd);
747 let out = cmd.output().map_err(|e| e.to_string())?;
748 if out.status.success() {
749 Ok(())
750 } else {
751 let stdout = String::from_utf8_lossy(&out.stdout);
752 let stderr = String::from_utf8_lossy(&out.stderr);
753 let combined = format!("{stdout} {stderr}").to_ascii_lowercase();
754 let hint = if combined.contains("access is denied") || combined.contains("access denied") {
755 " (Access denied: for a user-level task the XML must include your UserId/SID; try reinstalling this build, or run from an elevated prompt for --system)"
756 } else {
757 ""
758 };
759 Err(format!(
760 "command failed{hint}: stdout={stdout} stderr={stderr}"
761 ))
762 }
763}
764
765pub struct InstanceLock {
769 _file: File,
770 path: PathBuf,
771}
772
773pub fn acquire_instance_lock(enroll_state: &Path) -> Result<InstanceLock, String> {
774 fs::create_dir_all(enroll_state).map_err(|e| e.to_string())?;
775 let path = enroll_state.join("node.instance.lock");
776 #[cfg(windows)]
777 let file = {
778 use std::os::windows::fs::OpenOptionsExt;
779 OpenOptions::new()
780 .create(true)
781 .write(true)
782 .share_mode(0) .open(&path)
784 .map_err(|e| {
785 format!(
786 "another gate4agent-node already holds {} ({e})",
787 path.display()
788 )
789 })?
790 };
791 #[cfg(not(windows))]
792 let file = OpenOptions::new()
793 .create(true)
794 .write(true)
795 .open(&path)
796 .map_err(|e| format!("open {}: {e}", path.display()))?;
797 #[cfg(unix)]
798 {
799 use std::os::unix::io::AsRawFd;
800 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
801 if rc != 0 {
802 return Err(format!(
803 "another gate4agent-node already holds {}",
804 path.display()
805 ));
806 }
807 }
808 Ok(InstanceLock { _file: file, path })
809}
810
811pub fn install_panic_hook() {
812 let prev = std::panic::take_hook();
813 std::panic::set_hook(Box::new(move |info| {
814 let msg = if let Some(s) = info.payload().downcast_ref::<&str>() {
815 (*s).to_owned()
816 } else if let Some(s) = info.payload().downcast_ref::<String>() {
817 s.clone()
818 } else {
819 "panic".into()
820 };
821 let loc = info
822 .location()
823 .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
824 .unwrap_or_else(|| "unknown".into());
825 let _ = node_log::init_from_env();
826 node_log::error(
827 "process_panic",
828 &[("message", msg), ("location", loc)],
829 );
830 prev(info);
831 }));
832}
833
834pub fn run_supervised(child_args: &[String]) -> ! {
840 apply_sidecar_env(child_args);
841 ensure_node_log_from_args(child_args);
842 let _ = node_log::init_from_env();
843 install_panic_hook();
844 free_console();
845 ignore_console_close_events();
846 let exe = match std::env::current_exe() {
847 Ok(p) => p,
848 Err(e) => {
849 node_log::error("process_exit", &[("reason", format!("current_exe: {e}"))]);
850 std::process::exit(1);
851 }
852 };
853 let log_dir = child_log_dir(child_args);
854 let mut n: u64 = 0;
855 let mut backoff = Duration::from_secs(1);
856 loop {
857 n = n.saturating_add(1);
858 let stdout_path = log_dir.join("node.stdout.log");
859 let stderr_path = log_dir.join("node.stderr.log");
860 rotate_log_file(&stdout_path, 2_000_000);
861 rotate_log_file(&stderr_path, 2_000_000);
862 let mut cmd = Command::new(&exe);
863 cmd.args(child_args).stdin(Stdio::null());
864 match (OpenOptions::new().create(true).append(true).open(&stdout_path),
865 OpenOptions::new().create(true).append(true).open(&stderr_path))
866 {
867 (Ok(out), Ok(err)) => {
868 cmd.stdout(Stdio::from(out)).stderr(Stdio::from(err));
869 }
870 _ => {
871 #[cfg(windows)]
873 {
874 cmd.stdout(Stdio::null()).stderr(Stdio::null());
875 }
876 #[cfg(not(windows))]
877 {
878 cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
879 }
880 }
881 }
882 #[cfg(windows)]
883 {
884 set_creation_flags(&mut cmd, CREATE_NO_WINDOW);
887 }
888 cmd.env("GATE4AGENT_SUPERVISED_CHILD", "1");
889 for (k, v) in load_sidecar_env(child_args) {
891 cmd.env(k, v);
892 }
893 node_log::info(
894 "supervised_start",
895 &[
896 ("n", n.to_string()),
897 ("args_len", child_args.len().to_string()),
898 ("stderr_log", stderr_path.display().to_string()),
899 ],
900 );
901 let status = match cmd.status() {
902 Ok(s) => s,
903 Err(e) => {
904 node_log::error(
905 "process_exit",
906 &[
907 ("reason", format!("spawn: {e}")),
908 ("code", "spawn_error".into()),
909 ("supervised_n", n.to_string()),
910 ],
911 );
912 node_log::warn(
913 "supervised_restart",
914 &[("n", n.to_string()), ("backoff_secs", backoff.as_secs().to_string())],
915 );
916 std::thread::sleep(backoff);
917 backoff = (backoff * 2).min(Duration::from_secs(30));
918 continue;
919 }
920 };
921 let code = status
922 .code()
923 .map(|c| c.to_string())
924 .unwrap_or_else(|| "signal".into());
925 let stderr_tail = read_tail(&stderr_path, 4_000);
926 node_log::warn(
927 "process_exit",
928 &[
929 ("reason", "child_exit".into()),
930 ("code", code.clone()),
931 ("supervised_n", n.to_string()),
932 ("stderr_tail", stderr_tail),
933 ],
934 );
935 node_log::warn(
936 "supervised_restart",
937 &[("n", n.to_string()), ("backoff_secs", backoff.as_secs().to_string())],
938 );
939 std::thread::sleep(backoff);
940 backoff = (backoff * 2).min(Duration::from_secs(30));
941 if status.success() {
942 backoff = Duration::from_secs(1);
943 }
944 }
945}
946
947fn child_log_dir(child_args: &[String]) -> PathBuf {
948 if let Some(p) = extract_enroll_state(child_args) {
949 let _ = fs::create_dir_all(&p);
950 return p;
951 }
952 std::env::temp_dir().join("gate4agent-node-supervised")
953}
954
955fn ensure_node_log_from_args(child_args: &[String]) {
956 if std::env::var_os(crate::node_log::env_name()).is_some() {
957 return;
958 }
959 if let Some(dir) = extract_enroll_state(child_args) {
960 let path = crate::node_log::path_beside_state(&dir);
961 std::env::set_var(crate::node_log::env_name(), &path);
963 }
964}
965
966fn sidecar_env_path(child_args: &[String]) -> Option<PathBuf> {
967 let name = match extract_enroll_state(child_args) {
968 Some(p) => {
969 let key = p.to_string_lossy();
970 format!("gate4agent-node-{}", short_digest(&key))
971 }
972 None => "gate4agent-node".into(),
973 };
974 #[cfg(windows)]
975 {
976 let base = home_dir().ok()?.join("AppData").join("Local").join("gate4agent-node");
977 return Some(base.join(format!("{name}.env")));
978 }
979 #[cfg(not(windows))]
980 {
981 let base = home_dir().ok()?.join(".local").join("state").join("gate4agent-node");
982 Some(base.join(format!("{name}.env")))
983 }
984}
985
986fn load_sidecar_env(child_args: &[String]) -> Vec<(String, String)> {
987 let Some(path) = sidecar_env_path(child_args) else {
988 return Vec::new();
989 };
990 let Ok(text) = fs::read_to_string(&path) else {
991 return Vec::new();
992 };
993 let mut out = Vec::new();
994 for line in text.lines() {
995 let line = line.trim();
996 if line.is_empty() || line.starts_with('#') {
997 continue;
998 }
999 if let Some((k, v)) = line.split_once('=') {
1000 out.push((k.to_owned(), v.to_owned()));
1001 }
1002 }
1003 out
1004}
1005
1006fn apply_sidecar_env(child_args: &[String]) {
1007 for (k, v) in load_sidecar_env(child_args) {
1008 if std::env::var_os(&k).is_none() {
1009 std::env::set_var(&k, &v);
1010 }
1011 }
1012}
1013
1014fn rotate_log_file(path: &Path, max_bytes: u64) {
1015 let Ok(meta) = fs::metadata(path) else {
1016 return;
1017 };
1018 if meta.len() < max_bytes {
1019 return;
1020 }
1021 let bak = path.with_extension("log.1");
1022 let _ = fs::remove_file(&bak);
1023 let _ = fs::rename(path, bak);
1024}
1025
1026fn read_tail(path: &Path, max_chars: usize) -> String {
1027 let Ok(data) = fs::read(path) else {
1028 return String::new();
1029 };
1030 let text = String::from_utf8_lossy(&data);
1031 let t = text.trim();
1032 if t.len() <= max_chars {
1033 return t.to_owned();
1034 }
1035 t[t.len() - max_chars..].to_owned()
1036}
1037
1038pub fn dispatch(raw_args: &[String]) -> Result<bool, String> {
1041 let Some(cmd) = raw_args.first().map(String::as_str) else {
1042 return Ok(false);
1043 };
1044 match cmd {
1045 "install" => {
1046 let (system, rest) = split_system_and_args(&raw_args[1..]);
1047 let node_args = strip_leading_dd(&rest);
1048 if node_args.is_empty() {
1049 return Err("install requires node arguments after optional --system (and optional --)".into());
1050 }
1051 #[cfg(windows)]
1052 if system && !is_elevated_windows() {
1053 let mut elevated_args = vec!["install".into(), "--system".into(), "--".into()];
1054 elevated_args.extend(node_args);
1055 let code = relaunch_elevated(&elevated_args)?;
1056 if code != 0 {
1057 return Err(format!("elevated install exited with code {code}"));
1058 }
1059 println!("elevated --system install finished");
1060 return Ok(true);
1061 }
1062 let spec = ServiceSpec::from_install_args(system, &node_args)?;
1063 let msg = install(&spec)?;
1064 println!("{msg}");
1065 Ok(true)
1066 }
1067 "uninstall" => {
1068 let (system, _) = split_system_and_args(&raw_args[1..]);
1069 println!("{}", uninstall(system)?);
1070 Ok(true)
1071 }
1072 "service-status" => {
1073 let (system, _) = split_system_and_args(&raw_args[1..]);
1074 let st = status(system)?;
1075 println!(
1076 "scope={} unit={} installed={} running={} node_health={} detail={}",
1077 st.scope, st.unit_name, st.installed, st.running, st.node_health, st.detail
1078 );
1079 Ok(true)
1080 }
1081 "service-restart" => {
1082 let (system, _) = split_system_and_args(&raw_args[1..]);
1083 println!("{}", restart(system)?);
1084 Ok(true)
1085 }
1086 "run-supervised" => {
1087 let rest = strip_leading_dd(&raw_args[1..]);
1088 if rest.is_empty() {
1089 return Err("run-supervised requires `--` and node arguments".into());
1090 }
1091 run_supervised(&rest);
1092 }
1093 _ => Ok(false),
1094 }
1095}
1096
1097fn split_system_and_args(args: &[String]) -> (bool, Vec<String>) {
1098 let mut system = false;
1099 let mut rest = Vec::new();
1100 for a in args {
1101 if a == "--system" {
1102 system = true;
1103 } else {
1104 rest.push(a.clone());
1105 }
1106 }
1107 (system, rest)
1108}
1109
1110fn strip_leading_dd(args: &[String]) -> Vec<String> {
1111 match args.first().map(String::as_str) {
1112 Some("--") => args[1..].to_vec(),
1113 _ => args.to_vec(),
1114 }
1115}
1116
1117#[cfg(test)]
1118mod spec_tests {
1119 use super::*;
1120
1121 #[test]
1122 fn unit_name_stable_for_enroll_path() {
1123 let spec = ServiceSpec {
1124 system: false,
1125 exe: PathBuf::from("/bin/gate4agent-node"),
1126 args: vec![
1127 "--enrolled".into(),
1128 "--enroll-state".into(),
1129 "/tmp/enroll-a".into(),
1130 ],
1131 env: vec![],
1132 enroll_state: Some(PathBuf::from("/tmp/enroll-a")),
1133 };
1134 let n = spec.unit_name();
1135 assert!(n.starts_with("gate4agent-node-"));
1136 assert_eq!(n, spec.unit_name());
1137 assert!(!n.contains('/'));
1138 }
1139
1140 #[test]
1141 fn extract_enroll_state_from_args() {
1142 let args = vec![
1143 "--enrolled".into(),
1144 "--enroll-state".into(),
1145 "/var/lib/x".into(),
1146 ];
1147 assert_eq!(
1148 extract_enroll_state(&args),
1149 Some(PathBuf::from("/var/lib/x"))
1150 );
1151 }
1152}