Skip to main content

lean_ctx/gateway_server/
doctor.rs

1//! `lean-ctx gateway doctor` (enterprise#49) — go-live preflight.
2//!
3//! Every check prints one line (`ok` / `warn` / `FAIL`) with a concrete fix
4//! command; the process exits non-zero when any FAIL is present. Checks run
5//! against the *instance directory* (`--dir`, default `.`): its `.env`,
6//! `config.toml` and `gateway-keys.toml` — plus live probes (Postgres
7//! connect + `SELECT 1`, proxy/admin port reachability).
8
9use std::fmt;
10use std::path::{Path, PathBuf};
11
12/// Severity of a single check result.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Severity {
15    Ok,
16    Warn,
17    Fail,
18}
19
20/// One check line: what was checked, what was found, how to fix it.
21#[derive(Debug)]
22pub struct CheckResult {
23    pub severity: Severity,
24    pub name: &'static str,
25    pub detail: String,
26    pub fix: Option<String>,
27}
28
29impl fmt::Display for CheckResult {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        let tag = match self.severity {
32            Severity::Ok => "\x1b[32m ok \x1b[0m",
33            Severity::Warn => "\x1b[33mwarn\x1b[0m",
34            Severity::Fail => "\x1b[31mFAIL\x1b[0m",
35        };
36        write!(f, "[{tag}] {:<18} {}", self.name, self.detail)?;
37        if let Some(fix) = &self.fix {
38            write!(f, "\n{:24}fix: {fix}", "")?;
39        }
40        Ok(())
41    }
42}
43
44fn ok(name: &'static str, detail: impl Into<String>) -> CheckResult {
45    CheckResult {
46        severity: Severity::Ok,
47        name,
48        detail: detail.into(),
49        fix: None,
50    }
51}
52fn warn(name: &'static str, detail: impl Into<String>, fix: impl Into<String>) -> CheckResult {
53    CheckResult {
54        severity: Severity::Warn,
55        name,
56        detail: detail.into(),
57        fix: Some(fix.into()),
58    }
59}
60fn fail(name: &'static str, detail: impl Into<String>, fix: impl Into<String>) -> CheckResult {
61    CheckResult {
62        severity: Severity::Fail,
63        name,
64        detail: detail.into(),
65        fix: Some(fix.into()),
66    }
67}
68
69/// The `.env` slice doctor cares about.
70#[derive(Debug, Default)]
71struct EnvFile {
72    proxy_token: Option<String>,
73    admin_token: Option<String>,
74    database_url: Option<String>,
75}
76
77/// Parses `KEY=value` lines (the generated `.env` format; quotes not needed).
78fn parse_env_file(path: &Path) -> EnvFile {
79    let mut out = EnvFile::default();
80    let Ok(raw) = std::fs::read_to_string(path) else {
81        return out;
82    };
83    for line in raw.lines() {
84        let line = line.trim();
85        if line.starts_with('#') {
86            continue;
87        }
88        if let Some((k, v)) = line.split_once('=') {
89            let v = v.trim().to_string();
90            match k.trim() {
91                "LEAN_CTX_PROXY_TOKEN" => out.proxy_token = Some(v),
92                "LEAN_CTX_GATEWAY_ADMIN_TOKEN" => out.admin_token = Some(v),
93                "DATABASE_URL" => out.database_url = Some(v),
94                _ => {}
95            }
96        }
97    }
98    out
99}
100
101/// Runs all checks. `proxy_port`/`admin_port` are probed on localhost.
102pub async fn run_checks(dir: &Path, proxy_port: u16, admin_port: u16) -> Vec<CheckResult> {
103    let mut results = Vec::new();
104
105    // -- instance files ------------------------------------------------------
106    let config_path = dir.join("config.toml");
107    let config_raw = std::fs::read_to_string(&config_path).ok();
108    match &config_raw {
109        Some(raw) => match toml::from_str::<toml::Value>(raw) {
110            Ok(v) => {
111                results.push(ok("config.toml", "present, parses"));
112                results.extend(check_config_values(&v));
113            }
114            Err(e) => results.push(fail(
115                "config.toml",
116                format!("does not parse: {e}"),
117                "fix the TOML syntax (or regenerate with `lean-ctx gateway init`)",
118            )),
119        },
120        None => results.push(warn(
121            "config.toml",
122            format!("not found in {}", dir.display()),
123            "run `lean-ctx gateway init <dir>` or pass --dir <instance dir>",
124        )),
125    }
126
127    let keys_path = keys_path_for(dir);
128    match crate::proxy::gateway_identity::GatewayKeys::load(&keys_path) {
129        Ok(keys) if keys.is_empty() => results.push(warn(
130            "gateway-keys",
131            "no per-person keys — usage will meter as 'anonymous'",
132            format!(
133                "lean-ctx gateway keys add --person alice@example.com --file {}",
134                keys_path.display()
135            ),
136        )),
137        Ok(keys) => results.push(ok("gateway-keys", format!("{} identities", keys.len()))),
138        Err(e) => results.push(fail(
139            "gateway-keys",
140            format!("invalid: {e}"),
141            "fix the file — the gateway refuses to start on a malformed key set",
142        )),
143    }
144
145    // -- secrets -------------------------------------------------------------
146    let env = parse_env_file(&dir.join(".env"));
147    let proxy_token = env
148        .proxy_token
149        .or_else(|| std::env::var("LEAN_CTX_PROXY_TOKEN").ok());
150    match proxy_token {
151        Some(t) if t.len() >= 32 => results.push(ok("proxy token", "set")),
152        Some(_) => results.push(warn(
153            "proxy token",
154            "set but short (<32 chars)",
155            "use 32+ random bytes: openssl rand -hex 32",
156        )),
157        None => results.push(fail(
158            "proxy token",
159            "LEAN_CTX_PROXY_TOKEN not in .env or environment",
160            "add LEAN_CTX_PROXY_TOKEN=$(openssl rand -hex 32) to .env",
161        )),
162    }
163    let admin_token = env
164        .admin_token
165        .or_else(|| std::env::var(super::serve::ADMIN_TOKEN_ENV).ok());
166    match admin_token {
167        Some(t) if t.len() >= 32 => results.push(ok("admin token", "set")),
168        Some(_) => results.push(warn(
169            "admin token",
170            "set but short (<32 chars)",
171            "use 32+ random bytes: openssl rand -hex 32",
172        )),
173        None => results.push(warn(
174            "admin token",
175            format!(
176                "{} not set — admin console stays off",
177                super::serve::ADMIN_TOKEN_ENV
178            ),
179            "add LEAN_CTX_GATEWAY_ADMIN_TOKEN=$(openssl rand -hex 32) to .env",
180        )),
181    }
182
183    // -- Postgres ------------------------------------------------------------
184    let database_url = env
185        .database_url
186        .or_else(|| std::env::var(super::serve::DATABASE_URL_ENV).ok());
187    match database_url {
188        Some(url) => {
189            results.push(check_pg_tls_posture(&url));
190            results.push(check_postgres(&url).await);
191        }
192        None => results.push(warn(
193            "postgres",
194            "DATABASE_URL not set — metering/console off (traffic still works)",
195            "add DATABASE_URL=postgres://… to .env",
196        )),
197    }
198
199    // -- provider credentials (from config's registry) -----------------------
200    if let Some(raw) = &config_raw
201        && let Ok(v) = toml::from_str::<toml::Value>(raw)
202    {
203        results.extend(check_provider_credentials(
204            &v,
205            &parse_env_names(&dir.join(".env")),
206        ));
207    }
208
209    // -- live ports ----------------------------------------------------------
210    results.push(probe_http("proxy port", proxy_port, "/health", false).await);
211    results.push(probe_http("admin port", admin_port, "/healthz", true).await);
212
213    results
214}
215
216/// Static config sanity (bind/token posture).
217fn check_config_values(v: &toml::Value) -> Vec<CheckResult> {
218    let mut out = Vec::new();
219    let bind = v.get("proxy_bind_host").and_then(|b| b.as_str());
220    let require_token = v
221        .get("proxy_require_token")
222        .and_then(toml::Value::as_bool)
223        .unwrap_or(false);
224    match (bind, require_token) {
225        (Some(b), true) if b != "127.0.0.1" => {
226            out.push(ok("bind posture", format!("{b} with required tokens")));
227        }
228        (Some(b), false) if b != "127.0.0.1" => out.push(fail(
229            "bind posture",
230            format!("binds {b} WITHOUT proxy_require_token"),
231            "set proxy_require_token = true in config.toml",
232        )),
233        _ => out.push(ok("bind posture", "loopback (solo mode)")),
234    }
235    out.extend(check_security_posture(v));
236    if v.get("proxy")
237        .and_then(|p| p.get("baseline"))
238        .and_then(|b| b.get("reference_model"))
239        .and_then(|m| m.as_str())
240        .is_none()
241    {
242        out.push(warn(
243            "baseline",
244            "no [proxy.baseline] reference_model — avoided-cost stays 0",
245            "set reference_model = \"claude-opus-4.5\" (or your contract reference)",
246        ));
247    } else {
248        out.push(ok("baseline", "reference_model configured"));
249    }
250    out
251}
252
253/// Security posture (#54/#60): admin exposure, plaintext upstreams, PG TLS.
254/// Advisory (`warn`), never `FAIL`: all three have legitimate pilot/in-cluster
255/// configurations — the point is that go-live sign-off *sees* them.
256fn check_security_posture(v: &toml::Value) -> Vec<CheckResult> {
257    let mut out = Vec::new();
258
259    let admin_bind = v
260        .get("gateway_server")
261        .and_then(|g| g.get("admin_bind_host"))
262        .and_then(|b| b.as_str())
263        .unwrap_or("127.0.0.1");
264    if admin_bind == "127.0.0.1" || admin_bind == "::1" {
265        out.push(ok("admin exposure", "loopback (host-local console)"));
266    } else {
267        out.push(warn(
268            "admin exposure",
269            format!("admin listener binds {admin_bind}"),
270            "fine in-container behind a host-local port mapping; on bare hosts keep 127.0.0.1 or front with TLS",
271        ));
272    }
273
274    if v.get("proxy")
275        .and_then(|p| p.get("allow_insecure_http_upstream"))
276        .and_then(toml::Value::as_bool)
277        .unwrap_or(false)
278    {
279        out.push(warn(
280            "upstream tls",
281            "allow_insecure_http_upstream = true (plaintext to non-loopback upstreams)",
282            "keep only for trusted-network local inference (Ollama/vLLM); never for internet upstreams",
283        ));
284    } else {
285        out.push(ok("upstream tls", "HTTPS-only to non-loopback upstreams"));
286    }
287
288    out
289}
290
291/// Names (not values) defined in `.env` — for provider `api_key_env` checks.
292fn parse_env_names(path: &Path) -> Vec<String> {
293    std::fs::read_to_string(path)
294        .map(|raw| {
295            raw.lines()
296                .filter(|l| !l.trim_start().starts_with('#'))
297                .filter_map(|l| l.split_once('=').map(|(k, _)| k.trim().to_string()))
298                .collect()
299        })
300        .unwrap_or_default()
301}
302
303/// Each registry provider that injects a credential needs its env var — in the
304/// process env (solo) or declared in `.env` (compose passes it through).
305fn check_provider_credentials(v: &toml::Value, env_file_names: &[String]) -> Vec<CheckResult> {
306    let mut out = Vec::new();
307    let providers = v
308        .get("proxy")
309        .and_then(|p| p.get("providers"))
310        .and_then(|p| p.as_array());
311    for entry in providers.unwrap_or(&Vec::new()) {
312        let id = entry.get("id").and_then(|i| i.as_str()).unwrap_or("?");
313        let enabled = entry
314            .get("enabled")
315            .and_then(toml::Value::as_bool)
316            .unwrap_or(true);
317        if !enabled {
318            continue;
319        }
320        let Some(env_name) = entry.get("api_key_env").and_then(|e| e.as_str()) else {
321            continue;
322        };
323        let present = std::env::var(env_name).is_ok_and(|x| !x.trim().is_empty())
324            || env_file_names.iter().any(|n| n == env_name);
325        if present {
326            out.push(ok("provider key", format!("{id}: {env_name} available")));
327        } else {
328            out.push(fail(
329                "provider key",
330                format!("{id}: {env_name} missing"),
331                format!("add {env_name}=<key> to .env (and pass it through in docker-compose.yml)"),
332            ));
333        }
334    }
335    out
336}
337
338/// PG TLS posture (#54/#58): managed Postgres must say `sslmode=require`;
339/// plain is fine for in-cluster/compose-internal hosts only.
340fn check_pg_tls_posture(url: &str) -> CheckResult {
341    let requires_tls = url.contains("sslmode=require");
342    let internal_host = ["@postgres:", "@localhost:", "@127.0.0.1:", "@[::1]:"]
343        .iter()
344        .any(|h| url.contains(h));
345    if requires_tls {
346        ok("pg tls", "sslmode=require (rustls, verified)")
347    } else if internal_host {
348        ok("pg tls", "plain TCP to an in-cluster/local host")
349    } else {
350        warn(
351            "pg tls",
352            "remote Postgres without sslmode=require",
353            "append ?sslmode=require to DATABASE_URL (managed PG — Azure/AWS/GCP — enforces TLS)",
354        )
355    }
356}
357
358async fn check_postgres(url: &str) -> CheckResult {
359    // Container-internal hostnames (e.g. `postgres`) are normal in compose
360    // setups; from the host we resolve them to localhost for the probe.
361    let probe_url = url.replace("@postgres:", "@127.0.0.1:");
362    match super::store::pool_from_database_url(&probe_url) {
363        Ok(pool) => {
364            let probe = async {
365                let client = pool.get().await?;
366                client.query_one("SELECT 1", &[]).await?;
367                anyhow::Ok(())
368            };
369            match tokio::time::timeout(std::time::Duration::from_secs(4), probe).await {
370                Ok(Ok(())) => ok("postgres", "connected (SELECT 1 ok)"),
371                Ok(Err(e)) => warn(
372                    "postgres",
373                    format!("unreachable: {e:#}"),
374                    "start it (docker compose up -d postgres) — traffic is fail-open meanwhile",
375                ),
376                Err(_) => warn(
377                    "postgres",
378                    "connect timeout (4s)",
379                    "check host/port/firewall — traffic is fail-open meanwhile",
380                ),
381            }
382        }
383        Err(e) => fail(
384            "postgres",
385            format!("DATABASE_URL invalid: {e:#}"),
386            "fix the connection string in .env",
387        ),
388    }
389}
390
391async fn probe_http(name: &'static str, port: u16, path: &str, optional: bool) -> CheckResult {
392    let url = format!("http://127.0.0.1:{port}{path}");
393    let probe = tokio::task::spawn_blocking(move || {
394        ureq::get(&url)
395            .config()
396            .timeout_global(Some(std::time::Duration::from_secs(2)))
397            .build()
398            .call()
399            .is_ok()
400    });
401    match probe.await {
402        Ok(true) => ok(name, format!("listening on {port}")),
403        _ if optional => warn(
404            name,
405            format!("nothing on {port} (gateway not running?)"),
406            "start it: docker compose up -d  (or lean-ctx gateway serve)",
407        ),
408        _ => warn(
409            name,
410            format!("nothing on {port} (gateway not running?)"),
411            "start it: docker compose up -d  (or lean-ctx gateway serve)",
412        ),
413    }
414}
415
416fn keys_path_for(dir: &Path) -> PathBuf {
417    let local = dir.join("gateway-keys.toml");
418    if local.exists() {
419        local
420    } else {
421        crate::proxy::gateway_identity::GatewayKeys::default_path()
422    }
423}
424
425/// True when any check failed (exit code driver).
426#[must_use]
427pub fn has_failures(results: &[CheckResult]) -> bool {
428    results.iter().any(|r| r.severity == Severity::Fail)
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn config_posture_flags_open_bind_without_tokens() {
437        let open: toml::Value =
438            toml::from_str("proxy_bind_host = \"0.0.0.0\"\nproxy_require_token = false").unwrap();
439        let results = check_config_values(&open);
440        assert!(
441            results
442                .iter()
443                .any(|r| r.name == "bind posture" && r.severity == Severity::Fail),
444            "open bind without token must FAIL"
445        );
446
447        let hardened: toml::Value =
448            toml::from_str("proxy_bind_host = \"0.0.0.0\"\nproxy_require_token = true").unwrap();
449        let results = check_config_values(&hardened);
450        assert!(
451            results
452                .iter()
453                .any(|r| r.name == "bind posture" && r.severity == Severity::Ok)
454        );
455    }
456
457    #[test]
458    fn provider_credential_check_consults_env_file_names() {
459        let cfg: toml::Value = toml::from_str(
460            r#"
461            [[proxy.providers]]
462            id = "foundry"
463            shape = "openai"
464            base_url = "https://x.services.ai.azure.com/models"
465            api_key_env = "FOUNDRY_API_KEY_DOCTOR_TEST"
466
467            [[proxy.providers]]
468            id = "disabled-one"
469            shape = "openai"
470            base_url = "https://y.example.com"
471            api_key_env = "NEVER_CHECKED"
472            enabled = false
473            "#,
474        )
475        .unwrap();
476
477        let missing = check_provider_credentials(&cfg, &[]);
478        assert_eq!(missing.len(), 1, "disabled providers are skipped");
479        assert_eq!(missing[0].severity, Severity::Fail);
480
481        let present = check_provider_credentials(&cfg, &["FOUNDRY_API_KEY_DOCTOR_TEST".into()]);
482        assert_eq!(present[0].severity, Severity::Ok);
483    }
484
485    #[test]
486    fn security_posture_flags_wide_admin_bind_and_insecure_upstreams() {
487        let hardened: toml::Value = toml::from_str(
488            "[gateway_server]\nadmin_bind_host = \"127.0.0.1\"\n[proxy]\nallow_insecure_http_upstream = false",
489        )
490        .unwrap();
491        let r = check_security_posture(&hardened);
492        assert!(r.iter().all(|c| c.severity == Severity::Ok));
493
494        let widened: toml::Value = toml::from_str(
495            "[gateway_server]\nadmin_bind_host = \"0.0.0.0\"\n[proxy]\nallow_insecure_http_upstream = true",
496        )
497        .unwrap();
498        let r = check_security_posture(&widened);
499        assert_eq!(
500            r.iter().filter(|c| c.severity == Severity::Warn).count(),
501            2,
502            "wide admin bind + plaintext upstream must both surface as warnings"
503        );
504
505        // Unset section: defaults are the hardened posture.
506        let empty: toml::Value = toml::from_str("").unwrap();
507        assert!(
508            check_security_posture(&empty)
509                .iter()
510                .all(|c| c.severity == Severity::Ok)
511        );
512    }
513
514    #[test]
515    fn pg_tls_posture_requires_tls_only_for_remote_hosts() {
516        assert_eq!(
517            check_pg_tls_posture("postgres://u:p@db.example.com:5432/app?sslmode=require").severity,
518            Severity::Ok
519        );
520        assert_eq!(
521            check_pg_tls_posture("postgres://u:p@postgres:5432/leanctx").severity,
522            Severity::Ok,
523            "compose-internal host stays plain without a warning"
524        );
525        assert_eq!(
526            check_pg_tls_posture("postgres://u:p@db.example.com:5432/app").severity,
527            Severity::Warn,
528            "remote host without sslmode=require must warn"
529        );
530    }
531
532    #[test]
533    fn env_file_parsing_extracts_doctor_relevant_keys() {
534        let tmp = tempfile::tempdir().unwrap();
535        let path = tmp.path().join(".env");
536        std::fs::write(
537            &path,
538            "# comment\nLEAN_CTX_PROXY_TOKEN=abc\nDATABASE_URL=postgres://u:p@h:5432/db\nOTHER=x\n",
539        )
540        .unwrap();
541        let env = parse_env_file(&path);
542        assert_eq!(env.proxy_token.as_deref(), Some("abc"));
543        assert_eq!(
544            env.database_url.as_deref(),
545            Some("postgres://u:p@h:5432/db")
546        );
547        assert_eq!(env.admin_token, None);
548        let names = parse_env_names(&path);
549        assert!(names.contains(&"OTHER".to_string()));
550    }
551
552    #[test]
553    fn failure_detection_drives_exit_code() {
554        assert!(!has_failures(&[ok("x", "fine")]));
555        assert!(has_failures(&[ok("x", "fine"), fail("y", "bad", "fix")]));
556        assert!(!has_failures(&[warn("z", "meh", "later")]));
557    }
558}