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