//! Сборка axum-роутера: монтирует контроллеры всех модулей под `/api/v1` и //! `/api/v1/admin`, настраивает rate-limiting (tower_governor), CORS, Prometheus-метрики //! и health-пробы. Раздача собранного фронтенда (SPA fallback на index.html) висит на //! этом же роутере как `fallback_service`, поэтому бэкенд и фронт всегда деплоятся вместе. use axum::{ extract::{MatchedPath, Request, State}, http::{ header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, Method, StatusCode, }, middleware::{self, Next}, response::{IntoResponse, Response}, routing::get, Json, Router, }; use metrics_exporter_prometheus::{Matcher, PrometheusBuilder}; use serde_json::json; use std::{sync::Arc, time::Instant}; use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer}; use tower_http::{ cors::CorsLayer, services::{ServeDir, ServeFile}, trace::TraceLayer, }; use crate::db::repository::AppRepository; use crate::modules::{ auth::{controller as auth_ctrl, service::AuthService}, billing::{controller as bill_ctrl, service::BillingService}, nodes::{controller as node_ctrl, service::NodeService}, proxy_internal, referrals::{controller as ref_ctrl, service::ReferralService}, staff::{controller as staff_ctrl, service::StaffService}, users::{controller as user_ctrl, service::UserService}, }; pub struct ApiState { pub repo: Arc, pub auth_service: AuthService, pub billing_service: BillingService, pub node_service: NodeService, pub referral_service: ReferralService, pub user_service: UserService, pub staff_service: StaffService, pub jwt_secret: String, pub bot_token: String, /// Юзернейм бота (без `@`) — отдаётся фронту через `GET /api/v1/config`. /// Не публичный build-time env (`VITE_BOT_USERNAME`): фронт — статический /// SPA-бандл, собираемый один раз в CI и одинаковый что для прод-, что для /// dev-деплоя, значение зашилось бы намертво в конкретную сборку и не /// подхватывало бы разные боты на разных стендах (ровно так и оказалось на /// практике — dev-стенд показывал прод-бота, у которого домен в BotFather /// не совпадает с dev-доменом → "Bot domain invalid"). Читается на сервере /// заново на каждый запрос, поэтому корректно различается между стендами. pub bot_username: String, /// `Domain` для сессионных cookie. Пусто (по умолчанию, локальная разработка) → /// cookie host-only. В проде — `.netrunner-vpn.com`, чтобы одна сессия работала /// и на лендинге, и на ЛК (общий родительский домен, разные сабдомены). pub cookie_domain: String, /// `Secure`-атрибут сессионных cookie. По умолчанию `true` — обязателен для /// прода (HTTPS). На dev-стендах без TLS (голый HTTP на IP/домен, не /// `localhost`) браузер и любой нормальный HTTP-клиент **молча отбрасывает** /// `Secure`-cookie, полученную не по HTTPS, — сессия не переживает вообще ни /// одного запроса. Для таких стендов явно выставляется `false` через /// `COOKIE_SECURE=false`. pub cookie_secure: bool, /// Origin'ы, с которых разрешён cookie-based доступ к мутирующим ручкам /// (CSRF-проверка в `auth::guard`). Отдельно от `CORS_ALLOWED_ORIGINS`, т.к. туда /// же попадают Bearer-клиенты (Tauri), для которых CSRF неактуален. pub csrf_allowed_origins: Vec, /// Общий секрет для `netrunner-proxy` (`X-Internal-Secret`) — не пользовательская /// авторизация, см. `modules::proxy_internal::internal_secret_guard`. pub proxy_internal_secret: String, } impl Clone for ApiState { fn clone(&self) -> Self { Self { repo: self.repo.clone(), auth_service: self.auth_service.clone(), billing_service: self.billing_service.clone(), node_service: self.node_service.clone(), referral_service: self.referral_service.clone(), user_service: self.user_service.clone(), staff_service: self.staff_service.clone(), jwt_secret: self.jwt_secret.clone(), bot_token: self.bot_token.clone(), bot_username: self.bot_username.clone(), cookie_domain: self.cookie_domain.clone(), cookie_secure: self.cookie_secure, csrf_allowed_origins: self.csrf_allowed_origins.clone(), proxy_internal_secret: self.proxy_internal_secret.clone(), } } } pub fn create_router(state: ApiState, frontend_dir: &str) -> Router { tracing::info!("Initializing Nexus Grid Modular Architecture..."); let static_path = std::path::PathBuf::from(frontend_dir); let index_path = static_path.join("index.html"); let gov_conf = Arc::new( GovernorConfigBuilder::default() .per_millisecond(100) .burst_size(10) .finish() .unwrap(), ); let seed_limit_conf = Arc::new( GovernorConfigBuilder::default() .per_millisecond(5000) .burst_size(3) .finish() .unwrap(), ); let recorder_handle = PrometheusBuilder::new() .set_buckets_for_metric( Matcher::Full("http_request_duration_seconds".to_string()), &[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0], ) .unwrap() .install_recorder() .expect("Failed to install Prometheus"); let api_routes_v1 = Router::new() .nest( "/auth", auth_ctrl::router().layer(GovernorLayer::new(seed_limit_conf)), ) .nest("/users", user_ctrl::router(state.clone())) .nest("/billing", bill_ctrl::router(state.clone())) .nest("/nodes", node_ctrl::public_router(state.clone())) .nest("/referrals", ref_ctrl::router(state.clone())) .route("/config", get(get_public_config)) .layer(GovernorLayer::new(gov_conf)); let admin_routes_v1 = Router::new() .nest("/nodes", node_ctrl::admin_router(state.clone())) .nest("/users", user_ctrl::admin_router(state.clone())) .nest("/staff", staff_ctrl::router(state.clone())) .nest("/staff", staff_ctrl::owner_router(state.clone())) .nest("/staff", staff_ctrl::staff_router(state.clone())) .nest("/partners", staff_ctrl::partner_router(state.clone())); // Внутренний контракт для прокси-нод — отдельный секрет, не auth_guard // (прокси не пользовательская сессия) и не участвует в CORS ниже (никогда // не вызывается из браузера). let internal_routes = Router::new().nest( "/internal", proxy_internal::router().layer(middleware::from_fn_with_state( state.clone(), proxy_internal::internal_secret_guard, )), ); // Строгий CORS. Список разрешённых origin берём из env (через запятую), // чтобы помимо веб-фронта пускать и десктоп/мобайл-приложение (Tauri webview). let allowed_origins: Vec = std::env::var("CORS_ALLOWED_ORIGINS") .unwrap_or_else(|_| { "https://netrunner-vpn.com,https://account.netrunner-vpn.com,\ tauri://localhost,http://tauri.localhost,\ http://localhost:1420,http://192.168.110.169:1420" .to_string() }) .split(',') .filter_map(|s| s.trim().parse::().ok()) .collect(); let cors = CorsLayer::new() .allow_origin(allowed_origins) .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) .allow_credentials(true) .allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]); Router::new() .nest("/api/v1", api_routes_v1) .nest("/api/v1/admin", admin_routes_v1) .nest("/api/v1", internal_routes) // Liveness/readiness пробы для Docker/оркестратора и балансировщика. .route("/health", get(health_live)) .route("/health/ready", get(health_ready)) .route( "/metrics", axum::routing::get(move || std::future::ready(recorder_handle.render())), ) .route_layer(middleware::from_fn(track_metrics)) .fallback_service( ServeDir::new(&static_path) .append_index_html_on_directories(true) .fallback(ServeFile::new(&index_path)), ) .layer(cors) .layer(TraceLayer::new_for_http()) .with_state(state) } /// Публичная (без auth_guard) рантайм-конфигурация для статического SPA — /// см. `ApiState::bot_username` про то, почему это не build-time env фронта. async fn get_public_config(State(state): State) -> impl IntoResponse { Json(json!({ "bot_username": state.bot_username })) } /// Liveness: процесс жив и отвечает. Не трогает зависимости. async fn health_live() -> impl IntoResponse { (StatusCode::OK, Json(json!({ "status": "ok" }))) } /// Readiness: готов обслуживать трафик (есть живое соединение с БД). async fn health_ready(State(state): State) -> impl IntoResponse { match state.repo.ping().await { Ok(_) => (StatusCode::OK, Json(json!({ "status": "ready" }))), Err(e) => { tracing::warn!("Readiness check failed: {:?}", e); ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "status": "unavailable" })), ) } } } async fn track_metrics(req: Request, next: Next) -> Response { let start = Instant::now(); let path = req .extensions() .get::() .map(|m| m.as_str().to_owned()) .unwrap_or_else(|| req.uri().path().to_owned()); let method = req.method().to_string(); let response = next.run(req).await; let latency = start.elapsed().as_secs_f64(); let status = response.status().as_u16().to_string(); let labels = [("method", method), ("path", path), ("status", status)]; metrics::counter!("http_requests_total", &labels).increment(1); metrics::histogram!("http_request_duration_seconds", &labels).record(latency); response }