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:
2026-07-04 15:29:51 +07:00
parent 588adbb92b
commit 52531dd41a
41 changed files with 2219 additions and 508 deletions
+5 -2
View File
@@ -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::` напрямую).
+30 -7
View File
@@ -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
View File
@@ -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" }))))
}
+14 -2
View File
@@ -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
View File
@@ -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 (без изменений, кроме вызова генерации токена) ---
+9 -6
View File
@@ -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 теперь кешируется (см. выше), это ограничение — нет.
+71 -21
View File
@@ -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&currencies=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"][&currency_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(&currency_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&currencies={}",
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"][&currency_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(&currency_lower, rate).await;
rate
};
// ----------------------------------------
let nanotons = Self::calculate_ton_invoice(fiat_amount_minimal, ton_rate)
+22 -11
View File
@@ -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 больше не может бесконечно долго считать мёртвую ноду живой.
+62 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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 без действующей/когда-либо купленной подписки
нельзя.
+28 -10
View File
@@ -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})))
}
+261
View File
@@ -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));
}
}
+1
View File
@@ -2,4 +2,5 @@
//! баланс E$). См. README.md рядом с этим файлом.
pub mod controller;
pub mod cyberhack;
pub mod service;
+67 -5
View File
@@ -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)
}
}