[AI] fix some modules, develop the big part of modules. TODO fix critical errors, update pipeline, test everything

This commit is contained in:
2026-05-16 19:16:50 +07:00
parent 0824033602
commit 1ef93c2025
42 changed files with 2780 additions and 786 deletions
+42 -3
View File
@@ -1,10 +1,10 @@
use crate::{api::ApiState, error::AppError};
use axum::response::IntoResponse;
use axum::{extract::State, routing::post, Json, Router};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::instrument;
use crate::{api::ApiState, error::AppError};
#[derive(Deserialize, Debug)]
pub struct TgWidgetPayload {
pub id: i64,
@@ -16,8 +16,16 @@ pub struct TgWidgetPayload {
pub hash: String,
}
#[derive(Deserialize, Debug)]
pub struct SeedLoginPayload {
pub seed: String,
}
pub fn router() -> Router<ApiState> {
Router::new().route("/telegram", post(auth_telegram_api))
Router::new()
.route("/telegram", post(auth_telegram_api))
.route("/seed/generate", post(generate_seed_api))
.route("/seed/login", post(login_seed_api))
}
#[instrument(skip(state, payload), fields(tg_id = payload.id, username = ?payload.username))]
@@ -25,6 +33,7 @@ async fn auth_telegram_api(
State(state): State<ApiState>,
Json(payload): Json<TgWidgetPayload>,
) -> Result<Json<Value>, AppError> {
// 1. Проверяем подпись Telegram
state.auth_service.verify_tg_widget_auth(
&state.bot_token,
payload.id,
@@ -36,13 +45,43 @@ async fn auth_telegram_api(
&payload.hash,
)?;
// 2. Создаем или обновляем юзера
let user = state
.auth_service
.process_login(payload.id, payload.username.clone(), None)
.await?;
// 3. Генерируем токен. Теперь user.tg_id передается как Option автоматически.
let token = state
.auth_service
.generate_auth_token(user.id, user.tg_id, &state.jwt_secret)?;
Ok(Json(json!({ "status": "success", "token": token })))
}
#[axum::debug_handler]
async fn generate_seed_api(State(state): State<ApiState>) -> Result<impl IntoResponse, AppError> {
// Fail-fast для соли
let salt = std::env::var("ARGON_SALT").expect("КРИТИЧЕСКАЯ ОШИБКА: ARGON_SALT не задан!");
let (seed, token) = state
.auth_service
.register_seed(&salt, &state.jwt_secret)
.await?;
Ok(Json(
json!({ "status": "success", "seed": seed, "token": token }),
))
}
async fn login_seed_api(
State(state): State<ApiState>,
Json(payload): Json<SeedLoginPayload>,
) -> Result<impl IntoResponse, AppError> {
let salt = std::env::var("ARGON_SALT").expect("КРИТИЧЕСКАЯ ОШИБКА: ARGON_SALT не задан!");
let token = state
.auth_service
.login_seed(&payload.seed, &salt, &state.jwt_secret)
.await?;
Ok(Json(json!({ "status": "success", "token": token })))
}
+6 -1
View File
@@ -28,9 +28,14 @@ pub async fn auth_guard(
)
.map_err(|_| AppError::Unauthorized("Invalid session signature".into()))?;
// Парсим внутренний UUID из поля sub токена
let user_id = uuid::Uuid::parse_str(&token_data.claims.sub)
.map_err(|_| AppError::Unauthorized("Invalid token payload structure".into()))?;
// Ищем оперативника в сети по его системному UUID
let user = state
.repo
.get_user_by_tg_id(token_data.claims.tg_id)
.get_user_by_id(user_id)
.await?
.ok_or_else(|| AppError::NotFound("Operative not found in grid".into()))?;
+70 -22
View File
@@ -1,10 +1,12 @@
use argon2::Argon2;
use chrono::Utc;
use hmac::{Hmac, KeyInit, Mac};
use jsonwebtoken::{encode, EncodingKey, Header};
use rand::Rng;
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tracing::{error, info, instrument};
use uuid::Uuid;
use uuid::Uuid; // Не забудь добавить rand в Cargo.toml
use crate::{
db::{models::User, repository::AppRepository},
@@ -13,8 +15,8 @@ use crate::{
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct Claims {
pub sub: String,
pub tg_id: i64,
pub sub: String, // Внутренний UUID юзера
pub tg_id: Option<i64>, // Опционально (отсутствует у Ghost-юзеров)
pub exp: usize,
}
@@ -28,11 +30,12 @@ impl AuthService {
Self { repo }
}
/// Генерирует JWT на основе внутреннего ID и опционального Telegram ID
#[instrument(skip(self, secret))]
pub fn generate_auth_token(
&self,
user_id: Uuid,
tg_id: i64,
tg_id: Option<i64>,
secret: &str,
) -> Result<String, AppError> {
let claims = Claims {
@@ -47,11 +50,59 @@ impl AuthService {
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|e| {
error!("[Auth] Failed to generate JWT: {}", e);
AppError::Internal
error!("[Auth] JWT Generation Error: {}", e);
AppError::Internal(format!("Failed to generate JWT: {}", e))
})
}
fn hash_seed_argon2(seed: &str, salt: &str) -> Result<String, AppError> {
let mut hash_output = [0u8; 32];
Argon2::default()
.hash_password_into(seed.as_bytes(), salt.as_bytes(), &mut hash_output)
.map_err(|e| AppError::Internal(format!("Argon2 Hashing Error: {}", e)))?;
Ok(hex::encode(hash_output))
}
/// Ghost Protocol: Регистрация нового анонимного юзера
pub async fn register_seed(
&self,
salt: &str,
jwt_secret: &str,
) -> Result<(String, String), AppError> {
let seed: String = {
let mut rng = rand::thread_rng();
(0..16).map(|_| rng.gen_range(0..10).to_string()).collect()
};
// Используем мощный Argon2 вместо быстрого SHA256
let hash = Self::hash_seed_argon2(&seed, salt)?;
let user = self.repo.create_seed_user(&hash).await?;
let token = self.generate_auth_token(user.id, user.tg_id, jwt_secret)?;
Ok((seed, token))
}
/// Ghost Protocol: Вход по существующему Seed
pub async fn login_seed(
&self,
seed: &str,
salt: &str,
jwt_secret: &str,
) -> Result<String, AppError> {
let hash = Self::hash_seed_argon2(seed, salt)?;
let user = self
.repo
.get_user_by_seed_hash(&hash)
.await?
.ok_or_else(|| AppError::Unauthorized("Invalid credentials".into()))?;
self.generate_auth_token(user.id, user.tg_id, jwt_secret)
}
// --- Логика Telegram (без изменений, кроме вызова генерации токена) ---
#[instrument(skip(self, bot_token, hash))]
pub fn verify_tg_widget_auth(
&self,
@@ -65,17 +116,17 @@ impl AuthService {
hash: &str,
) -> Result<(), AppError> {
let mut data_check_arr = vec![format!("auth_date={}", auth_date), format!("id={}", id)];
if let Some(fn_) = first_name {
data_check_arr.push(format!("first_name={}", fn_));
if let Some(v) = first_name {
data_check_arr.push(format!("first_name={}", v));
}
if let Some(ln) = last_name {
data_check_arr.push(format!("last_name={}", ln));
if let Some(v) = last_name {
data_check_arr.push(format!("last_name={}", v));
}
if let Some(pu) = photo_url {
data_check_arr.push(format!("photo_url={}", pu));
if let Some(v) = photo_url {
data_check_arr.push(format!("photo_url={}", v));
}
if let Some(un) = username {
data_check_arr.push(format!("username={}", un));
if let Some(v) = username {
data_check_arr.push(format!("username={}", v));
}
data_check_arr.sort();
@@ -85,15 +136,15 @@ impl AuthService {
hasher.update(bot_token.trim().as_bytes());
let secret_key = hasher.finalize();
let mut mac =
Hmac::<Sha256>::new_from_slice(&secret_key).map_err(|_| AppError::Internal)?;
let mut mac = Hmac::<Sha256>::new_from_slice(&secret_key)
.map_err(|e| AppError::Internal(format!("HMAC Error: {}", e)))?;
mac.update(data_check_string.as_bytes());
if hex::encode(mac.finalize().into_bytes()) != hash {
return Err(AppError::Unauthorized("Invalid Telegram hash".into()));
return Err(AppError::Unauthorized("Signature mismatch".into()));
}
if Utc::now().timestamp() - auth_date > 86400 {
return Err(AppError::Unauthorized("Auth date expired".into()));
return Err(AppError::Unauthorized("Session expired".into()));
}
Ok(())
}
@@ -121,10 +172,7 @@ impl AuthService {
async fn handle_referral(&self, referrer_tg: i64, new_user_id: Uuid) -> Result<(), AppError> {
if let Some(referrer) = self.repo.get_user_by_tg_id(referrer_tg).await? {
info!(
"[Auth] Creating syndicate link: {} -> {}",
referrer.id, new_user_id
);
info!("[Auth] Syndicate Link: {} -> {}", referrer.id, new_user_id);
self.repo
.create_referral(referrer.id, new_user_id)
.await
+43 -12
View File
@@ -6,41 +6,72 @@ use axum::{
};
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::{api::ApiState, db::models::User, error::AppError, modules::auth::guard::auth_guard};
#[derive(Deserialize)]
pub struct SubPayload {
pub struct InitiatePaymentPayload {
pub plan: String,
pub provider: String,
pub currency: String,
}
#[derive(Deserialize)]
pub struct ConfirmPaymentPayload {
pub invoice_id: Uuid,
pub external_tx_id: String,
}
pub fn router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/subscribe", post(buy_sub_api))
.route("/pay/initiate", post(initiate_payment_api))
.route("/pay/confirm", post(confirm_payment_api))
.route("/trial", post(activate_trial_api))
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
async fn buy_sub_api(
/// Шаг 1: Запрашиваем у провайдера данные для оплаты (адрес, ссылку и т.д.)
async fn initiate_payment_api(
Extension(user): Extension<User>,
State(state): State<ApiState>,
Json(payload): Json<SubPayload>,
Json(payload): Json<InitiatePaymentPayload>,
) -> Result<Json<Value>, AppError> {
let checkout_data = state
.billing_service
.initiate_payment(&user, &payload.plan, &payload.provider, &payload.currency)
.await?;
Ok(Json(json!({
"status": "success",
"checkout_data": checkout_data
})))
}
/// Шаг 2: Подтверждаем транзакцию после того как юзер её оплатил
async fn confirm_payment_api(
Extension(_user): Extension<User>, // В будущем можно сверять ID юзера с инвойсом
State(state): State<ApiState>,
Json(payload): Json<ConfirmPaymentPayload>,
) -> Result<Json<Value>, AppError> {
state
.billing_service
.buy_subscription(user.tg_id, &payload.plan)
.confirm_payment(payload.invoice_id, &payload.external_tx_id)
.await?;
Ok(Json(
json!({"status": "success", "message": format!("Mock payment for {} successful", payload.plan)}),
))
Ok(Json(json!({
"status": "success",
"message": "Payment verified. Uplink activated."
})))
}
async fn activate_trial_api(
Extension(user): Extension<User>,
State(state): State<ApiState>,
) -> Result<Json<Value>, AppError> {
state.billing_service.activate_trial(user.tg_id).await?;
Ok(Json(
json!({"status": "success", "message": "Trial Uplink Activated"}),
))
state.billing_service.activate_trial(&user).await?;
Ok(Json(json!({
"status": "success",
"message": "Trial Uplink Activated"
})))
}
+1
View File
@@ -1,2 +1,3 @@
pub mod controller;
mod providers;
pub mod service;
+28
View File
@@ -0,0 +1,28 @@
use crate::error::AppError;
use async_trait::async_trait;
use serde_json::Value;
use uuid::Uuid;
pub mod ton;
pub struct InvoiceData {
pub invoice_id: Uuid,
pub amount: i64,
pub currency: String,
}
#[async_trait]
pub trait PaymentProvider: Send + Sync {
/// Имя провайдера (например, "TON", "STRIPE")
fn provider_id(&self) -> &'static str;
/// Создает платежную сессию/ссылку/payload для фронтенда
async fn create_checkout_session(&self, invoice: &InvoiceData) -> Result<Value, AppError>;
/// Проверяет статус платежа (например, обращение к блокчейну или обработка Webhook)
async fn verify_payment(
&self,
external_tx_id: &str,
expected_amount: i64,
) -> Result<bool, AppError>;
}
+95
View File
@@ -0,0 +1,95 @@
use super::{InvoiceData, PaymentProvider};
use crate::error::AppError;
use async_trait::async_trait;
use serde_json::{json, Value};
pub struct TonProvider {
pub master_wallet: String,
}
impl TonProvider {
/// Получает историю последних транзакций мастер-кошелька
pub async fn get_recent_transactions(&self) -> Result<Vec<Value>, AppError> {
let client = reqwest::Client::new();
// Запрашиваем последние 50 транзакций
let url = format!(
"https://tonapi.io/v2/blockchain/accounts/{}/transactions?limit=50",
self.master_wallet
);
let res = client
.get(&url)
.send()
.await
.map_err(|e| AppError::Internal(format!("Ошибка сети TonAPI: {}", e)))?;
if !res.status().is_success() {
return Ok(vec![]);
}
let data: Value = res.json().await.unwrap_or(json!({}));
Ok(data["transactions"].as_array().unwrap_or(&vec![]).clone())
}
}
#[async_trait]
impl PaymentProvider for TonProvider {
fn provider_id(&self) -> &'static str {
"TON"
}
async fn create_checkout_session(&self, invoice: &InvoiceData) -> Result<Value, AppError> {
// Для TON нам не нужно делать запрос к API эквайринга при создании.
// Мы просто отдаем фронтенду данные для формирования транзакции.
Ok(json!({
"destination": self.master_wallet,
"amount": invoice.amount, // В nanoTON
"comment": invoice.invoice_id.to_string() // Передаем ID инвойса в коммент, чтобы потом идентифицировать!
}))
}
async fn verify_payment(&self, tx_hash: &str, expected_amount: i64) -> Result<bool, AppError> {
let client = reqwest::Client::new();
let url = format!("https://tonapi.io/v2/blockchain/transactions/{}", tx_hash);
let res = client
.get(&url)
.send()
.await
.map_err(|e| AppError::Internal(format!("Ошибка запроса к TON API: {}", e)))?;
if !res.status().is_success() {
return Ok(false); // Транзакция не найдена или еще не подтверждена
}
let data: Value = res
.json()
.await
.map_err(|e| AppError::Internal(format!("Ошибка парсинга JSON от TON API: {}", e)))?;
// Проверяем входящее сообщение транзакции
let in_msg = &data["in_msg"];
if in_msg.is_null() {
return Ok(false);
}
let value = in_msg["value"].as_i64().unwrap_or(0);
let dest = in_msg["destination"]["address"].as_str().unwrap_or("");
// В боевой версии также стоит проверять:
// in_msg["decoded_body"]["text"] == ID инвойса, чтобы избежать оплаты чужих инвойсов
// Сверяем кошелек назначения и сумму (с допуском на мелкие расхождения/комиссии, если нужно)
if value >= expected_amount && dest == self.master_wallet {
tracing::info!("TON Transaction {} verified successfully!", tx_hash);
Ok(true)
} else {
tracing::warn!(
"TON Transaction mismatch. Expected: {}, Got: {}",
expected_amount,
value
);
Ok(false)
}
}
}
+176 -27
View File
@@ -1,19 +1,51 @@
use super::providers::{InvoiceData, PaymentProvider};
use crate::{
db::{models::Subscription, repository::AppRepository},
db::{
models::{Subscription, User},
repository::AppRepository,
},
error::AppError,
};
use std::sync::Arc;
use tracing::info;
use rust_decimal::prelude::FromPrimitive;
use std::{collections::HashMap, sync::Arc};
use uuid::Uuid;
#[derive(Clone)]
pub struct BillingService {
repo: Arc<dyn AppRepository>,
providers: HashMap<&'static str, Box<dyn PaymentProvider>>,
}
// Добавляем Clone вручную, так как dyn Traits не поддерживают derive(Clone) легко
impl Clone for BillingService {
fn clone(&self) -> Self {
// ВАЖНО: В реальном проекте провайдеры стоит обернуть в Arc,
// но для простоты структуры мы пересоздаем их при клонировании сервиса
let mut providers: HashMap<&'static str, Box<dyn PaymentProvider>> = HashMap::new();
providers.insert(
"TON",
Box::new(super::providers::ton::TonProvider {
master_wallet: "UQ_MASTER_WALLET_ADDRESS".into(),
}),
);
Self {
repo: self.repo.clone(),
providers,
}
}
}
impl BillingService {
pub fn new(repo: Arc<dyn AppRepository>) -> Self {
Self { repo }
let mut providers: HashMap<&'static str, Box<dyn PaymentProvider>> = HashMap::new();
providers.insert(
"TON",
Box::new(super::providers::ton::TonProvider {
master_wallet: "UQ_MASTER_WALLET_ADDRESS".into(),
}),
);
Self { repo, providers }
}
pub async fn get_subscription(&self, user_id: Uuid) -> Result<Option<Subscription>, AppError> {
@@ -23,36 +55,119 @@ impl BillingService {
.map_err(AppError::Database)
}
pub async fn buy_subscription(&self, tg_id: i64, plan_name: &str) -> Result<(), AppError> {
info!(
"Processing mock subscription for TG: {}, Plan: {}",
tg_id, plan_name
);
let price_usd = self.repo.get_plan_price_usd(plan_name).await?.unwrap_or(0);
/// Этап 1: Генерация инвойса и платежной ссылки
pub async fn initiate_payment(
&self,
user: &User,
plan_name: &str,
provider_name: &str,
currency: &str,
) -> Result<serde_json::Value, AppError> {
let provider = self
.providers
.get(provider_name)
.ok_or_else(|| AppError::BusinessLogic("Payment provider not supported".into()))?;
let user = self
let fiat_amount_minimal = self
.repo
.get_user_by_tg_id(tg_id)
.get_plan_price(plan_name, currency)
.await?
.ok_or(AppError::UserNotFound)?;
self.repo
.buy_subscription(user.id, plan_name)
.await
.map_err(AppError::Database)?;
.ok_or_else(|| {
AppError::BusinessLogic("Тариф недоступен для данного региона".into())
})?;
let revshare_bonus = (price_usd as i64) * 10;
if revshare_bonus > 0 {
let _ = self.repo.reward_referrer(user.id, revshare_bonus).await;
// --- ДИНАМИЧЕСКИЙ ЗАПРОС КУРСА TON ---
let client = reqwest::Client::new();
let rate_res: serde_json::Value = client
.get("https://tonapi.io/v2/rates?tokens=ton&currencies=usd,rub")
.send()
.await
.map_err(|e| AppError::Internal(format!("Ошибка сети при запросе курса: {}", e)))?
.json()
.await
.map_err(|e| AppError::Internal(format!("Ошибка парсинга курса TON: {}", e)))?;
let currency_lower = currency.to_lowercase();
let rate_f64 = rate_res["rates"]["TON"]["prices"][&currency_lower]
.as_f64()
.ok_or_else(|| AppError::Internal("TonAPI не вернул курс для этой валюты".into()))?;
let ton_rate = rust_decimal::Decimal::from_f64(rate_f64)
.ok_or_else(|| AppError::Internal("Ошибка конвертации f64 в Decimal".into()))?;
// ----------------------------------------
let nanotons = Self::calculate_ton_invoice(fiat_amount_minimal, ton_rate)
.map_err(AppError::BusinessLogic)?;
let invoice_id = self
.repo
.create_invoice(user.id, plan_name, provider_name, currency, nanotons)
.await?;
let invoice_data = InvoiceData {
invoice_id,
amount: nanotons,
currency: currency.into(),
};
provider.create_checkout_session(&invoice_data).await
}
/// Этап 2: Вебхук или ручная проверка оплаты юзером
pub async fn confirm_payment(
&self,
invoice_id: Uuid,
external_tx_id: &str,
) -> Result<(), AppError> {
// 1. Достаем инвойс из БД по ID
let invoice = self
.repo
.get_invoice(invoice_id)
.await?
.ok_or_else(|| AppError::NotFound("Инвойс не найден".into()))?;
if invoice.status == "paid" {
return Ok(());
}
// 2. Получаем провайдера
let provider = self.providers.get("TON").unwrap();
// 3. Провайдер лезет в блокчейн и проверяет транзакцию
let is_valid = provider
.verify_payment(external_tx_id, invoice.amount)
.await?;
if !is_valid {
self.repo
.update_invoice_status(invoice_id, "failed", Some(external_tx_id))
.await?;
return Err(AppError::BusinessLogic(
"Payment verification failed".into(),
));
}
// 4. Если всё четко - обновляем статус инвойса на PAID
self.repo
.update_invoice_status(invoice_id, "paid", Some(external_tx_id))
.await?;
// 5. АКТИВИРУЕМ ТАРИФ
self.repo
.buy_subscription(invoice.user_id, &invoice.plan_name)
.await?;
// 6. Выдаем реферальный бонус (10% от суммы инвойса)
let revshare_bonus = invoice.amount / 10;
let _ = self
.repo
.reward_referrer(invoice.user_id, revshare_bonus)
.await;
Ok(())
}
pub async fn activate_trial(&self, tg_id: i64) -> Result<(), AppError> {
let user = self
.repo
.get_user_by_tg_id(tg_id)
.await?
.ok_or(AppError::UserNotFound)?;
pub async fn activate_trial(&self, user: &User) -> Result<(), AppError> {
if user.has_used_trial {
return Err(AppError::TrialAlreadyUsed);
}
@@ -68,4 +183,38 @@ impl BillingService {
.await
.map_err(AppError::Database)
}
pub fn calculate_ton_invoice(
fiat_amount_minimal: i64, // В копейках/центах из БД
ton_rate_to_currency: rust_decimal::Decimal, // Полный путь до типа, чтобы не было конфликтов имён
) -> Result<i64, String> {
use rust_decimal::prelude::{ToPrimitive, Zero};
use rust_decimal::Decimal;
// 1. Создаем константу 100 безопасным способом (100.00)
let one_hundred = Decimal::new(100, 0);
// Избавляемся от копеек/центов, переводим в базовый фиат
let fiat_base = Decimal::from(fiat_amount_minimal) / one_hundred;
// 2. Считаем чистый объем TON
if ton_rate_to_currency.is_zero() {
return Err("Курс TON не может быть нулевым".to_string());
}
let ton_clean = fiat_base / ton_rate_to_currency;
// 3. Накидываем 2% буфера (умножаем на 1.02 -> 102 со сдвигом запятой на 2 знака)
let buffer_multiplier = Decimal::new(102, 2);
let ton_with_buffer = ton_clean * buffer_multiplier;
// 4. Переводим в системные nanoTON для блокчейна (умножаем на 1_000_000_000)
let nanoton_multiplier = Decimal::new(1_000_000_000, 0);
let nanotons = (ton_with_buffer * nanoton_multiplier)
.round()
.to_i64()
.ok_or_else(|| "Ошибка перевода типов Decimal в i64".to_string())?;
Ok(nanotons)
}
}
+53 -3
View File
@@ -4,6 +4,7 @@ use axum::{
routing::{delete, get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
@@ -14,13 +15,21 @@ use crate::{
modules::auth::{admin_guard, auth_guard},
};
#[derive(Deserialize)]
pub struct AddNodeRequest {
#[serde(flatten)]
pub payload: CreateNodePayload,
pub ssh_password: String,
}
/// Публичный роутер для юзеров
pub fn public_router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/", get(get_nodes))
.route("/stats", get(get_node_stats))
.route("/routing", get(get_node_routing))
.route_layer(middleware::from_fn_with_state(state.clone(), auth_guard))
}
/// Секретный роутер для Nexus (Admin)
pub fn admin_router(state: ApiState) -> Router<ApiState> {
Router::new()
@@ -36,14 +45,55 @@ async fn get_nodes(State(state): State<ApiState>) -> Result<Json<Vec<VpnNode>>,
Ok(Json(nodes))
}
async fn get_node_stats(State(state): State<ApiState>) -> Result<Json<Value>, AppError> {
let nodes = state.node_service.get_active_nodes().await?;
let stats: Vec<_> = nodes
.into_iter()
.map(|n| {
json!({
"country_code": n.country_code,
"current_load": n.current_load,
"status": n.status,
"uptime": "99.9%"
})
})
.collect();
Ok(Json(json!(stats)))
}
async fn add_node(
State(state): State<ApiState>,
Json(payload): Json<CreateNodePayload>,
Json(req): Json<AddNodeRequest>,
) -> Result<Json<VpnNode>, AppError> {
let node = state.node_service.add_node(payload).await?;
// Pass the separated payload and password to the service
let node = state
.node_service
.add_node(req.payload, &req.ssh_password)
.await?;
Ok(Json(node))
}
async fn get_node_routing(State(state): State<ApiState>) -> Result<Json<Value>, AppError> {
let nodes = state.node_service.get_active_nodes().await?;
let routing: Vec<_> = nodes
.into_iter()
.map(|n| {
json!({
"ip": n.ip_address,
"port": n.port,
"region": n.country_code,
"supported_protocols": ["tcp", "udp"],
})
})
.collect();
Ok(Json(json!(routing)))
}
async fn remove_node(
State(state): State<ApiState>,
Path(id): Path<Uuid>,
+89 -4
View File
@@ -8,6 +8,10 @@ use crate::{
use std::sync::Arc;
use uuid::Uuid;
use ssh2::Session;
use std::io::Read;
use std::net::TcpStream;
#[derive(Clone)]
pub struct NodeService {
repo: Arc<dyn AppRepository>,
@@ -25,11 +29,92 @@ impl NodeService {
.map_err(AppError::Database)
}
pub async fn add_node(&self, payload: CreateNodePayload) -> Result<VpnNode, AppError> {
self.repo
.add_vpn_node(payload)
pub async fn add_node(
&self,
payload: CreateNodePayload,
ssh_password: &str,
) -> Result<VpnNode, AppError> {
let ip_address = payload.ip_address.clone();
let password = ssh_password.to_string();
// Orchestrate SSH asynchronously so it does not block the Axum runtime
tokio::task::spawn_blocking(move || Self::provision_node_via_ssh(&ip_address, &password))
.await
.map_err(AppError::Database)
.map_err(|_| AppError::Internal("SSH Task panicked".into()))??;
let node = self.repo.add_vpn_node(payload).await?;
Ok(node)
}
fn provision_node_via_ssh(ip: &str, password: &str) -> Result<(), AppError> {
use std::fs;
use std::net::{SocketAddr, TcpStream};
use std::time::Duration;
// 1. Просто считываем уже гарантированно созданный на старте сервера ключ
let key_path = "keys/netrunner_master_ed25519";
let pub_key = fs::read_to_string(format!("{}.pub", key_path)).map_err(|e| {
AppError::Internal(format!("Критическая ошибка: Мастер-ключ не найден: {}", e))
})?;
// 2. Парсим адрес и жестко ограничиваем время ожидания ответа от удаленной ноды
let socket_addr = format!("{}:22", ip)
.parse::<SocketAddr>()
.map_err(|e| AppError::Internal(format!("Некорректный формат IP-адреса VPS: {}", e)))?;
// Защита рантайма: если узел лежит, таск отвалится через 15 секунд, не блокируя поток выполнения
let tcp =
TcpStream::connect_timeout(&socket_addr, Duration::from_secs(15)).map_err(|e| {
AppError::Internal(format!(
"Узел не ответил на запрос подключения по SSH (Timeout 15s): {}",
e
))
})?;
let mut sess = Session::new().unwrap();
sess.set_tcp_stream(tcp);
sess.handshake()
.map_err(|e| AppError::Internal(format!("Сбой SSH Handshake: {}", e)))?;
sess.userauth_password("root", password).map_err(|_| {
AppError::Unauthorized("Ошибка авторизации по SSH. Неверный пароль root?".into())
})?;
// 3. Подготавливаем команды автоматизации
let gh_token =
std::env::var("GHCR_TOKEN").expect("КРИТИЧЕСКАЯ ОШИБКА: GHCR_TOKEN missing!");
let init_script = fs::read_to_string("templates/init_node.sh")
.unwrap_or_else(|_| "echo 'init_node.sh missing'".into());
// Скрипт укрепления (Hardening): Прокидывает ключ и жестко вырубает пароли
let harden_script = format!(
"mkdir -p /root/.ssh\n\
echo '{}' > /root/.ssh/authorized_keys\n\
chmod 600 /root/.ssh/authorized_keys\n\
chmod 700 /root/.ssh\n\
sed -i -E 's/^#?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config\n\
sed -i -E 's/^#?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config\n\
sed -i -E 's/^#?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config.d/* 2>/dev/null || true\n\
systemctl restart ssh || systemctl restart sshd",
pub_key.trim()
);
let full_command = format!(
"NODE_IP='{}' GH_TOKEN='{}'\n{}\n{}",
ip, gh_token, init_script, harden_script
);
// 4. Исполняем сценарий развертывания контейнеров в изолированной сессии
let mut channel = sess.channel_session().unwrap();
channel
.exec(&full_command)
.map_err(|e| AppError::Internal(format!("Ошибка старта команд деплоя: {}", e)))?;
let mut output = String::new();
channel.read_to_string(&mut output).unwrap();
tracing::info!("SSH Execution Output from {}:\n{}", ip, output);
channel.wait_close().unwrap();
Ok(())
}
pub async fn delete_node(&self, node_id: Uuid) -> Result<(), AppError> {
+1 -1
View File
@@ -43,7 +43,7 @@ async fn submit_hack_result(
) -> Result<Json<Value>, AppError> {
let reward = state
.user_service
.add_hack_reward(user.tg_id, payload.score.clamp(0, 500))
.add_hack_reward(user.id, payload.score.clamp(0, 500))
.await?;
Ok(Json(json!({"status": "success", "reward_added": reward})))
}
+11 -7
View File
@@ -39,17 +39,21 @@ impl UserService {
.await
.map_err(AppError::Database)
}
#[instrument(skip(self))]
pub async fn add_hack_reward(&self, tg_id: i64, points: i64) -> Result<i64, AppError> {
let user = self
.get_user_profile(tg_id)
.await?
.ok_or(AppError::UserNotFound)?;
pub async fn add_hack_reward(&self, user_id: Uuid, points: i64) -> Result<i64, AppError> {
let success = self
.repo
.consume_ticket_and_reward(user.id, points)
.consume_ticket_and_reward(user_id, points)
.await
.map_err(AppError::Database)?;
.map_err(|e| {
if e == "TOO_FREQUENT" {
AppError::BusinessLogic("Слишком частые попытки взлома. Минимальный интервал между играми — 45 секунд.".into())
} else {
AppError::Internal(format!("Критический сбой СУБД при фиксации игры: {}", e))
}
})?;
Ok(if success { points } else { 0 })
}
}