Skip to main content

lean_ctx/gateway_server/
serve.rs

1//! `lean-ctx gateway serve` (enterprise#10) — the self-hosted org gateway.
2//!
3//! One process, three parts:
4//!
5//! 1. **Proxy** — the existing `proxy::start_proxy_with_token` with its
6//!    gateway hardening (`proxy_bind_host`, host allowlist, strict Bearer,
7//!    rate limit; enterprise#8/#37). Nothing proxy-related changes here.
8//! 2. **Usage store** — Postgres `usage_events` writer via `store::spawn_writer`
9//!    (enterprise#17/#18), wired to the proxy's `usage_sink`.
10//! 3. **Admin listener** — a *separate* port serving `GET /api/admin/usage`
11//!    (enterprise#20) and `GET /metrics` (Prometheus, enterprise#34) behind its
12//!    own Bearer token, plus an unauthenticated `/healthz`. Separate on purpose:
13//!    deployments keep it cluster-internal (no ingress) while the proxy port is
14//!    the only exposed surface.
15//!
16//! **Fail-open is the core rule (enterprise#12):** LLM traffic never depends on
17//! the periphery. Postgres down at startup → warn and serve anyway (the writer
18//! retries per event and drops, counted). Admin token missing → admin listener
19//! stays off, proxy serves. Store insert failures → logged, never propagated.
20//! The only hard startup failures are a malformed `gateway-keys.toml` (auth
21//! correctness) and an unbindable proxy port.
22
23use std::sync::Arc;
24
25use axum::response::IntoResponse;
26
27/// Environment variable holding the admin Bearer token. Env-only by design —
28/// tokens never live in config.toml (same rule as `LEAN_CTX_PROXY_TOKEN`).
29pub const ADMIN_TOKEN_ENV: &str = "LEAN_CTX_GATEWAY_ADMIN_TOKEN";
30
31/// Environment variable with the Postgres connection string for `usage_events`.
32pub const DATABASE_URL_ENV: &str = "DATABASE_URL";
33
34/// Options parsed by the CLI (`lean-ctx gateway serve`).
35#[derive(Debug, Clone)]
36pub struct ServeOptions {
37    /// Proxy port (the exposed surface). Defaults to the standard proxy port.
38    pub port: u16,
39    /// Admin/metrics port. Defaults to `port + 1`.
40    pub admin_port: Option<u16>,
41}
42
43/// Runs the gateway until shutdown. See module docs for the composition.
44///
45/// # Errors
46/// Fails on invalid gateway keys or an unbindable proxy/admin port — never on
47/// unavailable periphery (Postgres, missing admin token).
48pub async fn serve(opts: ServeOptions) -> anyhow::Result<()> {
49    let cfg = crate::core::config::Config::load();
50    let admin_port = opts
51        .admin_port
52        .unwrap_or_else(|| opts.port.saturating_add(1));
53
54    // Live model prices (#1179): every usage_events row is priced through
55    // ModelPricing — keep the provider price list current so unknown models
56    // never fall back to stale family heuristics. Fail-open.
57    crate::core::gain::live_pricing::spawn_background_refresh();
58
59    // -- Usage store (fail-open, enterprise#12/#17) -------------------------
60    let pool = match std::env::var(DATABASE_URL_ENV) {
61        Ok(url) if !url.trim().is_empty() => match super::store::pool_from_database_url(&url) {
62            Ok(pool) => {
63                match super::store::init_schema(&pool).await {
64                    Ok(()) => println!("  Store:     usage_events ready (Postgres)"),
65                    Err(e) => {
66                        // Pool stays: the writer retries per event once PG is back.
67                        println!(
68                            "  Store:     ⚠ Postgres unreachable at startup (fail-open): {e:#}"
69                        );
70                    }
71                }
72                if super::store::spawn_writer(pool.clone()) {
73                    Some(pool)
74                } else {
75                    tracing::warn!("usage sink already installed — store writer not started twice");
76                    Some(pool)
77                }
78            }
79            Err(e) => {
80                println!(
81                    "  Store:     ⚠ invalid {DATABASE_URL_ENV} (fail-open, metering off): {e:#}"
82                );
83                None
84            }
85        },
86        _ => {
87            println!(
88                "  Store:     off — set {DATABASE_URL_ENV} to enable org-wide usage_events metering"
89            );
90            None
91        }
92    };
93
94    // Personal usage view (enterprise#64): give `/me` on the proxy port its
95    // read path into the store. Without a store the endpoint answers 503 with
96    // an actionable error — never a broken page.
97    if let Some(pool) = pool.clone() {
98        super::user_api::install_pool(pool);
99        println!(
100            "  Me-View:   http://<gateway-host>:{}/me — personal usage, sign in with your own gateway key",
101            opts.port
102        );
103    }
104
105    // MCP observe channel (GL#91): with both a store and a registered MCP
106    // server, meter the tool channel into `mcp_events` + `mcp_tool_inventory`.
107    // Same fail-open contract as the LLM store — schema init failure degrades
108    // bookkeeping (the writer retries per event), never tool traffic.
109    let mcp_registered = !cfg
110        .gateway_server
111        .resolve_mcp_servers(cfg.proxy.allows_insecure_http_upstream())
112        .is_empty();
113    if mcp_registered && let Some(pool) = pool.clone() {
114        match super::mcp::store::init_schema(&pool).await {
115            Ok(()) => println!("  MCP-Store: mcp_events + tool inventory ready (Postgres)"),
116            Err(e) => {
117                println!("  MCP-Store: ⚠ Postgres unreachable at startup (fail-open): {e:#}");
118            }
119        }
120        if !super::mcp::metering::spawn_writer(pool) {
121            tracing::warn!("mcp metering sink already installed — writer not started twice");
122        }
123    } else if mcp_registered {
124        println!("  MCP-Store: off — set {DATABASE_URL_ENV} to meter MCP tool calls (mcp_events)");
125    }
126
127    // -- Admin listener (dashboard + admin API + /metrics, #20/#34/#45) -----
128    match (pool.clone(), admin_token()) {
129        (Some(pool), Some(token)) => {
130            let state = super::admin_api::AdminState {
131                pool,
132                seats: cfg.gateway_server.seats,
133                org_label: cfg.gateway_server.org_label.clone(),
134                started_at: std::time::Instant::now(),
135                providers: super::admin_status::provider_statuses(&cfg.proxy.resolve_providers()),
136                routing_enabled: cfg.proxy.routing.is_active(),
137                routing_aliases: cfg.proxy.routing.aliases.clone(),
138                reference_model: cfg.proxy.baseline.reference_model.clone(),
139                local_shadow_rate: cfg.proxy.baseline.effective_local_shadow_rate(),
140                mcp_servers: cfg
141                    .gateway_server
142                    .resolve_mcp_servers(cfg.proxy.allows_insecure_http_upstream()),
143            };
144            let router = admin_router(state, token);
145            // Secure by default (#54/#56): loopback unless explicitly widened
146            // via [gateway_server].admin_bind_host / the env override.
147            let bind_host = cfg.gateway_server.resolved_admin_bind_host();
148            let addr = std::net::SocketAddr::new(bind_host, admin_port);
149            let listener = tokio::net::TcpListener::bind(addr).await?;
150            let exposure = if bind_host.is_loopback() {
151                "host-local"
152            } else {
153                "network-exposed — front with TLS"
154            };
155            println!(
156                "  Admin:     http://{addr}/ ({exposure}) — dashboard + /api/admin/* + /metrics (Bearer via {ADMIN_TOKEN_ENV})"
157            );
158            tokio::spawn(async move {
159                if let Err(e) = axum::serve(
160                    listener,
161                    router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
162                )
163                .await
164                {
165                    tracing::warn!("admin listener terminated (proxy unaffected): {e:#}");
166                }
167            });
168        }
169        (Some(_), None) => {
170            println!(
171                "  Admin:     off — set {ADMIN_TOKEN_ENV} to serve the dashboard + /api/admin/*"
172            );
173        }
174        (None, _) => {
175            println!("  Admin:     off — requires the usage store ({DATABASE_URL_ENV})");
176        }
177    }
178
179    // -- Budget seeding (enterprise#25) --------------------------------------
180    // With a store present, periodically replace the in-memory budget windows
181    // with authoritative sums from usage_events so caps survive restarts and
182    // hold across replicas. Fail-open: a failed query keeps the last seed +
183    // live in-process counting.
184    if let Some(pool) = pool.clone() {
185        tokio::spawn(async move {
186            loop {
187                match super::store::budget_window_sums(&pool).await {
188                    Ok((person_day, project_month)) => {
189                        crate::proxy::policy_gate::seed_from_store(person_day, project_month);
190                    }
191                    Err(e) => {
192                        tracing::debug!("budget seed skipped (store unreachable): {e:#}");
193                    }
194                }
195                tokio::time::sleep(std::time::Duration::from_secs(30)).await;
196            }
197        });
198    }
199
200    // -- Usage retention (enterprise#36) --------------------------------------
201    // [gateway_server].usage_retention_days > 0 purges older rows periodically.
202    // Unset/0 keeps everything — retention is an explicit deployment decision.
203    let retention_days = crate::core::config::Config::load()
204        .gateway_server
205        .usage_retention_days
206        .unwrap_or(0);
207    if retention_days > 0
208        && let Some(pool) = pool.clone()
209    {
210        println!(
211            "  Retention: usage_events + mcp_events kept {retention_days} days (purge every 6h)"
212        );
213        tokio::spawn(async move {
214            loop {
215                match super::store::purge_events_older_than(&pool, retention_days).await {
216                    Ok(0) => {}
217                    Ok(purged) => {
218                        tracing::info!(
219                            "usage retention: purged {purged} events older than {retention_days} days"
220                        );
221                    }
222                    Err(e) => {
223                        tracing::debug!("usage retention purge skipped: {e:#}");
224                    }
225                }
226                // MCP events share the retention window (GL#102). A missing
227                // mcp_events table (no MCP traffic ever) is a silent no-op.
228                match super::mcp::store::purge_events_older_than(&pool, retention_days).await {
229                    Ok(0) | Err(_) => {}
230                    Ok(purged) => {
231                        tracing::info!(
232                            "mcp retention: purged {purged} events older than {retention_days} days"
233                        );
234                    }
235                }
236                tokio::time::sleep(std::time::Duration::from_hours(6)).await;
237            }
238        });
239    }
240
241    // -- Proxy (blocking; the actual gateway surface) ------------------------
242    println!("lean-ctx gateway: starting proxy on port {} …", opts.port);
243    let result = crate::proxy::start_proxy(opts.port).await;
244
245    // Graceful drain (enterprise#51): the proxy returned after SIGTERM/Ctrl-C
246    // finished in-flight requests; give the store writer a bounded window to
247    // flush queued usage events so a rollout doesn't shed metering.
248    if pool.is_some() {
249        drain_usage_queue(std::time::Duration::from_secs(5)).await;
250    }
251    result
252}
253
254/// Waits until the usage + MCP sink queues are empty or the deadline passes.
255async fn drain_usage_queue(max_wait: std::time::Duration) {
256    let started = std::time::Instant::now();
257    let pending =
258        || crate::proxy::usage_sink::pending_count() + super::mcp::metering::pending_count();
259    let mut left = pending();
260    while left > 0 && started.elapsed() < max_wait {
261        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
262        left = pending();
263    }
264    if left > 0 {
265        tracing::warn!("shutdown drain window elapsed with {left} event(s) unflushed");
266    } else {
267        println!("  Store:     usage queue drained.");
268    }
269}
270
271fn admin_token() -> Option<String> {
272    std::env::var(ADMIN_TOKEN_ENV)
273        .ok()
274        .map(|t| t.trim().to_string())
275        .filter(|t| !t.is_empty())
276}
277
278/// Admin router: `/healthz` and the dashboard's static shell open (the login
279/// screen must render without a token), all data APIs + /metrics Bearer-guarded.
280/// Every response passes the security-header layer (#54/#55); failed auth is
281/// throttled per IP and audit-logged (#54/#57).
282fn admin_router(state: super::admin_api::AdminState, token: String) -> axum::Router {
283    let token = Arc::new(token);
284    let throttle = Arc::new(super::security::AuthThrottle::default());
285    super::admin_api::router(state)
286        .route("/metrics", axum::routing::get(metrics_handler))
287        .layer(axum::middleware::from_fn(move |req, next| {
288            let token = token.clone();
289            let throttle = throttle.clone();
290            admin_auth_guard(req, next, token, throttle)
291        }))
292        .route("/healthz", axum::routing::get(|| async { "ok" }))
293        .merge(super::admin_ui::router())
294        .layer(axum::middleware::from_fn(super::security::security_headers))
295}
296
297async fn admin_auth_guard(
298    req: axum::extract::Request,
299    next: axum::middleware::Next,
300    expected: Arc<String>,
301    throttle: Arc<super::security::AuthThrottle>,
302) -> Result<axum::response::Response, axum::response::Response> {
303    let client_ip = req
304        .extensions()
305        .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
306        .map_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |c| {
307            c.0.ip()
308        });
309
310    if throttle.is_blocked(client_ip) {
311        tracing::warn!("admin auth throttled: {client_ip} exceeded the failed-attempt budget");
312        return Err((
313            axum::http::StatusCode::TOO_MANY_REQUESTS,
314            [(axum::http::header::RETRY_AFTER, "60")],
315            axum::Json(serde_json::json!({"error": "too many failed attempts — retry later"})),
316        )
317            .into_response());
318    }
319
320    let ok = req
321        .headers()
322        .get("authorization")
323        .and_then(|v| v.to_str().ok())
324        .and_then(|auth| auth.strip_prefix("Bearer "))
325        .is_some_and(|token| constant_time_eq(token.as_bytes(), expected.as_bytes()));
326    if ok {
327        throttle.record_success(client_ip);
328        Ok(next.run(req).await)
329    } else {
330        // Audit trail (#57): one structured line per failure — SIEM-collectable
331        // via the standard log pipeline. Never logs the presented credential.
332        let failures = throttle.record_failure(client_ip);
333        let path = req.uri().path();
334        tracing::warn!("admin auth failed: ip={client_ip} path={path} window_failures={failures}");
335        Err((
336            axum::http::StatusCode::UNAUTHORIZED,
337            axum::Json(
338                serde_json::json!({"error": format!("Bearer token required ({ADMIN_TOKEN_ENV})")}),
339            ),
340        )
341            .into_response())
342    }
343}
344
345fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
346    if a.len() != b.len() {
347        return false;
348    }
349    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
350}
351
352/// `GET /metrics` — Prometheus text exposition (enterprise#34).
353///
354/// Sourced from the live in-process meters (the proxy runs in this process):
355/// per-model measured usage/cost from `usage_meter`, sink drop counter from
356/// `usage_sink`, verified savings from the signed ledger. No timestamps — the
357/// scraper stamps samples (output-determinism rule #498 applies to bodies of
358/// tool outputs, not here, but stable ordering keeps diffs and dashboards sane).
359async fn metrics_handler() -> axum::response::Response {
360    let mut out = String::with_capacity(2048);
361    render_metrics(&mut out);
362    (
363        [(
364            axum::http::header::CONTENT_TYPE,
365            "text/plain; version=0.0.4",
366        )],
367        out,
368    )
369        .into_response()
370}
371
372fn render_metrics(out: &mut String) {
373    use std::fmt::Write as _;
374
375    let mut spend = crate::proxy::usage_meter::snapshot();
376    spend.sort_by(|a, b| a.model.cmp(&b.model));
377    let _ = writeln!(
378        out,
379        "# HELP leanctx_model_requests_total Measured requests per served model.\n# TYPE leanctx_model_requests_total counter"
380    );
381    for m in &spend {
382        let _ = writeln!(
383            out,
384            "leanctx_model_requests_total{{model=\"{}\"}} {}",
385            escape_label(&m.model),
386            m.requests
387        );
388    }
389    let _ = writeln!(
390        out,
391        "# HELP leanctx_model_tokens_total Billed tokens per served model and direction.\n# TYPE leanctx_model_tokens_total counter"
392    );
393    for m in &spend {
394        let model = escape_label(&m.model);
395        let _ = writeln!(
396            out,
397            "leanctx_model_tokens_total{{model=\"{model}\",direction=\"input\"}} {}",
398            m.input_tokens
399        );
400        let _ = writeln!(
401            out,
402            "leanctx_model_tokens_total{{model=\"{model}\",direction=\"output\"}} {}",
403            m.output_tokens
404        );
405        let _ = writeln!(
406            out,
407            "leanctx_model_tokens_total{{model=\"{model}\",direction=\"cache_read\"}} {}",
408            m.cache_read_tokens
409        );
410    }
411    let _ = writeln!(
412        out,
413        "# HELP leanctx_model_cost_usd_total Measured provider cost per served model (USD).\n# TYPE leanctx_model_cost_usd_total counter"
414    );
415    for m in &spend {
416        let _ = writeln!(
417            out,
418            "leanctx_model_cost_usd_total{{model=\"{}\"}} {}",
419            escape_label(&m.model),
420            m.cost_usd
421        );
422    }
423
424    let ledger = crate::core::savings_ledger::summary();
425    let _ = writeln!(
426        out,
427        "# HELP leanctx_saved_tokens_total Verified net tokens saved (signed ledger).\n# TYPE leanctx_saved_tokens_total counter\nleanctx_saved_tokens_total {}",
428        ledger.net_saved_tokens()
429    );
430    let _ = writeln!(
431        out,
432        "# HELP leanctx_saved_usd_total Verified USD saved (signed ledger).\n# TYPE leanctx_saved_usd_total counter\nleanctx_saved_usd_total {}",
433        ledger.saved_usd
434    );
435    for (mechanism, tokens, usd) in &ledger.by_mechanism {
436        let _ = writeln!(
437            out,
438            "leanctx_saved_by_mechanism_tokens_total{{mechanism=\"{}\"}} {tokens}",
439            escape_label(mechanism)
440        );
441        let _ = writeln!(
442            out,
443            "leanctx_saved_by_mechanism_usd_total{{mechanism=\"{}\"}} {usd}",
444            escape_label(mechanism)
445        );
446    }
447
448    let _ = writeln!(
449        out,
450        "# HELP leanctx_usage_events_dropped_total Usage events dropped because the store writer was saturated (fail-open).\n# TYPE leanctx_usage_events_dropped_total counter\nleanctx_usage_events_dropped_total {}",
451        crate::proxy::usage_sink::dropped_count()
452    );
453    let _ = writeln!(
454        out,
455        "# HELP leanctx_mcp_events_dropped_total MCP exchanges dropped because the metering writer was saturated (fail-open).\n# TYPE leanctx_mcp_events_dropped_total counter\nleanctx_mcp_events_dropped_total {}",
456        super::mcp::metering::dropped_count()
457    );
458
459    // Org-policy gate (enterprise#25, #66): blocked-request counters.
460    let (blocked_model, blocked_budget, blocked_rate) =
461        crate::proxy::policy_gate::blocked_counters();
462    let _ = writeln!(
463        out,
464        "# HELP leanctx_policy_blocked_total Requests refused by the enforced org policy.\n# TYPE leanctx_policy_blocked_total counter"
465    );
466    let _ = writeln!(
467        out,
468        "leanctx_policy_blocked_total{{reason=\"model_ceiling\"}} {blocked_model}"
469    );
470    let _ = writeln!(
471        out,
472        "leanctx_policy_blocked_total{{reason=\"budget\"}} {blocked_budget}"
473    );
474    let _ = writeln!(
475        out,
476        "leanctx_policy_blocked_total{{reason=\"rate_limit\"}} {blocked_rate}"
477    );
478}
479
480/// Prometheus label values: escape backslash, quote and newline.
481fn escape_label(v: &str) -> String {
482    v.replace('\\', "\\\\")
483        .replace('"', "\\\"")
484        .replace('\n', "\\n")
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn label_escaping_covers_prometheus_specials() {
493        assert_eq!(escape_label(r#"a"b\c"#), r#"a\"b\\c"#);
494        assert_eq!(escape_label("x\ny"), "x\\ny");
495    }
496
497    #[test]
498    fn metrics_render_is_valid_exposition_shape() {
499        let mut out = String::new();
500        render_metrics(&mut out);
501        // Every non-comment line is `name{labels} value` or `name value`.
502        for line in out.lines().filter(|l| !l.starts_with('#') && !l.is_empty()) {
503            let (name_part, value) = line.rsplit_once(' ').expect("metric line has value");
504            assert!(
505                value.parse::<f64>().is_ok(),
506                "metric value must be numeric: {line}"
507            );
508            assert!(
509                name_part.starts_with("leanctx_"),
510                "metric namespace: {line}"
511            );
512        }
513        // The fail-open drop counter is always present (enterprise#12/#34).
514        assert!(out.contains("leanctx_usage_events_dropped_total"));
515    }
516
517    #[test]
518    fn admin_token_requires_non_empty() {
519        // Not set in the test env → None (admin listener stays off).
520        // (Uses a scoped var name to avoid mutating the real one.)
521        assert!(std::env::var(ADMIN_TOKEN_ENV).is_err() || admin_token().is_some());
522    }
523
524    #[test]
525    fn constant_time_eq_basic() {
526        assert!(constant_time_eq(b"abc", b"abc"));
527        assert!(!constant_time_eq(b"abc", b"abd"));
528        assert!(!constant_time_eq(b"abc", b"ab"));
529    }
530}