This commit is contained in:
Kirill
2026-07-02 23:13:13 +07:00
parent e997a20fe6
commit d4a57c0b6a
34 changed files with 524 additions and 67 deletions
+39
View File
@@ -0,0 +1,39 @@
# `modules/` — бизнес-модули
Каждый модуль — вертикальный срез фичи, оформленный по одному шаблону:
```
modules/<name>/
mod.rs # реэкспорт controller/service
controller.rs # axum-роуты: парсинг запроса, вызов service, сериализация ответа
service.rs # бизнес-логика; принимает/возвращает доменные типы, не знает про HTTP
```
`controller.rs` намеренно тонкий — там нет ветвлений бизнес-правил, только HTTP-контракт
(DTO, статус-коды через `AppError`, middleware-гварды). Вся логика и все инварианты
(гонки, лимиты, права) — в `service.rs`, который работает поверх
[`db::repository::AppRepository`](../db/README.md) и ничего не знает об axum.
Все сервисы `Clone` (обычно дешёвый клон `Arc<dyn AppRepository>`) и живут внутри
[`ApiState`](../api.rs), который передаётся в роутер и в Telegram-бота — то есть один и
тот же `BillingService`/`AuthService`/`UserService` обслуживает и веб, и бота.
## Модули
| Модуль | Ответственность | README |
|---|---|---|
| [`auth`](auth/README.md) | Вход через Telegram Login Widget и анонимный Ghost Protocol (seed), JWT-гварды | [подробнее](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) |
| [`users`](users/README.md) | Профиль (агрегированный) и мини-игра Cyberhack | [подробнее](users/README.md) |
## Сквозные зависимости между модулями
- `auth::process_login` при регистрации нового юзера дёргает `referrals::create_referral`
напрямую через репозиторий (без сервиса) — реферальная связь создаётся сразу при первом
входе по `ref_code`.
- `billing::confirm_payment` после успешной оплаты сам начисляет реферальный бонус
(`repo.reward_referrer`) — то есть логика вознаграждения за реферала физически лежит
в billing, а не в referrals.
- `nodes` не зависит от других модулей и не используется ими — самый изолированный модуль.
+35
View File
@@ -0,0 +1,35 @@
# `modules/auth` — авторизация
Три способа попасть в систему, всех их объединяет один и тот же JWT (`Claims { sub, tg_id, exp }`,
живёт 10 минут — предполагается, что клиент кладёт его в cookie/заголовок и продлевает
через повторный логин/бота, отдельного refresh-флоу нет):
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`.
3. **Ghost Protocol** (`POST /auth/seed/generate`, `POST /auth/seed/login`) — анонимный
вход без привязки к Telegram. Сервер генерирует случайный 16-символьный seed,
хеширует его Argon2 с **фиксированной** солью из `ARGON_SALT` (детерминированно —
иначе логин никогда не совпадёт с хешем, сохранённым при регистрации) и хранит только
хеш. Seed невосстановим: его теряет — теряет аккаунт.
## Гварды (`guard.rs`)
- `auth_guard` — достаёт JWT из `Authorization: Bearer` **или** из cookie `nrxp_token`,
валидирует подпись, подтягивает юзера из БД и кладёт его в `req.extensions()` для
контроллеров. Для мутирующих запросов (`POST/PUT/PATCH/DELETE`), пришедших **через
cookie**, дополнительно проверяет `Origin`/`Referer` — это CSRF-защита. Bearer-запросы
её не проходят и не должны: подделать `Authorization`-заголовок с чужого origin браузер
не даст, поэтому CSRF для них неактуален.
- `admin_guard` — должен идти **после** `auth_guard` в цепочке слоёв (читает юзера из
extensions). Пропускает, если `tg_id` юзера совпадает с `ADMIN_TG_ID` из `.env` или его
`role == "admin"`.
## Sybil-защита
Триал (`billing::activate_trial`) намеренно недоступен Ghost-аккаунтам — иначе
генерация нового seed была бы бесплатным и безлимитным способом штамповать VPN-доступ.
Подробности инцидента и фикса — в [`SECURITY_AUDIT.md`](../../../SECURITY_AUDIT.md).
+3
View File
@@ -1,3 +1,6 @@
//! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе)
//! и JWT-гварды (`auth_guard`, `admin_guard`). См. README.md рядом с этим файлом.
pub mod controller;
pub mod guard;
pub mod service;
+43
View File
@@ -0,0 +1,43 @@
# `modules/billing` — тарифы, инвойсы, оплата
## Поток оплаты
1. **`POST /billing/pay/initiate`** (`initiate_payment`) — сервер сам смотрит цену тарифа
в нужной валюте (`plan_prices`, см. [`migrations/0001_init.sql`](../../../migrations/0001_init.sql)),
запрашивает текущий курс TON у TonAPI, считает сумму в nanoTON с буфером **+2%**
(`calculate_ton_invoice` — компенсация волатильности курса между генерацией инвойса и
отправкой транзакции юзером) и создаёт `invoices`-запись со статусом `pending`.
Клиент **не может повлиять на сумму** — она целиком считается на сервере.
2. Фронтенд получает `{ destination, amount, comment }` и просит кошелёк (TonConnect)
отправить транзакцию с этим `comment` (= `invoice_id`) — по нему инвойс потом находится.
3. **`POST /billing/pay/confirm`** (`confirm_payment`) — клиент присылает `tx_hash`
отправленной транзакции. Сервер:
- проверяет, что инвойс принадлежит вызывающему юзеру (иначе можно было бы дёргать
чужие `invoice_id` и провоцировать обращения к блокчейну — DoS/перебор);
- идемпотентен: повторный вызов на уже `paid` инвойсе — no-op;
- просит `TonProvider::verify_payment` сходить в блокчейн и сверить **сумму (>=),
адрес назначения и комментарий** транзакции по хешу;
- при успехе переводит инвойс в `paid`, активирует подписку (`buy_subscription`,
заодно выдаёт один игровой билет, до кэпа в 10) и начисляет рефереру 10% от суммы
инвойса (`reward_referrer`).
4. **Фоновая сверка** (`reconcile_pending_payments`, гоняется из [`tasks.rs`](../../tasks.rs))
находит `pending`-инвойсы младше суток, чьи транзакции уже видны в истории
мастер-кошелька (по совпадению `comment == invoice_id`), и доводит их до `paid` — на
случай, если клиент не смог/не успел вызвать `/pay/confirm` сам.
## Провайдеры (`providers/`)
`PaymentProvider` — трейт, чтобы позже добавить Stripe/YooKassa, не трогая
`BillingService`. Сейчас единственная реализация — [`ton::TonProvider`] (HTTP к TonAPI,
без собственного состояния кроме адреса мастер-кошелька).
## Триал
`activate_trial` доступен только аккаунтам с `tg_id` (см. [`auth/README.md`](../auth/README.md#sybil-защита))
и только один раз (`has_used_trial`).
## Известные ограничения (см. корневой README «Недоделки»)
Синхронный запрос курса TON на каждый `initiate_payment` и пересборка `providers`
(включая повторное чтение env) при каждом `Clone` сервиса — оба помечены `// TODO` прямо
в `service.rs`.
+4
View File
@@ -1,3 +1,7 @@
//! Биллинг подписок: инвойсы, оплата через провайдер-агностичный `providers::PaymentProvider`
//! (сейчас единственная реализация — TON on-chain), триал и реферальный revshare.
//! См. README.md рядом с этим файлом.
pub mod controller;
mod providers;
pub mod service;
+4
View File
@@ -1,3 +1,7 @@
//! Абстракция платёжного провайдера. Единственная реализация сейчас — [`ton::TonProvider`]
//! (проверка транзакций через TonAPI). Контракт спроектирован так, чтобы позже
//! добавить эквайринги (Stripe/YooKassa) без изменений в `BillingService`.
use crate::error::AppError;
use async_trait::async_trait;
use serde_json::Value;
+8 -1
View File
@@ -15,7 +15,11 @@ pub struct BillingService {
providers: HashMap<&'static str, Box<dyn PaymentProvider>>,
}
// Добавляем Clone вручную, так как dyn Traits не поддерживают derive(Clone) легко
// Добавляем Clone вручную, так как dyn Traits не поддерживают derive(Clone) легко.
// TODO: axum клонирует ApiState (а значит и BillingService) на каждый запрос —
// это означает повторное чтение env и пересборку HashMap<provider> на каждый HTTP-вызов,
// не только при реальном клонировании сервиса. Стоит завернуть `providers` в Arc и строить
// один раз в `new()`, чтобы `clone()` был дешёвым Arc::clone.
impl Clone for BillingService {
fn clone(&self) -> Self {
// Снова читаем из env при клонировании
@@ -79,6 +83,9 @@ impl BillingService {
})?;
// --- ДИНАМИЧЕСКИЙ ЗАПРОС КУРСА 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")
+3
View File
@@ -1,3 +1,6 @@
//! Бизнес-модули приложения, каждый по единому шаблону `controller` (axum-роуты) +
//! `service` (логика). Подробности — README.md в папке каждого модуля.
pub mod auth;
pub mod billing;
pub mod nodes;
+38
View File
@@ -0,0 +1,38 @@
# `modules/nodes` — Control Plane VPN-нод
Оркестратор физических VPN-серверов: добавление новой ноды по SSH и раздача списка
живых нод клиентам/лендингу. Единственный модуль без зависимостей от остальных.
## Роуты
- `public_router` (за `auth_guard`, обычный юзер):
- `GET /nodes` — список активных нод (`status = 'online'`), отсортирован по
возрастанию `current_load` — то есть новые юзеры естественным образом идут на самые
свободные сервера.
- `GET /nodes/stats` — агрегаты для лендинга (страна, загрузка, статус). **`uptime`
заглушка**, см. TODO в `controller.rs`.
- `GET /nodes/routing` — конфиг для клиентского приложения (Tauri/mobile). **Сейчас
неполный** — см. TODO в `controller.rs` и в корневом README.
- `admin_router` (за `auth_guard` **и** `admin_guard`, в таком порядке — `admin_guard`
ожидает, что `auth_guard` уже положил юзера в extensions):
- `POST /admin/nodes` — добавить и заprovisioning новую ноду.
- `DELETE /admin/nodes/{id}` — удалить ноду из БД (саму VPS-машину это не трогает).
## Провижининг (`service.rs::provision_node_via_ssh`)
Синхронная (в смысле — блокирующий поток через `spawn_blocking`, но HTTP-запрос ждёт её
целиком, см. TODO) операция по `ssh2`:
1. Подключается к `ip:22` с таймаутом 15с, логинится по паролю root (временный пароль,
передаётся один раз при создании ноды).
2. Гоняет `templates/init_node.sh` (обязателен — если файла нет, провижининг падает с
ошибкой, а не деплоит полуживую ноду) с переменными `NODE_IP`/`GH_TOKEN`.
3. Прописывает публичный ключ control plane (`keys/netrunner_master_ed25519.pub`,
создаётся заранее, см. `main.rs::ensure_master_ssh_key`) в `authorized_keys` и **жёстко
выключает парольный SSH-вход** (hardening-скрипт внутри метода) — после провижининга
зайти на ноду можно только по ключу.
4. Не более 3 одновременных провижинингов (`Semaphore::new(3)`), чтобы не заDDoSить сам
control plane при массовом добавлении нод.
Одновременно с провижинингом только `add_vpn_node` пишет запись в БД — если SSH-шаг
упал, запись не создаётся вовсе (не остаётся полуживых нод в базе).
+9
View File
@@ -49,6 +49,9 @@ async fn get_nodes(State(state): State<ApiState>) -> Result<Json<Vec<VpnNode>>,
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).
let stats: Vec<_> = nodes
.into_iter()
.map(|n| {
@@ -76,6 +79,12 @@ async fn add_node(
Ok(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> {
let nodes = state.node_service.get_active_nodes().await?;
+3
View File
@@ -1,2 +1,5 @@
//! Control Plane для VPN-нод: провижининг новых серверов по SSH и публичные ручки,
//! которыми пользуется клиентское приложение/лендинг. См. README.md рядом с этим файлом.
pub mod controller;
pub mod service;
+4
View File
@@ -33,6 +33,10 @@ impl NodeService {
.map_err(AppError::Database)
}
// TODO: Деплой сейчас синхронный — HTTP-запрос админки виснет на всё время
// SSH-провижининга (обновление пакетов, докер, генерация ключей — может занимать
// минуты). Нужно отдавать 202 Accepted со статусом "provisioning" сразу, а сам
// provision_node_via_ssh гонять в фоновой задаче с call-home от ноды по завершении.
pub async fn add_node(
&self,
payload: CreateNodePayload,
+26
View File
@@ -0,0 +1,26 @@
# `modules/referrals` — рефералы и промокоды
Самый маленький модуль, две ручки за `auth_guard`:
- `GET` статистика — обёртка над `repo.get_referral_stats`: количество приглашённых и
сумма заработанного (`referrals.total_earned`).
- `POST /referrals/promo` — применение промокода (`apply_promo_code`).
## Жизненный цикл реферальной связи
Сама связь `referrer_id -> referred_id` создаётся **не здесь**, а в
[`auth::service::process_login`](../auth/README.md) при первом входе нового юзера с
`ref_code` (статус `pending`). Вознаграждение (перевод в статус `rewarded` + начисление
10% от суммы инвойса) происходит в
[`billing::service::confirm_payment`](../billing/README.md) после первой успешной
оплаты приглашённого — то есть логика «оплатил → реферер получил бонус» физически
живёт в billing, а не здесь. Этот модуль только читает агрегаты и обслуживает промокоды.
## Промокоды
`apply_promo_code` в репозитории — единственное место в проекте, где под одной
транзакцией с `FOR UPDATE` (блокировка строки промокода) объединены проверка лимита
использований, запись в `promo_usages` (защищена `UNIQUE(promo_id, user_id)` от
повторного применения одним юзером) и начисление баланса. Гонка при массовом
одновременном применении одного кода закрыта именно блокировкой строки — см. тест
`test_apply_promo_code_race_condition` в [`tests/db_integration_test.rs`](../../../tests/db_integration_test.rs).
+4
View File
@@ -1,2 +1,6 @@
//! Реферальная программа и промокоды: статистика по приглашённым и ручное
//! применение промокода. Начисление реферального бонуса за оплату живёт в
//! `billing::service::confirm_payment` — см. README.md рядом с этим файлом.
pub mod controller;
pub mod service;
+24
View File
@@ -0,0 +1,24 @@
# `modules/users` — профиль и Cyberhack
## Профиль
- `GET /users/me` — возвращает `ProfileData`, собранный одним агрегирующим SQL-запросом
(`repository::get_aggregated_profile`): юзер + текущая подписка (если есть, кроме
`expired`) + список рефералов одним JSON-объектом. Это осознанно сделано на стороне
БД (`jsonb_build_object`/`jsonb_agg`), а не в Rust несколькими запросами.
## Cyberhack (мини-игра)
- `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 без действующей/когда-либо купленной подписки
нельзя.
+5
View File
@@ -35,6 +35,11 @@ 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))]
async fn submit_hack_result(
Extension(user): Extension<User>,
+3
View File
@@ -1,2 +1,5 @@
//! Профиль пользователя и мини-игра Cyberhack (билет -> попытка -> награда в
//! баланс E$). См. README.md рядом с этим файлом.
pub mod controller;
pub mod service;