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 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 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 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 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 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 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 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 println!("lean-ctx gateway: starting proxy on port {} …", opts.port);
201 let result = crate::proxy::start_proxy(opts.port).await;
202
203 if pool.is_some() {
207 drain_usage_queue(std::time::Duration::from_secs(5)).await;
208 }
209 result
210}
211
212async 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
234fn 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 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
308async 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 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
431fn 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 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 assert!(out.contains("leanctx_usage_events_dropped_total"));
466 }
467
468 #[test]
469 fn admin_token_requires_non_empty() {
470 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}