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