//! Точка входа. Поднимает Postgres-пул, собирает `ApiState` (репозиторий + сервисы), //! и параллельно запускает три задачи до сигнала остановки: HTTP API (axum), //! Telegram-бот (teloxide) и фоновый воркер биллинга (`tasks::run_background_tasks`). //! Флаг `--migrate` переводит процесс в одноразовый режим применения миграций и выхода — //! используется как отдельный шаг в деплое перед запуском самого сервера. use clap::Parser; use sqlx::postgres::PgPoolOptions; use std::net::SocketAddr; use std::sync::Arc; use teloxide::Bot; use tokio::signal; use tokio_util::sync::CancellationToken; use tracing::{error, info}; use netrunner_control_plane::api::{create_router, ApiState}; use netrunner_control_plane::bot; use netrunner_control_plane::db::repository::{AppRepository, PgRepository}; use netrunner_control_plane::modules::{ auth::service::AuthService, billing::service::BillingService, nodes::service::NodeService, referrals::service::ReferralService, staff::service::StaffService, users::service::UserService, }; use netrunner_control_plane::tasks; #[derive(Parser, Debug)] #[command(author, version, about = "Netrunner Control Plane")] struct Args { #[arg(long)] migrate: bool, /// Одноразовое создание первого Owner-аккаунта (логин+пароль, не /// Telegram) — прогоняется руками на VPS: `--create-owner --username admin`. #[arg(long)] create_owner: bool, #[arg(long)] username: Option, } fn init_tracing() { use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; let filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info,netrunner_control_plane=debug,sqlx=info".into()); let env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into()); let is_production = env.trim() == "production"; let registry = tracing_subscriber::registry().with(filter); if is_production { registry .with(fmt::layer().json().flatten_event(true)) .init(); } else { registry.with(fmt::layer().pretty()).init(); } } fn ensure_master_ssh_key() -> Result<(), Box> { use std::path::Path; let key_path = "keys/netrunner_master_ed25519.pub"; if Path::new(key_path).exists() { tracing::info!("✅ Статичный мастер-ключ обнаружен."); return Ok(()); } // В проде ключ обязателен (без него невозможен провижининг нод по SSH). // В дев-режиме это лишь предупреждение, чтобы не блокировать локальный запуск. let is_production = std::env::var("APP_ENV") .map(|e| e.trim() == "production") .unwrap_or(false); if is_production { tracing::error!( "КРИТИЧЕСКАЯ ОШИБКА: Мастер-ключ не найден по пути {}. \ Убедитесь, что ключи проброшены в контейнер (через volume).", key_path ); return Err("Master SSH key is missing".into()); } tracing::warn!( "⚠️ Мастер-ключ не найден ({}). Провижининг нод по SSH недоступен — \ это нормально для локальной разработки.", key_path ); Ok(()) } /// Ждёт сигнал завершения: Ctrl+C (SIGINT) или SIGTERM (docker stop / оркестратор). async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c() .await .expect("Не удалось установить обработчик Ctrl+C"); }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("Не удалось установить обработчик SIGTERM") .recv() .await; }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { _ = ctrl_c => info!("🛑 Получен SIGINT (Ctrl+C). Останавливаемся..."), _ = terminate => info!("🛑 Получен SIGTERM. Останавливаемся..."), } } #[tokio::main] async fn main() -> Result<(), Box> { init_tracing(); dotenvy::dotenv().ok(); tracing::info!("Инициализация Логера"); let args = Args::parse(); let db_url = std::env::var("DATABASE_URL").expect("КРИТИЧЕСКАЯ ОШИБКА: DATABASE_URL не задан в .env!"); // На WSL2/Docker DNS может ответить "name resolution failure" в первые секунды // после старта контейнера, даже когда db уже healthy. Делаем несколько попыток. let pool = { let url = db_url.trim(); let mut attempt = 0u8; loop { attempt += 1; match PgPoolOptions::new().max_connections(20).connect(url).await { Ok(p) => break p, Err(e) if attempt < 5 => { tracing::warn!( "БД недоступна (попытка {}/5): {}. Повтор через 3с…", attempt, e ); tokio::time::sleep(std::time::Duration::from_secs(3)).await; } Err(e) => return Err(e.into()), } } }; // Режим миграций требует только DATABASE_URL — ни ключей, ни токенов. // Используется как one-shot шаг в пайплайне деплоя перед стартом приложения. if args.migrate { info!("🛠 Запуск в режиме применения миграций..."); PgRepository::initialize_schema(&pool).await?; info!("✅ Миграции успешно применены. Выход."); return Ok(()); } // Бутстрап первого Owner'а — тоже требует только DATABASE_URL, тот же // one-shot паттерн, что и --migrate. Пароль вводится скрыто в консоли // (не через env/аргумент), чтобы не осесть в истории шелла. if args.create_owner { let username = args .username .clone() .ok_or("--create-owner требует --username <имя>")?; info!("🔐 Создание владельца (Owner): {}", username); let password = rpassword::prompt_password("Пароль для владельца: ")?; let password_confirm = rpassword::prompt_password("Повторите пароль: ")?; if password != password_confirm { return Err("Пароли не совпадают.".into()); } if password.len() < 8 { return Err("Пароль должен быть не короче 8 символов.".into()); } let hash = AuthService::hash_password(&password) .map_err(|e| format!("Ошибка хеширования пароля: {:?}", e))?; let repo = PgRepository::new(pool.clone()); let user = repo .create_staff_user(&username, &hash, "owner", false, None, None) .await .map_err(|e| { if let sqlx::Error::Database(db_err) = &e { if db_err.constraint() == Some("users_username_key") { return format!("Логин '{}' уже занят.", username); } } format!("Ошибка создания владельца: {e}") })?; info!("✅ Владелец создан: id={}, username={}", user.id, username); println!( "Владелец '{}' успешно создан. Войдите через /admin/login на фронтенде.", username ); return Ok(()); } // --- Полный запуск сервера: проверяем все обязательные ресурсы --- ensure_master_ssh_key()?; // Бот можно отключить для локальной разработки (BOT_ENABLED=false), // тогда реальный токен Telegram не требуется. let bot_enabled = std::env::var("BOT_ENABLED") .map(|v| v.trim().to_lowercase() != "false") .unwrap_or(true); let bot_token = std::env::var("TELOXIDE_TOKEN").unwrap_or_default(); if bot_enabled && bot_token.is_empty() { return Err( "КРИТИЧЕСКАЯ ОШИБКА: BOT_ENABLED=true, но TELOXIDE_TOKEN не задан в .env!".into(), ); } // Тот же бот, что читает bot.rs при старте — но здесь ещё и отдаётся фронту // через GET /api/v1/config (см. ApiState::bot_username), не как VITE_-переменная, // зашиваемая в SPA-бандл на этапе сборки в CI. let bot_username = std::env::var("BOT_USERNAME").unwrap_or_else(|_| "ntrnr_vpn_bot".to_string()); let jwt_secret = std::env::var("JWT_SECRET").expect("КРИТИЧЕСКАЯ ОШИБКА: JWT_SECRET не задан в .env!"); let frontend_dir = std::env::var("FRONTEND_DIR").expect("КРИТИЧЕСКАЯ ОШИБКА: FRONTEND_DIR не задан в .env!"); let port = std::env::var("PORT").expect("КРИТИЧЕСКАЯ ОШИБКА: PORT не задан в .env!"); info!("🚀 Запуск Netrunner Control Plane v2.0 (Modular Architecture)"); // Домен для Set-Cookie: пусто локально (host-only cookie), в проде // `.netrunner-vpn.com` — расшаривает сессию между лендингом и ЛК на сабдомене. let cookie_domain = std::env::var("COOKIE_DOMAIN").unwrap_or_default(); // По умолчанию true (обязателен в проде, там HTTPS). Явно выключается через // COOKIE_SECURE=false на стендах без TLS — иначе браузер молча дропает // Secure-cookie, полученную по голому HTTP (кроме localhost), и сессия не // переживает ни одного запроса. let cookie_secure = std::env::var("COOKIE_SECURE") .map(|v| v.trim().to_lowercase() != "false") .unwrap_or(true); if !cookie_secure { tracing::warn!( "⚠️ COOKIE_SECURE=false — сессионные cookie без атрибута Secure. \ Только для стендов без TLS, никогда не для прода!" ); } // Общий секрет, которым делятся все прокси-ноды при вызовах internal-эндпоинтов // (`POST /internal/validate`, `POST /internal/usage`) — не пользовательская // авторизация, поэтому отдельный env, не JWT_SECRET. let proxy_internal_secret = std::env::var("PROXY_INTERNAL_SECRET") .expect("КРИТИЧЕСКАЯ ОШИБКА: PROXY_INTERNAL_SECRET не задан в .env!"); let csrf_allowed_origins: Vec = std::env::var("CSRF_ALLOWED_ORIGINS") .unwrap_or_else(|_| { "https://netrunner-vpn.com,https://account.netrunner-vpn.com".to_string() }) .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); // Redis — только кеш (курс TON), не источник истины: недоступность не должна // блокировать старт приложения, просто отключает кеш и каждый инвойс идёт // напрямую в TonAPI (как было раньше). let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://redis:6379".into()); let redis_conn = match redis::Client::open(redis_url.as_str()) { Ok(client) => match redis::aio::ConnectionManager::new(client).await { Ok(conn) => { info!("✅ Redis подключён ({})", redis_url); Some(conn) } Err(e) => { tracing::warn!( "⚠️ Redis недоступен ({}): {}. Кеш курса TON отключён.", redis_url, e ); None } }, Err(e) => { tracing::warn!( "⚠️ Некорректный REDIS_URL ({}): {}. Кеш отключён.", redis_url, e ); None } }; let repo: Arc = Arc::new(PgRepository::new(pool.clone())); let cancel_token = CancellationToken::new(); let state = ApiState { repo: repo.clone(), auth_service: AuthService::new(repo.clone()), billing_service: BillingService::new(repo.clone(), redis_conn), node_service: NodeService::new(repo.clone()), referral_service: ReferralService::new(repo.clone()), user_service: UserService::new(repo.clone()), staff_service: StaffService::new(repo.clone()), jwt_secret, bot_token: bot_token.clone(), bot_username, cookie_domain, cookie_secure, csrf_allowed_origins, proxy_internal_secret, }; let app = create_router(state.clone(), &frontend_dir); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await?; info!("🌐 API & Prometheus Metrics запущены на порту {}", port); let api_cancel_token = cancel_token.clone(); let api_handle = axum::serve( listener, app.into_make_service_with_connect_info::(), ) .with_graceful_shutdown(async move { api_cancel_token.cancelled().await; }); let bot_cancel_token = cancel_token.clone(); let bot_auth = state.auth_service.clone(); let bot_users = state.user_service.clone(); let bot_billing = state.billing_service.clone(); let bot_referrals = state.referral_service.clone(); let bot_token_for_task = bot_token.clone(); let bot_handle = tokio::spawn(async move { if bot_enabled && !bot_token_for_task.is_empty() { let bot_instance = Bot::new(bot_token_for_task); bot::run_bot( bot_instance, bot_auth, bot_users, bot_billing, bot_referrals, bot_cancel_token, ) .await; } else { info!("🤖 Telegram-бот отключён (BOT_ENABLED=false). Polling не запущен."); // Держим задачу живой до общего сигнала остановки. bot_cancel_token.cancelled().await; } }); let tasks_token = cancel_token.clone(); let tasks_billing = state.billing_service.clone(); let tasks_repo = repo.clone(); let tasks_handle = tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(2)).await; tasks::run_background_tasks(tasks_billing, tasks_repo, tasks_token).await; }); // === КООРДИНАТОР АВАРИЙНОГО И СТАНДАРТНОГО ШУТДАУНА === tokio::select! { _ = shutdown_signal() => { cancel_token.cancel(); } res = api_handle => { error!("❌ API Сервер упал: {:?}", res); cancel_token.cancel(); } res = bot_handle => { error!("❌ Telegram Бот упал: {:?}", res); cancel_token.cancel(); } res = tasks_handle => { error!("❌ Фоновые задачи упали: {:?}", res); cancel_token.cancel(); } } info!("✅ Все процессы успешно остановлены. До связи, Netrunner."); Ok(()) }