//! 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, /// Admin token for the web panel (overrides `[admin].token_env`). #[arg(long)] admin_token: Option, /// 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::() { 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(()) }