1use std::fmt;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Severity {
15 Ok,
16 Warn,
17 Fail,
18}
19
20#[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#[derive(Debug, Default)]
71struct EnvFile {
72 proxy_token: Option<String>,
73 admin_token: Option<String>,
74 database_url: Option<String>,
75}
76
77fn 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
101pub async fn run_checks(dir: &Path, proxy_port: u16, admin_port: u16) -> Vec<CheckResult> {
103 let mut results = Vec::new();
104
105 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 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 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 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 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
215fn 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
252fn 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
290fn 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
302fn 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
337async 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 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
406async 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
430fn 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 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#[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 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}