The office-readiness audit found the foundation missing: configured API keys were decorative. Any bearer — or none — passed every model endpoint, per-key rate limits existed only in the form, and all real traffic was recorded as "anonymous", leaving the per-key spend table permanently blank. A middleware on the model surface (/v1/*, /route, /mcp) now enforces keys: - no keys configured → open mode as before (the console already warns); - keys exist → Bearer or x-api-key must match (401 invalid_api_key); - per-key rate_per_min → fixed one-minute window, 429 with Retry-After; - NEW per-key monthly_budget_usd → 429 insufficient_quota once the account's month-to-date spend crosses the ceiling; - the matched account rides the request into stats — the keys table's spend columns finally see real traffic. Month-to-date spend is tracked per account in stats and persisted in the totals sidecar with a watermark (no double counting across restarts). The key dialog gains a budget field; the table shows "budget $N/mo" in red once exhausted. All verified live: 401 wrong/missing key, 200 + attribution with the right key, 429 + Retry-After over the limit, 429 insufficient_quota on the second request past a $0.000001 budget. Also: the dashboard shows the OpenRouter credit balance (GET /api/v1/credits via the stored key, cached 5 min, hidden without a key, red under $5 — live: $16.18), the server drains connections on Ctrl-C instead of dropping them, request bodies are capped at 32 MB, and docs/DEPLOY(.ru) cover office deployment: systemd/launchd/Windows service, nginx/caddy TLS, security checklist, backups. 10 strings ×7 languages.
358 lines
12 KiB
Rust
358 lines
12 KiB
Rust
//! Cortiq Gateway — entry point.
|
|
//!
|
|
//! Loads the config, builds the shared state, assembles routes from protocols and
|
|
//! the admin panel, and starts the HTTP server. See docs/ARCHITECTURE.md.
|
|
|
|
// Part of the canonical model / error API surface (auth, rate-limit, idempotency,
|
|
// traceparent, vision/streaming caps) is intentionally reserved for upcoming phases;
|
|
// allow the unused items until those features land.
|
|
#![allow(dead_code)]
|
|
|
|
mod admin;
|
|
mod bench;
|
|
mod cache;
|
|
mod cmf_runtime;
|
|
mod config;
|
|
mod error;
|
|
mod import;
|
|
mod logbuf;
|
|
mod media;
|
|
mod model;
|
|
mod pipeline;
|
|
mod promotion;
|
|
mod protocols;
|
|
mod providers;
|
|
mod registry;
|
|
mod router_client;
|
|
mod routing;
|
|
mod secrets;
|
|
mod state;
|
|
mod stats;
|
|
mod sysmon;
|
|
|
|
use axum::{routing::get, Router};
|
|
use clap::Parser;
|
|
use config::Config;
|
|
use state::SharedState;
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "cortiq-gateway",
|
|
version,
|
|
about = "Universal LLM gateway with intelligent routing"
|
|
)]
|
|
struct Args {
|
|
/// Path to the TOML config file. If omitted, uses `config/gateway.toml` when running
|
|
/// inside the repo, otherwise `~/.cortiq-gateway/gateway.toml` (or `$CORTIQ_GATEWAY_HOME`).
|
|
/// Missing file is created automatically — no manual copying needed.
|
|
#[arg(long)]
|
|
config: Option<String>,
|
|
/// Admin token for the web panel (overrides `[admin].token_env`).
|
|
#[arg(long)]
|
|
admin_token: Option<String>,
|
|
/// Do not automatically open the browser on first run.
|
|
#[arg(long)]
|
|
no_browser: bool,
|
|
/// Do not auto-select a free port if the configured one is busy.
|
|
#[arg(long)]
|
|
no_port_search: bool,
|
|
}
|
|
|
|
/// Resolve the admin token: env from `[admin].token_env` → `--admin-token` →
|
|
///
|
|
/// By default no token is required (open mode). User sets it explicitly via
|
|
/// `--admin-token`, `[admin].token` or `[admin].token_env` if they want auth.
|
|
fn resolve_admin_token(cfg: &Config, args: &Args) -> String {
|
|
if let Some(env) = &cfg.admin.token_env {
|
|
if let Ok(v) = std::env::var(env) {
|
|
if !v.is_empty() {
|
|
return v;
|
|
}
|
|
}
|
|
}
|
|
if let Some(t) = &args.admin_token {
|
|
if !t.is_empty() {
|
|
return t.clone();
|
|
}
|
|
}
|
|
if let Some(t) = &cfg.admin.token {
|
|
if !t.is_empty() {
|
|
return t.clone();
|
|
}
|
|
}
|
|
String::new()
|
|
}
|
|
|
|
fn is_docker() -> bool {
|
|
std::path::Path::new("/.dockerenv").exists()
|
|
|| std::fs::read_to_string("/proc/1/cgroup")
|
|
.map(|s| s.contains("docker") || s.contains("kubepods"))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn should_open_browser(args: &Args) -> bool {
|
|
if args.no_browser {
|
|
return false;
|
|
}
|
|
if std::env::var("CORTIQ_NO_BROWSER")
|
|
.map(|v| !v.is_empty() && v != "0")
|
|
.unwrap_or(false)
|
|
{
|
|
return false;
|
|
}
|
|
if std::env::var("CI").is_ok() || std::env::var("NO_BROWSER").is_ok() {
|
|
return false;
|
|
}
|
|
if is_docker() {
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
fn parse_listen(listen: &str) -> (String, u16) {
|
|
if let Some((host, port_s)) = listen.rsplit_once(':') {
|
|
if let Ok(p) = port_s.parse::<u16>() {
|
|
return (host.to_string(), p);
|
|
}
|
|
}
|
|
(listen.to_string(), 9000)
|
|
}
|
|
|
|
fn is_port_free(host: &str, port: u16) -> bool {
|
|
let addr = format!("{host}:{port}");
|
|
// bind test + connect test (docker port forwarding can make bind succeed while port is still forwarded)
|
|
if std::net::TcpListener::bind(&addr).is_err() {
|
|
return false;
|
|
}
|
|
use std::net::ToSocketAddrs;
|
|
let probe_host = if host == "0.0.0.0" { "127.0.0.1" } else { host };
|
|
if let Ok(mut addrs) = (probe_host, port).to_socket_addrs() {
|
|
if let Some(a) = addrs.next() {
|
|
if std::net::TcpStream::connect_timeout(&a, std::time::Duration::from_millis(200))
|
|
.is_ok()
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
fn resolve_listen(cfg: &mut Config, no_search: bool) -> String {
|
|
if no_search {
|
|
return cfg.listen.clone();
|
|
}
|
|
let (host, port) = parse_listen(&cfg.listen);
|
|
let clean_host = if host.is_empty() {
|
|
"0.0.0.0".to_string()
|
|
} else {
|
|
host
|
|
};
|
|
if is_port_free(&clean_host, port) {
|
|
return cfg.listen.clone();
|
|
}
|
|
for p in (port + 1)..=port.saturating_add(50) {
|
|
if is_port_free(&clean_host, p) {
|
|
let new_listen = format!("{clean_host}:{p}");
|
|
eprintln!("port {port} busy — switching to free port {p} ({new_listen})");
|
|
cfg.listen = new_listen.clone();
|
|
return new_listen;
|
|
}
|
|
}
|
|
if !is_port_free(&clean_host, port) {
|
|
eprintln!(
|
|
"warning: port {port} busy and no free port found in next 50, will try to bind anyway"
|
|
);
|
|
}
|
|
cfg.listen.clone()
|
|
}
|
|
|
|
fn setup_marker_path(config_path: &str) -> std::path::PathBuf {
|
|
// marker lives next to config file: gateway.toml -> .setup_done
|
|
std::path::Path::new(config_path)
|
|
.parent()
|
|
.unwrap_or_else(|| std::path::Path::new("."))
|
|
.join(".setup_done")
|
|
}
|
|
|
|
fn needs_first_run_wizard(cfg: &Config, config_path: &str) -> bool {
|
|
let marker = setup_marker_path(config_path);
|
|
if marker.exists() {
|
|
return false;
|
|
}
|
|
// also consider "needs setup" if model pool is empty and no managed cmf
|
|
if !cfg.models.is_empty() || !cfg.cmf.effective_servers().is_empty() {
|
|
// still show wizard once if marker missing, but not if user already has models
|
|
// we treat missing marker + existing models as "already configured" to avoid nagging
|
|
// persist marker lazily
|
|
let _ = std::fs::write(&marker, "done");
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
let args = Args::parse();
|
|
let config_path = args
|
|
.config
|
|
.clone()
|
|
.unwrap_or_else(config::default_config_path);
|
|
|
|
let mut cfg = Config::load_or_create(&config_path)?;
|
|
// auto-pick a free port if the configured one is busy (unless disabled)
|
|
let original_listen = cfg.listen.clone();
|
|
let listen = resolve_listen(&mut cfg, args.no_port_search);
|
|
// persist the resolved port if it changed
|
|
if listen != original_listen {
|
|
let _ = cfg.save(&config_path);
|
|
}
|
|
{
|
|
use tracing_subscriber::layer::SubscriberExt;
|
|
use tracing_subscriber::util::SubscriberInitExt;
|
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| cfg.log.level.clone().into());
|
|
tracing_subscriber::registry()
|
|
.with(filter)
|
|
.with(tracing_subscriber::fmt::layer())
|
|
// second copy of every line, without ANSI, into the in-memory ring
|
|
// that backs the admin console's Логи view
|
|
.with(
|
|
tracing_subscriber::fmt::layer()
|
|
.with_ansi(false)
|
|
.with_writer(logbuf::RingMakeWriter),
|
|
)
|
|
.init();
|
|
}
|
|
|
|
let admin_enabled = cfg.admin.enabled;
|
|
let admin_token = resolve_admin_token(&cfg, &args);
|
|
let first_run = needs_first_run_wizard(&cfg, &config_path);
|
|
|
|
let state = SharedState::build(cfg, config_path.clone())?;
|
|
|
|
// Open admin console reachable from other machines: fine on a laptop,
|
|
// a footgun on a VPS. Flag it for the UI banner and say it in the log.
|
|
let host_public = listen.starts_with("0.0.0.0") || listen.starts_with("[::]");
|
|
if admin_enabled && admin_token.is_empty() && host_public && !is_docker() {
|
|
state
|
|
.admin_open_public
|
|
.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
tracing::warn!(
|
|
"admin console is OPEN (no token) and listening on {listen} — anyone on your network can manage this gateway; set a token via --admin-token or [admin].token if this machine is reachable from outside"
|
|
);
|
|
}
|
|
|
|
// Close the loop with the CMF format (github.com/infosave2007/cmf): install
|
|
// / update cortiq-cli and run a local `cortiq serve` if configured. Runs in
|
|
// the background so startup is never blocked by a cargo install or a model
|
|
// load; the served model is registered in the pool and usable offline.
|
|
{
|
|
let cmf_rt = state.cmf.clone();
|
|
let cmf_cfg = state.live().cfg.cmf.clone();
|
|
tokio::spawn(async move { cmf_runtime::manage(cmf_rt, cmf_cfg).await });
|
|
}
|
|
|
|
let mut app = Router::new()
|
|
.route("/healthz", get(|| async { "ok" }))
|
|
.route("/health", get(|| async { "ok" }))
|
|
.route("/readyz", get(|| async { "ready" }))
|
|
.route("/metrics", get(admin::metrics))
|
|
.merge(protocols::build_router(state.clone()));
|
|
|
|
if admin_enabled {
|
|
app = app
|
|
.merge(admin::api_routes(admin_token.clone()))
|
|
.fallback(admin::assets::fallback);
|
|
}
|
|
|
|
let cors = tower_http::cors::CorsLayer::new()
|
|
.allow_origin(tower_http::cors::Any)
|
|
.allow_methods(tower_http::cors::Any)
|
|
.allow_headers(tower_http::cors::Any);
|
|
|
|
// 32 MB request ceiling: enough for base64 keyframes and TOML imports,
|
|
// small enough that a hostile client can't balloon memory
|
|
let app =
|
|
app.with_state(state)
|
|
.layer(cors)
|
|
.layer(tower_http::limit::RequestBodyLimitLayer::new(
|
|
32 * 1024 * 1024,
|
|
));
|
|
|
|
let listener = {
|
|
let mut attempt = 0u32;
|
|
loop {
|
|
match tokio::net::TcpListener::bind(&listen).await {
|
|
Ok(l) => break l,
|
|
Err(e) => {
|
|
// during self-restart the old process is still letting go of
|
|
// the port — retry for a few seconds before giving up
|
|
let retrying = std::env::var("CORTIQ_BIND_RETRY").is_ok();
|
|
if retrying && attempt < 50 {
|
|
attempt += 1;
|
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
continue;
|
|
}
|
|
return Err(e.into());
|
|
}
|
|
}
|
|
}
|
|
};
|
|
tracing::info!("cortiq-gateway listening on {listen}");
|
|
if admin_enabled {
|
|
let host_for_url = if listen.starts_with("0.0.0.0:") {
|
|
listen.replacen("0.0.0.0", "127.0.0.1", 1)
|
|
} else {
|
|
listen.clone()
|
|
};
|
|
// On first run open the onboarding wizard, otherwise the normal console.
|
|
let admin_path = if first_run {
|
|
"/admin#/onboarding"
|
|
} else {
|
|
"/admin"
|
|
};
|
|
let url = if admin_token.is_empty() {
|
|
format!("http://{host_for_url}{admin_path}")
|
|
} else {
|
|
format!("http://{host_for_url}{admin_path}?token={admin_token}")
|
|
};
|
|
if admin_token.is_empty() {
|
|
tracing::info!("admin console (open, no token): http://{host_for_url}/admin");
|
|
} else {
|
|
tracing::info!("admin console: http://{host_for_url}/admin");
|
|
}
|
|
if first_run {
|
|
tracing::info!("first run — opening onboarding wizard at {url}");
|
|
}
|
|
if admin_enabled && should_open_browser(&args) {
|
|
let browser_url = url.clone();
|
|
tokio::spawn(async move {
|
|
// give server a moment to be ready
|
|
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
|
|
#[cfg(feature = "open-browser")]
|
|
{
|
|
if let Err(e) = open::that(&browser_url) {
|
|
tracing::warn!("failed to open browser: {e}");
|
|
} else {
|
|
tracing::info!("opened browser at {browser_url}");
|
|
}
|
|
}
|
|
#[cfg(not(feature = "open-browser"))]
|
|
{
|
|
tracing::info!("open browser: {browser_url} (feature disabled)");
|
|
}
|
|
});
|
|
} else if first_run {
|
|
tracing::info!("open this URL to finish setup: {url}");
|
|
}
|
|
}
|
|
axum::serve(listener, app)
|
|
.with_graceful_shutdown(async {
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
tracing::info!("shutdown signal received — draining connections");
|
|
})
|
|
.await?;
|
|
Ok(())
|
|
}
|