1#[cfg(any(target_os = "macos", target_os = "linux"))]
2use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
3#[cfg(any(target_os = "macos", target_os = "linux"))]
4use std::path::PathBuf;
5#[cfg(any(target_os = "macos", target_os = "linux"))]
6use std::time::{Duration, Instant};
7
8#[cfg(target_os = "macos")]
9const PLIST_LABEL: &str = "com.leanctx.proxy";
10#[cfg(target_os = "linux")]
11const SYSTEMD_SERVICE: &str = "lean-ctx-proxy";
12
13pub(crate) fn recorded_proxy_pid() -> Option<u32> {
18 health_proxy_pid(crate::core::proxy_endpoint::default_port())
19}
20
21#[must_use]
26pub fn health_proxy_pid(port: u16) -> Option<u32> {
27 let response = ureq::get(&format!("http://127.0.0.1:{port}/health"))
28 .config()
29 .timeout_global(Some(std::time::Duration::from_millis(500)))
30 .build()
31 .call()
32 .ok()?;
33 let body = response.into_body().read_to_string().ok()?;
34 proxy_pid_from_health(&body)
35}
36
37fn proxy_pid_from_health(body: &str) -> Option<u32> {
38 let health: serde_json::Value = serde_json::from_str(body).ok()?;
39 if health.get("status").and_then(serde_json::Value::as_str) != Some("ok")
40 || !health_identifies_lean_ctx_proxy(body)
41 {
42 return None;
43 }
44 let pid = u32::try_from(health.get("pid")?.as_u64()?).ok()?;
45 (pid > 0).then_some(pid)
46}
47
48fn health_identifies_lean_ctx_proxy(body: &str) -> bool {
49 serde_json::from_str::<serde_json::Value>(body)
50 .ok()
51 .and_then(|health| health.get("service")?.as_str().map(str::to_owned))
52 .is_some_and(|service| service == "lean-ctx-proxy")
53}
54
55#[must_use]
59pub fn proxy_on_port(port: u16) -> Option<crate::ipc::process::ServiceProcess> {
60 use crate::ipc::process::{RecordedPid, ServiceKind, ServiceSweep, find_service_processes};
61 let pid = health_proxy_pid(port)?;
62 find_service_processes(
63 &[RecordedPid::bare(pid)],
64 ServiceSweep::Only(ServiceKind::Proxy),
65 )
66 .into_iter()
67 .next()
68}
69
70#[cfg(any(target_os = "macos", target_os = "linux"))]
71fn port_is_open(port: u16) -> bool {
72 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
73 TcpStream::connect_timeout(&addr, Duration::from_millis(150)).is_ok()
74}
75
76#[cfg(any(target_os = "macos", target_os = "linux"))]
83fn release_proxy_port(port: u16, quiet: bool) -> bool {
84 if !port_is_open(port) {
85 return true;
86 }
87
88 let Some(proxy) = proxy_on_port(port) else {
92 if !quiet {
93 eprintln!(
94 " Refusing managed proxy startup: port {port} is occupied by an unidentified service."
95 );
96 }
97 return false;
98 };
99 let pid = proxy.pid;
100
101 proxy.signal(crate::ipc::process::StopSignal::Terminate);
102 let graceful_deadline = Instant::now() + Duration::from_secs(2);
103 while Instant::now() < graceful_deadline {
104 if !proxy.is_running() && !port_is_open(port) {
105 if !quiet {
106 eprintln!(
107 " Handed port {port} from standalone proxy PID {pid} to managed service."
108 );
109 }
110 return true;
111 }
112 std::thread::sleep(Duration::from_millis(50));
113 }
114
115 if proxy.is_running() {
116 proxy.signal(crate::ipc::process::StopSignal::Kill);
117 }
118 let force_deadline = Instant::now() + Duration::from_secs(1);
119 while Instant::now() < force_deadline {
120 if !port_is_open(port) {
121 if !quiet {
122 eprintln!(
123 " Handed port {port} from standalone proxy PID {pid} to managed service."
124 );
125 }
126 return true;
127 }
128 std::thread::sleep(Duration::from_millis(50));
129 }
130
131 if !quiet {
132 eprintln!(" Refusing managed proxy startup: port {port} was not released by PID {pid}.");
133 }
134 false
135}
136
137pub fn install(port: u16, quiet: bool) -> bool {
138 let binary = find_binary();
139 install_with_binary(&binary, port, quiet)
140}
141
142pub fn install_with_binary(binary: &str, port: u16, quiet: bool) -> bool {
143 if binary.is_empty() {
144 if !quiet {
145 tracing::error!("Cannot find lean-ctx binary for autostart");
146 }
147 return false;
148 }
149
150 #[cfg(target_os = "macos")]
151 {
152 install_launchagent(binary, port, quiet)
153 }
154
155 #[cfg(target_os = "linux")]
156 {
157 install_systemd(binary, port, quiet)
158 }
159
160 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
161 {
162 let _ = (binary, quiet);
163 println!(" Autostart not supported on this platform");
164 println!(" Run manually: lean-ctx proxy start --port={port}");
165 false
166 }
167}
168
169pub fn stop() {
170 #[cfg(target_os = "macos")]
171 {
172 let plist_path = launchagent_path();
173 if plist_path.exists() {
174 crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
175 }
176 }
177
178 #[cfg(target_os = "linux")]
179 {
180 let _ = std::process::Command::new("systemctl")
181 .args(["--user", "stop", SYSTEMD_SERVICE])
182 .output();
183 }
184}
185
186pub fn start() -> bool {
187 start_on_port(crate::core::proxy_endpoint::default_port())
188}
189
190pub fn start_on_port(port: u16) -> bool {
194 #[cfg(target_os = "macos")]
195 {
196 let plist_path = launchagent_path();
197 if plist_path.exists() {
198 crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
199 if !release_proxy_port(port, false) {
200 return false;
201 }
202 return crate::core::launchd::bootstrap(PLIST_LABEL, &plist_path);
203 }
204 false
205 }
206
207 #[cfg(target_os = "linux")]
208 {
209 let _ = std::process::Command::new("systemctl")
210 .args(["--user", "stop", SYSTEMD_SERVICE])
211 .output();
212 if release_proxy_port(port, false) {
213 std::process::Command::new("systemctl")
214 .args(["--user", "start", SYSTEMD_SERVICE])
215 .status()
216 .is_ok_and(|status| status.success())
217 } else {
218 false
219 }
220 }
221
222 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
223 {
224 let _ = port;
225 false
226 }
227}
228
229pub fn uninstall(_quiet: bool) {
230 #[cfg(target_os = "macos")]
231 uninstall_launchagent(_quiet);
232
233 #[cfg(target_os = "linux")]
234 uninstall_systemd(_quiet);
235}
236
237pub fn is_supported() -> bool {
241 cfg!(any(target_os = "macos", target_os = "linux"))
242}
243
244pub fn is_installed() -> bool {
246 #[cfg(target_os = "macos")]
247 {
248 launchagent_path().exists()
249 }
250 #[cfg(target_os = "linux")]
251 {
252 systemd_path().exists()
253 }
254 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
255 {
256 false
257 }
258}
259
260pub fn is_loaded() -> bool {
263 #[cfg(target_os = "macos")]
264 {
265 is_installed() && crate::core::launchd::is_loaded(PLIST_LABEL)
266 }
267 #[cfg(target_os = "linux")]
268 {
269 is_installed()
270 && std::process::Command::new("systemctl")
271 .args(["--user", "is-active", "--quiet", SYSTEMD_SERVICE])
272 .status()
273 .is_ok_and(|status| status.success())
274 }
275 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
276 {
277 false
278 }
279}
280
281pub fn status() {
282 #[cfg(target_os = "macos")]
283 {
284 let plist_path = launchagent_path();
285 if plist_path.exists() {
286 println!(" LaunchAgent: installed at {}", plist_path.display());
287 if crate::core::launchd::is_loaded(PLIST_LABEL) {
288 println!(" Status: loaded");
289 } else {
290 println!(" Status: not loaded (run: lean-ctx proxy start)");
291 }
292 } else {
293 println!(" LaunchAgent: not installed");
294 }
295 }
296
297 #[cfg(target_os = "linux")]
298 {
299 let service_path = systemd_path();
300 if service_path.exists() {
301 println!(" systemd user service: installed");
302 let output = std::process::Command::new("systemctl")
303 .args(["--user", "is-active", SYSTEMD_SERVICE])
304 .output();
305 match output {
306 Ok(o) => {
307 let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
308 println!(" Status: {state}");
309 }
310 Err(_) => println!(" Status: unknown"),
311 }
312 } else {
313 println!(" systemd service: not installed");
314 }
315 }
316
317 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
318 {
319 println!(" Autostart not available on this platform");
320 }
321}
322
323#[cfg(target_os = "macos")]
324fn launchagent_path() -> PathBuf {
325 dirs::home_dir()
326 .unwrap_or_else(|| PathBuf::from("/tmp"))
327 .join("Library/LaunchAgents")
328 .join(format!("{PLIST_LABEL}.plist"))
329}
330
331#[cfg(target_os = "macos")]
332fn install_launchagent(binary: &str, port: u16, quiet: bool) -> bool {
333 let plist_dir = dirs::home_dir()
334 .unwrap_or_else(|| PathBuf::from("/tmp"))
335 .join("Library/LaunchAgents");
336 let _ = std::fs::create_dir_all(&plist_dir);
337
338 let plist_path = plist_dir.join(format!("{PLIST_LABEL}.plist"));
339 crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
343 if !release_proxy_port(port, quiet) {
344 return false;
345 }
346 let log_dir = crate::core::paths::state_dir()
350 .unwrap_or_else(|_| std::env::temp_dir().join("lean-ctx"))
351 .join("logs");
352 let _ = std::fs::create_dir_all(&log_dir);
353
354 let port_arg = format!("--port={port}");
357 let program_args = crate::core::tcc_guard_sandbox::program_args_xml(
358 &crate::core::tcc_guard_sandbox::wrap_launchd_args(binary, &["proxy", "start", &port_arg]),
359 " ",
360 );
361
362 let env_vars = crate::core::tcc_guard_sandbox::pinned_layout_env_xml();
369
370 let plist = format!(
371 r#"<?xml version="1.0" encoding="UTF-8"?>
372<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
373<plist version="1.0">
374<dict>
375 <key>Label</key>
376 <string>{PLIST_LABEL}</string>
377 <key>ProgramArguments</key>
378 <array>
379{program_args}
380 </array>
381{env_vars} <key>RunAtLoad</key>
382 <true/>
383 <key>KeepAlive</key>
384 <true/>
385 <key>StandardOutPath</key>
386 <string>{stdout}</string>
387 <key>StandardErrorPath</key>
388 <string>{stderr}</string>
389</dict>
390</plist>"#,
391 stdout = log_dir.join("proxy.stdout.log").display(),
392 stderr = log_dir.join("proxy.stderr.log").display(),
393 );
394
395 let _ = std::fs::write(&plist_path, &plist);
396
397 let ok = crate::core::launchd::bootstrap(PLIST_LABEL, &plist_path);
398
399 if !quiet {
400 if ok {
401 println!(" Installed LaunchAgent: {}", plist_path.display());
402 println!(" Proxy will start on login and restart if stopped");
403 } else {
404 println!(" Created LaunchAgent at {}", plist_path.display());
405 println!(" Load reported a problem; check: launchctl print {PLIST_LABEL}");
406 }
407 }
408 ok
409}
410
411#[cfg(target_os = "macos")]
412fn uninstall_launchagent(quiet: bool) {
413 let plist_path = launchagent_path();
414 if !plist_path.exists() {
415 if !quiet {
416 println!(" LaunchAgent not installed, nothing to remove");
417 }
418 return;
419 }
420
421 crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
422
423 let _ = std::fs::remove_file(&plist_path);
424 if !quiet {
425 println!(" Removed LaunchAgent: {}", plist_path.display());
426 }
427}
428
429#[cfg(target_os = "linux")]
430fn systemd_path() -> PathBuf {
431 dirs::home_dir()
432 .unwrap_or_else(|| PathBuf::from("/tmp"))
433 .join(".config/systemd/user")
434 .join(format!("{SYSTEMD_SERVICE}.service"))
435}
436
437#[cfg(target_os = "linux")]
438fn install_systemd(binary: &str, port: u16, quiet: bool) -> bool {
439 let service_dir = dirs::home_dir()
440 .unwrap_or_else(|| PathBuf::from("/tmp"))
441 .join(".config/systemd/user");
442 let _ = std::fs::create_dir_all(&service_dir);
443
444 let service_path = service_dir.join(format!("{SYSTEMD_SERVICE}.service"));
445
446 let _ = std::process::Command::new("systemctl")
447 .args(["--user", "stop", SYSTEMD_SERVICE])
448 .output();
449 if !release_proxy_port(port, quiet) {
450 return false;
451 }
452
453 let unit = format!(
454 r"[Unit]
455Description=lean-ctx API Proxy
456After=network.target
457StartLimitIntervalSec=300
458StartLimitBurst=5
459
460[Service]
461Type=simple
462ExecStart={binary} proxy start --port={port}
463Restart=on-failure
464RestartSec=5
465StandardOutput=journal
466StandardError=journal
467Environment=RUST_LOG=info
468
469[Install]
470WantedBy=default.target
471"
472 );
473
474 let _ = std::fs::write(&service_path, &unit);
475
476 let _ = std::process::Command::new("systemctl")
477 .args(["--user", "daemon-reload"])
478 .output();
479
480 let result = std::process::Command::new("systemctl")
481 .args(["--user", "enable", "--now", SYSTEMD_SERVICE])
482 .output();
483
484 if !quiet {
485 match &result {
486 Ok(o) if o.status.success() => {
487 println!(" Installed systemd user service: {SYSTEMD_SERVICE}");
488 println!(" Proxy will start on login and restart if stopped");
489 }
490 Ok(o) => {
491 let err = String::from_utf8_lossy(&o.stderr);
492 println!(" Created service file but enable failed: {err}");
493 }
494 Err(e) => {
495 println!(" Created service file at {}", service_path.display());
496 println!(" Could not enable: {e}");
497 }
498 }
499 }
500 result.is_ok_and(|output| output.status.success())
501}
502
503#[cfg(target_os = "linux")]
504fn uninstall_systemd(quiet: bool) {
505 let service_path = systemd_path();
506 if !service_path.exists() {
507 if !quiet {
508 println!(" systemd service not installed, nothing to remove");
509 }
510 return;
511 }
512
513 let _ = std::process::Command::new("systemctl")
514 .args(["--user", "stop", SYSTEMD_SERVICE])
515 .output();
516 let _ = std::process::Command::new("systemctl")
517 .args(["--user", "disable", SYSTEMD_SERVICE])
518 .output();
519 let _ = std::fs::remove_file(&service_path);
520 let _ = std::process::Command::new("systemctl")
521 .args(["--user", "daemon-reload"])
522 .output();
523
524 if !quiet {
525 println!(" Removed systemd service: {SYSTEMD_SERVICE}");
526 }
527}
528
529pub fn find_binary() -> String {
530 crate::core::portable_binary::resolve_portable_binary()
531}
532
533#[cfg(test)]
534mod tests {
535 use super::{health_identifies_lean_ctx_proxy, proxy_pid_from_health};
536 #[cfg(any(target_os = "macos", target_os = "linux"))]
537 use super::{port_is_open, release_proxy_port};
538 #[cfg(any(target_os = "macos", target_os = "linux"))]
539 use std::io::{Read, Write};
540 #[cfg(any(target_os = "macos", target_os = "linux"))]
541 use std::net::TcpListener;
542
543 #[test]
544 fn health_pid_parser_accepts_only_well_formed_health() {
545 assert_eq!(
546 proxy_pid_from_health(r#"{"status":"ok","service":"lean-ctx-proxy","pid":4242}"#),
547 Some(4242)
548 );
549 assert_eq!(proxy_pid_from_health(r#"{"status":"ok","pid":4242}"#), None);
552 assert_eq!(
553 proxy_pid_from_health(r#"{"status":"ok","service":"other","pid":4242}"#),
554 None
555 );
556 assert_eq!(
557 proxy_pid_from_health(r#"{"status":"busy","service":"lean-ctx-proxy","pid":4242}"#),
558 None
559 );
560 assert_eq!(
561 proxy_pid_from_health(r#"{"status":"ok","service":"lean-ctx-proxy","pid":0}"#),
562 None
563 );
564 assert_eq!(proxy_pid_from_health(r#"{"status":"ok"}"#), None);
565 assert_eq!(proxy_pid_from_health("not json"), None);
566 assert!(health_identifies_lean_ctx_proxy(
567 r#"{"status":"ok","service":"lean-ctx-proxy","pid":4242}"#
568 ));
569 assert!(!health_identifies_lean_ctx_proxy(
570 r#"{"status":"ok","pid":4242}"#
571 ));
572 }
573
574 #[test]
575 #[cfg(any(target_os = "macos", target_os = "linux"))]
576 fn managed_handoff_fails_closed_for_unidentified_listener() {
577 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
578 let port = listener.local_addr().unwrap().port();
579 let server = std::thread::spawn(move || {
580 for _ in 0..2 {
581 let (mut stream, _) = listener.accept().unwrap();
582 stream
583 .set_read_timeout(Some(std::time::Duration::from_secs(1)))
584 .unwrap();
585 let mut request = [0_u8; 512];
586 if stream.read(&mut request).unwrap_or(0) > 0 {
587 let body = r#"{"status":"ok"}"#;
588 write!(
589 stream,
590 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
591 body.len()
592 )
593 .unwrap();
594 }
595 }
596 });
597
598 assert!(!release_proxy_port(port, true));
599 server.join().unwrap();
600 }
601
602 #[cfg(unix)]
603 struct ChildGuard(std::process::Child);
604
605 #[cfg(unix)]
606 impl Drop for ChildGuard {
607 fn drop(&mut self) {
608 let _ = self.0.kill();
609 let _ = self.0.wait();
610 }
611 }
612
613 #[test]
617 #[cfg(unix)]
618 fn managed_handoff_never_signals_an_unverified_health_pid() {
619 let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
620 let port = reservation.local_addr().unwrap().port();
621 drop(reservation);
622
623 let child = std::process::Command::new(std::env::current_exe().unwrap())
624 .args([
625 "--ignored",
626 "--exact",
627 "proxy_autostart::tests::managed_handoff_test_child",
628 "--nocapture",
629 ])
630 .env("LEAN_CTX_HANDOFF_TEST_PORT", port.to_string())
631 .spawn()
632 .unwrap();
633 let mut child = ChildGuard(child);
634
635 let ready_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
636 while !port_is_open(port) && std::time::Instant::now() < ready_deadline {
637 std::thread::sleep(std::time::Duration::from_millis(25));
638 }
639 assert!(port_is_open(port), "test proxy did not bind port {port}");
640 assert!(!release_proxy_port(port, true));
641 assert!(
642 child.0.try_wait().unwrap().is_none(),
643 "an unverified /health PID must never be signalled"
644 );
645 assert!(port_is_open(port));
646 }
647
648 #[test]
649 #[ignore = "helper process for managed_handoff_never_signals_an_unverified_health_pid"]
650 #[cfg(unix)]
651 fn managed_handoff_test_child() {
652 let port: u16 = std::env::var("LEAN_CTX_HANDOFF_TEST_PORT")
653 .unwrap()
654 .parse()
655 .unwrap();
656 let listener = TcpListener::bind(("127.0.0.1", port)).unwrap();
657 loop {
658 let (mut stream, _) = listener.accept().unwrap();
659 stream
660 .set_read_timeout(Some(std::time::Duration::from_secs(1)))
661 .unwrap();
662 let mut request = [0_u8; 512];
663 if stream.read(&mut request).unwrap_or(0) > 0 {
664 let body = format!(
665 r#"{{"status":"ok","service":"lean-ctx-proxy","pid":{}}}"#,
666 std::process::id()
667 );
668 write!(
669 stream,
670 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
671 body.len()
672 )
673 .unwrap();
674 }
675 }
676 }
677}