1use std::sync::Arc;
24
25use axum::response::IntoResponse;
26
27pub const ADMIN_TOKEN_ENV: &str = "LEAN_CTX_GATEWAY_ADMIN_TOKEN";
30
31pub const DATABASE_URL_ENV: &str = "DATABASE_URL";
33
34#[derive(Debug, Clone)]
36pub struct ServeOptions {
37 pub port: u16,
39 pub admin_port: Option<u16>,
41}
42
43pub 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 crate::core::gain::live_pricing::spawn_background_refresh();
58
59 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 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 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 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 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 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 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 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 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 println!("lean-ctx gateway: starting proxy on port {} …", opts.port);
243 let result = crate::proxy::start_proxy(opts.port).await;
244
245 if pool.is_some() {
249 drain_usage_queue(std::time::Duration::from_secs(5)).await;
250 }
251 result
252}
253
254async 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
278fn 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 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
352async 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 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
480fn 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 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 assert!(out.contains("leanctx_usage_events_dropped_total"));
515 }
516
517 #[test]
518 fn admin_token_requires_non_empty() {
519 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}