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