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 results.extend(check_provider_credentials(
204 &v,
205 &parse_env_names(&dir.join(".env")),
206 ));
207 }
208
209 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
216fn 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
253fn 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
291fn 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
303fn 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
338fn 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 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#[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 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}