docs
This commit is contained in:
@@ -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` не зависит от других модулей и не используется ими — самый изолированный модуль.
|
||||
@@ -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).
|
||||
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
@@ -1,3 +1,7 @@
|
||||
//! Биллинг подписок: инвойсы, оплата через провайдер-агностичный `providers::PaymentProvider`
|
||||
//! (сейчас единственная реализация — TON on-chain), триал и реферальный revshare.
|
||||
//! См. README.md рядом с этим файлом.
|
||||
|
||||
pub mod controller;
|
||||
mod providers;
|
||||
pub mod service;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! Абстракция платёжного провайдера. Единственная реализация сейчас — [`ton::TonProvider`]
|
||||
//! (проверка транзакций через TonAPI). Контракт спроектирован так, чтобы позже
|
||||
//! добавить эквайринги (Stripe/YooKassa) без изменений в `BillingService`.
|
||||
|
||||
use crate::error::AppError;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -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¤cies=usd,rub")
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Бизнес-модули приложения, каждый по единому шаблону `controller` (axum-роуты) +
|
||||
//! `service` (логика). Подробности — README.md в папке каждого модуля.
|
||||
|
||||
pub mod auth;
|
||||
pub mod billing;
|
||||
pub mod nodes;
|
||||
|
||||
@@ -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-шаг
|
||||
упал, запись не создаётся вовсе (не остаётся полуживых нод в базе).
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
//! Control Plane для VPN-нод: провижининг новых серверов по SSH и публичные ручки,
|
||||
//! которыми пользуется клиентское приложение/лендинг. См. README.md рядом с этим файлом.
|
||||
|
||||
pub mod controller;
|
||||
pub mod service;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
@@ -1,2 +1,6 @@
|
||||
//! Реферальная программа и промокоды: статистика по приглашённым и ручное
|
||||
//! применение промокода. Начисление реферального бонуса за оплату живёт в
|
||||
//! `billing::service::confirm_payment` — см. README.md рядом с этим файлом.
|
||||
|
||||
pub mod controller;
|
||||
pub mod service;
|
||||
|
||||
@@ -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 без действующей/когда-либо купленной подписки
|
||||
нельзя.
|
||||
@@ -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>,
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
//! Профиль пользователя и мини-игра Cyberhack (билет -> попытка -> награда в
|
||||
//! баланс E$). См. README.md рядом с этим файлом.
|
||||
|
||||
pub mod controller;
|
||||
pub mod service;
|
||||
|
||||
Reference in New Issue
Block a user