1use std::sync::Arc;
24
25use axum::response::IntoResponse;
26
27use crate::core::ocla::OclaRegistry;
28
29pub const ADMIN_TOKEN_ENV: &str = "LEAN_CTX_GATEWAY_ADMIN_TOKEN";
32
33pub 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#[derive(Debug, Clone)]
55pub struct ServeOptions {
56 pub port: u16,
58 pub admin_port: Option<u16>,
60}
61
62pub 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 crate::core::gain::live_pricing::spawn_background_refresh();
77
78 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 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 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 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 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 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 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 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 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 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 println!("lean-ctx gateway: starting proxy on port {} …", opts.port);
278 let result = crate::proxy::start_proxy(opts.port).await;
279
280 if pool.is_some() {
284 drain_usage_queue(std::time::Duration::from_secs(5)).await;
285 }
286 result
287}
288
289async 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
313fn 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 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
380fn 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
401async fn metrics_handler() -> axum::response::Response {
409 let mut out = String::with_capacity(2048);
410 render_metrics(&mut out);
411
412 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 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
544fn 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 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 assert!(out.contains("leanctx_usage_events_dropped_total"));
579 }
580
581 #[test]
582 fn admin_token_requires_non_empty() {
583 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 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}