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        let env_names = parse_env_names(&dir.join(".env"));
204        results.extend(check_provider_credentials(&v, &env_names));
205        results.extend(check_mcp_servers(&v, &env_names).await);
206    }
207
208    // -- live ports ----------------------------------------------------------
209    results.push(probe_http("proxy port", proxy_port, "/health", false).await);
210    results.push(probe_http("admin port", admin_port, "/healthz", true).await);
211
212    results
213}
214
215/// Static config sanity (bind/token posture).
216fn check_config_values(v: &toml::Value) -> Vec<CheckResult> {
217    let mut out = Vec::new();
218    let bind = v.get("proxy_bind_host").and_then(|b| b.as_str());
219    let require_token = v
220        .get("proxy_require_token")
221        .and_then(toml::Value::as_bool)
222        .unwrap_or(false);
223    match (bind, require_token) {
224        (Some(b), true) if b != "127.0.0.1" => {
225            out.push(ok("bind posture", format!("{b} with required tokens")));
226        }
227        (Some(b), false) if b != "127.0.0.1" => out.push(fail(
228            "bind posture",
229            format!("binds {b} WITHOUT proxy_require_token"),
230            "set proxy_require_token = true in config.toml",
231        )),
232        _ => out.push(ok("bind posture", "loopback (solo mode)")),
233    }
234    out.extend(check_security_posture(v));
235    if v.get("proxy")
236        .and_then(|p| p.get("baseline"))
237        .and_then(|b| b.get("reference_model"))
238        .and_then(|m| m.as_str())
239        .is_none()
240    {
241        out.push(warn(
242            "baseline",
243            "no [proxy.baseline] reference_model — avoided-cost stays 0",
244            "set reference_model = \"claude-opus-4.5\" (or your contract reference)",
245        ));
246    } else {
247        out.push(ok("baseline", "reference_model configured"));
248    }
249    out
250}
251
252/// Security posture (#54/#60): admin exposure, plaintext upstreams, PG TLS.
253/// Advisory (`warn`), never `FAIL`: all three have legitimate pilot/in-cluster
254/// configurations — the point is that go-live sign-off *sees* them.
255fn check_security_posture(v: &toml::Value) -> Vec<CheckResult> {
256    let mut out = Vec::new();
257
258    let admin_bind = v
259        .get("gateway_server")
260        .and_then(|g| g.get("admin_bind_host"))
261        .and_then(|b| b.as_str())
262        .unwrap_or("127.0.0.1");
263    if admin_bind == "127.0.0.1" || admin_bind == "::1" {
264        out.push(ok("admin exposure", "loopback (host-local console)"));
265    } else {
266        out.push(warn(
267            "admin exposure",
268            format!("admin listener binds {admin_bind}"),
269            "fine in-container behind a host-local port mapping; on bare hosts keep 127.0.0.1 or front with TLS",
270        ));
271    }
272
273    if v.get("proxy")
274        .and_then(|p| p.get("allow_insecure_http_upstream"))
275        .and_then(toml::Value::as_bool)
276        .unwrap_or(false)
277    {
278        out.push(warn(
279            "upstream tls",
280            "allow_insecure_http_upstream = true (plaintext to non-loopback upstreams)",
281            "keep only for trusted-network local inference (Ollama/vLLM); never for internet upstreams",
282        ));
283    } else {
284        out.push(ok("upstream tls", "HTTPS-only to non-loopback upstreams"));
285    }
286
287    out
288}
289
290/// Names (not values) defined in `.env` — for provider `api_key_env` checks.
291fn parse_env_names(path: &Path) -> Vec<String> {
292    std::fs::read_to_string(path)
293        .map(|raw| {
294            raw.lines()
295                .filter(|l| !l.trim_start().starts_with('#'))
296                .filter_map(|l| l.split_once('=').map(|(k, _)| k.trim().to_string()))
297                .collect()
298        })
299        .unwrap_or_default()
300}
301
302/// Each registry provider that injects a credential needs its env var — in the
303/// process env (solo) or declared in `.env` (compose passes it through).
304fn check_provider_credentials(v: &toml::Value, env_file_names: &[String]) -> Vec<CheckResult> {
305    let mut out = Vec::new();
306    let providers = v
307        .get("proxy")
308        .and_then(|p| p.get("providers"))
309        .and_then(|p| p.as_array());
310    for entry in providers.unwrap_or(&Vec::new()) {
311        let id = entry.get("id").and_then(|i| i.as_str()).unwrap_or("?");
312        let enabled = entry
313            .get("enabled")
314            .and_then(toml::Value::as_bool)
315            .unwrap_or(true);
316        if !enabled {
317            continue;
318        }
319        let Some(env_name) = entry.get("api_key_env").and_then(|e| e.as_str()) else {
320            continue;
321        };
322        let present = std::env::var(env_name).is_ok_and(|x| !x.trim().is_empty())
323            || env_file_names.iter().any(|n| n == env_name);
324        if present {
325            out.push(ok("provider key", format!("{id}: {env_name} available")));
326        } else {
327            out.push(fail(
328                "provider key",
329                format!("{id}: {env_name} missing"),
330                format!("add {env_name}=<key> to .env (and pass it through in docker-compose.yml)"),
331            ));
332        }
333    }
334    out
335}
336
337/// MCP registry checks (GL#99): every enabled `[[gateway_server.mcp_servers]]`
338/// entry gets its config validated (id/URL through the same resolver the
339/// proxy uses), its `auth_env` presence verified, and its endpoint probed —
340/// an MCP server that answers anything at all to a bare GET is reachable
341/// (405/406 are normal answers for a GET without an SSE accept header).
342async fn check_mcp_servers(v: &toml::Value, env_file_names: &[String]) -> Vec<CheckResult> {
343    let mut out = Vec::new();
344    let Some(entries) = v
345        .get("gateway_server")
346        .and_then(|g| g.get("mcp_servers"))
347        .and_then(|m| m.as_array())
348    else {
349        return out;
350    };
351    if entries.is_empty() {
352        return out;
353    }
354
355    // Re-parse through the typed config so doctor applies exactly the rules
356    // the serving proxy applies (single source of validation truth).
357    let typed: Vec<crate::core::config::McpServerEntry> = entries
358        .iter()
359        .filter_map(|e| e.clone().try_into().ok())
360        .collect();
361    let cfg = crate::core::config::GatewayServerConfig {
362        mcp_servers: typed.clone(),
363        ..Default::default()
364    };
365    let allow_insecure = v
366        .get("proxy")
367        .and_then(|p| p.get("allow_insecure_http_upstream"))
368        .and_then(toml::Value::as_bool)
369        .unwrap_or(false);
370    let resolved = cfg.resolve_mcp_servers(allow_insecure);
371
372    let enabled_count = typed.iter().filter(|e| e.enabled.unwrap_or(true)).count();
373    if resolved.len() < enabled_count {
374        out.push(fail(
375            "mcp registry",
376            format!(
377                "{} of {enabled_count} enabled entr{} rejected (invalid id or URL)",
378                enabled_count - resolved.len(),
379                if enabled_count == 1 { "y" } else { "ies" }
380            ),
381            "check the gateway logs at startup — ids are lowercase alnum/-/_, URLs need \
382             https:// (or the insecure-HTTP opt-in for trusted LANs)",
383        ));
384    }
385
386    for server in &resolved {
387        if let Some(env_name) = server.auth_env.as_deref() {
388            let present = std::env::var(env_name).is_ok_and(|x| !x.trim().is_empty())
389                || env_file_names.iter().any(|n| n == env_name);
390            if !present {
391                out.push(fail(
392                    "mcp credential",
393                    format!("{}: {env_name} missing", server.id),
394                    format!(
395                        "add {env_name}=<token> to .env (and pass it through in docker-compose.yml)"
396                    ),
397                ));
398                continue;
399            }
400        }
401        out.push(probe_mcp_endpoint(server).await);
402    }
403    out
404}
405
406/// Reachability probe for one MCP upstream. Any HTTP answer (including 4xx —
407/// Streamable-HTTP servers commonly reject bare GETs) proves the endpoint is
408/// alive; only a transport error is a warning.
409async fn probe_mcp_endpoint(server: &crate::core::config::ResolvedMcpServer) -> CheckResult {
410    let client = reqwest::Client::builder()
411        .connect_timeout(std::time::Duration::from_secs(4))
412        .timeout(std::time::Duration::from_secs(6))
413        .build();
414    let Ok(client) = client else {
415        return warn("mcp upstream", "probe client failed to build", "retry");
416    };
417    match client.get(&server.url).send().await {
418        Ok(resp) => ok(
419            "mcp upstream",
420            format!("{}: reachable (HTTP {})", server.id, resp.status().as_u16()),
421        ),
422        Err(e) => warn(
423            "mcp upstream",
424            format!("{}: unreachable: {e}", server.id),
425            "traffic through /mcp/{id} will answer 502 until the upstream is up (fail-open metering)",
426        ),
427    }
428}
429
430/// PG TLS posture (#54/#58): managed Postgres must say `sslmode=require`;
431/// plain is fine for in-cluster/compose-internal hosts only.
432fn check_pg_tls_posture(url: &str) -> CheckResult {
433    let requires_tls = url.contains("sslmode=require");
434    let internal_host = ["@postgres:", "@localhost:", "@127.0.0.1:", "@[::1]:"]
435        .iter()
436        .any(|h| url.contains(h));
437    if requires_tls {
438        ok("pg tls", "sslmode=require (rustls, verified)")
439    } else if internal_host {
440        ok("pg tls", "plain TCP to an in-cluster/local host")
441    } else {
442        warn(
443            "pg tls",
444            "remote Postgres without sslmode=require",
445            "append ?sslmode=require to DATABASE_URL (managed PG — Azure/AWS/GCP — enforces TLS)",
446        )
447    }
448}
449
450async fn check_postgres(url: &str) -> CheckResult {
451    // Container-internal hostnames (e.g. `postgres`) are normal in compose
452    // setups; from the host we resolve them to localhost for the probe.
453    let probe_url = url.replace("@postgres:", "@127.0.0.1:");
454    match super::store::pool_from_database_url(&probe_url) {
455        Ok(pool) => {
456            let probe = async {
457                let client = pool.get().await?;
458                client.query_one("SELECT 1", &[]).await?;
459                anyhow::Ok(())
460            };
461            match tokio::time::timeout(std::time::Duration::from_secs(4), probe).await {
462                Ok(Ok(())) => ok("postgres", "connected (SELECT 1 ok)"),
463                Ok(Err(e)) => warn(
464                    "postgres",
465                    format!("unreachable: {e:#}"),
466                    "start it (docker compose up -d postgres) — traffic is fail-open meanwhile",
467                ),
468                Err(_) => warn(
469                    "postgres",
470                    "connect timeout (4s)",
471                    "check host/port/firewall — traffic is fail-open meanwhile",
472                ),
473            }
474        }
475        Err(e) => fail(
476            "postgres",
477            format!("DATABASE_URL invalid: {e:#}"),
478            "fix the connection string in .env",
479        ),
480    }
481}
482
483async fn probe_http(name: &'static str, port: u16, path: &str, optional: bool) -> CheckResult {
484    let url = format!("http://127.0.0.1:{port}{path}");
485    let probe = tokio::task::spawn_blocking(move || {
486        ureq::get(&url)
487            .config()
488            .timeout_global(Some(std::time::Duration::from_secs(2)))
489            .build()
490            .call()
491            .is_ok()
492    });
493    match probe.await {
494        Ok(true) => ok(name, format!("listening on {port}")),
495        _ if optional => warn(
496            name,
497            format!("nothing on {port} (gateway not running?)"),
498            "start it: docker compose up -d  (or lean-ctx gateway serve)",
499        ),
500        _ => warn(
501            name,
502            format!("nothing on {port} (gateway not running?)"),
503            "start it: docker compose up -d  (or lean-ctx gateway serve)",
504        ),
505    }
506}
507
508fn keys_path_for(dir: &Path) -> PathBuf {
509    let local = dir.join("gateway-keys.toml");
510    if local.exists() {
511        local
512    } else {
513        crate::proxy::gateway_identity::GatewayKeys::default_path()
514    }
515}
516
517/// True when any check failed (exit code driver).
518#[must_use]
519pub fn has_failures(results: &[CheckResult]) -> bool {
520    results.iter().any(|r| r.severity == Severity::Fail)
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn config_posture_flags_open_bind_without_tokens() {
529        let open: toml::Value =
530            toml::from_str("proxy_bind_host = \"0.0.0.0\"\nproxy_require_token = false").unwrap();
531        let results = check_config_values(&open);
532        assert!(
533            results
534                .iter()
535                .any(|r| r.name == "bind posture" && r.severity == Severity::Fail),
536            "open bind without token must FAIL"
537        );
538
539        let hardened: toml::Value =
540            toml::from_str("proxy_bind_host = \"0.0.0.0\"\nproxy_require_token = true").unwrap();
541        let results = check_config_values(&hardened);
542        assert!(
543            results
544                .iter()
545                .any(|r| r.name == "bind posture" && r.severity == Severity::Ok)
546        );
547    }
548
549    #[test]
550    fn provider_credential_check_consults_env_file_names() {
551        let cfg: toml::Value = toml::from_str(
552            r#"
553            [[proxy.providers]]
554            id = "foundry"
555            shape = "openai"
556            base_url = "https://x.services.ai.azure.com/models"
557            api_key_env = "FOUNDRY_API_KEY_DOCTOR_TEST"
558
559            [[proxy.providers]]
560            id = "disabled-one"
561            shape = "openai"
562            base_url = "https://y.example.com"
563            api_key_env = "NEVER_CHECKED"
564            enabled = false
565            "#,
566        )
567        .unwrap();
568
569        let missing = check_provider_credentials(&cfg, &[]);
570        assert_eq!(missing.len(), 1, "disabled providers are skipped");
571        assert_eq!(missing[0].severity, Severity::Fail);
572
573        let present = check_provider_credentials(&cfg, &["FOUNDRY_API_KEY_DOCTOR_TEST".into()]);
574        assert_eq!(present[0].severity, Severity::Ok);
575    }
576
577    #[test]
578    fn security_posture_flags_wide_admin_bind_and_insecure_upstreams() {
579        let hardened: toml::Value = toml::from_str(
580            "[gateway_server]\nadmin_bind_host = \"127.0.0.1\"\n[proxy]\nallow_insecure_http_upstream = false",
581        )
582        .unwrap();
583        let r = check_security_posture(&hardened);
584        assert!(r.iter().all(|c| c.severity == Severity::Ok));
585
586        let widened: toml::Value = toml::from_str(
587            "[gateway_server]\nadmin_bind_host = \"0.0.0.0\"\n[proxy]\nallow_insecure_http_upstream = true",
588        )
589        .unwrap();
590        let r = check_security_posture(&widened);
591        assert_eq!(
592            r.iter().filter(|c| c.severity == Severity::Warn).count(),
593            2,
594            "wide admin bind + plaintext upstream must both surface as warnings"
595        );
596
597        // Unset section: defaults are the hardened posture.
598        let empty: toml::Value = toml::from_str("").unwrap();
599        assert!(
600            check_security_posture(&empty)
601                .iter()
602                .all(|c| c.severity == Severity::Ok)
603        );
604    }
605
606    #[test]
607    fn pg_tls_posture_requires_tls_only_for_remote_hosts() {
608        assert_eq!(
609            check_pg_tls_posture("postgres://u:p@db.example.com:5432/app?sslmode=require").severity,
610            Severity::Ok
611        );
612        assert_eq!(
613            check_pg_tls_posture("postgres://u:p@postgres:5432/leanctx").severity,
614            Severity::Ok,
615            "compose-internal host stays plain without a warning"
616        );
617        assert_eq!(
618            check_pg_tls_posture("postgres://u:p@db.example.com:5432/app").severity,
619            Severity::Warn,
620            "remote host without sslmode=require must warn"
621        );
622    }
623
624    #[test]
625    fn env_file_parsing_extracts_doctor_relevant_keys() {
626        let tmp = tempfile::tempdir().unwrap();
627        let path = tmp.path().join(".env");
628        std::fs::write(
629            &path,
630            "# comment\nLEAN_CTX_PROXY_TOKEN=abc\nDATABASE_URL=postgres://u:p@h:5432/db\nOTHER=x\n",
631        )
632        .unwrap();
633        let env = parse_env_file(&path);
634        assert_eq!(env.proxy_token.as_deref(), Some("abc"));
635        assert_eq!(
636            env.database_url.as_deref(),
637            Some("postgres://u:p@h:5432/db")
638        );
639        assert_eq!(env.admin_token, None);
640        let names = parse_env_names(&path);
641        assert!(names.contains(&"OTHER".to_string()));
642    }
643
644    #[test]
645    fn failure_detection_drives_exit_code() {
646        assert!(!has_failures(&[ok("x", "fine")]));
647        assert!(has_failures(&[ok("x", "fine"), fail("y", "bad", "fix")]));
648        assert!(!has_failures(&[warn("z", "meh", "later")]));
649    }
650}