Skip to main content

lean_ctx/core/
proxy_endpoint.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Where the local lean-ctx proxy listens and whether it answers.
3//!
4//! The engine (updater, autostart, gain) and the setup/doctor surfaces share
5//! this single source of truth for the proxy port, the reachability timeout
6//! and the "is the proxy up" check.
7
8pub const DEFAULT_PROXY_PORT: u16 = 4444;
9
10/// Proxy reachability timeout. Priority: env var > config.toml > 200ms default.
11pub fn proxy_timeout() -> std::time::Duration {
12    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS")
13        && let Ok(ms) = val.parse::<u64>()
14    {
15        return std::time::Duration::from_millis(ms);
16    }
17    if let Some(ms) = crate::core::config::Config::load().proxy_timeout_ms {
18        return std::time::Duration::from_millis(ms);
19    }
20    std::time::Duration::from_millis(200)
21}
22
23/// True when the lean-ctx proxy itself answers `GET /health` on `port`. An
24/// open port alone proves nothing: a foreign process (or another user's
25/// proxy) listening there must never count as "proxy up".
26pub fn lean_ctx_proxy_responding(port: u16) -> bool {
27    use std::io::{Read, Write};
28    use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
29
30    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
31    let Ok(mut stream) = TcpStream::connect_timeout(&addr, proxy_timeout()) else {
32        return false;
33    };
34    let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(3)));
35    let req =
36        format!("GET /health HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n");
37    if stream.write_all(req.as_bytes()).is_err() {
38        return false;
39    }
40    let mut buf = Vec::with_capacity(1024);
41    let _ = stream.take(8 * 1024).read_to_end(&mut buf);
42    is_lean_ctx_health_response(&String::from_utf8_lossy(&buf))
43}
44
45/// The `/health` answer of a lean-ctx proxy: HTTP 200 carrying the
46/// `"service":"lean-ctx-proxy"` marker its handler always emits.
47fn is_lean_ctx_health_response(response: &str) -> bool {
48    let status_ok = response
49        .lines()
50        .next()
51        .is_some_and(|line| line.starts_with("HTTP/1.") && line.split(' ').nth(1) == Some("200"));
52    status_ok && response.contains("\"service\":\"lean-ctx-proxy\"")
53}
54
55/// Single source of truth for "is the proxy up" across `doctor`, `status` and
56/// `gain`: the proxy must be enabled in config AND the configured endpoint must
57/// answer as a lean-ctx proxy.
58pub fn configured_proxy_live() -> bool {
59    crate::core::config::Config::load().proxy_enabled == Some(true)
60        && lean_ctx_proxy_responding(default_port())
61}
62
63pub fn default_port() -> u16 {
64    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT")
65        && let Ok(port) = val.parse::<u16>()
66    {
67        return port;
68    }
69    let cfg = crate::core::config::Config::load();
70    if let Some(port) = cfg.proxy_port {
71        return port;
72    }
73    uid_based_port()
74}
75
76/// Derives a deterministic port from the user's UID to avoid collisions
77/// on multi-user systems. uid 1000 → 4444, uid 1001 → 4445, etc.
78/// System accounts (uid < 1000) and root always get the base port 4444.
79pub fn uid_based_port() -> u16 {
80    #[cfg(unix)]
81    {
82        // SAFETY: `getuid` takes no arguments, always succeeds, and only reads
83        // the calling process's real UID — no preconditions, no UB.
84        let uid = unsafe { libc::getuid() } as u16;
85        let offset = uid.saturating_sub(1000) % 1000;
86        DEFAULT_PROXY_PORT + offset
87    }
88    #[cfg(not(unix))]
89    {
90        DEFAULT_PROXY_PORT
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn uid_port_first_regular_user() {
100        // uid 1000 (first regular user on most Linux) → base port
101        assert_eq!(DEFAULT_PROXY_PORT, 4444);
102    }
103
104    #[test]
105    fn uid_port_no_overflow() {
106        // Ensure port stays in valid range even with high UIDs
107        // uid 2999 → offset (2999-1000) % 1000 = 999 → port 5443
108        let port = DEFAULT_PROXY_PORT + 999;
109        assert_eq!(port, 5443);
110        assert!(port < u16::MAX);
111    }
112
113    #[test]
114    fn uid_port_system_accounts_get_base() {
115        // uid < 1000 → saturating_sub gives 0 → base port
116        let uid: u16 = 500;
117        let offset = uid.saturating_sub(1000) % 1000;
118        assert_eq!(DEFAULT_PROXY_PORT + offset, DEFAULT_PROXY_PORT);
119    }
120
121    #[test]
122    fn proxy_timeout_default_200ms() {
123        if std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS").is_ok() {
124            return;
125        }
126        assert_eq!(proxy_timeout(), std::time::Duration::from_millis(200));
127    }
128
129    #[test]
130    fn proxy_timeout_is_non_zero() {
131        let t = proxy_timeout();
132        assert!(t.as_millis() > 0);
133    }
134
135    #[test]
136    fn proxy_liveness_ignores_a_foreign_process_on_the_port() {
137        use std::io::{Read, Write};
138        // A foreign HTTP server on the proxy port answers 200 "ok"; only the
139        // lean-ctx proxy's own /health marker may count as "proxy up".
140        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
141        let port = listener.local_addr().expect("addr").port();
142        let server = std::thread::spawn(move || {
143            let bodies = [
144                r#"{"status":"ok"}"#,
145                r#"{"pid":1,"service":"lean-ctx-proxy","status":"ok"}"#,
146            ];
147            for body in bodies {
148                let (mut stream, _) = listener.accept().expect("accept");
149                let mut req = [0u8; 512];
150                let _ = stream.read(&mut req);
151                let resp = format!(
152                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}",
153                    body.len()
154                );
155                let _ = stream.write_all(resp.as_bytes());
156            }
157        });
158        assert!(
159            !lean_ctx_proxy_responding(port),
160            "foreign 200 must not count"
161        );
162        assert!(
163            lean_ctx_proxy_responding(port),
164            "lean-ctx /health must count"
165        );
166        server.join().expect("server thread");
167    }
168}