Files
netrunner-backend/src/main.rs
T
nineap 375b333978 Ролевая админка (Owner/Moderator/Partner) + оплата через @CryptoBot
Ролевая система вместо привязки к 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>
2026-07-08 22:53:02 +07:00

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(())
}