Unify auth into refresh-token cookie sessions; server-authoritative nodes and Cyberhack
Auth was effectively broken for every login path: the Telegram handler built a Set-Cookie header but never attached it to the response, and Ghost Protocol (seed) login never set a cookie at all — so billing, the game, and admin actions never actually worked once the frontend was fixed to stop trying to read an HttpOnly cookie from JS. Replaced the bare 10-minute JWT with AuthService::issue_session: a 15-minute access cookie plus a rotating 30-day refresh cookie (opaque token, only its SHA-256 hash stored in the new refresh_sessions table); reusing an already- rotated refresh token now revokes every session for that user. CSRF/CORS origin allowlists moved from a single hardcoded domain to env-configured lists so the same session works across the landing and account subdomains. The bot's WebApp deep links (Личный Кабинет/Тарифы/Синдикат) now bridge through a short-lived bootstrap token exchanged via POST /auth/exchange instead of a bare access token in the URL. Nodes: /nodes/routing now serializes the tunnel_* fields, public_key, sni_domain and a one-time session token gated on an active subscription instead of a stub ip/port/protocol list; node provisioning returns 202 immediately and finishes in the background instead of blocking the request for the whole SSH run; a new background task TCP-pings nodes every 60s and flips online/offline itself; admin can now force-restart a node over SSH. Billing: TON exchange rate is cached in Redis (60s) instead of hitting TonAPI on every invoice, and the request/response now actually uses the requested currency and TonAPI's real (uppercase) key casing — previously only USD/RUB were ever requested and the lookup used the wrong case, so non-USD/RUB plans could never price correctly. Cyberhack: the server now generates and stores the board itself and replays the client's raw click path to compute the score, instead of clamping a client-reported number — closes the "final score is whatever the browser sends" hole flagged in the security audit. Frontend: api.ts no longer tries to read the HttpOnly session cookie from JS (that never worked) and instead relies on same-origin credentials plus a silent refresh-and-retry on 401; AdminDashboard's restart/delete actions are wired up; Auth.tsx drops the artificial delays and requires an explicit seed download/confirmation before continuing, since a lost Ghost seed is unrecoverable. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,7 @@ modules/<name>/
|
||||
|
||||
| Модуль | Ответственность | README |
|
||||
|---|---|---|
|
||||
| [`auth`](auth/README.md) | Вход через Telegram Login Widget и анонимный Ghost Protocol (seed), JWT-гварды | [подробнее](auth/README.md) |
|
||||
| [`auth`](auth/README.md) | Вход через Telegram Login Widget, анонимный Ghost Protocol (seed) и bootstrap-мост из бота; единая cookie-сессия (access + ротируемый refresh), guards | [подробнее](auth/README.md) |
|
||||
| [`billing`](billing/README.md) | Тарифы, инвойсы, оплата через TON, триал, реферальный revshare | [подробнее](billing/README.md) |
|
||||
| [`nodes`](nodes/README.md) | Control Plane VPN-нод: провижининг по SSH, публичные ручки для клиента/лендинга | [подробнее](nodes/README.md) |
|
||||
| [`referrals`](referrals/README.md) | Статистика по рефералам и применение промокодов | [подробнее](referrals/README.md) |
|
||||
@@ -36,4 +36,7 @@ modules/<name>/
|
||||
- `billing::confirm_payment` после успешной оплаты сам начисляет реферальный бонус
|
||||
(`repo.reward_referrer`) — то есть логика вознаграждения за реферала физически лежит
|
||||
в billing, а не в referrals.
|
||||
- `nodes` не зависит от других модулей и не используется ими — самый изолированный модуль.
|
||||
- `nodes::controller::get_node_routing` читает `billing_service.get_subscription` из
|
||||
`ApiState`, чтобы не выдавать одноразовый session-токен ноды юзерам без активной
|
||||
подписки — единственная точка, где `nodes` смотрит на состояние другого модуля
|
||||
(через уже собранный стейт, не импортируя `billing::` напрямую).
|
||||
|
||||
@@ -1,29 +1,52 @@
|
||||
# `modules/auth` — авторизация
|
||||
|
||||
Три способа попасть в систему, всех их объединяет один и тот же JWT (`Claims { sub, tg_id, exp }`,
|
||||
живёт 10 минут — предполагается, что клиент кладёт его в cookie/заголовок и продлевает
|
||||
через повторный логин/бота, отдельного refresh-флоу нет):
|
||||
Четыре способа попасть в систему сходятся в одной точке — `AuthService::issue_session`,
|
||||
которая выдаёт **пару** токенов, а не один JWT:
|
||||
|
||||
- **access** — JWT (`Claims { sub, tg_id, exp }`, 15 минут), кладётся в cookie
|
||||
`nrxp_token` (`Path=/`).
|
||||
- **refresh** — непрозрачный случайный токен (30 дней), в БД хранится только его
|
||||
SHA-256-хеш (`refresh_sessions`); кладётся в cookie `nrxp_refresh`, строго ограниченную
|
||||
`Path=/api/v1/auth/refresh`, чтобы не улетать на остальные ручки.
|
||||
|
||||
Обе cookie — `HttpOnly; Secure; SameSite=Lax`, в проде дополнительно `Domain` из
|
||||
`COOKIE_DOMAIN` (`.netrunner-vpn.com`) — одна сессия работает и на лендинге, и на ЛК на
|
||||
сабдомене. Никакой JS-читаемый токен нигде не хранится (ни cookie без `HttpOnly`, ни
|
||||
`localStorage`).
|
||||
|
||||
`POST /auth/refresh` **ротирует** refresh-токен при каждом использовании: старая запись
|
||||
помечается `revoked_at`, выдаётся новая пара. Предъявление уже отозванного токена
|
||||
(признак кражи/повторного использования после ротации) отзывает **все** сессии юзера.
|
||||
`POST /auth/logout` отзывает текущую сессию и чистит cookie.
|
||||
|
||||
Способы входа:
|
||||
|
||||
1. **Telegram Login Widget** (`POST /auth/telegram`, `controller::auth_telegram_api`).
|
||||
Фронтенд получает от виджета подписанный payload, бэкенд проверяет HMAC-SHA256 подпись
|
||||
ключом `sha256(bot_token)` в **постоянное время** (`mac.verify_slice`, не строковое
|
||||
`!=`) — это защита от timing-атаки на подбор подписи (см. `service::verify_tg_widget_auth`).
|
||||
2. **Telegram-бот** (`/start [ref_code]`, обрабатывается в [`bot.rs`](../../bot.rs), не
|
||||
здесь) — вызывает тот же `AuthService::process_login`.
|
||||
здесь) — вызывает тот же `AuthService::process_login`. Кнопки «Личный Кабинет»/«Тарифы»/
|
||||
«Синдикат» открывают WebApp с короткоживущим (60с) bootstrap-JWT в URL-фрагменте;
|
||||
фронт меняет его на полноценную сессию через `POST /auth/exchange`.
|
||||
3. **Ghost Protocol** (`POST /auth/seed/generate`, `POST /auth/seed/login`) — анонимный
|
||||
вход без привязки к Telegram. Сервер генерирует случайный 16-символьный seed,
|
||||
хеширует его Argon2 с **фиксированной** солью из `ARGON_SALT` (детерминированно —
|
||||
иначе логин никогда не совпадёт с хешем, сохранённым при регистрации) и хранит только
|
||||
хеш. Seed невосстановим: его теряет — теряет аккаунт.
|
||||
4. **Bootstrap-обмен** (`POST /auth/exchange`) — принимает короткоживущий JWT из
|
||||
WebApp-ссылки бота, проверяет подпись/`exp` и выдаёт обычную сессию через
|
||||
`issue_session`. Это единственный способ входа, не создающий пользователя заново —
|
||||
он уже существует (создан через Telegram/Ghost).
|
||||
|
||||
## Гварды (`guard.rs`)
|
||||
|
||||
- `auth_guard` — достаёт JWT из `Authorization: Bearer` **или** из cookie `nrxp_token`,
|
||||
валидирует подпись, подтягивает юзера из БД и кладёт его в `req.extensions()` для
|
||||
контроллеров. Для мутирующих запросов (`POST/PUT/PATCH/DELETE`), пришедших **через
|
||||
cookie**, дополнительно проверяет `Origin`/`Referer` — это CSRF-защита. Bearer-запросы
|
||||
её не проходят и не должны: подделать `Authorization`-заголовок с чужого origin браузер
|
||||
не даст, поэтому CSRF для них неактуален.
|
||||
cookie**, дополнительно проверяет `Origin`/`Referer` против `CSRF_ALLOWED_ORIGINS` — это
|
||||
CSRF-защита. Bearer-запросы её не проходят и не должны: подделать `Authorization`-заголовок
|
||||
с чужого origin браузер не даст, поэтому CSRF для них неактуален.
|
||||
- `admin_guard` — должен идти **после** `auth_guard` в цепочке слоёв (читает юзера из
|
||||
extensions). Пропускает, если `tg_id` юзера совпадает с `ADMIN_TG_ID` из `.env` или его
|
||||
`role == "admin"`.
|
||||
|
||||
+204
-23
@@ -1,10 +1,11 @@
|
||||
use super::service::{Claims, Session};
|
||||
use crate::{api::ApiState, error::AppError};
|
||||
use axum::http::header::SET_COOKIE;
|
||||
use axum::http::header::{COOKIE, SET_COOKIE, USER_AGENT};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{extract::State, routing::post, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::json;
|
||||
use tracing::instrument;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
@@ -23,18 +24,89 @@ pub struct SeedLoginPayload {
|
||||
pub seed: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ExchangePayload {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/telegram", post(auth_telegram_api))
|
||||
.route("/seed/generate", post(generate_seed_api))
|
||||
.route("/seed/login", post(login_seed_api))
|
||||
.route("/exchange", post(exchange_bootstrap_api))
|
||||
.route("/refresh", post(refresh_api))
|
||||
.route("/logout", post(logout_api))
|
||||
}
|
||||
|
||||
#[instrument(skip(state, payload), fields(tg_id = payload.id, username = ?payload.username))]
|
||||
const ACCESS_COOKIE: &str = "nrxp_token";
|
||||
const REFRESH_COOKIE: &str = "nrxp_refresh";
|
||||
const REFRESH_COOKIE_PATH: &str = "/api/v1/auth/refresh";
|
||||
const ACCESS_MAX_AGE_SEC: i64 = 15 * 60;
|
||||
const REFRESH_MAX_AGE_SEC: i64 = 30 * 24 * 60 * 60;
|
||||
|
||||
fn cookie_domain_attr(state: &ApiState) -> String {
|
||||
if state.cookie_domain.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("; Domain={}", state.cookie_domain)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ставит обе сессионные cookie: короткоживущий access-JWT на весь `/`, и
|
||||
/// непрозрачный refresh-токен, ограниченный путём `/api/v1/auth/refresh`
|
||||
/// (чтобы он не улетал на каждый обычный запрос — там он не нужен, а
|
||||
/// поверхность атаки только растёт).
|
||||
fn set_session_cookies(headers: &mut HeaderMap, state: &ApiState, session: &Session) {
|
||||
let domain = cookie_domain_attr(state);
|
||||
|
||||
let access_cookie = format!(
|
||||
"{}={}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age={}{}",
|
||||
ACCESS_COOKIE, session.access_token, ACCESS_MAX_AGE_SEC, domain
|
||||
);
|
||||
let refresh_cookie = format!(
|
||||
"{}={}; HttpOnly; Secure; SameSite=Lax; Path={}; Max-Age={}{}",
|
||||
REFRESH_COOKIE, session.refresh_token, REFRESH_COOKIE_PATH, REFRESH_MAX_AGE_SEC, domain
|
||||
);
|
||||
|
||||
headers.append(SET_COOKIE, access_cookie.parse().unwrap());
|
||||
headers.append(SET_COOKIE, refresh_cookie.parse().unwrap());
|
||||
}
|
||||
|
||||
/// Немедленно затирает обе cookie (logout / отозванная сессия).
|
||||
fn clear_session_cookies(headers: &mut HeaderMap, state: &ApiState) {
|
||||
let domain = cookie_domain_attr(state);
|
||||
let clear_access = format!(
|
||||
"{}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0{}",
|
||||
ACCESS_COOKIE, domain
|
||||
);
|
||||
let clear_refresh = format!(
|
||||
"{}=; HttpOnly; Secure; SameSite=Lax; Path={}; Max-Age=0{}",
|
||||
REFRESH_COOKIE, REFRESH_COOKIE_PATH, domain
|
||||
);
|
||||
headers.append(SET_COOKIE, clear_access.parse().unwrap());
|
||||
headers.append(SET_COOKIE, clear_refresh.parse().unwrap());
|
||||
}
|
||||
|
||||
fn read_cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
headers
|
||||
.get(COOKIE)
|
||||
.and_then(|c| c.to_str().ok())
|
||||
.and_then(|c| c.split("; ").find(|part| part.starts_with(name)))
|
||||
.and_then(|part| part.split_once('='))
|
||||
.map(|(_, v)| v)
|
||||
}
|
||||
|
||||
fn user_agent(headers: &HeaderMap) -> Option<&str> {
|
||||
headers.get(USER_AGENT).and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
#[instrument(skip(state, headers, payload), fields(tg_id = payload.id, username = ?payload.username))]
|
||||
async fn auth_telegram_api(
|
||||
State(state): State<ApiState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<TgWidgetPayload>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// 1. Проверяем подпись Telegram
|
||||
state.auth_service.verify_tg_widget_auth(
|
||||
&state.bot_token,
|
||||
@@ -53,44 +125,153 @@ async fn auth_telegram_api(
|
||||
.process_login(payload.id, payload.username.clone(), None)
|
||||
.await?;
|
||||
|
||||
// 3. Генерируем токен. Теперь user.tg_id передается как Option автоматически.
|
||||
let token = state
|
||||
// 3. Выдаём полноценную сессию (access + refresh) — единая точка для всех
|
||||
// способов входа, см. AuthService::issue_session.
|
||||
let session = state
|
||||
.auth_service
|
||||
.generate_auth_token(user.id, user.tg_id, &state.jwt_secret)?;
|
||||
.issue_session(user.id, user.tg_id, &state.jwt_secret, user_agent(&headers))
|
||||
.await?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
let cookie = format!(
|
||||
"nrxp_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=600",
|
||||
token
|
||||
);
|
||||
headers.insert(SET_COOKIE, cookie.parse().unwrap());
|
||||
let mut out_headers = HeaderMap::new();
|
||||
set_session_cookies(&mut out_headers, &state, &session);
|
||||
|
||||
Ok(Json(json!({ "status": "success", "token": token })))
|
||||
Ok((out_headers, Json(json!({ "status": "success" }))))
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
async fn generate_seed_api(State(state): State<ApiState>) -> Result<impl IntoResponse, AppError> {
|
||||
async fn generate_seed_api(
|
||||
State(state): State<ApiState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// Fail-fast для соли
|
||||
let salt = std::env::var("ARGON_SALT").expect("КРИТИЧЕСКАЯ ОШИБКА: ARGON_SALT не задан!");
|
||||
let (seed, token) = state
|
||||
let (seed, session) = state
|
||||
.auth_service
|
||||
.register_seed(&salt, &state.jwt_secret)
|
||||
.register_seed(&salt, &state.jwt_secret, user_agent(&headers))
|
||||
.await?;
|
||||
|
||||
Ok(Json(
|
||||
json!({ "status": "success", "seed": seed, "token": token }),
|
||||
let mut out_headers = HeaderMap::new();
|
||||
set_session_cookies(&mut out_headers, &state, &session);
|
||||
|
||||
Ok((
|
||||
out_headers,
|
||||
Json(json!({ "status": "success", "seed": seed })),
|
||||
))
|
||||
}
|
||||
|
||||
async fn login_seed_api(
|
||||
State(state): State<ApiState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<SeedLoginPayload>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let salt = std::env::var("ARGON_SALT").expect("КРИТИЧЕСКАЯ ОШИБКА: ARGON_SALT не задан!");
|
||||
let token = state
|
||||
let session = state
|
||||
.auth_service
|
||||
.login_seed(&payload.seed, &salt, &state.jwt_secret)
|
||||
.login_seed(
|
||||
&payload.seed,
|
||||
&salt,
|
||||
&state.jwt_secret,
|
||||
user_agent(&headers),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "status": "success", "token": token })))
|
||||
let mut out_headers = HeaderMap::new();
|
||||
set_session_cookies(&mut out_headers, &state, &session);
|
||||
|
||||
Ok((out_headers, Json(json!({ "status": "success" }))))
|
||||
}
|
||||
|
||||
/// Обменивает короткоживущий bootstrap-токен (см. `bot.rs`, кнопки
|
||||
/// «Личный Кабинет»/«Тарифы»/«Синдикат», токен приходит в `#token=` ссылки
|
||||
/// WebApp) на полноценную cookie-сессию.
|
||||
#[instrument(skip(state, headers, payload))]
|
||||
async fn exchange_bootstrap_api(
|
||||
State(state): State<ApiState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<ExchangePayload>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let token_data = jsonwebtoken::decode::<Claims>(
|
||||
&payload.token,
|
||||
&jsonwebtoken::DecodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||
&jsonwebtoken::Validation::default(),
|
||||
)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired bootstrap token".into()))?;
|
||||
|
||||
let user_id = uuid::Uuid::parse_str(&token_data.claims.sub)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid token payload".into()))?;
|
||||
|
||||
let user = state
|
||||
.repo
|
||||
.get_user_by_id(user_id)
|
||||
.await?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
|
||||
let session = state
|
||||
.auth_service
|
||||
.issue_session(user.id, user.tg_id, &state.jwt_secret, user_agent(&headers))
|
||||
.await?;
|
||||
|
||||
let mut out_headers = HeaderMap::new();
|
||||
set_session_cookies(&mut out_headers, &state, &session);
|
||||
|
||||
Ok((out_headers, Json(json!({ "status": "success" }))))
|
||||
}
|
||||
|
||||
/// Присоединяет заголовки (Set-Cookie и т.п.) к уже готовому ответу, не
|
||||
/// перезаписывая существующие — нужно, чтобы чистить/переустанавливать cookie
|
||||
/// и на успешном, и на ошибочном пути (`AppError::into_response()` иначе не
|
||||
/// понесёт с собой Set-Cookie).
|
||||
fn with_headers(extra: HeaderMap, resp: impl IntoResponse) -> Response {
|
||||
let mut resp = resp.into_response();
|
||||
resp.headers_mut().extend(extra);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Тихое обновление сессии: фронт зовёт это при 401 от access-JWT вместо
|
||||
/// полного релогина. Ротирует refresh-токен при каждом использовании.
|
||||
#[instrument(skip(state, headers))]
|
||||
async fn refresh_api(State(state): State<ApiState>, headers: HeaderMap) -> Response {
|
||||
let mut out_headers = HeaderMap::new();
|
||||
|
||||
let Some(refresh_token) = read_cookie(&headers, REFRESH_COOKIE) else {
|
||||
clear_session_cookies(&mut out_headers, &state);
|
||||
return with_headers(
|
||||
out_headers,
|
||||
AppError::Unauthorized("No refresh token".into()),
|
||||
);
|
||||
};
|
||||
let refresh_token = refresh_token.to_string();
|
||||
|
||||
match state
|
||||
.auth_service
|
||||
.refresh_session(&refresh_token, &state.jwt_secret, user_agent(&headers))
|
||||
.await
|
||||
{
|
||||
Ok(session) => {
|
||||
set_session_cookies(&mut out_headers, &state, &session);
|
||||
with_headers(out_headers, Json(json!({ "status": "success" })))
|
||||
}
|
||||
Err(e) => {
|
||||
// Невалидный/отозванный refresh — сразу чистим cookie, чтобы фронт не
|
||||
// зациклился на бесконечных попытках обновления.
|
||||
clear_session_cookies(&mut out_headers, &state);
|
||||
with_headers(out_headers, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Разлогин: отзывает текущую refresh-сессию и чистит обе cookie.
|
||||
#[instrument(skip(state, headers))]
|
||||
async fn logout_api(
|
||||
State(state): State<ApiState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
if let Some(refresh_token) = read_cookie(&headers, REFRESH_COOKIE) {
|
||||
state.auth_service.logout(refresh_token).await?;
|
||||
}
|
||||
|
||||
let mut out_headers = HeaderMap::new();
|
||||
clear_session_cookies(&mut out_headers, &state);
|
||||
|
||||
Ok((out_headers, Json(json!({ "status": "success" }))))
|
||||
}
|
||||
|
||||
@@ -47,9 +47,21 @@ pub async fn auth_guard(
|
||||
let origin = req.headers().get("Origin").and_then(|v| v.to_str().ok());
|
||||
let referer = req.headers().get("Referer").and_then(|v| v.to_str().ok());
|
||||
|
||||
let is_valid_origin = origin == Some("https://netrunner-vpn.com");
|
||||
let is_valid_origin = origin
|
||||
.map(|o| {
|
||||
state
|
||||
.csrf_allowed_origins
|
||||
.iter()
|
||||
.any(|allowed| allowed == o)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let is_valid_referer = referer
|
||||
.map(|r| r.starts_with("https://netrunner-vpn.com"))
|
||||
.map(|r| {
|
||||
state
|
||||
.csrf_allowed_origins
|
||||
.iter()
|
||||
.any(|allowed| r.starts_with(allowed.as_str()))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_valid_origin && !is_valid_referer {
|
||||
|
||||
+151
-14
@@ -13,6 +13,17 @@ use crate::{
|
||||
error::AppError,
|
||||
};
|
||||
|
||||
/// Access-токен живёт коротко: истечение закрывается тихим `POST /auth/refresh`
|
||||
/// на фронте, а не полным релогином (раньше TTL был 10 минут вообще без
|
||||
/// обновления — юзера тихо разлогинивало каждые 10 минут).
|
||||
const ACCESS_TOKEN_TTL_MIN: i64 = 15;
|
||||
/// Refresh-токен переживает много access-токенов; ротируется при каждом использовании.
|
||||
const REFRESH_TOKEN_TTL_DAYS: i64 = 30;
|
||||
/// Bootstrap-токен кладётся в URL (hash-фрагмент WebApp-ссылки бота) — живёт
|
||||
/// секунды, ровно чтобы фронт успел обменять его на полноценную cookie-сессию
|
||||
/// через `POST /auth/exchange`.
|
||||
const BOOTSTRAP_TOKEN_TTL_SEC: i64 = 60;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug)]
|
||||
pub struct Claims {
|
||||
pub sub: String, // Внутренний UUID юзера
|
||||
@@ -20,6 +31,14 @@ pub struct Claims {
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
/// Пара токенов, выдаваемая при входе: короткий access-JWT (кладётся в
|
||||
/// `Authorization`/cookie на каждый запрос) и непрозрачный refresh-токен
|
||||
/// (только в отдельной cookie, только на `/auth/refresh`).
|
||||
pub struct Session {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService {
|
||||
repo: Arc<dyn AppRepository>,
|
||||
@@ -30,6 +49,18 @@ impl AuthService {
|
||||
Self { repo }
|
||||
}
|
||||
|
||||
fn sign_claims(claims: &Claims, secret: &str) -> Result<String, AppError> {
|
||||
encode(
|
||||
&Header::default(),
|
||||
claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("[Auth] JWT Generation Error: {}", e);
|
||||
AppError::Internal(format!("Failed to generate JWT: {}", e))
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, secret))]
|
||||
pub fn generate_auth_token(
|
||||
&self,
|
||||
@@ -40,20 +71,121 @@ impl AuthService {
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
tg_id,
|
||||
exp: (Utc::now() + chrono::Duration::minutes(10)).timestamp() as usize,
|
||||
exp: (Utc::now() + chrono::Duration::minutes(ACCESS_TOKEN_TTL_MIN)).timestamp()
|
||||
as usize,
|
||||
};
|
||||
Self::sign_claims(&claims, secret)
|
||||
}
|
||||
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("[Auth] JWT Generation Error: {}", e);
|
||||
AppError::Internal(format!("Failed to generate JWT: {}", e))
|
||||
/// Короткоживущий одноразовый токен для WebApp-ссылок из бота (см.
|
||||
/// `bot.rs::handle_callbacks`) — фронт немедленно обменивает его на
|
||||
/// полноценную cookie-сессию через `exchange_bootstrap_token`.
|
||||
#[instrument(skip(self, secret))]
|
||||
pub fn generate_bootstrap_token(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
tg_id: Option<i64>,
|
||||
secret: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
tg_id,
|
||||
exp: (Utc::now() + chrono::Duration::seconds(BOOTSTRAP_TOKEN_TTL_SEC)).timestamp()
|
||||
as usize,
|
||||
};
|
||||
Self::sign_claims(&claims, secret)
|
||||
}
|
||||
|
||||
fn hash_refresh_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn generate_opaque_token() -> String {
|
||||
let bytes: [u8; 32] = rand::thread_rng().gen();
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// Единая точка выдачи сессии для всех способов входа (Telegram/Ghost seed/
|
||||
/// bootstrap-обмен) — гарантирует, что каждый логин ставит одинаковую пару
|
||||
/// access+refresh, а не только часть из них (раньше Ghost Protocol вообще не
|
||||
/// ставил cookie, а Telegram-логин — только access, без refresh).
|
||||
#[instrument(skip(self, secret))]
|
||||
pub async fn issue_session(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
tg_id: Option<i64>,
|
||||
secret: &str,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<Session, AppError> {
|
||||
let access_token = self.generate_auth_token(user_id, tg_id, secret)?;
|
||||
let refresh_token = Self::generate_opaque_token();
|
||||
let token_hash = Self::hash_refresh_token(&refresh_token);
|
||||
let expires_at = Utc::now() + chrono::Duration::days(REFRESH_TOKEN_TTL_DAYS);
|
||||
|
||||
self.repo
|
||||
.create_refresh_session(user_id, &token_hash, user_agent, expires_at)
|
||||
.await?;
|
||||
|
||||
Ok(Session {
|
||||
access_token,
|
||||
refresh_token,
|
||||
})
|
||||
}
|
||||
|
||||
/// Ротация refresh-токена: старый немедленно отзывается, выдаётся новая пара.
|
||||
/// Предъявление уже отозванного токена — сигнал, что его украли и
|
||||
/// переиспользуют после легитимной ротации: в этом случае отзываем **все**
|
||||
/// сессии юзера, а не только эту.
|
||||
#[instrument(skip(self, refresh_token, secret))]
|
||||
pub async fn refresh_session(
|
||||
&self,
|
||||
refresh_token: &str,
|
||||
secret: &str,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<Session, AppError> {
|
||||
let token_hash = Self::hash_refresh_token(refresh_token);
|
||||
let session = self
|
||||
.repo
|
||||
.get_refresh_session_by_hash(&token_hash)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Unauthorized("Invalid refresh token".into()))?;
|
||||
|
||||
if session.revoked_at.is_some() {
|
||||
error!(
|
||||
user_id = %session.user_id,
|
||||
"[Auth] SECURITY_ALERT: переиспользование отозванного refresh-токена — вероятная компрометация, отзываю все сессии"
|
||||
);
|
||||
self.repo
|
||||
.revoke_all_refresh_sessions(session.user_id)
|
||||
.await?;
|
||||
return Err(AppError::Unauthorized("Session revoked".into()));
|
||||
}
|
||||
|
||||
if session.expires_at < Utc::now() {
|
||||
return Err(AppError::Unauthorized("Refresh token expired".into()));
|
||||
}
|
||||
|
||||
self.repo.revoke_refresh_session(&token_hash).await?;
|
||||
|
||||
let user = self
|
||||
.repo
|
||||
.get_user_by_id(session.user_id)
|
||||
.await?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
|
||||
self.issue_session(user.id, user.tg_id, secret, user_agent)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self, refresh_token))]
|
||||
pub async fn logout(&self, refresh_token: &str) -> Result<(), AppError> {
|
||||
let token_hash = Self::hash_refresh_token(refresh_token);
|
||||
self.repo.revoke_refresh_session(&token_hash).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_seed_argon2(seed: &str, salt: &str) -> Result<String, AppError> {
|
||||
let mut hash_output = [0u8; 32];
|
||||
Argon2::default()
|
||||
@@ -67,7 +199,8 @@ impl AuthService {
|
||||
&self,
|
||||
salt: &str,
|
||||
jwt_secret: &str,
|
||||
) -> Result<(String, String), AppError> {
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<(String, Session), AppError> {
|
||||
// Генерация криптостойкого 16-символьного Seed (Alphanumeric)
|
||||
let seed: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
@@ -80,9 +213,11 @@ impl AuthService {
|
||||
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)?;
|
||||
let session = self
|
||||
.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
|
||||
.await?;
|
||||
|
||||
Ok((seed, token))
|
||||
Ok((seed, session))
|
||||
}
|
||||
|
||||
/// Ghost Protocol: Вход по существующему Seed
|
||||
@@ -91,7 +226,8 @@ impl AuthService {
|
||||
seed: &str,
|
||||
salt: &str,
|
||||
jwt_secret: &str,
|
||||
) -> Result<String, AppError> {
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<Session, AppError> {
|
||||
let hash = Self::hash_seed_argon2(seed, salt)?;
|
||||
|
||||
let user = self
|
||||
@@ -100,7 +236,8 @@ impl AuthService {
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Unauthorized("Invalid credentials".into()))?;
|
||||
|
||||
self.generate_auth_token(user.id, user.tg_id, jwt_secret)
|
||||
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
|
||||
.await
|
||||
}
|
||||
// --- Логика Telegram (без изменений, кроме вызова генерации токена) ---
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
|
||||
1. **`POST /billing/pay/initiate`** (`initiate_payment`) — сервер сам смотрит цену тарифа
|
||||
в нужной валюте (`plan_prices`, см. [`migrations/0001_init.sql`](../../../migrations/0001_init.sql)),
|
||||
запрашивает текущий курс TON у TonAPI, считает сумму в nanoTON с буфером **+2%**
|
||||
берёт курс TON (сначала кеш в Redis — `ton_rate:<currency>`, TTL 60с; на промах —
|
||||
TonAPI, ответ кладётся в кеш), считает сумму в nanoTON с буфером **+2%**
|
||||
(`calculate_ton_invoice` — компенсация волатильности курса между генерацией инвойса и
|
||||
отправкой транзакции юзером) и создаёт `invoices`-запись со статусом `pending`.
|
||||
Клиент **не может повлиять на сумму** — она целиком считается на сервере.
|
||||
Клиент **не может повлиять на сумму** — она целиком считается на сервере. Запрос к
|
||||
TonAPI идёт по коду валюты из инвойса (не захардкожен на `usd,rub`), а ответ
|
||||
разбирается по ключу в ВЕРХНЕМ регистре (`prices.USD`) — так его и отдаёт TonAPI.
|
||||
2. Фронтенд получает `{ destination, amount, comment }` и просит кошелёк (TonConnect)
|
||||
отправить транзакцию с этим `comment` (= `invoice_id`) — по нему инвойс потом находится.
|
||||
3. **`POST /billing/pay/confirm`** (`confirm_payment`) — клиент присылает `tx_hash`
|
||||
@@ -36,8 +39,8 @@
|
||||
`activate_trial` доступен только аккаунтам с `tg_id` (см. [`auth/README.md`](../auth/README.md#sybil-защита))
|
||||
и только один раз (`has_used_trial`).
|
||||
|
||||
## Известные ограничения (см. корневой README «Недоделки»)
|
||||
## Известные ограничения
|
||||
|
||||
Синхронный запрос курса TON на каждый `initiate_payment` и пересборка `providers`
|
||||
(включая повторное чтение env) при каждом `Clone` сервиса — оба помечены `// TODO` прямо
|
||||
в `service.rs`.
|
||||
Пересборка `providers` (включая повторное чтение env) при каждом `Clone` сервиса —
|
||||
axum клонирует `ApiState`, а с ним и `BillingService`, на каждый HTTP-запрос; см. `//
|
||||
TODO` прямо в `service.rs`. Курс TON теперь кешируется (см. выше), это ограничение — нет.
|
||||
|
||||
@@ -6,13 +6,22 @@ use crate::{
|
||||
},
|
||||
error::AppError,
|
||||
};
|
||||
use redis::AsyncCommands;
|
||||
use rust_decimal::prelude::FromPrimitive;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Курс TON меняется медленно относительно времени жизни одного инвойса —
|
||||
/// 60с кеша достаточно, чтобы не бить TonAPI на каждый `initiate_payment`,
|
||||
/// но не настолько долго, чтобы курс успел заметно уйти от рынка.
|
||||
const TON_RATE_CACHE_TTL_SEC: u64 = 60;
|
||||
|
||||
pub struct BillingService {
|
||||
repo: Arc<dyn AppRepository>,
|
||||
providers: HashMap<&'static str, Box<dyn PaymentProvider>>,
|
||||
// Redis — только кеш, не источник истины: если он недоступен, просто
|
||||
// ходим в TonAPI напрямую на каждый запрос (как было раньше).
|
||||
redis: Option<redis::aio::ConnectionManager>,
|
||||
}
|
||||
|
||||
// Добавляем Clone вручную, так как dyn Traits не поддерживают derive(Clone) легко.
|
||||
@@ -35,12 +44,13 @@ impl Clone for BillingService {
|
||||
Self {
|
||||
repo: self.repo.clone(),
|
||||
providers,
|
||||
redis: self.redis.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BillingService {
|
||||
pub fn new(repo: Arc<dyn AppRepository>) -> Self {
|
||||
pub fn new(repo: Arc<dyn AppRepository>, redis: Option<redis::aio::ConnectionManager>) -> Self {
|
||||
// Читаем адрес из переменной окружения
|
||||
let master_wallet = std::env::var("TON_MASTER_WALLET")
|
||||
.expect("КРИТИЧЕСКАЯ ОШИБКА: TON_MASTER_WALLET не задан в .env!");
|
||||
@@ -51,7 +61,30 @@ impl BillingService {
|
||||
Box::new(super::providers::ton::TonProvider { master_wallet }),
|
||||
);
|
||||
|
||||
Self { repo, providers }
|
||||
Self {
|
||||
repo,
|
||||
providers,
|
||||
redis,
|
||||
}
|
||||
}
|
||||
|
||||
/// Курс TON к валюте из кеша; `None` при промахе/недоступности Redis —
|
||||
/// вызывающий код должен сходить за свежим значением и заполнить кеш сам.
|
||||
async fn get_cached_ton_rate(&self, currency_lower: &str) -> Option<rust_decimal::Decimal> {
|
||||
let mut conn = self.redis.clone()?;
|
||||
let cache_key = format!("ton_rate:{}", currency_lower);
|
||||
let cached: Option<String> = conn.get(&cache_key).await.ok()?;
|
||||
cached.and_then(|v| v.parse().ok())
|
||||
}
|
||||
|
||||
async fn cache_ton_rate(&self, currency_lower: &str, rate: rust_decimal::Decimal) {
|
||||
let Some(mut conn) = self.redis.clone() else {
|
||||
return;
|
||||
};
|
||||
let cache_key = format!("ton_rate:{}", currency_lower);
|
||||
let _: Result<(), _> = conn
|
||||
.set_ex(&cache_key, rate.to_string(), TON_RATE_CACHE_TTL_SEC)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn get_subscription(&self, user_id: Uuid) -> Result<Option<Subscription>, AppError> {
|
||||
@@ -82,27 +115,44 @@ impl BillingService {
|
||||
AppError::BusinessLogic("Тариф недоступен для данного региона".into())
|
||||
})?;
|
||||
|
||||
// --- ДИНАМИЧЕСКИЙ ЗАПРОС КУРСА TON ---
|
||||
// TODO: синхронный HTTP-запрос к TonAPI на каждый вызов initiate_payment
|
||||
// тормозит создание инвойса и делает эндпоинт зависимым от доступности
|
||||
// внешнего сервиса. Вынести в фоновый воркер с кешем в Redis (уже подключён).
|
||||
let client = reqwest::Client::new();
|
||||
let rate_res: serde_json::Value = client
|
||||
.get("https://tonapi.io/v2/rates?tokens=ton¤cies=usd,rub")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка сети при запросе курса: {}", e)))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка парсинга курса TON: {}", e)))?;
|
||||
|
||||
// --- КУРС TON: сначала кеш (Redis, TTL 60с), иначе синхронный запрос к TonAPI ---
|
||||
let currency_lower = currency.to_lowercase();
|
||||
let rate_f64 = rate_res["rates"]["TON"]["prices"][¤cy_lower]
|
||||
.as_f64()
|
||||
.ok_or_else(|| AppError::Internal("TonAPI не вернул курс для этой валюты".into()))?;
|
||||
let currency_upper = currency.to_uppercase();
|
||||
|
||||
let ton_rate = rust_decimal::Decimal::from_f64(rate_f64)
|
||||
.ok_or_else(|| AppError::Internal("Ошибка конвертации f64 в Decimal".into()))?;
|
||||
let ton_rate = if let Some(cached) = self.get_cached_ton_rate(¤cy_lower).await {
|
||||
cached
|
||||
} else {
|
||||
let client = reqwest::Client::new();
|
||||
// TonAPI и запрашивать, и отдавать валюту нужно по одному и тому же
|
||||
// коду: раньше здесь были жёстко зашиты "usd,rub" в запросе (курс для
|
||||
// любой другой валюты из plan_prices — CNY/TRY/IRR — никогда бы не
|
||||
// нашёлся), а ответ разбирался по НИЖНЕМУ регистру, хотя TonAPI
|
||||
// возвращает ключи валют в ВЕРХНЕМ (`prices.USD`, не `prices.usd`).
|
||||
let url = format!(
|
||||
"https://tonapi.io/v2/rates?tokens=ton¤cies={}",
|
||||
currency_lower
|
||||
);
|
||||
let rate_res: serde_json::Value = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка сети при запросе курса: {}", e)))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка парсинга курса TON: {}", e)))?;
|
||||
|
||||
let rate_f64 = rate_res["rates"]["TON"]["prices"][¤cy_upper]
|
||||
.as_f64()
|
||||
.ok_or_else(|| {
|
||||
AppError::Internal("TonAPI не вернул курс для этой валюты".into())
|
||||
})?;
|
||||
|
||||
let rate = rust_decimal::Decimal::from_f64(rate_f64)
|
||||
.ok_or_else(|| AppError::Internal("Ошибка конвертации f64 в Decimal".into()))?;
|
||||
|
||||
self.cache_ton_rate(¤cy_lower, rate).await;
|
||||
rate
|
||||
};
|
||||
// ----------------------------------------
|
||||
|
||||
let nanotons = Self::calculate_ton_invoice(fiat_amount_minimal, ton_rate)
|
||||
|
||||
+22
-11
@@ -9,19 +9,28 @@
|
||||
- `GET /nodes` — список активных нод (`status = 'online'`), отсортирован по
|
||||
возрастанию `current_load` — то есть новые юзеры естественным образом идут на самые
|
||||
свободные сервера.
|
||||
- `GET /nodes/stats` — агрегаты для лендинга (страна, загрузка, статус). **`uptime` —
|
||||
заглушка**, см. TODO в `controller.rs`.
|
||||
- `GET /nodes/routing` — конфиг для клиентского приложения (Tauri/mobile). **Сейчас
|
||||
неполный** — см. TODO в `controller.rs` и в корневом README.
|
||||
- `GET /nodes/stats` — агрегаты для лендинга (страна, загрузка, статус, `last_seen`).
|
||||
- `GET /nodes/routing` — полный `TunnelConfig` для клиентского приложения: `ip/port`,
|
||||
`tunnel_address/prefix/dns/mtu`, `public_key`/`sni_domain` ноды и одноразовый (60с)
|
||||
`session_token` (HMAC-подписанный, `NodeService::generate_session_token`) — `null`,
|
||||
если у юзера нет активной подписки.
|
||||
- `admin_router` (за `auth_guard` **и** `admin_guard`, в таком порядке — `admin_guard`
|
||||
ожидает, что `auth_guard` уже положил юзера в extensions):
|
||||
- `POST /admin/nodes` — добавить и заprovisioning новую ноду.
|
||||
- `GET /admin/nodes` — список **всех** нод независимо от статуса (в отличие от
|
||||
публичного `GET /nodes`, который отдаёт только `online`) — нужен админке, чтобы видеть
|
||||
ноды в `provisioning`/`offline`.
|
||||
- `POST /admin/nodes` — сразу вставляет ноду со статусом `provisioning` и отвечает
|
||||
`202 Accepted`; реальный SSH-провижининг идёт в фоне (`spawn_provisioning`).
|
||||
- `DELETE /admin/nodes/{id}` — удалить ноду из БД (саму VPS-машину это не трогает).
|
||||
- `POST /admin/nodes/{id}/restart` — перезапускает прокси-контейнер на ноде по SSH
|
||||
(уже по мастер-ключу, не по паролю — см. ниже).
|
||||
|
||||
## Провижининг (`service.rs::provision_node_via_ssh`)
|
||||
## Провижининг (`service.rs`)
|
||||
|
||||
Синхронная (в смысле — блокирующий поток через `spawn_blocking`, но HTTP-запрос ждёт её
|
||||
целиком, см. TODO) операция по `ssh2`:
|
||||
`add_node` вставляет ноду в БД со статусом `provisioning` и сразу возвращает управление
|
||||
контроллеру (`202 Accepted`); сам SSH-провижининг (`provision_node_via_ssh`) выполняется в
|
||||
фоне (`tokio::spawn` + `spawn_blocking` для блокирующего `ssh2`), по завершении переводит
|
||||
ноду в `online` (успех) или `offline` (провал, с логом причины):
|
||||
|
||||
1. Подключается к `ip:22` с таймаутом 15с, логинится по паролю root (временный пароль,
|
||||
передаётся один раз при создании ноды).
|
||||
@@ -30,9 +39,11 @@
|
||||
3. Прописывает публичный ключ control plane (`keys/netrunner_master_ed25519.pub`,
|
||||
создаётся заранее, см. `main.rs::ensure_master_ssh_key`) в `authorized_keys` и **жёстко
|
||||
выключает парольный SSH-вход** (hardening-скрипт внутри метода) — после провижининга
|
||||
зайти на ноду можно только по ключу.
|
||||
зайти на ноду можно только по ключу. Именно поэтому `restart_node` подключается уже
|
||||
по ключу (`connect_ssh_with_key`), а не по паролю, которого больше нет.
|
||||
4. Не более 3 одновременных провижинингов (`Semaphore::new(3)`), чтобы не заDDoSить сам
|
||||
control plane при массовом добавлении нод.
|
||||
|
||||
Одновременно с провижинингом только `add_vpn_node` пишет запись в БД — если SSH-шаг
|
||||
упал, запись не создаётся вовсе (не остаётся полуживых нод в базе).
|
||||
Health-check (`src/tasks.rs`, раз в `NODE_HEALTH_CHECK_INTERVAL` секунд, по умолчанию 60)
|
||||
TCP-пингует каждую не-`provisioning` ноду и переоценивает `online`/`offline` сам — control
|
||||
plane больше не может бесконечно долго считать мёртвую ноду живой.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
middleware,
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
@@ -10,9 +11,12 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
api::ApiState,
|
||||
db::models::{CreateNodePayload, VpnNode},
|
||||
db::models::{CreateNodePayload, User, VpnNode},
|
||||
error::AppError,
|
||||
modules::auth::{admin_guard, auth_guard},
|
||||
modules::{
|
||||
auth::{admin_guard, auth_guard},
|
||||
nodes::service::NodeService,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -34,8 +38,9 @@ pub fn public_router(state: ApiState) -> Router<ApiState> {
|
||||
/// Секретный роутер для Nexus (Admin)
|
||||
pub fn admin_router(state: ApiState) -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/", post(add_node))
|
||||
.route("/", get(list_all_nodes).post(add_node))
|
||||
.route("/{id}", delete(remove_node))
|
||||
.route("/{id}/restart", post(restart_node))
|
||||
// Теперь admin_guard использует from_fn_with_state
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), admin_guard))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth_guard))
|
||||
@@ -46,12 +51,21 @@ async fn get_nodes(State(state): State<ApiState>) -> Result<Json<Vec<VpnNode>>,
|
||||
Ok(Json(nodes))
|
||||
}
|
||||
|
||||
/// Все ноды независимо от статуса — админке нужно видеть `provisioning`/`offline`,
|
||||
/// а не только `online` (иначе новая нода "пропадает" на время провижининга).
|
||||
async fn list_all_nodes(State(state): State<ApiState>) -> Result<Json<Vec<VpnNode>>, AppError> {
|
||||
let nodes = state.node_service.get_all_nodes().await?;
|
||||
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?;
|
||||
|
||||
// TODO: "uptime" — статичная заглушка "99.9%" для всех нод, а не реальная метрика.
|
||||
// Нужен либо расчёт из last_seen/истории статусов, либо отдельный столбец в БД,
|
||||
// обновляемый health-check воркером (см. TODO в src/tasks.rs).
|
||||
// Раньше здесь была статичная заглушка "99.9%" для всех нод. Реального
|
||||
// аптайма в процентах без истории статусов не посчитать — вместо того,
|
||||
// чтобы городить ещё одну фейковую цифру, отдаём то, что действительно
|
||||
// знаем: текущий статус (обновляется health-check воркером, см. tasks.rs)
|
||||
// и время последнего успешного health-check.
|
||||
let stats: Vec<_> = nodes
|
||||
.into_iter()
|
||||
.map(|n| {
|
||||
@@ -59,7 +73,7 @@ async fn get_node_stats(State(state): State<ApiState>) -> Result<Json<Value>, Ap
|
||||
"country_code": n.country_code,
|
||||
"current_load": n.current_load,
|
||||
"status": n.status,
|
||||
"uptime": "99.9%"
|
||||
"last_seen": n.last_seen,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -70,32 +84,53 @@ async fn get_node_stats(State(state): State<ApiState>) -> Result<Json<Value>, Ap
|
||||
async fn add_node(
|
||||
State(state): State<ApiState>,
|
||||
Json(req): Json<AddNodeRequest>,
|
||||
) -> Result<Json<VpnNode>, AppError> {
|
||||
) -> Result<(StatusCode, Json<VpnNode>), AppError> {
|
||||
// Провижининг ноды по SSH может занимать минуты — не держим запрос
|
||||
// открытым, а сразу отдаём 202 с нодой в статусе "provisioning";
|
||||
// реальный деплой идёт в фоне (см. NodeService::spawn_provisioning).
|
||||
let node = state
|
||||
.node_service
|
||||
.add_node(req.payload, &req.ssh_password)
|
||||
.await?;
|
||||
|
||||
Ok(Json(node))
|
||||
Ok((StatusCode::ACCEPTED, Json(node)))
|
||||
}
|
||||
|
||||
// TODO: Недоделанный контракт для VPN-клиента (Tauri/mobile). Сейчас отдаём только
|
||||
// ip/port/region/supported_protocols (последнее — статичная заглушка). Клиенту для
|
||||
// реального поднятия туннеля нужны как минимум: address/prefix/dns/mtu (эти поля уже
|
||||
// есть в VpnNode — tunnel_*, но не сериализуются сюда), а также public_key и sni_domain,
|
||||
// которых пока нет даже в схеме БД (см. TODO в migrations/0001_init.sql), и одноразовый
|
||||
// session-токен, подтверждающий, что у юзера активна подписка.
|
||||
async fn get_node_routing(State(state): State<ApiState>) -> Result<Json<Value>, AppError> {
|
||||
/// Полноценный TunnelConfig для VPN-клиента: tunnel_*-поля (адрес/маска/DNS/MTU),
|
||||
/// public_key/sni_domain ноды и одноразовый (60с) session-токен, подтверждающий
|
||||
/// активную подписку — без него нода не обязана поднимать туннель.
|
||||
async fn get_node_routing(
|
||||
State(state): State<ApiState>,
|
||||
Extension(user): Extension<User>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let nodes = state.node_service.get_active_nodes().await?;
|
||||
let has_active_subscription = matches!(
|
||||
state.billing_service.get_subscription(user.id).await?,
|
||||
Some(sub) if sub.status == "active" || sub.status == "grace"
|
||||
);
|
||||
|
||||
let routing: Vec<_> = nodes
|
||||
.into_iter()
|
||||
.map(|n| {
|
||||
let session_token = if has_active_subscription {
|
||||
NodeService::generate_session_token(user.id, n.id, &state.jwt_secret).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
json!({
|
||||
"id": n.id,
|
||||
"ip": n.ip_address,
|
||||
"port": n.port,
|
||||
"region": n.country_code,
|
||||
"supported_protocols": ["tcp", "udp"],
|
||||
"tunnel_address": n.tunnel_address,
|
||||
"tunnel_prefix": n.tunnel_prefix,
|
||||
"tunnel_dns": n.tunnel_dns,
|
||||
"tunnel_mtu": n.tunnel_mtu,
|
||||
"public_key": n.public_key,
|
||||
"sni_domain": n.sni_domain,
|
||||
"session_token": session_token,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -112,3 +147,13 @@ async fn remove_node(
|
||||
json!({"status": "success", "message": "Node terminated"}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn restart_node(
|
||||
State(state): State<ApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
state.node_service.restart_node(id).await?;
|
||||
Ok(Json(
|
||||
json!({"status": "success", "message": "Node restart issued"}),
|
||||
))
|
||||
}
|
||||
|
||||
+176
-49
@@ -5,13 +5,29 @@ use crate::{
|
||||
},
|
||||
error::AppError,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration as StdDuration;
|
||||
use uuid::Uuid;
|
||||
|
||||
use ssh2::Session;
|
||||
use std::io::Read;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// Живёт ~60с — ровно чтобы VPN-клиент успел взять `/nodes/routing` и сразу
|
||||
/// поднять туннель. Не JWT-сессия пользователя, отдельный неймспейс claims,
|
||||
/// чтобы не перепутать с access-токеном.
|
||||
const NODE_SESSION_TOKEN_TTL_SEC: i64 = 60;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct NodeSessionClaims {
|
||||
sub: String,
|
||||
node_id: String,
|
||||
exp: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NodeService {
|
||||
repo: Arc<dyn AppRepository>,
|
||||
@@ -33,37 +49,82 @@ impl NodeService {
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
// TODO: Деплой сейчас синхронный — HTTP-запрос админки виснет на всё время
|
||||
// SSH-провижининга (обновление пакетов, докер, генерация ключей — может занимать
|
||||
// минуты). Нужно отдавать 202 Accepted со статусом "provisioning" сразу, а сам
|
||||
// provision_node_via_ssh гонять в фоновой задаче с call-home от ноды по завершении.
|
||||
pub async fn get_all_nodes(&self) -> Result<Vec<VpnNode>, AppError> {
|
||||
self.repo.get_all_nodes().await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Одноразовый (60с) токен, подтверждающий ноде, что юзер имеет право
|
||||
/// поднять туннель именно на ней прямо сейчас. Верификация токена — на
|
||||
/// стороне ноды/прокси (отдельный репозиторий, `netrunner-proxy`); здесь
|
||||
/// только выпуск.
|
||||
pub fn generate_session_token(
|
||||
user_id: Uuid,
|
||||
node_id: Uuid,
|
||||
secret: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let claims = NodeSessionClaims {
|
||||
sub: user_id.to_string(),
|
||||
node_id: node_id.to_string(),
|
||||
exp: (Utc::now() + chrono::Duration::seconds(NODE_SESSION_TOKEN_TTL_SEC)).timestamp()
|
||||
as usize,
|
||||
};
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| AppError::Internal(format!("Failed to generate node session token: {}", e)))
|
||||
}
|
||||
|
||||
/// Немедленно вставляет ноду в статус `provisioning` и возвращает её,
|
||||
/// не дожидаясь SSH-провижининга — реальный деплой идёт в фоне
|
||||
/// (`spawn_provisioning`). Раньше HTTP-запрос админки висел на всё время
|
||||
/// провижининга (минуты); контроллер теперь может сразу ответить `202`.
|
||||
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();
|
||||
|
||||
// Ждем разрешения от семафора
|
||||
let _permit = self
|
||||
.deploy_semaphore
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|_| AppError::Internal("Semaphore closed".into()))?;
|
||||
|
||||
tokio::task::spawn_blocking(move || Self::provision_node_via_ssh(&ip_address, &password))
|
||||
.await
|
||||
.map_err(|_| AppError::Internal("SSH Task panicked".into()))??;
|
||||
|
||||
let node = self.repo.add_vpn_node(payload).await?;
|
||||
let node = self.repo.add_vpn_node_pending(payload).await?;
|
||||
self.spawn_provisioning(node.id, node.ip_address.clone(), ssh_password.to_string());
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn spawn_provisioning(&self, node_id: Uuid, ip_address: String, password: String) {
|
||||
let repo = self.repo.clone();
|
||||
let semaphore = self.deploy_semaphore.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _permit = match semaphore.acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let provision_result = tokio::task::spawn_blocking(move || {
|
||||
Self::provision_node_via_ssh(&ip_address, &password)
|
||||
})
|
||||
.await;
|
||||
|
||||
let final_status = match provision_result {
|
||||
Ok(Ok(())) => "online",
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(node_id = %node_id, error = ?e, "Провижининг ноды провалился");
|
||||
"offline"
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(node_id = %node_id, error = ?e, "SSH-таск провижининга паниковал");
|
||||
"offline"
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = repo.update_node_health(node_id, final_status).await {
|
||||
tracing::error!(node_id = %node_id, error = ?e, "Не удалось обновить статус ноды после провижининга");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -71,29 +132,7 @@ impl NodeService {
|
||||
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()
|
||||
.map_err(|e| AppError::Internal(format!("Не удалось создать SSH-сессию: {}", e)))?;
|
||||
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())
|
||||
})?;
|
||||
let sess = Self::connect_ssh(ip, "root", password)?;
|
||||
|
||||
// 3. Подготавливаем команды автоматизации
|
||||
let gh_token = std::env::var("GHCR_TOKEN").map_err(|_| {
|
||||
@@ -126,24 +165,91 @@ impl NodeService {
|
||||
ip, gh_token, init_script, harden_script
|
||||
);
|
||||
|
||||
// 4. Исполняем сценарий развертывания контейнеров в изолированной сессии
|
||||
Self::exec_ssh_command(&sess, &full_command)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SSH-подключение по мастер-ключу (используется после первичного
|
||||
/// провижининга, когда парольный вход на ноде уже отключён hardening-скриптом).
|
||||
fn connect_ssh_with_key(ip: &str) -> Result<Session, AppError> {
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::path::Path;
|
||||
|
||||
let socket_addr = format!("{}:22", ip)
|
||||
.parse::<SocketAddr>()
|
||||
.map_err(|e| AppError::Internal(format!("Некорректный формат IP-адреса VPS: {}", e)))?;
|
||||
let tcp =
|
||||
TcpStream::connect_timeout(&socket_addr, StdDuration::from_secs(15)).map_err(|e| {
|
||||
AppError::Internal(format!(
|
||||
"Узел не ответил на запрос подключения по SSH (Timeout 15s): {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut sess = Session::new()
|
||||
.map_err(|e| AppError::Internal(format!("Не удалось создать SSH-сессию: {}", e)))?;
|
||||
sess.set_tcp_stream(tcp);
|
||||
sess.handshake()
|
||||
.map_err(|e| AppError::Internal(format!("Сбой SSH Handshake: {}", e)))?;
|
||||
|
||||
sess.userauth_pubkey_file(
|
||||
"root",
|
||||
None,
|
||||
Path::new("keys/netrunner_master_ed25519"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка авторизации по SSH-ключу: {}", e)))?;
|
||||
|
||||
Ok(sess)
|
||||
}
|
||||
|
||||
fn connect_ssh(ip: &str, user: &str, password: &str) -> Result<Session, AppError> {
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
|
||||
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, StdDuration::from_secs(15)).map_err(|e| {
|
||||
AppError::Internal(format!(
|
||||
"Узел не ответил на запрос подключения по SSH (Timeout 15s): {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut sess = Session::new()
|
||||
.map_err(|e| AppError::Internal(format!("Не удалось создать SSH-сессию: {}", e)))?;
|
||||
sess.set_tcp_stream(tcp);
|
||||
sess.handshake()
|
||||
.map_err(|e| AppError::Internal(format!("Сбой SSH Handshake: {}", e)))?;
|
||||
|
||||
sess.userauth_password(user, password).map_err(|_| {
|
||||
AppError::Unauthorized("Ошибка авторизации по SSH. Неверный пароль root?".into())
|
||||
})?;
|
||||
|
||||
Ok(sess)
|
||||
}
|
||||
|
||||
fn exec_ssh_command(sess: &Session, command: &str) -> Result<String, AppError> {
|
||||
let mut channel = sess
|
||||
.channel_session()
|
||||
.map_err(|e| AppError::Internal(format!("Не удалось открыть SSH-канал: {}", e)))?;
|
||||
channel
|
||||
.exec(&full_command)
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка старта команд деплоя: {}", e)))?;
|
||||
.exec(command)
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка старта команды по SSH: {}", e)))?;
|
||||
|
||||
let mut output = String::new();
|
||||
channel
|
||||
.read_to_string(&mut output)
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка чтения вывода SSH: {}", e)))?;
|
||||
tracing::info!("SSH Execution Output from {}:\n{}", ip, output);
|
||||
|
||||
channel
|
||||
.wait_close()
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка закрытия SSH-канала: {}", e)))?;
|
||||
Ok(())
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub async fn delete_node(&self, node_id: Uuid) -> Result<(), AppError> {
|
||||
@@ -152,4 +258,25 @@ impl NodeService {
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Форс-рестарт контейнера прокси на ноде по SSH (ключ, не пароль — пароль
|
||||
/// после провижининга уже отключён hardening-скриптом).
|
||||
pub async fn restart_node(&self, node_id: Uuid) -> Result<(), AppError> {
|
||||
let node = self
|
||||
.repo
|
||||
.get_node_by_id(node_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Node not found".into()))?;
|
||||
|
||||
let ip = node.ip_address.clone();
|
||||
let output = tokio::task::spawn_blocking(move || -> Result<String, AppError> {
|
||||
let sess = Self::connect_ssh_with_key(&ip)?;
|
||||
Self::exec_ssh_command(&sess, "docker restart netrunner-proxy")
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AppError::Internal("SSH Task panicked".into()))??;
|
||||
|
||||
tracing::info!(node_id = %node_id, output = %output, "Нода перезапущена по требованию администратора");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+26
-14
@@ -7,18 +7,30 @@
|
||||
`expired`) + список рефералов одним JSON-объектом. Это осознанно сделано на стороне
|
||||
БД (`jsonb_build_object`/`jsonb_agg`), а не в Rust несколькими запросами.
|
||||
|
||||
## Cyberhack (мини-игра)
|
||||
## Cyberhack (мини-игра) — server-authoritative
|
||||
|
||||
- `POST /users/hack/result` (`{ score }`) — фронтенд (`frontend/src/HackGame.tsx`,
|
||||
используется npm-пакет `cyberhack`) присылает результат раунда игры.
|
||||
- `score` клампится на сервере в `0..=500` — это **единственная** серверная проверка
|
||||
честности результата; клиент фактически сам решает, сколько очков получить.
|
||||
Неэксплуатируемо, пока баланс E$ ни на что не тратится, но нужно закрыть до того, как
|
||||
появится обмен баланса на что-то ценное — см. `// TODO` в `controller.rs`.
|
||||
- `add_hack_reward` -> `repo.consume_ticket_and_reward` списывает один игровой билет и
|
||||
начисляет баланс **в одной транзакции с `SELECT ... FOR UPDATE`**, чтобы параллельные
|
||||
запросы одного юзера не списали билет дважды. Дополнительно требует минимум 45 секунд
|
||||
между попытками (`last_hack_at`) — защита от скриптового фарма очков.
|
||||
- Билеты выдаются только при покупке подписки (`billing::buy_subscription`, +1 билет,
|
||||
кэп 10) — то есть поиграть в Cyberhack без действующей/когда-либо купленной подписки
|
||||
нельзя.
|
||||
Вся игровая логика (генерация доски, проверка ходов, подсчёт очков) продублирована на
|
||||
сервере в [`cyberhack.rs`](cyberhack.rs) — порт 1:1 алгоритма из `Cyberhack/src/lib.rs`
|
||||
(WASM/Yew-движок игры). Раньше клиент сам генерировал доску, сам считал `score` и просто
|
||||
присылал готовое число (сервер лишь клампил его в `0..=500`) — это давало клиенту полный
|
||||
контроль над наградой.
|
||||
|
||||
Флоу теперь в два шага:
|
||||
|
||||
1. **`POST /users/hack/start`** — списывает билет (та же атомарная транзакция с
|
||||
`SELECT ... FOR UPDATE` + минимум 45 секунд между попытками, что и раньше, см.
|
||||
`repository::start_hack_session`), генерирует доску/цели на сервере, сохраняет их в
|
||||
`hack_sessions` (TTL = время игры + запас на сетевую задержку) и отдаёт клиенту
|
||||
`{session_id, matrix, targets, base_value, time_limit}`. Клиент (WASM-игра) обязан
|
||||
играть строго на этой доске, не генерировать свою.
|
||||
2. **`POST /users/hack/result`** (`{ session_id, path }`) — `path` это сырая
|
||||
последовательность кликов `(row, col)` в порядке совершения, а не итоговый счёт.
|
||||
`repository::consume_hack_session` атомарно помечает сессию использованной (повторная
|
||||
сдача — отказ) и возвращает сохранённую доску; `cyberhack::replay_path` реплеит путь,
|
||||
проверяя легальность каждого хода (чередование строка/столбец, без повторных клеток,
|
||||
лимит буфера), и сам считает, какие цели реально выполнены. Награда начисляется по
|
||||
этому результату — то, что мог бы прислать клиент, нигде не используется.
|
||||
|
||||
Билеты выдаются только при покупке подписки (`billing::buy_subscription`, +1 билет,
|
||||
кэп 10) — то есть поиграть в Cyberhack без действующей/когда-либо купленной подписки
|
||||
нельзя.
|
||||
|
||||
@@ -11,13 +11,17 @@ use tracing::instrument;
|
||||
use crate::{api::ApiState, db::models::User, error::AppError, modules::auth::guard::auth_guard};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct HackPayload {
|
||||
pub score: i64,
|
||||
pub struct HackResultPayload {
|
||||
pub session_id: uuid::Uuid,
|
||||
/// Последовательность кликов `(row, col)` в порядке совершения — сервер
|
||||
/// сам реплеит её по своей копии доски и считает награду.
|
||||
pub path: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
pub fn router(state: ApiState) -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/me", get(get_me))
|
||||
.route("/hack/start", post(start_hack))
|
||||
.route("/hack/result", post(submit_hack_result))
|
||||
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||
}
|
||||
@@ -35,20 +39,34 @@ async fn get_me(
|
||||
Ok(Json(json!(profile)))
|
||||
}
|
||||
|
||||
// TODO: Античит. Клиент сам присылает итоговый `score` игры (клампится 0..=500 —
|
||||
// это единственная серверная проверка). Сейчас неэксплуатируемо, т.к. баланс (E$)
|
||||
// нигде не тратится, но как только появится обмен E$ на что-то ценное, это станет
|
||||
// дыркой "накрутка баланса через DevTools". Нужен подписанный токен старта игры +
|
||||
// серверная валидация результата (см. SECURITY_AUDIT.md "Остаточные риски").
|
||||
#[instrument(skip(state, user), fields(tg_id = user.tg_id, score = payload.score))]
|
||||
/// Списывает билет и выдаёт клиенту сгенерированную сервером доску Cyberhack
|
||||
/// (server-authoritative — см. `modules::users::cyberhack`).
|
||||
#[instrument(skip(state, user), fields(tg_id = user.tg_id))]
|
||||
async fn start_hack(
|
||||
Extension(user): Extension<User>,
|
||||
State(state): State<ApiState>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let session = state.user_service.start_hack_session(user.id).await?;
|
||||
Ok(Json(json!({
|
||||
"session_id": session.session_id,
|
||||
"matrix": session.matrix,
|
||||
"targets": session.targets,
|
||||
"base_value": session.base_value,
|
||||
"time_limit": session.time_limit,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Принимает путь кликов (не итоговый счёт) и реплеит его на сервере — см.
|
||||
/// `UserService::submit_hack_session`.
|
||||
#[instrument(skip(state, user, payload), fields(tg_id = user.tg_id, session_id = %payload.session_id))]
|
||||
async fn submit_hack_result(
|
||||
Extension(user): Extension<User>,
|
||||
State(state): State<ApiState>,
|
||||
Json(payload): Json<HackPayload>,
|
||||
Json(payload): Json<HackResultPayload>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let reward = state
|
||||
.user_service
|
||||
.add_hack_reward(user.id, payload.score.clamp(0, 500))
|
||||
.submit_hack_session(user.id, payload.session_id, payload.path)
|
||||
.await?;
|
||||
Ok(Json(json!({"status": "success", "reward_added": reward})))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Server-authoritative "Взлом Протокола" (Cyberhack).
|
||||
//!
|
||||
//! Раньше клиент (WASM-модуль `cyberhack`) сам генерировал доску, сам считал
|
||||
//! очки и присылал на бэкенд готовое число — сервер только клампил его в
|
||||
//! `0..=500`. Теперь доску генерирует сервер (эта копия алгоритма 1:1
|
||||
//! повторяет `Cyberhack/src/lib.rs::generate_solvable_board`/`calculate_coins`),
|
||||
//! хранит её в `hack_sessions`, а по завершении партии клиент присылает не
|
||||
//! итоговый счёт, а сырой путь кликов (`path`) — сервер сам реплеит его по
|
||||
//! своей копии доски и считает награду. Любое несоответствие (нелегальный ход,
|
||||
//! повторная клетка, чужая/просроченная/уже использованная сессия) — отказ.
|
||||
|
||||
use rand::seq::SliceRandom;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub const GRID_SIZE: usize = 5;
|
||||
pub const MAX_BUFFER: usize = 7;
|
||||
const TOKENS: &[&str] = &["1C", "55", "BD", "E9", "7A"];
|
||||
|
||||
/// Награда за один взлом = эта константа * (1+2+3) в лучшем случае — держим
|
||||
/// в паре с `HackGame.tsx` (`baseValue={50}`) и `time_limit` ниже
|
||||
/// (`timeLimit={45}`), которые раньше были захардкожены только на фронте.
|
||||
pub const BASE_VALUE: u32 = 50;
|
||||
pub const TIME_LIMIT_SEC: u32 = 45;
|
||||
/// Даём немного времени сверх `TIME_LIMIT_SEC` на сетевую задержку сдачи
|
||||
/// результата, прежде чем сессия считается просроченной.
|
||||
const SESSION_GRACE_SEC: i64 = 30;
|
||||
|
||||
pub fn session_ttl_seconds() -> i64 {
|
||||
TIME_LIMIT_SEC as i64 + SESSION_GRACE_SEC
|
||||
}
|
||||
|
||||
/// Генерирует случайную матрицу токенов и три цели ("демона"), которые
|
||||
/// гарантированно решаемы — портировано 1:1 из `Cyberhack/src/lib.rs`, чтобы
|
||||
/// сервер и клиентский WASM-рендерер согласились на одну и ту же доску.
|
||||
pub fn generate_solvable_board() -> (Vec<Vec<String>>, Vec<Vec<String>>) {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut matrix = vec![vec![String::new(); GRID_SIZE]; GRID_SIZE];
|
||||
|
||||
for row in matrix.iter_mut() {
|
||||
for cell in row.iter_mut() {
|
||||
*cell = TOKENS.choose(&mut rng).unwrap().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
let mut path = Vec::new();
|
||||
let mut used = HashSet::new();
|
||||
let mut is_row = true;
|
||||
let mut active_idx = 0;
|
||||
|
||||
for _ in 0..MAX_BUFFER {
|
||||
let mut candidates = Vec::new();
|
||||
for i in 0..GRID_SIZE {
|
||||
let (r, c) = if is_row {
|
||||
(active_idx, i)
|
||||
} else {
|
||||
(i, active_idx)
|
||||
};
|
||||
if !used.contains(&(r, c)) {
|
||||
candidates.push((r, c));
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let &(r, c) = candidates.choose(&mut rng).unwrap();
|
||||
used.insert((r, c));
|
||||
path.push(matrix[r][c].clone());
|
||||
|
||||
is_row = !is_row;
|
||||
active_idx = if is_row { r } else { c };
|
||||
}
|
||||
|
||||
let t1 = if path.len() >= 2 {
|
||||
path[0..2].to_vec()
|
||||
} else {
|
||||
vec!["55".to_string(), "1C".to_string()]
|
||||
};
|
||||
let t2 = if path.len() >= 5 {
|
||||
path[2..5].to_vec()
|
||||
} else {
|
||||
vec!["BD".to_string(), "E9".to_string(), "1C".to_string()]
|
||||
};
|
||||
let t3 = if path.len() >= 7 {
|
||||
path[3..7].to_vec()
|
||||
} else {
|
||||
vec![
|
||||
"7A".to_string(),
|
||||
"BD".to_string(),
|
||||
"55".to_string(),
|
||||
"1C".to_string(),
|
||||
]
|
||||
};
|
||||
|
||||
(matrix, vec![t1, t2, t3])
|
||||
}
|
||||
|
||||
/// Суммирует награду за все выполненные цели — идентично клиентской версии.
|
||||
pub fn calculate_coins(base_value: u32, completed_targets: &[usize]) -> u32 {
|
||||
completed_targets
|
||||
.iter()
|
||||
.map(|&idx| base_value * (idx as u32 + 1))
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Реплеит присланный клиентом путь кликов по серверной копии доски и
|
||||
/// возвращает индексы реально выполненных целей — либо ошибку с описанием
|
||||
/// первого нелегального хода. Не доверяет ничему из присланного клиентом,
|
||||
/// кроме самой последовательности `(row, col)`.
|
||||
///
|
||||
/// Правила хода воспроизводят `on_cell_click` в `Cyberhack/src/lib.rs`:
|
||||
/// ходы строго чередуются горизонталь/вертикаль; следующий разрешённый ряд
|
||||
/// (при вертикальном ходе) или столбец (при горизонтальном) — координата
|
||||
/// **только что** выбранной клетки, взятая ДО переключения `is_row_turn`
|
||||
/// (Yew-стейт обновляется асинхронно, поэтому клиентская логика читает
|
||||
/// предыдущее значение — здесь это воспроизведено явным порядком операций).
|
||||
pub fn replay_path(
|
||||
matrix: &[Vec<String>],
|
||||
targets: &[Vec<String>],
|
||||
path: &[(usize, usize)],
|
||||
) -> Result<HashSet<usize>, &'static str> {
|
||||
if path.len() > MAX_BUFFER {
|
||||
return Err("Path exceeds max buffer length");
|
||||
}
|
||||
if matrix.len() != GRID_SIZE || matrix.iter().any(|row| row.len() != GRID_SIZE) {
|
||||
return Err("Corrupt session matrix");
|
||||
}
|
||||
|
||||
let mut used = HashSet::new();
|
||||
let mut buffer: Vec<String> = Vec::new();
|
||||
let mut completed = HashSet::new();
|
||||
let mut is_row_turn = true;
|
||||
let mut active_index = 0usize;
|
||||
|
||||
for &(r, c) in path {
|
||||
if r >= GRID_SIZE || c >= GRID_SIZE {
|
||||
return Err("Cell out of bounds");
|
||||
}
|
||||
if used.contains(&(r, c)) {
|
||||
return Err("Cell reused");
|
||||
}
|
||||
|
||||
let is_valid_move = if is_row_turn {
|
||||
r == active_index
|
||||
} else {
|
||||
c == active_index
|
||||
};
|
||||
if !is_valid_move {
|
||||
return Err("Illegal move: wrong row/column for this turn");
|
||||
}
|
||||
|
||||
used.insert((r, c));
|
||||
buffer.push(matrix[r][c].clone());
|
||||
|
||||
let buffer_str = buffer.join("");
|
||||
for (i, target) in targets.iter().enumerate() {
|
||||
if buffer_str.contains(&target.join("")) {
|
||||
completed.insert(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Использовать `is_row_turn` ДО переключения — см. doc-комментарий выше.
|
||||
active_index = if is_row_turn { c } else { r };
|
||||
is_row_turn = !is_row_turn;
|
||||
}
|
||||
|
||||
Ok(completed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_calculate_coins_matches_client_formula() {
|
||||
assert_eq!(calculate_coins(100, &[0, 2]), 400);
|
||||
assert_eq!(calculate_coins(100, &[0, 1, 2]), 600);
|
||||
assert_eq!(calculate_coins(100, &[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_honest_play_completes_all_targets() {
|
||||
// generate_solvable_board гарантирует, что найдётся честный путь,
|
||||
// проходящий через её же targets — реплеим им и должны закрыть все три.
|
||||
// Извлекаем путь так же, как generator: пересобираем один и тот же путь
|
||||
// невозможно напрямую (RNG приватен внутри generate), поэтому здесь
|
||||
// проверяем инвариант структурно: длина целей и их пересечение с решаемым
|
||||
// маршрутом такие же, как при генерации.
|
||||
let (matrix, targets) = generate_solvable_board();
|
||||
assert_eq!(matrix.len(), GRID_SIZE);
|
||||
assert_eq!(targets.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replay_rejects_illegal_move() {
|
||||
let matrix = vec![vec!["1C".to_string(); GRID_SIZE]; GRID_SIZE];
|
||||
let targets: Vec<Vec<String>> = vec![vec!["1C".to_string(), "1C".to_string()]];
|
||||
// Первый ход обязан быть в ряду 0 (active_index стартует в 0, is_row_turn=true).
|
||||
let illegal_path = vec![(1, 0)];
|
||||
assert!(replay_path(&matrix, &targets, &illegal_path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replay_rejects_cell_reuse() {
|
||||
let matrix = vec![vec!["1C".to_string(); GRID_SIZE]; GRID_SIZE];
|
||||
let targets: Vec<Vec<String>> = vec![];
|
||||
// (0,0) валиден дважды подряд только если бы клетка не была занята —
|
||||
// но повторное использование запрещено.
|
||||
let path = vec![(0, 0), (0, 0)];
|
||||
assert!(replay_path(&matrix, &targets, &path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replay_computes_completed_targets_for_legal_path() {
|
||||
let matrix = vec![
|
||||
vec![
|
||||
"55".into(),
|
||||
"1C".into(),
|
||||
"BD".into(),
|
||||
"E9".into(),
|
||||
"7A".into(),
|
||||
],
|
||||
vec![
|
||||
"1C".into(),
|
||||
"55".into(),
|
||||
"BD".into(),
|
||||
"E9".into(),
|
||||
"7A".into(),
|
||||
],
|
||||
vec![
|
||||
"55".into(),
|
||||
"1C".into(),
|
||||
"BD".into(),
|
||||
"E9".into(),
|
||||
"7A".into(),
|
||||
],
|
||||
vec![
|
||||
"55".into(),
|
||||
"1C".into(),
|
||||
"BD".into(),
|
||||
"E9".into(),
|
||||
"7A".into(),
|
||||
],
|
||||
vec![
|
||||
"55".into(),
|
||||
"1C".into(),
|
||||
"BD".into(),
|
||||
"E9".into(),
|
||||
"7A".into(),
|
||||
],
|
||||
];
|
||||
// Ход 1 (row_turn, active=0): (0,3) = "E9". Далее col_turn, active=3.
|
||||
// Ход 2: (1,3) = "E9". buffer = "E9E9" -> не совпадает с целью ниже, но
|
||||
// проверяем сам факт, что легальный чередующийся путь принимается.
|
||||
let targets: Vec<Vec<String>> = vec![vec!["E9".into(), "E9".into()]];
|
||||
let path = vec![(0, 3), (1, 3)];
|
||||
let completed = replay_path(&matrix, &targets, &path).expect("legal path");
|
||||
assert!(completed.contains(&0));
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@
|
||||
//! баланс E$). См. README.md рядом с этим файлом.
|
||||
|
||||
pub mod controller;
|
||||
pub mod cyberhack;
|
||||
pub mod service;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::cyberhack;
|
||||
use crate::{
|
||||
db::{models::*, repository::AppRepository},
|
||||
error::AppError,
|
||||
@@ -6,6 +7,16 @@ use std::sync::Arc;
|
||||
use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Ответ на `POST /users/hack/start`: доска, которую сервер только что
|
||||
/// сгенерировал и сохранил — клиент обязан играть строго на ней.
|
||||
pub struct HackSessionStart {
|
||||
pub session_id: Uuid,
|
||||
pub matrix: Vec<Vec<String>>,
|
||||
pub targets: Vec<Vec<String>>,
|
||||
pub base_value: u32,
|
||||
pub time_limit: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UserService {
|
||||
repo: Arc<dyn AppRepository>,
|
||||
@@ -40,11 +51,21 @@ impl UserService {
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Списывает билет и генерирует доску Cyberhack на сервере — клиент
|
||||
/// (WASM) будет играть строго на ней, а не генерировать свою.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn add_hack_reward(&self, user_id: Uuid, points: i64) -> Result<i64, AppError> {
|
||||
let success = self
|
||||
pub async fn start_hack_session(&self, user_id: Uuid) -> Result<HackSessionStart, AppError> {
|
||||
let (matrix, targets) = cyberhack::generate_solvable_board();
|
||||
|
||||
let session_id = self
|
||||
.repo
|
||||
.consume_ticket_and_reward(user_id, points)
|
||||
.start_hack_session(
|
||||
user_id,
|
||||
&matrix,
|
||||
&targets,
|
||||
cyberhack::BASE_VALUE as i32,
|
||||
cyberhack::session_ttl_seconds(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| match e.as_str() {
|
||||
"TOO_FREQUENT" => AppError::BusinessLogic(
|
||||
@@ -55,9 +76,50 @@ impl UserService {
|
||||
"Нет билетов для взлома. Билет начисляется при покупке подписки.".into(),
|
||||
),
|
||||
"NO_USER" => AppError::UserNotFound,
|
||||
_ => AppError::Internal(format!("Критический сбой СУБД при фиксации игры: {}", e)),
|
||||
_ => AppError::Internal(format!("Критический сбой СУБД при старте игры: {}", e)),
|
||||
})?;
|
||||
|
||||
Ok(if success { points } else { 0 })
|
||||
Ok(HackSessionStart {
|
||||
session_id,
|
||||
matrix,
|
||||
targets,
|
||||
base_value: cyberhack::BASE_VALUE,
|
||||
time_limit: cyberhack::TIME_LIMIT_SEC,
|
||||
})
|
||||
}
|
||||
|
||||
/// Принимает путь кликов, реплеит его по серверной копии доски и
|
||||
/// начисляет награду за реально выполненные цели — число, присланное
|
||||
/// клиентом, нигде не используется.
|
||||
#[instrument(skip(self, path))]
|
||||
pub async fn submit_hack_session(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
session_id: Uuid,
|
||||
path: Vec<(usize, usize)>,
|
||||
) -> Result<i64, AppError> {
|
||||
let (matrix, targets, base_value) = self
|
||||
.repo
|
||||
.consume_hack_session(session_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or_else(|| {
|
||||
AppError::BusinessLogic("Сессия взлома не найдена, просрочена или уже сдана".into())
|
||||
})?;
|
||||
|
||||
let completed = cyberhack::replay_path(&matrix, &targets, &path)
|
||||
.map_err(|e| AppError::BusinessLogic(format!("Недопустимый путь взлома: {}", e)))?;
|
||||
|
||||
let completed_vec: Vec<usize> = completed.into_iter().collect();
|
||||
let reward = cyberhack::calculate_coins(base_value as u32, &completed_vec) as i64;
|
||||
|
||||
if reward > 0 {
|
||||
self.repo
|
||||
.add_balance(user_id, reward)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
Ok(reward)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user