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
345/// Constant-time byte comparison, side-channel-hardened against the
346/// admin-token *length* leaking through response timing (#962).
347///
348/// A plain `if a.len() != b.len() { return false }` early return is itself
349/// constant-time *per call*, but a fast rejection only ever happens when the
350/// guessed length is wrong — an attacker measuring many requests can binary
351/// search the presented token's length down to the expected token's exact
352/// length before ever needing to guess a single byte. Hashing both sides to
353/// a fixed-size (32-byte) digest first removes the correlation entirely:
354/// every comparison — regardless of either input's length — walks the same
355/// 32 bytes, so there is nothing left for a length probe to distinguish.
356fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
357    let ha = blake3::hash(a);
358    let hb = blake3::hash(b);
359    ha.as_bytes()
360        .iter()
361        .zip(hb.as_bytes())
362        .fold(0u8, |acc, (x, y)| acc | (x ^ y))
363        == 0
364}
365
366/// `GET /metrics` — Prometheus text exposition (enterprise#34).
367///
368/// Sourced from the live in-process meters (the proxy runs in this process):
369/// per-model measured usage/cost from `usage_meter`, sink drop counter from
370/// `usage_sink`, verified savings from the signed ledger. No timestamps — the
371/// scraper stamps samples (output-determinism rule #498 applies to bodies of
372/// tool outputs, not here, but stable ordering keeps diffs and dashboards sane).
373async fn metrics_handler() -> axum::response::Response {
374    let mut out = String::with_capacity(2048);
375    render_metrics(&mut out);
376    (
377        [(
378            axum::http::header::CONTENT_TYPE,
379            "text/plain; version=0.0.4",
380        )],
381        out,
382    )
383        .into_response()
384}
385
386fn render_metrics(out: &mut String) {
387    use std::fmt::Write as _;
388
389    let mut spend = crate::proxy::usage_meter::snapshot();
390    spend.sort_by(|a, b| a.model.cmp(&b.model));
391    let _ = writeln!(
392        out,
393        "# HELP leanctx_model_requests_total Measured requests per served model.\n# TYPE leanctx_model_requests_total counter"
394    );
395    for m in &spend {
396        let _ = writeln!(
397            out,
398            "leanctx_model_requests_total{{model=\"{}\"}} {}",
399            escape_label(&m.model),
400            m.requests
401        );
402    }
403    let _ = writeln!(
404        out,
405        "# HELP leanctx_model_tokens_total Billed tokens per served model and direction.\n# TYPE leanctx_model_tokens_total counter"
406    );
407    for m in &spend {
408        let model = escape_label(&m.model);
409        let _ = writeln!(
410            out,
411            "leanctx_model_tokens_total{{model=\"{model}\",direction=\"input\"}} {}",
412            m.input_tokens
413        );
414        let _ = writeln!(
415            out,
416            "leanctx_model_tokens_total{{model=\"{model}\",direction=\"output\"}} {}",
417            m.output_tokens
418        );
419        let _ = writeln!(
420            out,
421            "leanctx_model_tokens_total{{model=\"{model}\",direction=\"cache_read\"}} {}",
422            m.cache_read_tokens
423        );
424    }
425    let _ = writeln!(
426        out,
427        "# HELP leanctx_model_cost_usd_total Measured provider cost per served model (USD).\n# TYPE leanctx_model_cost_usd_total counter"
428    );
429    for m in &spend {
430        let _ = writeln!(
431            out,
432            "leanctx_model_cost_usd_total{{model=\"{}\"}} {}",
433            escape_label(&m.model),
434            m.cost_usd
435        );
436    }
437
438    let ledger = crate::core::savings_ledger::summary();
439    let _ = writeln!(
440        out,
441        "# HELP leanctx_saved_tokens_total Verified net tokens saved (signed ledger).\n# TYPE leanctx_saved_tokens_total counter\nleanctx_saved_tokens_total {}",
442        ledger.net_saved_tokens()
443    );
444    let _ = writeln!(
445        out,
446        "# HELP leanctx_saved_usd_total Verified USD saved (signed ledger).\n# TYPE leanctx_saved_usd_total counter\nleanctx_saved_usd_total {}",
447        ledger.saved_usd
448    );
449    for (mechanism, tokens, usd) in &ledger.by_mechanism {
450        let _ = writeln!(
451            out,
452            "leanctx_saved_by_mechanism_tokens_total{{mechanism=\"{}\"}} {tokens}",
453            escape_label(mechanism)
454        );
455        let _ = writeln!(
456            out,
457            "leanctx_saved_by_mechanism_usd_total{{mechanism=\"{}\"}} {usd}",
458            escape_label(mechanism)
459        );
460    }
461
462    let _ = writeln!(
463        out,
464        "# 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 {}",
465        crate::proxy::usage_sink::dropped_count()
466    );
467    let _ = writeln!(
468        out,
469        "# 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 {}",
470        super::mcp::metering::dropped_count()
471    );
472
473    // Org-policy gate (enterprise#25, #66): blocked-request counters.
474    let (blocked_model, blocked_budget, blocked_rate) =
475        crate::proxy::policy_gate::blocked_counters();
476    let _ = writeln!(
477        out,
478        "# HELP leanctx_policy_blocked_total Requests refused by the enforced org policy.\n# TYPE leanctx_policy_blocked_total counter"
479    );
480    let _ = writeln!(
481        out,
482        "leanctx_policy_blocked_total{{reason=\"model_ceiling\"}} {blocked_model}"
483    );
484    let _ = writeln!(
485        out,
486        "leanctx_policy_blocked_total{{reason=\"budget\"}} {blocked_budget}"
487    );
488    let _ = writeln!(
489        out,
490        "leanctx_policy_blocked_total{{reason=\"rate_limit\"}} {blocked_rate}"
491    );
492}
493
494/// Prometheus label values: escape backslash, quote and newline.
495fn escape_label(v: &str) -> String {
496    v.replace('\\', "\\\\")
497        .replace('"', "\\\"")
498        .replace('\n', "\\n")
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn label_escaping_covers_prometheus_specials() {
507        assert_eq!(escape_label(r#"a"b\c"#), r#"a\"b\\c"#);
508        assert_eq!(escape_label("x\ny"), "x\\ny");
509    }
510
511    #[test]
512    fn metrics_render_is_valid_exposition_shape() {
513        let mut out = String::new();
514        render_metrics(&mut out);
515        // Every non-comment line is `name{labels} value` or `name value`.
516        for line in out.lines().filter(|l| !l.starts_with('#') && !l.is_empty()) {
517            let (name_part, value) = line.rsplit_once(' ').expect("metric line has value");
518            assert!(
519                value.parse::<f64>().is_ok(),
520                "metric value must be numeric: {line}"
521            );
522            assert!(
523                name_part.starts_with("leanctx_"),
524                "metric namespace: {line}"
525            );
526        }
527        // The fail-open drop counter is always present (enterprise#12/#34).
528        assert!(out.contains("leanctx_usage_events_dropped_total"));
529    }
530
531    #[test]
532    fn admin_token_requires_non_empty() {
533        // Not set in the test env → None (admin listener stays off).
534        // (Uses a scoped var name to avoid mutating the real one.)
535        assert!(std::env::var(ADMIN_TOKEN_ENV).is_err() || admin_token().is_some());
536    }
537
538    #[test]
539    fn constant_time_eq_basic() {
540        assert!(constant_time_eq(b"abc", b"abc"));
541        assert!(!constant_time_eq(b"abc", b"abd"));
542        assert!(!constant_time_eq(b"abc", b"ab"));
543    }
544
545    #[test]
546    fn constant_time_eq_handles_wildly_different_lengths() {
547        // #962: the hash-first comparison must remain correct however far
548        // apart the two inputs' lengths are — not just off-by-one.
549        assert!(!constant_time_eq(b"", b"nonempty"));
550        assert!(constant_time_eq(b"", b""));
551        let short = b"tok";
552        let long = [b'a'; 4096];
553        assert!(!constant_time_eq(short, &long));
554    }
555}