375b333978
Ролевая система вместо привязки к Telegram ---------------------------------------- Раньше единственный способ получить админ-доступ — admin_guard, пускавший по ADMIN_TG_ID или role=="admin" (без сеттера для роли). Заменяет на Owner/Moderator/Partner: - migrations/0005_roles_and_partners.sql — username/password_hash на users (NULL у обычных Ghost/Telegram-юзеров, тот же паттерн, что и у seed_hash/ tg_id), can_manage_nodes, commission_percent, partner_code, referred_by_partner_id; новые таблицы partner_commissions и withdrawal_requests. - auth/guard.rs — admin_guard заменён на owner_guard/staff_guard/ node_manage_guard/partner_guard; ADMIN_TG_ID убран из ApiState и main.rs. - auth/service.rs — hash_password/login_staff со случайной Argon2-солью на каждого юзера (не общий фиксированный ARGON_SALT, который годится для высокоэнтропийных seed-логинов, но не для человеческих паролей). - POST /api/v1/auth/admin/login — вход модератора/партнёра/овнера паролем, переиспользует существующий issue_session(). - --create-owner CLI-флаг (main.rs) со скрытым вводом пароля (rpassword) — вместо ручного SQL для создания первого владельца с VPS-консоли. - src/modules/staff/ — эндпоинты создания модераторов/партнёров, переключения can_manage_nodes, списка заявок на вывод и их approve/reject/paid. - Комиссия партнёра начисляется в BillingService::confirm_payment отдельным шагом после существующей рефералки, пересчитывается через get_plan_price(invoice.plan_name, invoice.currency) — НЕ через invoice.amount напрямую, потому что у провайдеров разная внутренняя единица (TON — nanoTON+2% буфер, CryptoBot — фиатные центы напрямую). - bot.rs — /start p_XXXXXX привязывает покупателя к партнёру по коду (first-wins, до существующих веток numeric-tg_id-рефералки и кода входа). - frontend/ — AdminLogin/AdminLayout/StaffPage/WithdrawalsPage/EarningsPage поверх существующего Vite-админ-фронта (без отдельного проекта). Оплата через @CryptoBot ------------------------ - modules/billing/providers/cryptobot.rs — новый PaymentProvider (Crypto Pay API), плюс попутно исправлены два реальных бага в существующем коде: confirm_payment был жёстко завязан на TON-провайдера при любом инвойсе, и InvoiceRecord не хранил currency, из-за чего TON-курс молча применялся ко всем провайдерам вместо родной валюты инвойса. - bot.rs — экран выбора способа оплаты для CryptoBot-инвойсов. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
377 lines
16 KiB
Rust
377 lines
16 KiB
Rust
//! Точка входа. Поднимает 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<String>,
|
|
}
|
|
|
|
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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<String> = 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<dyn AppRepository> = 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::<SocketAddr>(),
|
|
)
|
|
.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(())
|
|
}
|