Compare commits

...

33 Commits

Author SHA1 Message Date
nineap f034de43cf Fix: сбой платёжного API навсегда терял реальную оплату юзера
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Successful in 26s
Build & Push Image / Build and push to Gitea Registry (push) Successful in 3m32s
CI / fmt + clippy + test (push) Failing after 21m21s
TonProvider::verify_payment и CryptoBotProvider::verify_payment на любой
неуспешный HTTP-ответ (rate-limit/5xx/недоступность провайдера) возвращали
Ok(false) — confirm_payment на это необратимо помечает инвойс `failed`.
reconcile_pending_payments подбирает только `pending`, так что реальный
платёж, наткнувшийся на временный сбой TonAPI/CryptoBot именно в момент
подтверждения, терялся навсегда без пути восстановления.

Теперь неуспешный HTTP-статус/ответ "не ok" от провайдера — это Err (не
трогаем статус инвойса, можно повторить), а не Ok(false) (окончательное
"не оплачено"). Найдено в ходе pre-launch аудита критичных путей оплаты.
2026-07-11 03:54:16 +07:00
nineap dca8d067f3 Fix: rate-limiter (tower_governor) считал весь трафик одним клиентом
CI / fmt + clippy + test (push) Has been cancelled
Build & Push Image / Build and push to Gitea Registry (push) Successful in 3m23s
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Successful in 20s
Дефолтный PeerIpKeyExtractor брал IP из TCP-peer'а, а не заголовков — а
nginx проксирует локально, так что peer всегда 127.0.0.1. В итоге лимит
"3 запроса/5с" на /auth и "10/100мс" на остальной API/v1 был не per-visitor,
а глобальным на весь прод: несколько попыток логина от кого угодно — и все
остальные посетители ловили 429 на /auth (это и мешало зайти в свежесозданную
админку — 401 на пробное неверное дело быстро съедал бюджет, дальше только
429). Добавлен SmartIpKeyExtractor (x-forwarded-for/x-real-ip/forwarded,
именно то, что реально шлёт nginx) в оба GovernorConfigBuilder.
2026-07-11 03:30:52 +07:00
nineap 74aab70a29 Docs: команда создания первого Owner-аккаунта админки
Build & Push Image / Build and push to Gitea Registry (push) Successful in 28s
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Successful in 24s
CI / fmt + clippy + test (push) Failing after 5s
Флаг --create-owner уже существовал, но нигде не было записано, как
запускать его на проде (через docker compose run, не bare-бинарник) —
пришлось вспоминать вручную.
2026-07-11 03:22:26 +07:00
nineap d8dd97ba39 Fix: deploy-prod не синкал promtail-config.yml, из-за чего падал весь стек
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Successful in 24s
Build & Push Image / Build and push to Gitea Registry (push) Successful in 26s
CI / fmt + clippy + test (push) Failing after 21m25s
docker-compose.prod.yml монтирует promtail-config.yml как volume, но
workflow копировал на сервер только сам compose-файл. При отсутствии
источника Docker молча создавал вместо файла пустую директорию — promtail
падал с ошибкой монтирования, и docker compose up не успевал поднять app.
На проде поправлено руками (файл выложен, контейнеры подняты), этот
коммит просто чинит воркфлоу на будущее.
2026-07-11 03:16:00 +07:00
nineap 9d465167f4 Fix: IRR-цена была переплатой, не скидкой — пересчитана по курсу
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Successful in 26s
Build & Push Image / Build and push to Gitea Registry (push) Successful in 3m40s
CI / fmt + clippy + test (push) Failing after 21m34s
Старая цена (из 0001_init.sql, до этой сессии) рассчитана под курс риала
в разы крепче нынешнего — за время девальвации превратилась в переплату
на 65-186% относительно US-цены в реальном долларовом эквиваленте.

Пересчитано по актуальному рыночному курсу (~1 820 500 IRR/USD, свободный
рынок) с той же целевой скидкой ~55%, что у остальных региональных валют.
Итог — цена в риалах = цена в долларах x 1 000 000, легко проверить и
поддерживать вручную при следующей девальвации.
2026-07-11 01:42:46 +07:00
nineap d91aef012e Region-aware pricing: GeoIP + VPN-детект + публичный /plans + бот
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Successful in 26s
Build & Push Image / Build and push to Gitea Registry (push) Successful in 9m36s
CI / fmt + clippy + test (push) Failing after 24m37s
- modules::geoip — DB-IP Lite (.mmdb, без регистрации/ключа, в отличие от
  MaxMind) для страны по IP, X4BNet lists_vpn для грубой VPN/датацентр-
  эвристики. Обе базы скачиваются в рантайме, обновление — фоновая
  задача в tasks.rs (раз в сутки по умолчанию, GEOIP_REFRESH_INTERVAL).
- GET /api/v1/billing/plans (public_router, без auth_guard) — цены в
  валюте, определённой по IP посетителя (X-Real-IP/X-Forwarded-For,
  см. nginx-конфиги). VPN/датацентр IP -> всегда базовый USD, чтобы
  обход региона через любой публичный VPN не обесценивал скидки.
- Бот: Bot API не отдаёт IP пользователя вообще — валюта определяется по
  Telegram language_code. Показ цены — в валюте региона, САМА оплата
  через CryptoBot остаётся в USD/USDT (провайдер биллит 1:1 к USD-цене
  тарифа, менять валюту списания нельзя, не меняя сам провайдер).
2026-07-11 00:55:03 +07:00
nineap a89721fbee Региональные цены: UAH/BYN/VND/UZS + обобщённый список по валюте
plan_prices уже поддерживал мультивалютность (USD/RUB/CNY/TRY/IRR) — просто
добавляю ещё 4 по аналогии, по локалям уже сделанным в этой сессии
(uk/be/vi/uz). Цифры — прикидка (~45% от рыночной конвертации, тот же
порядок, что уже неявно в RUB/CNY/TRY), не сверено с реальными
конкурентами по каждому рынку — легко поправить UPDATE'ом по этой же
таблице.

list_active_plans_by_currency — обобщённая версия list_active_plans_usd,
нужна для мультивалютного /plans и бота.
2026-07-11 00:54:48 +07:00
nineap a84d5dc820 Fix: кеш сборки type=gha не работает на self-hosted Gitea Actions
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Failing after 7m15s
Build & Push Image / Build and push to Gitea Registry (push) Successful in 18m55s
CI / fmt + clippy + test (push) Failing after 25m51s
act_runner не реализует GitHub Actions Cache proxy — экспорт кеша висит
и падает по DeadlineExceeded (dial tcp .../i/o timeout). Меняю на
type=registry (тот же приём, что уже в netrunner-landing) — кеш кладётся
отдельным тегом ":cache" в тот же Gitea Registry, никакого спецсервиса
не требует.
2026-07-10 21:54:28 +07:00
nineap 403a6127b1 Переименовать GITEA_REGISTRY_TOKEN → GT_REGISTRY_TOKEN (правила именования Gitea не пускают префикс GITEA_)
Build & Push Image / Build and push to Gitea Registry (push) Failing after 10m34s
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Failing after 4m6s
CI / fmt + clippy + test (push) Failing after 21m30s
2026-07-10 21:28:25 +07:00
nineap c617c05233 Переезд с ghcr.io на встроенный Container Registry Gitea
Build & Push Image / Build and push to Gitea Registry (push) Failing after 14s
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Failing after 13s
CI / fmt + clippy + test (push) Has been cancelled
CI (build/deploy) и рантайм самого бэкенда (провижининг новых нод через
NodeService::provision_node_via_ssh — раньше читал GHCR_TOKEN, теперь
GITEA_REGISTRY_TOKEN) теперь используют gitea.netrunner-vpn.com вместо
ghcr.io. .github/workflows остались нетронутыми — та копия для настоящего
GitHub CI, там ghcr.io уместен и дальше (свой GITHUB_TOKEN, не наш секрет).
2026-07-10 20:44:03 +07:00
nineap 51788f73ee nginx+TLS перед бэкендом на прод VPS вместо голых портов наружу
Build & Push Image / Build and push to GHCR (push) Failing after 15s
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Failing after 13s
CI / fmt + clippy + test (push) Failing after 21m30s
Порт 8080 закрыт для всего интернета (ufw allow только с IP VPS данных —
нужен центральному Prometheus для /metrics), публичный вход только
account.netrunner-vpn.com через system nginx с настоящим Let's Encrypt
сертификатом. Тот же принцип, что и nginx у netrunner-data.
2026-07-10 19:07:10 +07:00
nineap 4e998d789b Обновить комментарии: Grafana теперь за nginx+Basic Auth, не Caddy
Auth-гейт перед Grafana на VPS с данными переехал с Caddy forward_auth
(зависел от задеплоенного бэкенда, которого ещё не было) на system nginx
с HTTP Basic Auth прямо на том же VPS — не зависит ни от чего.
observability_router/staff/controller.rs оставлен неиспользуемым на случай,
если позже понадобится реальный SSO поверх basic-auth.
2026-07-10 18:18:47 +07:00
nineap 864d4bc852 CI сам собирает .env на прод/dev VPS из repo Variables/Secrets
Раньше .env копировался на сервер руками по .env.example — теперь
deploy-prod/deploy-dev в deploy.yml пишут его сами перед docker compose up,
тем же принципом, что уже был у netrunner-data. .env.example остаётся
только как справочник по составу переменных, не инструкция "скопируй на
сервер".
2026-07-10 18:18:47 +07:00
nineap 583682f330 Админка: страницы Тикеты + Мониторинг, роль support в навигации
TicketsPage.tsx — список open/closed тикетов + тред сообщений + ответ +
закрытие (owner/moderator/support). ObservabilityPage.tsx — вкладки
дашбордов Grafana через iframe за auth-гейтом Caddy, URL берётся из
GET /api/v1/config (grafana_url), страница просто не показывает
дашборды, пока GRAFANA_PUBLIC_URL не задан на сервере. StaffPage.tsx —
форма создания support-аккаунта (только Owner).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 23:20:59 +07:00
nineap 91e214dad2 Единый мониторинг: бизнес-метрики + вынос Prometheus/Loki/Grafana на VPS с данными
Prometheus/Loki/Grafana больше не живут внутри backend-стека — единый
стек теперь на VPS с данными (netrunner-data), т.к. ему нужно видеть
ещё лендинг и все прокси-ноды, не только бэкенд. Отсюда только тонкий
promtail, пушащий логи по сети (LOKI_PUSH_URL).

Новые бизнес-метрики поверх уже существующих generic HTTP-метрик:
payments_total{provider,status}, nodes_online/nodes_total,
support_tickets_total{status}, referral_signups_total,
partner_commission_total{currency} — тем же макросом metrics::counter!/
gauge!, что уже был в api.rs/error.rs.

Новый гейт-эндпоинт GET /api/v1/admin/observability/check (только
Owner/Moderator) — для Caddy forward_auth перед Grafana (см.
netrunner-data/observability/Caddyfile), встройка в админку через
GRAFANA_PUBLIC_URL в GET /api/v1/config.

templates/init_node.sh — добавляет --metrics-port 9093 в провижининг
новых прокси-нод.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 23:20:44 +07:00
nineap 4f6bba6885 Дописывает support_service в тестовый build_state proxy_internal
Пропустил этот файл в предыдущем коммите — тот же тестовый ApiState,
что и в auth/guard.rs, тоже требует поле support_service.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 23:20:26 +07:00
nineap d543fb5585 Роль Support + тикет-система техподдержки через бота
Единственный канал создания тикета — кнопка "Поддержка" в Telegram-боте:
переводит юзера в разовый режим ожидания (awaiting_support_message),
следующее свободное сообщение становится тикетом/ответом в уже открытый
(случайный текст мимо кнопки по-прежнему игнорируется бота). Саппорты
отвечают из веб-админки, ответ уходит юзеру обратно в тот же
Telegram-чат. Новая роль support видит только раздел тикетов
(support_access_guard) — создаётся только Owner'ом.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 23:20:09 +07:00
nineap 3feb2f431c Убирает свой Postgres — БД физически переезжает на VPS с Vaultwarden
backend будет жить на отдельном, ещё не арендованном VPS — свой
контейнер `db` и бэкапы (scripts/backup.sh) здесь больше не нужны и
переехали в netrunner-data, где Postgres теперь физически стоит.
DATABASE_URL собирается из DB_HOST/DB_PORT (внешний IP того VPS) с
обязательным sslmode=require — трафик пересекает публичную сеть между
двумя разными серверами.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 17:17:20 +07:00
nineap ebc618743a CI: VPS_IP/DEV_VPS_IP переносит из secrets в vars
IP control-plane сервера сам по себе не секрет (не даёт доступа), просто
не хочется палить его без причины — vars в приватном репо видны так же,
как код, но не маскируются в логах и не write-only, как secrets.
Настоящие секреты (SSH-ключи, GHCR_TOKEN) не тронуты.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:35:55 +07:00
nineap 379f2a26cf Провижининг ноды: пробрасывает port/decoy_host/auth в SSH-команду запуска
init_node.sh раньше запускал образ вообще без CLI-аргументов — все
провижинингованные ноды молча использовали дефолты и не требовали
авторизации, несмотря на то что admin-панель как будто настраивала
per-node SNI/порт. Заодно убирает захардкоженный IP control plane.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:35:46 +07:00
nineap 875f679fc3 Добавляет периодический backup Postgres + фиксирует имя сети netrunner_grid
pg_dump + gzip по расписанию (BACKUP_INTERVAL_SEC) с ротацией
(BACKUP_RETENTION_DAYS) и опциональной офсайт-копией через rclone.
Явное имя сети netrunner_grid нужно для внешнего подключения Vaultwarden
(netrunner-data) к этому же Postgres-контейнеру.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:35:46 +07:00
nineap b902a28dae Убирает panic=abort из release-профиля
tokio по умолчанию изолирует панику одной задачи через catch_unwind на
границе task — panic=abort эту изоляцию отключает и роняет весь процесс
разом при панике в любом одном обработчике запроса.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:35:46 +07:00
nineap 591643d364 Чинит .env.example: убирает мёртвый ADMIN_TG_ID, добавляет PROXY_INTERNAL_SECRET
ADMIN_TG_ID нигде не читается с тех пор, как admin_guard заменили на ролевую
систему (owner/staff/node_manage/partner_guard) — оставлять его в примерах
вводит в заблуждение. PROXY_INTERNAL_SECRET, наоборот, обязателен (main.rs
падает без него при старте), но отсутствовал во всех трёх .env*.example.

Заодно поправлен src/modules/auth/README.md — описывал admin_guard,
которого больше не существует.
2026-07-09 15:33:33 +07:00
nineap e6c81bf8fd Дублирует пайплайны для Gitea Actions (.gitea/workflows/)
Build & Push Image / Build and push to GHCR (push) Failing after 3m1s
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Failing after 11s
CI / fmt + clippy + test (push) Failing after 21m4s
Копии .github/workflows/{ci,build,deploy}.yml — Gitea Actions синтаксически
совместим с GitHub Actions, поэтому большая часть 1:1. Отличие: логин на
ghcr.io в build.yml теперь через secrets.GHCR_TOKEN (настоящий GitHub PAT),
не secrets.GITHUB_TOKEN — автотокен Gitea валиден только для самого Gitea,
на ghcr.io им не залогиниться. deploy.yml уже использовал GHCR_TOKEN,
не тронут в этой части.

Нужно завести в Gitea: секрет GHCR_TOKEN (write:packages), плюс все
остальные секреты, что уже есть в GitHub Actions (отдельное хранилище,
не шарится автоматически). Триггер workflow_run в deploy.yml поддержан не
во всех версиях Gitea Actions — см. комментарий в файле.
2026-07-09 14:28:05 +07:00
nineap 7290f9483b Тесты для ключевой бизнес-логики + 3 найденных бага
Добавляет 52 теста (было 7), приоритет: биллинг -> auth -> RBAC-гварды ->
staff/выводы средств -> hack-сессии -> рефералы -> proxy_internal.

Попутно найдены и исправлены реальные баги:
- migrations/0004: ALTER TABLE auth_sessions падал на чистой БД, потому что
  0002 эту таблицу уже дропнул — ломало --migrate на CI/новом инстансе и
  весь sqlx::test. Заменено на CREATE TABLE IF NOT EXISTS.
- request_withdrawal: гонка при параллельных заявках на вывод (не было
  FOR UPDATE) — партнёр мог одновременно вывести баланс кратно больше
  доступного. Новый repository::request_partner_withdrawal блокирует
  строку партнёра и пересчитывает остаток в одной транзакции.
- get_referral_stats: SUM() над BIGINT возвращал NUMERIC, sqlx падал на
  ColumnDecode для любого юзера с рефералами.
- reward_referrer: начислял рефереру 10% при каждой повторной покупке
  того же приведённого юзера вместо разового бонуса — добавлен фильтр
  WHERE status = 'pending'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 01:18:15 +07:00
nineap 375b333978 Ролевая админка (Owner/Moderator/Partner) + оплата через @CryptoBot
Ролевая система вместо привязки к Telegram
----------------------------------------
Раньше единственный способ получить админ-доступ — admin_guard, пускавший
по ADMIN_TG_ID или role=="admin" (без сеттера для роли). Заменяет на
Owner/Moderator/Partner:

- migrations/0005_roles_and_partners.sql — username/password_hash на users
  (NULL у обычных Ghost/Telegram-юзеров, тот же паттерн, что и у seed_hash/
  tg_id), can_manage_nodes, commission_percent, partner_code,
  referred_by_partner_id; новые таблицы partner_commissions и
  withdrawal_requests.
- auth/guard.rs — admin_guard заменён на owner_guard/staff_guard/
  node_manage_guard/partner_guard; ADMIN_TG_ID убран из ApiState и main.rs.
- auth/service.rs — hash_password/login_staff со случайной Argon2-солью на
  каждого юзера (не общий фиксированный ARGON_SALT, который годится для
  высокоэнтропийных seed-логинов, но не для человеческих паролей).
- POST /api/v1/auth/admin/login — вход модератора/партнёра/овнера паролем,
  переиспользует существующий issue_session().
- --create-owner CLI-флаг (main.rs) со скрытым вводом пароля (rpassword) —
  вместо ручного SQL для создания первого владельца с VPS-консоли.
- src/modules/staff/ — эндпоинты создания модераторов/партнёров, переключения
  can_manage_nodes, списка заявок на вывод и их approve/reject/paid.
- Комиссия партнёра начисляется в BillingService::confirm_payment отдельным
  шагом после существующей рефералки, пересчитывается через
  get_plan_price(invoice.plan_name, invoice.currency) — НЕ через
  invoice.amount напрямую, потому что у провайдеров разная внутренняя
  единица (TON — nanoTON+2% буфер, CryptoBot — фиатные центы напрямую).
- bot.rs — /start p_XXXXXX привязывает покупателя к партнёру по коду
  (first-wins, до существующих веток numeric-tg_id-рефералки и кода входа).
- frontend/ — AdminLogin/AdminLayout/StaffPage/WithdrawalsPage/EarningsPage
  поверх существующего Vite-админ-фронта (без отдельного проекта).

Оплата через @CryptoBot
------------------------
- modules/billing/providers/cryptobot.rs — новый PaymentProvider (Crypto Pay
  API), плюс попутно исправлены два реальных бага в существующем коде:
  confirm_payment был жёстко завязан на TON-провайдера при любом инвойсе, и
  InvoiceRecord не хранил currency, из-за чего TON-курс молча применялся ко
  всем провайдерам вместо родной валюты инвойса.
- bot.rs — экран выбора способа оплаты для CryptoBot-инвойсов.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 22:53:02 +07:00
nineap c8eea8203e Добавить проверку токена прокси, динамические лимиты трафика и Telegram magic-link вход
Прокси теперь может валидировать клиентские JWT и отчитываться о расходе
трафика через новые internal-эндпоинты (защищены X-Internal-Secret), лимит
на юзера настраивается динамически через admin PATCH без передеплоя прокси.
Заодно реализован задокументированный, но не подключённый флоу входа через
Telegram-бота (magic-link) для десктоп/мобильного приложения, и seed-эндпоинты
теперь возвращают access_token в JSON (не только в cookie) — без этого
Tauri-приложение не могло ими пользоваться.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 00:30:25 +07:00
nineap 1a26403afb Переключить dev Caddy на порт 443 вместо 8443
Telegram Login Widget в браузере требует именно порт 443 — его CSP
frame-ancestors от oauth.telegram.org не учитывает порт домена и всегда
подразумевает дефолтный порт схемы, поэтому на 8443 виджет рендерился, но
iframe блокировался как cross-origin. VPN-нода, ранее занимавшая 443 на
dev VPS, перенесена на другой порт, так что конфликта больше нет.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 20:00:09 +07:00
nineap 3af0c46130 docs: reflect runtime bot_username config in frontend README
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 14:23:31 +07:00
nineap 818d4fa384 Serve bot_username at runtime instead of baking it into the SPA build
Auth.tsx and Profile.tsx read import.meta.env.VITE_BOT_USERNAME — a Vite
build-time constant. This ЛК frontend is a single static bundle built
once in CI and served identically by both prod and dev (no
server-rendering to re-inject a value per environment, unlike the
Next.js landing fix from earlier). So the Telegram Login Widget on the
dev stand was rendering with whatever bot the CI build happened to be
compiled with — in practice the fallback default, the prod bot's
username — and Telegram correctly refused it with "Bot domain invalid"
since that bot's registered domain is the prod domain, not
dev.netrunner-vpn.com.

Added GET /api/v1/config (unauthenticated, reads BOT_USERNAME from the
server's actual environment on every request) and switched both files to
fetch it at runtime instead of reading the baked-in env var. Verified
locally: the dev container's /api/v1/config correctly returns its own
dev bot's username, and a real headless Chrome run confirms the Login
Widget script tag gets that value, not the hardcoded fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 14:21:55 +07:00
nineap 4f1efc6ff9 Fix infinite reload loop on the login screen itself
apiFetch()'s fallback for a failed silent-refresh unconditionally does
window.location.href = "/" — but Auth.tsx (the "/" route) calls this same
apiFetch("/users/me") on mount just to check whether a session already
exists. For any fresh/anonymous visit that 401s (the normal, expected
case), the failed-refresh branch fired a navigation to "/" while already
sitting on "/" — which browsers still treat as a real reload, not a
no-op. That reload remounts the app, which repeats the exact same check,
which fails the exact same way: an infinite reload loop, observed live as
the page "loading forever" and — since the bot's WebApp bridge lands on
/profile and bounces to "/" on any auth hiccup — the "Личный Кабинет"
button appearing to do nothing.

Only redirect when not already on "/". Verified with a real headless
Chrome run (Playwright): before, this path can't be distinguished from a
one-shot failure without watching for repeated navigation; confirmed here
that with the guard in place the app performs exactly one GET /users/me +
one POST /auth/refresh and then sits idle, no repeat requests or
navigations over 12s of observation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 14:03:45 +07:00
nineap 4a8816b908 Build the Caddy image in CI instead of on the dev VPS
The previous deploy attempt confirmed the failure mode directly: `docker
compose build caddy` over SSH got most of the way through compiling
xcaddy (downloading Go modules) and then the whole SSH command hit
appleboy/ssh-action's timeout mid-build, aborting the deploy before it
ever reached `up -d`. Compiling anything on that VPS was already a
deliberate anti-pattern here — see DEPLOYMENT.md's "compilation happens
in GitHub Actions, never on the VPS" — this just hadn't been applied to
the new Caddy service yet.

Added a build-caddy job to build.yml pushing
ghcr.io/nineap/netrunner-caddy-cloudflare:latest (hardcoded lowercase
owner — github.repository_owner resolves to "nineAp" and Docker rejects
mixed-case image refs, same fix already applied to the main image's
deploy script). docker-compose.dev-remote.yml now just pulls that image;
deploy.yml no longer builds it over SSH or ships Dockerfile.caddy to the
server at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 13:52:07 +07:00
nineap d22cd0de41 Add Caddy for real TLS on dev — Telegram Web Apps require https://
Bot buttons that open a Web App (Личный Кабинет/Тарифы/Синдикат) appeared
to load and then silently do nothing on the dev stand. Root cause:
teloxide's WebAppInfo::url (and the underlying Bot API) requires an HTTPS
URL — Telegram's client refuses to render a Web App served over plain
HTTP, with no error surfaced to the user, just a stuck loading state.
WEB_APP_BASE_URL was http://dev.netrunner-vpn.com:8080.

Port 443 on the dev VPS is already in use by a VPN node, and port 80 is
closed, so a normal HTTP-01 ACME challenge won't work. Added a custom
Caddy build (caddy-dns/cloudflare) terminating TLS via DNS-01 on port
8443 instead, reverse-proxying to the existing plain-HTTP app container.
WEB_APP_BASE_URL and the CORS/CSRF allowlists now point at the :8443
origin, and COOKIE_SECURE reverts to true (the default, matching prod) —
now that the backend actually has real TLS to serve it over, we no
longer need the plain-HTTP workaround introduced for the earlier
cookie-drop bug. Verified the custom image builds and the Caddyfile
validates (only the dummy Cloudflare token used for the local test is
rejected, as expected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 19:36:38 +07:00
74 changed files with 7592 additions and 343 deletions
+43 -12
View File
@@ -1,20 +1,48 @@
# ===================================================================== # =====================================================================
# DEV VPS (docker-compose.dev-remote.yml). Скопируй в .env прямо на # УСТАРЕЛО КАК ИНСТРУКЦИЯ: .env на dev VPS теперь собирает сам deploy.yml
# сервере (в DEPLOY_DIR) — файл гитигнорится, в репозиторий не попадает. # (job deploy-dev) из repo Variables/Secrets — руками копировать больше не
# нужно. Файл остаётся только как справочник по составу переменных.
#
# DEV VPS (docker-compose.dev-remote.yml).
# #
# ВАЖНО: Telegram Login Widget не работает на голом IP — /setdomain в # ВАЖНО: Telegram Login Widget не работает на голом IP — /setdomain в
# BotFather принимает только доменное имя. На этот dev VPS (45.76.161.137) # BotFather принимает только доменное имя. На этот dev VPS (45.76.161.137)
# заведён DNS A-record dev.netrunner-vpn.com — используй его во всех # заведён DNS A-record dev.netrunner-vpn.com — используй его во всех
# URL ниже (и в аналогичном .env для netrunner-landing), а не IP напрямую. # URL ниже (и в аналогичном .env для netrunner-landing), а не IP напрямую.
#
# ВАЖНО #2: Telegram Web Apps (кнопки бота "Личный Кабинет"/"Тарифы"/
# "Синдикат") требуют https:// URL, а Telegram Login Widget в обычном браузере
# требует именно порт 443 (его CSP frame-ancestors не учитывает порт и всегда
# подразумевает дефолтный для схемы) — оба жёсткие требования платформы, не
# нашего кода. HTTPS для dev поднят через Caddy (docker-compose.dev-remote.yml)
# на порту 443, сертификат — через DNS-01 challenge (Cloudflare, порт 80
# закрыт — обычный HTTP-01 не пройдёт). Нужен CF_API_TOKEN ниже. Порт 443
# раньше занимала VPN-нода — перенесена на другой порт на этом же VPS.
# ===================================================================== # =====================================================================
DB_USER=netrunner_admin DB_USER=netrunner_admin
DB_PASSWORD=CHANGE_ME_DEV_DB_PASSWORD DB_PASSWORD=CHANGE_ME_DEV_DB_PASSWORD
DB_NAME=netrunner DB_NAME=netrunner
# Токен Cloudflare для DNS-01 (выпуск TLS-сертификата Caddy'ем без открытых
# 80/443) — создать в Cloudflare: My Profile → API Tokens → Create Token →
# шаблон "Edit zone DNS", ограниченный зоной netrunner-vpn.com. НЕ Global API Key.
CF_API_TOKEN=CHANGE_ME_CLOUDFLARE_DNS_EDIT_TOKEN
JWT_SECRET=CHANGE_ME_DEV_JWT_SECRET JWT_SECRET=CHANGE_ME_DEV_JWT_SECRET
ARGON_SALT=CHANGE_ME_DEV_ARGON_SALT_MIN_16_CHARS ARGON_SALT=CHANGE_ME_DEV_ARGON_SALT_MIN_16_CHARS
TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
ADMIN_TG_ID=0
# Общий секрет с прокси-нодами этого dev-стенда (--require-auth) — обязателен,
# без него бэкенд не стартует. То же значение должно быть в .env.example
# netrunner-proxy на самих нодах.
PROXY_INTERNAL_SECRET=CHANGE_ME_DEV_PROXY_INTERNAL_SECRET
# Опционально: без него оплата через @CryptoBot просто вернёт "provider not
# supported", остальное продолжает работать как раньше. Токен берётся у
# @CryptoBot: /pay -> Create App -> Payment Token (лучше завести отдельное
# приложение для dev, чтобы инвойсы не путались с прод-платежами).
CRYPTOBOT_API_TOKEN=CHANGE_ME_CRYPTOBOT_API_TOKEN
CRYPTOBOT_TESTNET=true
BOT_ENABLED=true BOT_ENABLED=true
# ОТДЕЛЬНЫЙ dev-бот, не прод-бот: у прод-бота в BotFather уже прописан # ОТДЕЛЬНЫЙ dev-бот, не прод-бот: у прод-бота в BotFather уже прописан
@@ -25,21 +53,24 @@ BOT_ENABLED=true
# использует прод-бота и Telegram ответит "Bot domain invalid". # использует прод-бота и Telegram ответит "Bot domain invalid".
TELOXIDE_TOKEN=123456789:YOUR_DEV_BOT_TOKEN_HERE TELOXIDE_TOKEN=123456789:YOUR_DEV_BOT_TOKEN_HERE
BOT_USERNAME=your_dev_bot_username BOT_USERNAME=your_dev_bot_username
WEB_APP_BASE_URL=http://dev.netrunner-vpn.com:8080 # https, порт 443 — через Caddy (см. выше), не порт 8080 напрямую: WebApp
# от Telegram по http:// открывать откажется.
WEB_APP_BASE_URL=https://dev.netrunner-vpn.com
SUBSCRIPTION_CHECK_INTERVAL=1800 SUBSCRIPTION_CHECK_INTERVAL=1800
RUST_LOG=netrunner_control_plane=debug,axum=info,tower_http=debug,sqlx=warn RUST_LOG=netrunner_control_plane=debug,axum=info,tower_http=debug,sqlx=warn
# dev.netrunner-vpn.com:3000 — netrunner-landing, задеплоенный на этот же dev # dev.netrunner-vpn.com:3000 — netrunner-landing, задеплоенный на этот же dev
# VPS (docker-compose.dev-remote.yml в репозитории netrunner-landing). # VPS (docker-compose.dev-remote.yml в репозитории netrunner-landing), обычный
# HTTP. dev.netrunner-vpn.com — сам бэкенд через Caddy (см. выше).
# COOKIE_DOMAIN нарочно пуст: host-only cookie уже расшаривается между # COOKIE_DOMAIN нарочно пуст: host-only cookie уже расшаривается между
# портами одного и того же хоста браузером — Domain нужен только для # портами одного и того же хоста браузером — Domain нужен только для
# реальных сабдоменов (прод). # реальных сабдоменов (прод).
CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost,http://dev.netrunner-vpn.com:3000 CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost,http://dev.netrunner-vpn.com:3000,https://dev.netrunner-vpn.com
CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,http://dev.netrunner-vpn.com:3000 CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,http://dev.netrunner-vpn.com:3000,https://dev.netrunner-vpn.com
COOKIE_DOMAIN= COOKIE_DOMAIN=
# ОБЯЗАТЕЛЬНО false: на этом VPS нет TLS (прямой HTTP на IP/домен, порт 443 занят # true (как в проде): теперь у бэкенда есть настоящий TLS через Caddy на 443.
# VPN-нодой). Secure-cookie по голому HTTP браузер молча выбрасывает для любого # Прямой доступ на голый :8080 (без Caddy) для браузерных auth-флоу больше не
# хоста, кроме localhost, — с COOKIE_SECURE=true (дефолт) сессия не переживёт # годится — Secure-cookie по нему браузер по-прежнему отбросит, это ожидаемо:
# вообще ни одного запроса на этом стенде. # все клиентские сценарии (бот, лендинг) идут через Caddy на 443.
COOKIE_SECURE=false COOKIE_SECURE=true
+10 -1
View File
@@ -21,9 +21,18 @@ RUST_LOG=netrunner_control_plane=debug,axum=info,tower_http=debug,sqlx=warn
JWT_SECRET=dev_jwt_secret_change_me JWT_SECRET=dev_jwt_secret_change_me
ARGON_SALT=dev_stealth_salt_minimum_16_chars ARGON_SALT=dev_stealth_salt_minimum_16_chars
TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
ADMIN_TG_ID=0
TELOXIDE_TOKEN=123456789:YOUR_DEV_BOT_TOKEN_HERE TELOXIDE_TOKEN=123456789:YOUR_DEV_BOT_TOKEN_HERE
# Общий секрет с локальным прокси (--require-auth, см. .env.example
# netrunner-proxy) — обязателен, без него бэкенд не стартует.
PROXY_INTERNAL_SECRET=dev_stealth_salt_minimum_16_chars_proxy
# Опционально: без него оплата через @CryptoBot вернёт "provider not
# supported", остальное работает как обычно. Токен — у @CryptoBot: /pay ->
# Create App -> Payment Token.
CRYPTOBOT_API_TOKEN=
CRYPTOBOT_TESTNET=true
# CORS: пускаем локальные дев-origin'ы Vite/Tauri-приложения. # CORS: пускаем локальные дев-origin'ы Vite/Tauri-приложения.
CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost
CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173 CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173
+86 -18
View File
@@ -1,17 +1,37 @@
# =====================================================================
# ЭТОТ ФАЙЛ БОЛЬШЕ НЕ КОПИРУЕТСЯ РУКАМИ НА СЕРВЕР. .env на проде (и на
# dev-стенде) теперь целиком собирается .gitea/workflows/deploy.yml (job
# deploy-prod/deploy-dev) из repo Variables/Secrets этого репозитория при
# каждом деплое — см. комментарии в самом deploy.yml, там точная карта
# "какая переменная .env ← какой vars.*/secrets.*". Файл ниже остаётся
# только как справочник по тому, какие ключи вообще существуют, и для
# локальной разработки (docker-compose.yml, см. .env.dev.example).
# =====================================================================
# ===================================================================== # =====================================================================
# === DATABASE CONFIGURATION === # === DATABASE CONFIGURATION ===
# ===================================================================== # =====================================================================
# ДЛЯ ЛОКАЛЬНЫХ ТЕСТОВ НА ТВОЕМ ПК: # ВАЖНО: значения ниже — это плейсхолдеры-заглушки (CHANGE_ME_*), не реальный
# DATABASE_URL=postgres://netrunner_admin:dev_root_123@77.110.106.113:5432/netrunner # пароль. Не копировать "как есть" — реальный пароль генерировать самостоятельно
# (например `openssl rand -base64 24`) и никогда не коммитить .env с настоящим
# значением (см. .gitignore — .env/.env.* уже исключены).
# #
# ДЛЯ ЗАПУСКА НА САМОЙ ВПС (Через Docker Compose): # Postgres физически стоит НЕ на этом VPS, а на отдельном сервере вместе с
# DATABASE_URL=postgres://netrunner_admin:dev_root_123@db:5432/netrunner # Vaultwarden (см. netrunner-data/docker-compose.yml, сервис `db`) — этот
# бэкенд подключается к нему по сети. Полный DATABASE_URL собирает сам
DATABASE_URL=postgres://netrunner_admin:dev_root_123@77.110.106.113:5432/netrunner # docker-compose.prod.yml из DB_USER/DB_PASSWORD/DB_HOST/DB_PORT/DB_NAME ниже
# (не пишите его здесь отдельной строкой — env_file не подставляет ${...},
# в отличие от самого docker-compose, так что отдельная строка DATABASE_URL
# тут просто не сработает). sslmode=require обязателен: без него подключение
# к hostssl-правилу в pg_hba.conf того сервера будет отклонено.
DB_USER=netrunner_admin DB_USER=netrunner_admin
DB_NAME=netrunner DB_NAME=netrunner
DB_PASSWORD=dev_root_123 DB_PASSWORD=CHANGE_ME_DB_PASSWORD
# IP VPS, где физически стоит Postgres (netrunner-data). Совпадает с DATA_VPS_IP
# в CI-переменных netrunner-data.
DB_HOST=CHANGE_ME_DB_HOST_IP
DB_PORT=5432
# Кеш курса TON (не источник истины — если недоступен, приложение просто # Кеш курса TON (не источник истины — если недоступен, приложение просто
# ходит в TonAPI напрямую на каждый инвойс, как раньше). # ходит в TonAPI напрямую на каждый инвойс, как раньше).
@@ -20,11 +40,37 @@ REDIS_URL=redis://redis:6379
# ===================================================================== # =====================================================================
# === SECRETS & CRYPTO SECURITY === # === SECRETS & CRYPTO SECURITY ===
# ===================================================================== # =====================================================================
JWT_SECRET=YOUR_RANDOM_SECRET_KEY_CHANGE_ME_IN_PRODUCTION JWT_SECRET=CHANGE_ME_JWT_SECRET_openssl_rand_hex_32
ARGON_SALT=какая-то-рандомная-длинная-строка-минимум-16-символов ARGON_SALT=CHANGE_ME_ARGON_SALT_MIN_16_CHARS
GRAFANA_PASSWORD=netrunner_admin_2026
TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y
# Бэкапы Postgres (pg_dump + опциональный rclone) теперь настраиваются в
# netrunner-data/.env (там же, где физически стоит БД) — см.
# netrunner-data/scripts/backup.sh. Здесь их больше нет.
# =====================================================================
# === НАБЛЮДАЕМОСТЬ ===
# =====================================================================
# Prometheus/Loki/Grafana теперь единым стеком на VPS с данными (см.
# netrunner-data/docker-compose.observability.yml) — GRAFANA_PASSWORD
# больше не нужен здесь. Этот бэкенд только пушит логи туда через тонкий
# promtail (сервис `promtail` в docker-compose.prod.yml) — публичный адрес
# того VPS, порт 3100 (обязательно зафайрволенный там на IP этого хоста).
LOKI_PUSH_URL=http://CHANGE_ME_DATA_VPS_IP:3100
# Публичный URL Grafana за auth-гейтом Caddy (см.
# netrunner-data/observability/Caddyfile) — отдаётся фронту через
# GET /api/v1/config для встройки в ObservabilityPage.tsx (frontend/src/).
# Не задан — страница просто не показывает дашборды. Ставить ТОЛЬКО после
# того, как в Caddyfile настоящий backend-URL вместо CHANGE_ME_BACKEND_PUBLIC_URL —
# иначе будет вести на нерабочий auth-гейт.
GRAFANA_PUBLIC_URL=https://CHANGE_ME_GRAFANA_DOMAIN
# Опционально: без него оплата через @CryptoBot вернёт "provider not
# supported", остальные провайдеры продолжают работать. Токен — у @CryptoBot:
# /pay -> Create App -> Payment Token.
CRYPTOBOT_API_TOKEN=
# ===================================================================== # =====================================================================
# === TELEGRAM API INTEGRATION === # === TELEGRAM API INTEGRATION ===
# ===================================================================== # =====================================================================
@@ -34,6 +80,31 @@ TELOXIDE_TOKEN=123456789:YOUR_REAL_TELEGRAM_BOT_TOKEN_HERE
BOT_USERNAME=ntrnr_vpn_bot BOT_USERNAME=ntrnr_vpn_bot
WEB_APP_BASE_URL=https://account.netrunner-vpn.com WEB_APP_BASE_URL=https://account.netrunner-vpn.com
# =====================================================================
# === ВНУТРЕННИЙ КОНТРАКТ С ПРОКСИ-НОДАМИ (обязателен, без него старт падает) ===
# =====================================================================
# Общий секрет между бэкендом и КАЖДОЙ прокси-нодой (X-Internal-Secret на
# POST /internal/validate и /internal/usage, см. modules::proxy_internal) —
# то же самое значение прописывается на нодах при провижининге
# (NodeService::provision_node_via_ssh) и в .env.example netrunner-proxy.
PROXY_INTERNAL_SECRET=CHANGE_ME_PROXY_INTERNAL_SECRET_openssl_rand_hex_32
# =====================================================================
# === РЕГИОНАЛЬНЫЕ ЦЕНЫ: GEOIP + VPN-ДЕТЕКТ (см. modules::geoip) ===
# =====================================================================
# Все три — опциональны, дефолты в main.rs указывают прямо на публичные
# раздачи, ничего заполнять не обязательно для старта:
# GEOIP_DB_URL_TEMPLATE — DB-IP Lite (.mmdb.gz, тот же формат, что
# MaxMind, но без регистрации/лицензионного ключа), "{}" — плейсхолдер
# под "YYYY-MM" (у DB-IP имя файла с датой, единой "latest"-ссылки нет).
# VPN_LIST_URL — X4BNet lists_vpn, открытый список CIDR-диапазонов
# известных VPN/датацентров (эвристика, не 100% точная — см. их README).
# GEOIP_REFRESH_INTERVAL — как часто (сек) перекачивать обе базы,
# по умолчанию раз в сутки (86400).
# GEOIP_DB_URL_TEMPLATE=https://download.db-ip.com/free/dbip-country-lite-{}.mmdb.gz
# VPN_LIST_URL=https://raw.githubusercontent.com/X4BNet/lists_vpn/main/output/vpn/ipv4.txt
# GEOIP_REFRESH_INTERVAL=86400
# ===================================================================== # =====================================================================
# === APPLICATION RUNTIME SETTINGS === # === APPLICATION RUNTIME SETTINGS ===
# ===================================================================== # =====================================================================
@@ -65,15 +136,12 @@ COOKIE_SECURE=true
# ===================================================================== # =====================================================================
# === DEPLOYMENT & REPOSITORY SETTINGS === # === DEPLOYMENT & REPOSITORY SETTINGS ===
# ===================================================================== # =====================================================================
GHCR_TOKEN=ghp_ТВОЙ_ТОКЕН_ОТ_ГИТХАБА_ДЛЯ_ДОСТУПА_К_РЕЕСТРУ # Токен Gitea (право write:package) — нужен самому бэкенду в рантайме для
VPS_IP=77.110.106.113 # провижининга новых нод (см. NodeService::provision_node_via_ssh, шлёт этот
# же токен на ноду, чтобы она тоже могла тянуть образ с Gitea Registry).
GT_REGISTRY_TOKEN=CHANGE_ME_GT_REGISTRY_TOKEN
VPS_IP=CHANGE_ME_VPS_IP
DEPLOY_DIR=/root/netrunner-core DEPLOY_DIR=/root/netrunner-core
BINARY_NAME=netrunner-control-plane BINARY_NAME=netrunner-control-plane
# Образ, который тянет прод-стек (docker-compose.prod.yml). # Образ, который тянет прод-стек (docker-compose.prod.yml).
IMAGE=ghcr.io/nineap/netrunner-control-plane:latest IMAGE=ghcr.io/nineap/netrunner-control-plane:latest
# =====================================================================
# === ADMIN ===
# =====================================================================
ADMIN_TG_ID=00000
+104
View File
@@ -0,0 +1,104 @@
# Копия .github/workflows/build.yml для Gitea Actions. Отличие от оригинала:
# реестр — встроенный Container Registry самой Gitea (gitea.netrunner-vpn.com),
# не ghcr.io — секрет secrets.GITHUB_TOKEN там валиден только на самом
# GitHub. Логин — токеном Gitea с правом write:package
# (secrets.GT_REGISTRY_TOKEN), один и тот же токен используется во всех
# репозиториях, где собираются образы (netrunner-backend/netrunner-landing/
# netrunner-proxy) — пакеты в Gitea принадлежат аккаунту, не конкретному
# репозиторию, заводить по секрету на каждый репозиторий не нужно, но
# значение нужно продублировать в Settings каждого из них (Gitea не даёт
# организационных секретов на уровне всего аккаунта для personal-репозиториев).
name: Build & Push Image
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
env:
REGISTRY: gitea.netrunner-vpn.com
IMAGE_NAME: nineap/netrunner-control-plane
jobs:
build:
name: Build and push to Gitea Registry
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GT_REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=long
type=ref,event=tag
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Кеш слоёв между прогонами — type=gha (GitHub Actions Cache) не
# работает на self-hosted Gitea Actions раннере (act_runner не
# реализует этот прокси-сервис, сборка виснет и падает по таймауту
# на попытке экспортировать кеш) — вместо этого кеш кладём в тот же
# registry отдельным тегом, тот же приём, что уже в netrunner-landing.
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache,mode=max
# Кастомная сборка Caddy (модуль caddy-dns/cloudflare, нужен для TLS на
# dev-стенде — см. Caddyfile.dev). Компилировать xcaddy'ем прямо на VPS
# оказалось слишком долго и один раз реально уронило SSH-деплой по таймауту
# (компиляция go-модулей на слабой машине не укладывалась в command_timeout
# appleboy/ssh-action) — та же причина, по которой Rust вообще не собирается
# на VPS. Собираем здесь и просто тянем готовый образ, как и основной.
build-caddy:
name: Build and push Caddy (Cloudflare DNS) image
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GT_REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.caddy
push: true
# Захардкожено, не ${{ github.repository_owner }}: он резолвится в
# "nineAp" (смешанный регистр), Docker такие теги отклоняет.
tags: ${{ env.REGISTRY }}/nineap/netrunner-caddy-cloudflare:latest
cache-from: type=registry,ref=${{ env.REGISTRY }}/nineap/netrunner-caddy-cloudflare:cache
cache-to: type=registry,ref=${{ env.REGISTRY }}/nineap/netrunner-caddy-cloudflare:cache,mode=max
+54
View File
@@ -0,0 +1,54 @@
name: CI
on:
pull_request:
push:
branches: [main]
env:
CARGO_TERM_COLOR: always
SQLX_OFFLINE: "true"
jobs:
check:
name: fmt + clippy + test
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
# sqlx::test сам создаёт временные БД и накатывает ./migrations.
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Format check
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
- name: Tests
run: cargo test --all-targets
+69
View File
@@ -0,0 +1,69 @@
name: Deploy Nginx
# Единственный публичный вход (443/80) на прод VPS (backend+лендинг вместе) —
# system nginx, тот же принцип, что и в netrunner-data. Только ручной запуск.
#
# Порядок первого запуска:
# 1. Запустить эту джобу (поднимет http-конфиг + сам nginx).
# 2. Один раз руками на сервере:
# sudo apt-get install -y certbot
# sudo certbot certonly --webroot -w /var/www/certbot \
# -d account.netrunner-vpn.com
# (если лендинг деплоится на этот же VPS первым — можно сразу одной
# командой на оба домена, см. deploy-nginx.yml у netrunner-landing)
# 3. Запустить эту джобу ещё раз — включит HTTPS.
on:
workflow_dispatch:
jobs:
deploy:
name: Deploy nginx config for account.netrunner-vpn.com
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Sync nginx configs to VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ vars.VPS_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "nginx/account-http.conf,nginx/account-ssl.conf"
target: "/root/netrunner-core"
strip_components: 0
- name: Install/enable nginx via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ vars.VPS_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
set -e
if ! command -v nginx >/dev/null 2>&1; then
apt-get update
apt-get install -y nginx
fi
mkdir -p /var/www/certbot
rm -f /etc/nginx/sites-enabled/default
cp /root/netrunner-core/nginx/account-http.conf /etc/nginx/sites-available/account-http.conf
cp /root/netrunner-core/nginx/account-ssl.conf /etc/nginx/sites-available/account-ssl.conf
ln -sf /etc/nginx/sites-available/account-http.conf /etc/nginx/sites-enabled/account-http.conf
CERT=/etc/letsencrypt/live/account.netrunner-vpn.com/fullchain.pem
if [ -f "$CERT" ]; then
ln -sf /etc/nginx/sites-available/account-ssl.conf /etc/nginx/sites-enabled/account-ssl.conf
echo "🔒 Сертификат найден — HTTPS-конфиг включён."
else
rm -f /etc/nginx/sites-enabled/account-ssl.conf
echo "⚠️ Сертификата ещё нет ($CERT) — HTTPS-конфиг НЕ включён."
fi
nginx -t
systemctl enable --now nginx
systemctl reload nginx
echo "✅ nginx (account.netrunner-vpn.com) synchronized."
+205
View File
@@ -0,0 +1,205 @@
# Копия .github/workflows/deploy.yml для Gitea Actions. Образы тянутся из
# встроенного Container Registry самой Gitea (gitea.netrunner-vpn.com), не
# ghcr.io — secrets.GT_REGISTRY_TOKEN, не GHCR_TOKEN/GITHUB_TOKEN.
# Триггер workflow_run (авто-деплой после успешной сборки) поддерживается не
# во всех версиях Gitea Actions — если deploy-dev не срабатывает автоматически
# после build, проверить версию Gitea или временно дергать деплой вручную
# через workflow_dispatch.
name: Deploy
# Один пайплайн, две джобы:
# - deploy-dev: авто, срабатывает после успешного "Build & Push Image" на main.
# - deploy-prod: вручную из вкладки Actions ("Run workflow"), как и раньше.
#
# .env на обоих VPS теперь полностью собирается этим workflow из repo
# Variables/Secrets (тот же принцип, что и в netrunner-data) — руками на
# сервере ничего не создаётся и не редактируется, весь конфиг живёт в
# Settings → Actions этого репозитория.
on:
workflow_run:
workflows: ["Build & Push Image"]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
image_tag:
description: "Тег образа в Gitea Registry для прод-деплоя"
required: true
default: "latest"
jobs:
deploy-dev:
name: Auto-deploy to Dev VPS
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
# Копируем compose-файл + Caddyfile (примонтированный конфиг, не образ —
# сам Caddy-образ, как и образ бэкенда, дев-сервер тянет из Gitea Registry,
# собран в build.yml::build-caddy. Собирать xcaddy прямо на VPS один раз уже не
# уложилось в таймаут SSH-деплоя и уронило весь прогон).
- name: Sync compose file to Dev VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ vars.DEV_VPS_IP }}
username: root
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
source: "docker-compose.dev-remote.yml,Caddyfile.dev"
target: "/root/netrunner-core"
strip_components: 0
- name: Write .env and deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ vars.DEV_VPS_IP }}
username: root
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
envs: "DB_PASSWORD,CF_API_TOKEN,JWT_SECRET,ARGON_SALT,TON_MASTER_WALLET,PROXY_INTERNAL_SECRET,CRYPTOBOT_API_TOKEN,TELOXIDE_TOKEN,BOT_USERNAME,GT_REGISTRY_TOKEN"
script: |
set -e
cd /root/netrunner-core
# .env — статические значения (никогда не меняются между деплоями)
# зашиты здесь же литералом, секреты/domain-специфичное — из vars/secrets.
cat > .env <<EOF
DB_USER=netrunner_admin
DB_PASSWORD=${DB_PASSWORD}
DB_NAME=netrunner
CF_API_TOKEN=${CF_API_TOKEN}
JWT_SECRET=${JWT_SECRET}
ARGON_SALT=${ARGON_SALT}
TON_MASTER_WALLET=${TON_MASTER_WALLET}
PROXY_INTERNAL_SECRET=${PROXY_INTERNAL_SECRET}
# Нужен самому бэкенду в рантайме, не только CI — см.
# NodeService::provision_node_via_ssh (шлёт этот же токен на новую
# ноду, чтобы она тоже могла тянуть образ с gitea.netrunner-vpn.com).
GT_REGISTRY_TOKEN=${GT_REGISTRY_TOKEN}
CRYPTOBOT_API_TOKEN=${CRYPTOBOT_API_TOKEN}
CRYPTOBOT_TESTNET=true
BOT_ENABLED=true
TELOXIDE_TOKEN=${TELOXIDE_TOKEN}
BOT_USERNAME=${BOT_USERNAME}
WEB_APP_BASE_URL=https://dev.netrunner-vpn.com
SUBSCRIPTION_CHECK_INTERVAL=1800
RUST_LOG=netrunner_control_plane=debug,axum=info,tower_http=debug,sqlx=warn
CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost,http://dev.netrunner-vpn.com:3000,https://dev.netrunner-vpn.com
CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,http://dev.netrunner-vpn.com:3000,https://dev.netrunner-vpn.com
COOKIE_DOMAIN=
COOKIE_SECURE=true
EOF
chmod 600 .env
export IMAGE="gitea.netrunner-vpn.com/nineap/netrunner-control-plane:latest"
export CADDY_IMAGE="gitea.netrunner-vpn.com/nineap/netrunner-caddy-cloudflare:latest"
echo "🚀 Deploying ${IMAGE} to DEV"
echo "${{ secrets.GT_REGISTRY_TOKEN }}" | docker login gitea.netrunner-vpn.com -u ${{ github.actor }} --password-stdin
docker compose -f docker-compose.dev-remote.yml pull
docker compose -f docker-compose.dev-remote.yml up -d --remove-orphans
docker image prune -f
echo "✅ Dev synchronized."
env:
DB_PASSWORD: ${{ secrets.DEV_DB_PASSWORD }}
CF_API_TOKEN: ${{ secrets.DEV_CF_API_TOKEN }}
JWT_SECRET: ${{ secrets.DEV_JWT_SECRET }}
ARGON_SALT: ${{ secrets.DEV_ARGON_SALT }}
TON_MASTER_WALLET: ${{ vars.TON_MASTER_WALLET }}
PROXY_INTERNAL_SECRET: ${{ secrets.DEV_PROXY_INTERNAL_SECRET }}
CRYPTOBOT_API_TOKEN: ${{ secrets.DEV_CRYPTOBOT_API_TOKEN }}
TELOXIDE_TOKEN: ${{ secrets.DEV_TELOXIDE_TOKEN }}
BOT_USERNAME: ${{ vars.DEV_BOT_USERNAME }}
GT_REGISTRY_TOKEN: ${{ secrets.GT_REGISTRY_TOKEN }}
deploy-prod:
name: Pull & restart on Prod VPS
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Sync compose file to Prod VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ vars.VPS_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "docker-compose.prod.yml,promtail-config.yml"
target: "/root/netrunner-core"
strip_components: 0
- name: Write .env and deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ vars.VPS_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
envs: "DB_PASSWORD,DATA_VPS_IP,JWT_SECRET,ARGON_SALT,TON_MASTER_WALLET,GRAFANA_PUBLIC_URL,PROXY_INTERNAL_SECRET,CRYPTOBOT_API_TOKEN,TELOXIDE_TOKEN,BOT_USERNAME,WEB_APP_BASE_URL,GT_REGISTRY_TOKEN"
script: |
set -e
cd /root/netrunner-core
cat > .env <<EOF
DB_USER=netrunner_admin
DB_NAME=netrunner
DB_PASSWORD=${DB_PASSWORD}
DB_HOST=${DATA_VPS_IP}
DB_PORT=5432
REDIS_URL=redis://redis:6379
JWT_SECRET=${JWT_SECRET}
ARGON_SALT=${ARGON_SALT}
TON_MASTER_WALLET=${TON_MASTER_WALLET}
LOKI_PUSH_URL=http://${DATA_VPS_IP}:3100
GRAFANA_PUBLIC_URL=${GRAFANA_PUBLIC_URL}
CRYPTOBOT_API_TOKEN=${CRYPTOBOT_API_TOKEN}
BOT_ENABLED=true
TELOXIDE_TOKEN=${TELOXIDE_TOKEN}
BOT_USERNAME=${BOT_USERNAME}
WEB_APP_BASE_URL=${WEB_APP_BASE_URL}
PROXY_INTERNAL_SECRET=${PROXY_INTERNAL_SECRET}
# Нужен самому бэкенду в рантайме, не только CI — см.
# NodeService::provision_node_via_ssh.
GT_REGISTRY_TOKEN=${GT_REGISTRY_TOKEN}
PORT=8080
SUBSCRIPTION_CHECK_INTERVAL=1800
NODE_HEALTH_CHECK_INTERVAL=60
APP_ENV=production
RUST_LOG=netrunner_control_plane=info,axum=info,tower_http=info,sqlx=warn
CORS_ALLOWED_ORIGINS=https://netrunner-vpn.com,https://account.netrunner-vpn.com,tauri://localhost,http://tauri.localhost
CSRF_ALLOWED_ORIGINS=https://netrunner-vpn.com,https://account.netrunner-vpn.com
COOKIE_DOMAIN=.netrunner-vpn.com
COOKIE_SECURE=true
EOF
chmod 600 .env
export IMAGE="gitea.netrunner-vpn.com/nineap/netrunner-control-plane:${{ github.event.inputs.image_tag }}"
echo "🚀 Deploying ${IMAGE}"
echo "${{ secrets.GT_REGISTRY_TOKEN }}" | docker login gitea.netrunner-vpn.com -u ${{ github.actor }} --password-stdin
# Тянем свежий образ, прогоняем миграции (one-shot) и поднимаем сервисы.
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
# Чистим старые слои.
docker image prune -f
echo "✅ Uplink synchronized."
env:
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DATA_VPS_IP: ${{ vars.DATA_VPS_IP }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
ARGON_SALT: ${{ secrets.ARGON_SALT }}
TON_MASTER_WALLET: ${{ vars.TON_MASTER_WALLET }}
GRAFANA_PUBLIC_URL: ${{ vars.GRAFANA_PUBLIC_URL }}
PROXY_INTERNAL_SECRET: ${{ secrets.PROXY_INTERNAL_SECRET }}
CRYPTOBOT_API_TOKEN: ${{ secrets.CRYPTOBOT_API_TOKEN }}
TELOXIDE_TOKEN: ${{ secrets.TELOXIDE_TOKEN }}
BOT_USERNAME: ${{ vars.BOT_USERNAME }}
WEB_APP_BASE_URL: ${{ vars.WEB_APP_BASE_URL }}
GT_REGISTRY_TOKEN: ${{ secrets.GT_REGISTRY_TOKEN }}
+39
View File
@@ -51,3 +51,42 @@ jobs:
# Кеш слоёв между прогонами — повторные сборки в разы быстрее. # Кеш слоёв между прогонами — повторные сборки в разы быстрее.
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
# Кастомная сборка Caddy (модуль caddy-dns/cloudflare, нужен для TLS на
# dev-стенде — см. Caddyfile.dev). Компилировать xcaddy'ем прямо на VPS
# оказалось слишком долго и один раз реально уронило SSH-деплой по таймауту
# (компиляция go-модулей на слабой машине не укладывалась в command_timeout
# appleboy/ssh-action) — та же причина, по которой Rust вообще не собирается
# на VPS. Собираем здесь и просто тянем готовый образ, как и основной.
build-caddy:
name: Build and push Caddy (Cloudflare DNS) image
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.caddy
push: true
# Захардкожено, не ${{ github.repository_owner }}: он резолвится в
# "nineAp" (смешанный регистр), Docker такие теги отклоняет — та же
# причина, по которой deploy.yml тоже хардкодит "nineap" для
# основного образа вместо репозиторного контекста.
tags: ${{ env.REGISTRY }}/nineap/netrunner-caddy-cloudflare:latest
cache-from: type=gha,scope=caddy
cache-to: type=gha,mode=max,scope=caddy
+69
View File
@@ -0,0 +1,69 @@
name: Deploy Nginx
# Единственный публичный вход (443/80) на прод VPS (backend+лендинг вместе) —
# system nginx, тот же принцип, что и в netrunner-data. Только ручной запуск.
#
# Порядок первого запуска:
# 1. Запустить эту джобу (поднимет http-конфиг + сам nginx).
# 2. Один раз руками на сервере:
# sudo apt-get install -y certbot
# sudo certbot certonly --webroot -w /var/www/certbot \
# -d account.netrunner-vpn.com
# (если лендинг деплоится на этот же VPS первым — можно сразу одной
# командой на оба домена, см. deploy-nginx.yml у netrunner-landing)
# 3. Запустить эту джобу ещё раз — включит HTTPS.
on:
workflow_dispatch:
jobs:
deploy:
name: Deploy nginx config for account.netrunner-vpn.com
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Sync nginx configs to VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ vars.VPS_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "nginx/account-http.conf,nginx/account-ssl.conf"
target: "/root/netrunner-core"
strip_components: 0
- name: Install/enable nginx via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ vars.VPS_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
set -e
if ! command -v nginx >/dev/null 2>&1; then
apt-get update
apt-get install -y nginx
fi
mkdir -p /var/www/certbot
rm -f /etc/nginx/sites-enabled/default
cp /root/netrunner-core/nginx/account-http.conf /etc/nginx/sites-available/account-http.conf
cp /root/netrunner-core/nginx/account-ssl.conf /etc/nginx/sites-available/account-ssl.conf
ln -sf /etc/nginx/sites-available/account-http.conf /etc/nginx/sites-enabled/account-http.conf
CERT=/etc/letsencrypt/live/account.netrunner-vpn.com/fullchain.pem
if [ -f "$CERT" ]; then
ln -sf /etc/nginx/sites-available/account-ssl.conf /etc/nginx/sites-enabled/account-ssl.conf
echo "🔒 Сертификат найден — HTTPS-конфиг включён."
else
rm -f /etc/nginx/sites-enabled/account-ssl.conf
echo "⚠️ Сертификата ещё нет ($CERT) — HTTPS-конфиг НЕ включён."
fi
nginx -t
systemctl enable --now nginx
systemctl reload nginx
echo "✅ nginx (account.netrunner-vpn.com) synchronized."
+109 -9
View File
@@ -3,6 +3,11 @@ name: Deploy
# Один пайплайн, две джобы: # Один пайплайн, две джобы:
# - deploy-dev: авто, срабатывает после успешного "Build & Push Image" на main. # - deploy-dev: авто, срабатывает после успешного "Build & Push Image" на main.
# - deploy-prod: вручную из вкладки Actions ("Run workflow"), как и раньше. # - deploy-prod: вручную из вкладки Actions ("Run workflow"), как и раньше.
#
# .env на обоих VPS теперь полностью собирается этим workflow из repo
# Variables/Secrets (тот же принцип, что и в netrunner-data) — руками на
# сервере ничего не создаётся и не редактируется, весь конфиг живёт в
# Settings → Actions этого репозитория.
on: on:
workflow_run: workflow_run:
workflows: ["Build & Push Image"] workflows: ["Build & Push Image"]
@@ -25,29 +30,59 @@ jobs:
with: with:
ref: ${{ github.event.workflow_run.head_sha }} ref: ${{ github.event.workflow_run.head_sha }}
# Копируем только compose-файл — сам образ дев-сервер тянет из GHCR, # Копируем compose-файл + Caddyfile (примонтированный конфиг, не образ —
# полный клон репозитория на нём не нужен. # сам Caddy-образ, как и образ бэкенда, дев-сервер тянет из GHCR, собран
# в build.yml::build-caddy. Собирать xcaddy прямо на VPS один раз уже не
# уложилось в таймаут SSH-деплоя и уронило весь прогон).
- name: Sync compose file to Dev VPS - name: Sync compose file to Dev VPS
uses: appleboy/scp-action@v0.1.7 uses: appleboy/scp-action@v0.1.7
with: with:
host: ${{ secrets.DEV_VPS_IP }} host: ${{ vars.DEV_VPS_IP }}
username: root username: root
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }} key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
source: "docker-compose.dev-remote.yml" source: "docker-compose.dev-remote.yml,Caddyfile.dev"
target: "/root/netrunner-core" target: "/root/netrunner-core"
strip_components: 0 strip_components: 0
- name: Remote deploy via SSH - name: Write .env and deploy via SSH
uses: appleboy/ssh-action@v1 uses: appleboy/ssh-action@v1
with: with:
host: ${{ secrets.DEV_VPS_IP }} host: ${{ vars.DEV_VPS_IP }}
username: root username: root
key: ${{ secrets.DEV_SSH_PRIVATE_KEY }} key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
envs: "DB_PASSWORD,CF_API_TOKEN,JWT_SECRET,ARGON_SALT,TON_MASTER_WALLET,PROXY_INTERNAL_SECRET,CRYPTOBOT_API_TOKEN,TELOXIDE_TOKEN,BOT_USERNAME"
script: | script: |
set -e set -e
cd /root/netrunner-core cd /root/netrunner-core
# .env — статические значения (никогда не меняются между деплоями)
# зашиты здесь же литералом, секреты/domain-специфичное — из vars/secrets.
cat > .env <<EOF
DB_USER=netrunner_admin
DB_PASSWORD=${DB_PASSWORD}
DB_NAME=netrunner
CF_API_TOKEN=${CF_API_TOKEN}
JWT_SECRET=${JWT_SECRET}
ARGON_SALT=${ARGON_SALT}
TON_MASTER_WALLET=${TON_MASTER_WALLET}
PROXY_INTERNAL_SECRET=${PROXY_INTERNAL_SECRET}
CRYPTOBOT_API_TOKEN=${CRYPTOBOT_API_TOKEN}
CRYPTOBOT_TESTNET=true
BOT_ENABLED=true
TELOXIDE_TOKEN=${TELOXIDE_TOKEN}
BOT_USERNAME=${BOT_USERNAME}
WEB_APP_BASE_URL=https://dev.netrunner-vpn.com
SUBSCRIPTION_CHECK_INTERVAL=1800
RUST_LOG=netrunner_control_plane=debug,axum=info,tower_http=debug,sqlx=warn
CORS_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,tauri://localhost,http://tauri.localhost,http://dev.netrunner-vpn.com:3000,https://dev.netrunner-vpn.com
CSRF_ALLOWED_ORIGINS=http://localhost:1420,http://localhost:5173,http://dev.netrunner-vpn.com:3000,https://dev.netrunner-vpn.com
COOKIE_DOMAIN=
COOKIE_SECURE=true
EOF
chmod 600 .env
export IMAGE="ghcr.io/nineap/netrunner-control-plane:latest" export IMAGE="ghcr.io/nineap/netrunner-control-plane:latest"
export CADDY_IMAGE="ghcr.io/nineap/netrunner-caddy-cloudflare:latest"
echo "🚀 Deploying ${IMAGE} to DEV" echo "🚀 Deploying ${IMAGE} to DEV"
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
@@ -58,22 +93,75 @@ jobs:
docker image prune -f docker image prune -f
echo "✅ Dev synchronized." echo "✅ Dev synchronized."
env:
DB_PASSWORD: ${{ secrets.DEV_DB_PASSWORD }}
CF_API_TOKEN: ${{ secrets.DEV_CF_API_TOKEN }}
JWT_SECRET: ${{ secrets.DEV_JWT_SECRET }}
ARGON_SALT: ${{ secrets.DEV_ARGON_SALT }}
TON_MASTER_WALLET: ${{ vars.TON_MASTER_WALLET }}
PROXY_INTERNAL_SECRET: ${{ secrets.DEV_PROXY_INTERNAL_SECRET }}
CRYPTOBOT_API_TOKEN: ${{ secrets.DEV_CRYPTOBOT_API_TOKEN }}
TELOXIDE_TOKEN: ${{ secrets.DEV_TELOXIDE_TOKEN }}
BOT_USERNAME: ${{ vars.DEV_BOT_USERNAME }}
deploy-prod: deploy-prod:
name: Pull & restart on Prod VPS name: Pull & restart on Prod VPS
if: github.event_name == 'workflow_dispatch' if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Remote deploy via SSH - uses: actions/checkout@v4
uses: appleboy/ssh-action@v1
- name: Sync compose file to Prod VPS
uses: appleboy/scp-action@v0.1.7
with: with:
host: ${{ secrets.VPS_IP }} host: ${{ vars.VPS_IP }}
username: root username: root
key: ${{ secrets.SSH_PRIVATE_KEY }} key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "docker-compose.prod.yml"
target: "/root/netrunner-core"
strip_components: 0
- name: Write .env and deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ vars.VPS_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
envs: "DB_PASSWORD,DATA_VPS_IP,JWT_SECRET,ARGON_SALT,TON_MASTER_WALLET,GRAFANA_PUBLIC_URL,PROXY_INTERNAL_SECRET,CRYPTOBOT_API_TOKEN,TELOXIDE_TOKEN,BOT_USERNAME,WEB_APP_BASE_URL"
script: | script: |
set -e set -e
cd /root/netrunner-core cd /root/netrunner-core
cat > .env <<EOF
DB_USER=netrunner_admin
DB_NAME=netrunner
DB_PASSWORD=${DB_PASSWORD}
DB_HOST=${DATA_VPS_IP}
DB_PORT=5432
REDIS_URL=redis://redis:6379
JWT_SECRET=${JWT_SECRET}
ARGON_SALT=${ARGON_SALT}
TON_MASTER_WALLET=${TON_MASTER_WALLET}
LOKI_PUSH_URL=http://${DATA_VPS_IP}:3100
GRAFANA_PUBLIC_URL=${GRAFANA_PUBLIC_URL}
CRYPTOBOT_API_TOKEN=${CRYPTOBOT_API_TOKEN}
BOT_ENABLED=true
TELOXIDE_TOKEN=${TELOXIDE_TOKEN}
BOT_USERNAME=${BOT_USERNAME}
WEB_APP_BASE_URL=${WEB_APP_BASE_URL}
PROXY_INTERNAL_SECRET=${PROXY_INTERNAL_SECRET}
PORT=8080
SUBSCRIPTION_CHECK_INTERVAL=1800
NODE_HEALTH_CHECK_INTERVAL=60
APP_ENV=production
RUST_LOG=netrunner_control_plane=info,axum=info,tower_http=info,sqlx=warn
CORS_ALLOWED_ORIGINS=https://netrunner-vpn.com,https://account.netrunner-vpn.com,tauri://localhost,http://tauri.localhost
CSRF_ALLOWED_ORIGINS=https://netrunner-vpn.com,https://account.netrunner-vpn.com
COOKIE_DOMAIN=.netrunner-vpn.com
COOKIE_SECURE=true
EOF
chmod 600 .env
export IMAGE="ghcr.io/nineap/netrunner-control-plane:${{ github.event.inputs.image_tag }}" export IMAGE="ghcr.io/nineap/netrunner-control-plane:${{ github.event.inputs.image_tag }}"
echo "🚀 Deploying ${IMAGE}" echo "🚀 Deploying ${IMAGE}"
@@ -87,3 +175,15 @@ jobs:
docker image prune -f docker image prune -f
echo "✅ Uplink synchronized." echo "✅ Uplink synchronized."
env:
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DATA_VPS_IP: ${{ vars.DATA_VPS_IP }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
ARGON_SALT: ${{ secrets.ARGON_SALT }}
TON_MASTER_WALLET: ${{ vars.TON_MASTER_WALLET }}
GRAFANA_PUBLIC_URL: ${{ vars.GRAFANA_PUBLIC_URL }}
PROXY_INTERNAL_SECRET: ${{ secrets.PROXY_INTERNAL_SECRET }}
CRYPTOBOT_API_TOKEN: ${{ secrets.CRYPTOBOT_API_TOKEN }}
TELOXIDE_TOKEN: ${{ secrets.TELOXIDE_TOKEN }}
BOT_USERNAME: ${{ vars.BOT_USERNAME }}
WEB_APP_BASE_URL: ${{ vars.WEB_APP_BASE_URL }}
+17
View File
@@ -0,0 +1,17 @@
# Реверс-прокси с реальным TLS для dev VPS — нужен и потому, что Telegram Web
# Apps (кнопки бота "Личный Кабинет"/"Тарифы"/"Синдикат") требуют https:// URL
# (см. teloxide WebAppInfo::url), и потому, что Telegram Login Widget в
# обычном браузере требует именно порт 443 — его CSP `frame-ancestors` от
# oauth.telegram.org не учитывает порт в зарегистрированном в BotFather домене
# и всегда подразумевает дефолтный порт схемы (443 для https); на 8443 виджет
# рендерился, но браузер блокировал iframe как cross-origin.
#
# Порт 80 на этом VPS закрыт — обычный HTTP-01 ACME challenge не пройдёт,
# поэтому TLS через DNS-01 (Cloudflare, см. Dockerfile.caddy), которому
# открытые 80/443 не нужны для самого выпуска сертификата.
dev.netrunner-vpn.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy app:8080
}
Generated
+90
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.7.8" version = "0.7.8"
@@ -649,6 +655,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam-epoch" name = "crossbeam-epoch"
version = "0.9.18" version = "0.9.18"
@@ -1056,6 +1071,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@@ -1856,6 +1881,15 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "ipnetwork"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "iri-string" name = "iri-string"
version = "0.7.12" version = "0.7.12"
@@ -2055,6 +2089,18 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "maxminddb"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c"
dependencies = [
"ipnetwork",
"log",
"memchr",
"serde",
]
[[package]] [[package]]
name = "md-5" name = "md-5"
version = "0.10.6" version = "0.10.6"
@@ -2135,6 +2181,16 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.2.0"
@@ -2173,14 +2229,18 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"dotenvy", "dotenvy",
"flate2",
"hex", "hex",
"hmac 0.13.0", "hmac 0.13.0",
"ipnet",
"jsonwebtoken", "jsonwebtoken",
"maxminddb",
"metrics", "metrics",
"metrics-exporter-prometheus", "metrics-exporter-prometheus",
"rand 0.8.6", "rand 0.8.6",
"redis", "redis",
"reqwest 0.11.27", "reqwest 0.11.27",
"rpassword",
"rust_decimal", "rust_decimal",
"serde", "serde",
"serde_json", "serde_json",
@@ -3035,6 +3095,17 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@@ -3055,6 +3126,16 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rtoolbox"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rust_decimal" name = "rust_decimal"
version = "1.42.0" version = "1.42.0"
@@ -3429,6 +3510,12 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "simdutf8" name = "simdutf8"
version = "0.1.5" version = "0.1.5"
@@ -3553,6 +3640,7 @@ dependencies = [
"memchr", "memchr",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"rust_decimal",
"rustls", "rustls",
"serde", "serde",
"serde_json", "serde_json",
@@ -3637,6 +3725,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"rand 0.8.6", "rand 0.8.6",
"rsa", "rsa",
"rust_decimal",
"serde", "serde",
"sha1", "sha1",
"sha2 0.10.9", "sha2 0.10.9",
@@ -3676,6 +3765,7 @@ dependencies = [
"memchr", "memchr",
"once_cell", "once_cell",
"rand 0.8.6", "rand 0.8.6",
"rust_decimal",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
+10 -2
View File
@@ -12,7 +12,7 @@ tower_governor = "0.8"
teloxide = { version = "0.17", features = ["macros", "rustls"] } teloxide = { version = "0.17", features = ["macros", "rustls"] }
# 🔥 ИСПРАВЛЕНО: Включаем "migrate" для автоматического создания баз в тестах # 🔥 ИСПРАВЛЕНО: Включаем "migrate" для автоматического создания баз в тестах
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate", "rust_decimal"] }
redis = { version = "1.2.0", features = ["tokio-comp", "aio", "connection-manager"] } redis = { version = "1.2.0", features = ["tokio-comp", "aio", "connection-manager"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@@ -37,12 +37,20 @@ clap = { version = "4.6.1", features = ["derive"] }
ssh2 = "0.9.5" ssh2 = "0.9.5"
rand = "0.8" rand = "0.8"
argon2 = "0.5" argon2 = "0.5"
rpassword = "7.5"
rust_decimal = { version = "1.42.0", features = ["serde"] } rust_decimal = { version = "1.42.0", features = ["serde"] }
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json"] }
maxminddb = "0.24"
ipnet = "2.10"
flate2 = "1.0"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
lto = "thin" lto = "thin"
codegen-units = 16 codegen-units = 16
panic = "abort" # НЕ panic="abort": по умолчанию tokio ловит панику отдельной задачи (одного
# запроса) через catch_unwind на границе задачи и превращает её в JoinError —
# остальные соединения продолжают жить. panic="abort" эту изоляцию отключает:
# любая паника где угодно в обработчике одного запроса мгновенно убивает весь
# процесс и рвёт всех подключённых пользователей разом.
strip = true strip = true
+20 -5
View File
@@ -61,6 +61,11 @@ Dev-окружение отдельного ручного шага не тре
`GITHUB_TOKEN` для push в GHCR выдаётся автоматически. `GITHUB_TOKEN` для push в GHCR выдаётся автоматически.
Отдельно на самом dev VPS (не GitHub-секрет, а переменная в `.env` рядом с
`docker-compose.dev-remote.yml`) — `CF_API_TOKEN`: Cloudflare-токен для Caddy
(DNS-01 challenge, см. ниже), шаблон "Edit zone DNS", ограниченный зоной
`netrunner-vpn.com`. Не Global API Key.
### Что должно лежать на VPS (`/root/netrunner-core`) ### Что должно лежать на VPS (`/root/netrunner-core`)
- `docker-compose.prod.yml` - `docker-compose.prod.yml`
@@ -73,14 +78,24 @@ Dev-окружение отдельного ручного шага не тре
### Что должно лежать на dev VPS (`/root/netrunner-core`) ### Что должно лежать на dev VPS (`/root/netrunner-core`)
- `docker-compose.dev-remote.yml` - `docker-compose.dev-remote.yml`, `Dockerfile.caddy`, `Caddyfile.dev` (все три
синкает CI при каждом `deploy-dev`, руками копировать не нужно)
- `.env` (см. [`.env.dev-remote.example`](.env.dev-remote.example) — свои - `.env` (см. [`.env.dev-remote.example`](.env.dev-remote.example) — свои
`JWT_SECRET`/`ARGON_SALT`/`DB_PASSWORD`, `CORS_ALLOWED_ORIGINS`/`CSRF_ALLOWED_ORIGINS` `JWT_SECRET`/`ARGON_SALT`/`DB_PASSWORD`, `CF_API_TOKEN`,
включают origin dev-лендинга на этом же хосте) `CORS_ALLOWED_ORIGINS`/`CSRF_ALLOWED_ORIGINS` включают origin dev-лендинга и
Caddy-порта на этом же хосте)
- `keys/netrunner_master_ed25519[.pub]` — свой мастер-ключ, отдельный от прода - `keys/netrunner_master_ed25519[.pub]` — свой мастер-ключ, отдельный от прода
Dev-стек без мониторинга: `migrate``app` (порт `8080`, публично, без reverse-proxy) Dev-стек: `migrate``app` (порт `8080`, обычный HTTP, без TLS) + `db` + `redis`
+ `db` + `redis`. + `caddy` (порт `443`, реальный TLS через DNS-01/Cloudflare — см.
[`Caddyfile.dev`](Caddyfile.dev)). Caddy здесь нужен по двум причинам: Telegram
Web Apps (кнопки бота "Личный Кабинет"/"Тарифы"/"Синдикат") требуют `https://`
URL, а Telegram Login Widget в обычном браузере требует именно порт 443 (его
CSP `frame-ancestors` не учитывает порт и всегда подразумевает дефолтный порт
схемы). Порт 80 на этом VPS закрыт — обычный HTTP-01 challenge не проходит,
поэтому DNS-01. Все браузерные auth-флоу (бот, лендинг) должны идти через
Caddy на 443, не напрямую в `:8080``COOKIE_SECURE=true` (как в проде), и
Secure-cookie по голому HTTP на `:8080` браузер по-прежнему отбросит.
### Локальная сборка образа (фолбэк, обычно не нужно) ### Локальная сборка образа (фолбэк, обычно не нужно)
+9
View File
@@ -0,0 +1,9 @@
# syntax=docker/dockerfile:1
# Кастомная сборка Caddy с DNS-провайдером Cloudflare — нужна для DNS-01
# ACME challenge на dev VPS, где порты 80/443 недоступны/заняты (443 —
# VPN-нодой, 80 закрыт). DNS-01 не требует открытых входящих 80/443 вообще.
FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
+22
View File
@@ -69,6 +69,28 @@ make dev # docker compose: db + redis + app с hot-reload (cargo
Приложение поднимется на `http://localhost:8080` (`/health`, `/health/ready`). Приложение поднимется на `http://localhost:8080` (`/health`, `/health/ready`).
Остальные команды — `make help`. Остальные команды — `make help`.
## Создание первого Owner-аккаунта админки
Единственный способ попасть в `/admin/login` (React-админка) — учётка с ролью
`owner`, логин+пароль (не Telegram). Создаётся one-shot CLI-флагом
`--create-owner` (нужен только `DATABASE_URL`, ни ключей, ни токенов;
пароль вводится скрыто в консоли, не через аргумент/env — не оседает в
истории шелла):
```bash
# Локально/дев (бинарник собран, .env заполнен):
./netrunner-control-plane --create-owner --username admin
# На проде (задеплоено через docker-compose.prod.yml) — выполнить на самом
# VPS из /root/netrunner-core:
docker compose -f docker-compose.prod.yml run --rm app \
./netrunner-control-plane --create-owner --username admin
```
`docker compose run` (не `up`) — контейнер разовый, не публикует порт 8080,
не конфликтует с уже работающим `app`. Логин уже занят — упадёт с понятной
ошибкой, а не тихо перезапишет существующего владельца.
## Nodes, Cyberhack, биллинг — текущее состояние ## Nodes, Cyberhack, биллинг — текущее состояние
- **`GET /nodes/routing`** отдаёт полный `TunnelConfig`: `tunnel_*`-поля, - **`GET /nodes/routing`** отдаёт полный `TunnelConfig`: `tunnel_*`-поля,
+29 -2
View File
@@ -6,7 +6,7 @@
services: services:
# One-shot: применяет миграции и завершается. Требует только DATABASE_URL. # One-shot: применяет миграции и завершается. Требует только DATABASE_URL.
migrate: migrate:
image: ${IMAGE:-ghcr.io/nineap/netrunner-control-plane:latest} image: ${IMAGE:-gitea.netrunner-vpn.com/nineap/netrunner-control-plane:latest}
container_name: netrunner-migrate-dev container_name: netrunner-migrate-dev
command: ["./netrunner-control-plane", "--migrate"] command: ["./netrunner-control-plane", "--migrate"]
environment: environment:
@@ -19,7 +19,7 @@ services:
- netrunner_grid_dev - netrunner_grid_dev
app: app:
image: ${IMAGE:-ghcr.io/nineap/netrunner-control-plane:latest} image: ${IMAGE:-gitea.netrunner-vpn.com/nineap/netrunner-control-plane:latest}
container_name: netrunner-app-dev-remote container_name: netrunner-app-dev-remote
restart: always restart: always
ports: ports:
@@ -76,9 +76,36 @@ services:
networks: networks:
- netrunner_grid_dev - netrunner_grid_dev
# Реверс-прокси с реальным TLS (DNS-01 через Cloudflare) — нужен для
# Telegram Web Apps (требуют https://) и для Telegram Login Widget в
# обычном браузере (требует именно порт 443 — см. Caddyfile.dev). VPN-нода,
# которая раньше занимала 443 на этом VPS, перенесена на другой порт.
#
# Образ собирается в GitHub Actions (build.yml::build-caddy), не на VPS:
# компиляция xcaddy (Go, тянет много модулей) на этой машине один раз уже
# не уложилась в таймаут SSH-деплоя и уронила весь прогон.
caddy:
image: ${CADDY_IMAGE:-gitea.netrunner-vpn.com/nineap/netrunner-caddy-cloudflare:latest}
container_name: netrunner-caddy-dev
restart: always
ports:
- "443:443"
environment:
- CF_API_TOKEN=${CF_API_TOKEN}
volumes:
- ./Caddyfile.dev:/etc/caddy/Caddyfile:ro
- caddy_data_dev:/data
- caddy_config_dev:/config
depends_on:
- app
networks:
- netrunner_grid_dev
networks: networks:
netrunner_grid_dev: netrunner_grid_dev:
driver: bridge driver: bridge
volumes: volumes:
netrunner_db_data_dev: netrunner_db_data_dev:
caddy_data_dev:
caddy_config_dev:
+25 -76
View File
@@ -1,24 +1,30 @@
# ===================================================================== # =====================================================================
# PRODUCTION — VPS только тянет готовый образ из GHCR и запускает его. # PRODUCTION — VPS только тянет готовый образ из GHCR и запускает его.
# Никакой компиляции Rust на сервере. Деплой = pull + migrate + up. # Никакой компиляции Rust на сервере. Деплой = pull + migrate + up.
#
# ВАЖНО: своего Postgres здесь больше нет. БД физически стоит на отдельном
# VPS (см. netrunner-data/docker-compose.yml — там же Vaultwarden, тот же
# Postgres-процесс, вторая база) и слушает по сети, не в одном
# Docker-стеке с этим compose. DATABASE_URL ниже указывает на DB_HOST:DB_PORT
# (внешний IP того VPS), не на "db" как раньше — и требует sslmode=require,
# т.к. трафик идёт по публичной сети между двумя разными серверами. Бэкапы
# тоже переехали туда же (netrunner-data/scripts/backup.sh) — дампить
# логичнее там, где физически лежат данные.
# ===================================================================== # =====================================================================
services: services:
# One-shot: применяет миграции и завершается. Требует только DATABASE_URL. # One-shot: применяет миграции и завершается. Требует только DATABASE_URL.
migrate: migrate:
image: ${IMAGE:-ghcr.io/nineap/netrunner-control-plane:latest} image: ${IMAGE:-gitea.netrunner-vpn.com/nineap/netrunner-control-plane:latest}
container_name: netrunner-migrate container_name: netrunner-migrate
command: ["./netrunner-control-plane", "--migrate"] command: ["./netrunner-control-plane", "--migrate"]
environment: environment:
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT:-5432}/${DB_NAME}?sslmode=require
depends_on:
db:
condition: service_healthy
restart: "no" restart: "no"
networks: networks:
- netrunner_grid - netrunner_grid
app: app:
image: ${IMAGE:-ghcr.io/nineap/netrunner-control-plane:latest} image: ${IMAGE:-gitea.netrunner-vpn.com/nineap/netrunner-control-plane:latest}
container_name: netrunner-app container_name: netrunner-app
restart: always restart: always
ports: ports:
@@ -28,12 +34,10 @@ services:
environment: environment:
- APP_ENV=production - APP_ENV=production
- FRONTEND_DIR=/app/frontend/dist - FRONTEND_DIR=/app/frontend/dist
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT:-5432}/${DB_NAME}?sslmode=require
volumes: volumes:
- ./keys:/app/keys:ro - ./keys:/app/keys:ro
depends_on: depends_on:
db:
condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
migrate: migrate:
@@ -47,24 +51,6 @@ services:
networks: networks:
- netrunner_grid - netrunner_grid
db:
image: postgres:16-alpine
container_name: netrunner-db
restart: always
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USER}
POSTGRES_DB: ${DB_NAME}
volumes:
- netrunner_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- netrunner_grid
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: netrunner-redis container_name: netrunner-redis
@@ -76,68 +62,31 @@ services:
- netrunner_grid - netrunner_grid
# ========================================== # ==========================================
# МОНИТОРИНГ # НАБЛЮДАЕМОСТЬ
# ========================================== # ==========================================
prometheus: # Prometheus/Loki/Grafana больше НЕ здесь — единый стек теперь на VPS с
image: prom/prometheus:latest # данными (netrunner-data/docker-compose.observability.yml), т.к. ему
container_name: netrunner-prometheus # всё равно нужно видеть лендинг и все прокси-ноды, не только бэкенд.
volumes: # /metrics бэкенда (см. src/api.rs) — тот central Prometheus скрейпит
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro # удалённо; отсюда только пуш логов через тонкий promtail.
ports:
- "127.0.0.1:9090:9090"
restart: always
networks:
- netrunner_grid
loki:
image: grafana/loki:latest
container_name: netrunner-loki
volumes:
- ./loki-config.yml:/etc/loki/local-config.yaml:ro
ports:
- "127.0.0.1:3100:3100"
command: -config.file=/etc/loki/local-config.yaml
restart: always
networks:
- netrunner_grid
promtail: promtail:
image: grafana/promtail:latest image: grafana/promtail:latest
container_name: netrunner-promtail container_name: netrunner-promtail
environment:
- LOKI_PUSH_URL=${LOKI_PUSH_URL}
volumes: volumes:
- ./promtail-config.yml:/etc/promtail/config.yml:ro - ./promtail-config.yml:/etc/promtail/config.yml:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro - /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
command: -config.file=/etc/promtail/config.yml # -config.expand-env: без него promtail не подставит ${LOKI_PUSH_URL}
# внутри самого config.yml — сама переменная передаётся выше через
# environment, файл конфига читает её только с этим флагом.
command: -config.file=/etc/promtail/config.yml -config.expand-env=true
restart: always restart: always
user: root user: root
depends_on:
- loki
networks:
- netrunner_grid
grafana:
image: grafana/grafana:latest
container_name: netrunner-grafana
ports:
- "127.0.0.1:3030:3000"
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
volumes:
- grafana_data:/var/lib/grafana
- ./grafana-datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml:ro
depends_on:
- prometheus
- loki
restart: always
networks: networks:
- netrunner_grid - netrunner_grid
networks: networks:
netrunner_grid: netrunner_grid:
driver: bridge driver: bridge
volumes:
netrunner_db_data:
grafana_data:
+8 -1
View File
@@ -39,4 +39,11 @@ pnpm lint
``` ```
Переменные окружения (`.env.production` / Vite `import.meta.env`): Переменные окружения (`.env.production` / Vite `import.meta.env`):
`VITE_API_BASE` (по умолчанию `/api/v1`), `VITE_BOT_USERNAME`. `VITE_API_BASE` (по умолчанию `/api/v1`).
Юзернейм бота (для Telegram Login Widget и реферальной ссылки) **не** build-time
переменная — этот SPA собирается один раз в CI и раздаётся одинаково и прод-, и
dev-стендом, так что зашитое в бандл значение не различалось бы между ними. Вместо
`VITE_BOT_USERNAME` `Auth.tsx`/`Profile.tsx` берут его рантаймом через
`GET /api/v1/config` (`api.ts::getPublicConfig`), который читает `BOT_USERNAME` из
окружения сервера заново на каждый запрос.
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useState } from "react";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { Loader2, Server, Users, Wallet, DollarSign, LogOut, LifeBuoy, Activity } from "lucide-react";
import { fetchMyRole, logout, type StaffRole } from "./api";
const STAFF_ROLES = ["owner", "moderator", "partner", "support"];
export default function AdminLayout() {
const navigate = useNavigate();
const location = useLocation();
const [role, setRole] = useState<StaffRole | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchMyRole()
.then((r) => {
if (!STAFF_ROLES.includes(r.role)) {
navigate("/admin/login");
return;
}
// Support не видит "Ноды" (индекс-роут /admin) — после логина сразу
// на единственный раздел, который ему доступен.
if (r.role === "support" && location.pathname === "/admin") {
navigate("/admin/tickets", { replace: true });
}
setRole(r);
})
.catch(() => navigate("/admin/login"))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#05050A]">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
</div>
);
}
if (!role) return null;
const navClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-2 px-4 py-2.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-white/5 hover:text-foreground"
}`;
const canSeeNodes = role.role === "owner" || (role.role === "moderator" && role.can_manage_nodes);
return (
<div className="min-h-screen bg-[#05050A] font-mono">
<nav className="border-b border-white/10 px-6 py-4 flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
{canSeeNodes && (
<NavLink to="/admin" end className={navClass}>
<Server className="w-4 h-4" /> Ноды
</NavLink>
)}
{role.role === "owner" && (
<NavLink to="/admin/staff" className={navClass}>
<Users className="w-4 h-4" /> Staff
</NavLink>
)}
{role.role === "moderator" && (
<NavLink to="/admin/staff" className={navClass}>
<Users className="w-4 h-4" /> Партнёры
</NavLink>
)}
{(role.role === "owner" || role.role === "moderator") && (
<NavLink to="/admin/withdrawals" className={navClass}>
<Wallet className="w-4 h-4" /> Выводы
</NavLink>
)}
{(role.role === "owner" || role.role === "moderator" || role.role === "support") && (
<NavLink to="/admin/tickets" className={navClass}>
<LifeBuoy className="w-4 h-4" /> Тикеты
</NavLink>
)}
{(role.role === "owner" || role.role === "moderator") && (
<NavLink to="/admin/observability" className={navClass}>
<Activity className="w-4 h-4" /> Мониторинг
</NavLink>
)}
{role.role === "partner" && (
<NavLink to="/admin/earnings" className={navClass}>
<DollarSign className="w-4 h-4" /> Доходы
</NavLink>
)}
</div>
<button
onClick={logout}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-widest text-muted-foreground hover:bg-white/5 hover:text-red-500 transition-colors"
>
<LogOut className="w-4 h-4" /> Выход
</button>
</nav>
<Outlet context={role} />
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Shield, Loader2 } from "lucide-react";
import { adminLogin } from "./api";
export default function AdminLogin() {
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await adminLogin(username, password);
navigate("/admin");
} catch {
setError("Неверный логин или пароль.");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#05050A] font-mono px-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm bg-white/[0.02] border border-white/10 rounded-2xl p-8 space-y-6"
>
<div className="flex flex-col items-center gap-3 text-center">
<Shield className="w-10 h-10 text-primary" />
<h1 className="text-lg font-black tracking-widest text-primary uppercase">
Admin Access
</h1>
</div>
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground uppercase tracking-widest">
Логин
</label>
<input
required
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm outline-none focus:border-primary transition-colors"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground uppercase tracking-widest">
Пароль
</label>
<input
required
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm outline-none focus:border-primary transition-colors"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-primary/10 hover:bg-primary/20 text-primary border border-primary/30 font-bold uppercase tracking-widest text-xs py-3 rounded-lg transition-all flex items-center justify-center gap-2 disabled:opacity-50"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Войти"}
</button>
</form>
</div>
);
}
+16 -1
View File
@@ -7,6 +7,13 @@ const Profile = lazy(() => import("./Profile"));
const HackGame = lazy(() => import("./HackGame")); const HackGame = lazy(() => import("./HackGame"));
const AdminDashboard = lazy(() => import("./AdminDashboard")); const AdminDashboard = lazy(() => import("./AdminDashboard"));
const AuthClient = lazy(() => import("./Auth")); const AuthClient = lazy(() => import("./Auth"));
const AdminLogin = lazy(() => import("./AdminLogin"));
const AdminLayout = lazy(() => import("./AdminLayout"));
const StaffPage = lazy(() => import("./StaffPage"));
const WithdrawalsPage = lazy(() => import("./WithdrawalsPage"));
const EarningsPage = lazy(() => import("./EarningsPage"));
const TicketsPage = lazy(() => import("./TicketsPage"));
const ObservabilityPage = lazy(() => import("./ObservabilityPage"));
const DICT = { const DICT = {
en: { loading: "Loading protocols..." }, en: { loading: "Loading protocols..." },
@@ -83,7 +90,15 @@ export default function App() {
<Route path="/" element={<AuthClient />} /> <Route path="/" element={<AuthClient />} />
<Route path="/profile" element={<Profile />} /> <Route path="/profile" element={<Profile />} />
<Route path="/hack" element={<HackGame />} /> <Route path="/hack" element={<HackGame />} />
<Route path="/admin" element={<AdminDashboard />} /> <Route path="/admin/login" element={<AdminLogin />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<AdminDashboard />} />
<Route path="staff" element={<StaffPage />} />
<Route path="withdrawals" element={<WithdrawalsPage />} />
<Route path="earnings" element={<EarningsPage />} />
<Route path="tickets" element={<TicketsPage />} />
<Route path="observability" element={<ObservabilityPage />} />
</Route>
</Routes> </Routes>
</AuthHandler> </AuthHandler>
</BrowserRouter> </BrowserRouter>
+12 -3
View File
@@ -10,7 +10,7 @@ import {
Key, Key,
Download, Download,
} from "lucide-react"; } from "lucide-react";
import { apiFetch } from "./api"; import { apiFetch, getPublicConfig } from "./api";
export interface TelegramAuthUser { export interface TelegramAuthUser {
id: number; id: number;
@@ -107,7 +107,15 @@ export default function AuthClient() {
const widgetInjected = useRef(false); const widgetInjected = useRef(false);
const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1"; const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
const BOT_USERNAME = import.meta.env.VITE_BOT_USERNAME || "ntrnr_vpn_bot"; const [BOT_USERNAME, setBotUsername] = useState("ntrnr_vpn_bot");
const [configLoaded, setConfigLoaded] = useState(false);
useEffect(() => {
getPublicConfig().then((cfg) => {
setBotUsername(cfg.bot_username);
setConfigLoaded(true);
});
}, []);
useEffect(() => { useEffect(() => {
if (initialized.current) return; if (initialized.current) return;
@@ -179,6 +187,7 @@ export default function AuthClient() {
useEffect(() => { useEffect(() => {
if ( if (
status === "awaiting" && status === "awaiting" &&
configLoaded &&
tgContainerRef.current && tgContainerRef.current &&
!widgetInjected.current !widgetInjected.current
) { ) {
@@ -192,7 +201,7 @@ export default function AuthClient() {
script.setAttribute("data-request-access", "write"); script.setAttribute("data-request-access", "write");
tgContainerRef.current.appendChild(script); tgContainerRef.current.appendChild(script);
} }
}, [status, BOT_USERNAME]); }, [status, BOT_USERNAME, configLoaded]);
const handleGhostGenerate = async () => { const handleGhostGenerate = async () => {
setStatus("processing"); setStatus("processing");
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useState } from "react";
import { RefreshCw, DollarSign, Send, Copy, Check } from "lucide-react";
import {
fetchMyEarnings,
requestWithdrawal,
getPublicConfig,
type MyEarnings,
} from "./api";
export default function EarningsPage() {
const [data, setData] = useState<MyEarnings | null>(null);
const [loading, setLoading] = useState(true);
const [botUsername, setBotUsername] = useState("");
const [copied, setCopied] = useState(false);
const [currency, setCurrency] = useState("");
const [amount, setAmount] = useState("");
const [note, setNote] = useState("");
const [error, setError] = useState("");
const load = async () => {
setLoading(true);
try {
const earnings = await fetchMyEarnings();
setData(earnings);
if (!currency && earnings.earnings.length > 0) {
setCurrency(earnings.earnings[0].currency);
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
getPublicConfig().then((cfg) => setBotUsername(cfg.bot_username));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const referralLink = data?.partner_code
? `https://t.me/${botUsername}?start=${data.partner_code}`
: "";
const handleCopy = async () => {
if (!referralLink) return;
await navigator.clipboard.writeText(referralLink);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
await requestWithdrawal(currency, Math.round(Number(amount) * 100), note || undefined);
setAmount("");
setNote("");
await load();
} catch {
setError("Не удалось создать заявку (сумма превышает доступный остаток?).");
}
};
return (
<div className="p-6 sm:p-10 max-w-3xl mx-auto space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
Мои доходы
</h1>
<button
onClick={load}
className="p-2 bg-white/5 hover:bg-white/10 rounded-full transition-colors"
>
<RefreshCw className={`w-4 h-4 text-secondary ${loading ? "animate-spin" : ""}`} />
</button>
</div>
{referralLink && (
<div className="bg-black/40 border border-white/10 rounded-2xl p-5 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[10px] text-muted-foreground uppercase tracking-widest mb-1">
Ваша реферальная ссылка
</p>
<p className="font-mono text-sm truncate">{referralLink}</p>
</div>
<button
onClick={handleCopy}
className="p-2 bg-white/5 hover:bg-white/10 rounded-lg transition-colors shrink-0"
>
{copied ? <Check className="w-4 h-4 text-secondary" /> : <Copy className="w-4 h-4" />}
</button>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{(data?.earnings ?? []).map((e) => (
<div
key={e.currency}
className="bg-black/60 border border-white/5 rounded-xl p-5 space-y-2"
>
<div className="flex items-center gap-2 text-primary">
<DollarSign className="w-4 h-4" />
<span className="font-black text-lg">{e.currency}</span>
</div>
<p className="text-xs text-muted-foreground">
Заработано: <span className="text-foreground font-bold">{(e.total_earned / 100).toFixed(2)}</span>
</p>
<p className="text-xs text-muted-foreground">
Выведено: {(e.total_withdrawn / 100).toFixed(2)}
</p>
<p className="text-xs text-muted-foreground">
В обработке: {(e.pending_withdrawal / 100).toFixed(2)}
</p>
<p className="text-sm font-bold text-secondary">
Доступно: {(e.available / 100).toFixed(2)}
</p>
</div>
))}
{(data?.earnings ?? []).length === 0 && !loading && (
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest sm:col-span-2">
Пока нет заработка
</p>
)}
</div>
<form
onSubmit={handleSubmit}
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
>
<h2 className="text-xs font-bold uppercase tracking-widest text-secondary flex items-center gap-2">
<Send className="w-4 h-4" /> Заявка на вывод
</h2>
<div className="flex gap-3">
<select
required
value={currency}
onChange={(e) => setCurrency(e.target.value)}
className="bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
>
<option value="" disabled>
Валюта
</option>
{(data?.earnings ?? []).map((e) => (
<option key={e.currency} value={e.currency}>
{e.currency}
</option>
))}
</select>
<input
required
type="number"
min="0.01"
step="0.01"
placeholder="Сумма"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="flex-1 bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
/>
</div>
<input
placeholder="Комментарий (реквизиты и т.п., необязательно)"
value={note}
onChange={(e) => setNote(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
/>
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="submit"
className="w-full bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
>
Отправить заявку
</button>
</form>
<div className="space-y-3">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
История заявок
</h2>
{(data?.withdrawals ?? []).map((w) => (
<div
key={w.id}
className="bg-black/60 border border-white/5 rounded-xl p-4 flex items-center justify-between"
>
<span className="font-mono text-sm">
{(w.amount / 100).toFixed(2)} {w.currency}
</span>
<span className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
{w.status}
</span>
</div>
))}
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from "react";
import { getPublicConfig } from "./api";
const DASHBOARDS = [
{ uid: "netrunner-backend", title: "Backend" },
{ uid: "netrunner-landing", title: "Лендинг" },
{ uid: "netrunner-proxy-nodes", title: "Прокси-ноды (ядро)" },
] as const;
export default function ObservabilityPage() {
const [grafanaUrl, setGrafanaUrl] = useState<string | null | undefined>(undefined);
const [active, setActive] = useState<(typeof DASHBOARDS)[number]["uid"]>(DASHBOARDS[0].uid);
useEffect(() => {
getPublicConfig().then((cfg) => setGrafanaUrl(cfg.grafana_url));
}, []);
if (grafanaUrl === undefined) {
return null;
}
if (!grafanaUrl) {
return (
<div className="p-6 sm:p-10 max-w-4xl mx-auto">
<p className="text-center py-20 text-muted-foreground text-sm uppercase tracking-widest">
Grafana ещё не настроена (GRAFANA_PUBLIC_URL не задан на сервере).
</p>
</div>
);
}
return (
<div className="p-6 sm:p-10 max-w-6xl mx-auto space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
Мониторинг
</h1>
<div className="flex gap-2 flex-wrap">
{DASHBOARDS.map((d) => (
<button
key={d.uid}
onClick={() => setActive(d.uid)}
className={`px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors ${
active === d.uid
? "bg-primary/10 text-primary border border-primary/30"
: "bg-white/5 text-muted-foreground hover:bg-white/10"
}`}
>
{d.title}
</button>
))}
</div>
</div>
{/* Ключ на iframe меняет DOM-узел при смене дашборда — проще, чем
городить логику перезагрузки src внутри одного и того же iframe. */}
<iframe
key={active}
title={`Grafana: ${active}`}
src={`${grafanaUrl}/d/${active}/?kiosk&theme=dark`}
className="w-full rounded-2xl border border-white/10"
style={{ height: "80vh" }}
/>
</div>
);
}
+6 -1
View File
@@ -25,6 +25,7 @@ import {
activateTrial, activateTrial,
initiatePayment, initiatePayment,
confirmPayment, confirmPayment,
getPublicConfig,
} from "./api"; } from "./api";
// --- ТИПИЗАЦИЯ --- // --- ТИПИЗАЦИЯ ---
@@ -199,12 +200,16 @@ export default function Profile() {
const [lang, setLang] = useState<"en" | "ru">(getLang); const [lang, setLang] = useState<"en" | "ru">(getLang);
const t = DICT[lang]; const t = DICT[lang];
const botUsername = import.meta.env.VITE_BOT_USERNAME || "ntrnr_vpn_bot"; const [botUsername, setBotUsername] = useState("ntrnr_vpn_bot");
// TON Connect Hooks // TON Connect Hooks
const [tonConnectUI] = useTonConnectUI(); const [tonConnectUI] = useTonConnectUI();
const userFriendlyAddress = useTonAddress(); const userFriendlyAddress = useTonAddress();
useEffect(() => {
getPublicConfig().then((cfg) => setBotUsername(cfg.bot_username));
}, []);
useEffect(() => { useEffect(() => {
fetchProfile() fetchProfile()
.then((res: ProfileDataResponse) => setData(res)) .then((res: ProfileDataResponse) => setData(res))
+277
View File
@@ -0,0 +1,277 @@
import { useEffect, useState } from "react";
import { useOutletContext } from "react-router-dom";
import { UserPlus, RefreshCw } from "lucide-react";
import {
createModerator,
createPartner,
createSupport,
fetchStaffUsers,
setNodePermission,
type StaffRole,
type StaffUser,
} from "./api";
export default function StaffPage() {
const role = useOutletContext<StaffRole>();
const [staff, setStaff] = useState<StaffUser[]>([]);
const [loading, setLoading] = useState(true);
const [modUsername, setModUsername] = useState("");
const [modPassword, setModPassword] = useState("");
const [modCanManageNodes, setModCanManageNodes] = useState(false);
const [partnerUsername, setPartnerUsername] = useState("");
const [partnerPassword, setPartnerPassword] = useState("");
const [partnerPercent, setPartnerPercent] = useState("10");
const [supportUsername, setSupportUsername] = useState("");
const [supportPassword, setSupportPassword] = useState("");
const [error, setError] = useState("");
const load = async () => {
setLoading(true);
try {
setStaff(await fetchStaffUsers());
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const handleCreateModerator = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
await createModerator(modUsername, modPassword, modCanManageNodes);
setModUsername("");
setModPassword("");
setModCanManageNodes(false);
await load();
} catch {
setError("Не удалось создать модератора (логин уже занят?).");
}
};
const handleCreatePartner = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
await createPartner(partnerUsername, partnerPassword, Number(partnerPercent));
setPartnerUsername("");
setPartnerPassword("");
setPartnerPercent("10");
await load();
} catch {
setError("Не удалось создать партнёра (логин уже занят?).");
}
};
const handleCreateSupport = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
await createSupport(supportUsername, supportPassword);
setSupportUsername("");
setSupportPassword("");
await load();
} catch {
setError("Не удалось создать аккаунт саппорта (логин уже занят?).");
}
};
const handleTogglePermission = async (userId: string, current: boolean) => {
try {
await setNodePermission(userId, !current);
await load();
} catch (e) {
console.error(e);
}
};
return (
<div className="p-6 sm:p-10 max-w-5xl mx-auto space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
Staff
</h1>
<button
onClick={load}
className="p-2 bg-white/5 hover:bg-white/10 rounded-full transition-colors"
>
<RefreshCw className={`w-4 h-4 text-secondary ${loading ? "animate-spin" : ""}`} />
</button>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{role.role === "owner" && (
<form
onSubmit={handleCreateModerator}
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
>
<h2 className="text-xs font-bold uppercase tracking-widest text-secondary flex items-center gap-2">
<UserPlus className="w-4 h-4" /> Новый модератор
</h2>
<input
required
placeholder="Логин"
value={modUsername}
onChange={(e) => setModUsername(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
/>
<input
required
type="password"
placeholder="Пароль"
value={modPassword}
onChange={(e) => setModPassword(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
/>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={modCanManageNodes}
onChange={(e) => setModCanManageNodes(e.target.checked)}
className="accent-secondary"
/>
Право управлять нодами
</label>
<button
type="submit"
className="w-full bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
>
Создать
</button>
</form>
)}
{role.role === "owner" && (
<form
onSubmit={handleCreateSupport}
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
>
<h2 className="text-xs font-bold uppercase tracking-widest text-secondary flex items-center gap-2">
<UserPlus className="w-4 h-4" /> Новый саппорт
</h2>
<input
required
placeholder="Логин"
value={supportUsername}
onChange={(e) => setSupportUsername(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
/>
<input
required
type="password"
placeholder="Пароль"
value={supportPassword}
onChange={(e) => setSupportPassword(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
/>
<p className="text-[10px] text-muted-foreground">
Видит только раздел "Тикеты" доступа к нодам/staff/выводам нет.
</p>
<button
type="submit"
className="w-full bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
>
Создать
</button>
</form>
)}
<form
onSubmit={handleCreatePartner}
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
>
<h2 className="text-xs font-bold uppercase tracking-widest text-primary flex items-center gap-2">
<UserPlus className="w-4 h-4" /> Новый партнёр
</h2>
<input
required
placeholder="Логин"
value={partnerUsername}
onChange={(e) => setPartnerUsername(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
/>
<input
required
type="password"
placeholder="Пароль"
value={partnerPassword}
onChange={(e) => setPartnerPassword(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
/>
<div className="flex items-center gap-2">
<input
required
type="number"
min="0"
max="100"
step="0.01"
value={partnerPercent}
onChange={(e) => setPartnerPercent(e.target.value)}
className="w-24 bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
/>
<span className="text-xs text-muted-foreground">% с продаж</span>
</div>
<button
type="submit"
className="w-full bg-primary/10 hover:bg-primary/20 text-primary border border-primary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
>
Создать
</button>
</form>
</div>
<div className="space-y-3">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Список staff
</h2>
{staff.map((u) => (
<div
key={u.id}
className="bg-black/60 border border-white/5 rounded-xl p-4 flex items-center justify-between gap-4"
>
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-sm">{u.username}</span>
<span className="text-[10px] bg-white/10 px-2 py-0.5 rounded uppercase text-muted-foreground">
{u.role}
</span>
</div>
{u.role === "partner" && (
<p className="text-xs text-muted-foreground font-mono mt-1">
{u.commission_percent}% · код: {u.partner_code}
</p>
)}
</div>
{role.role === "owner" && u.role === "moderator" && (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={u.can_manage_nodes}
onChange={() => handleTogglePermission(u.id, u.can_manage_nodes)}
className="accent-secondary"
/>
Ноды
</label>
)}
</div>
))}
{staff.length === 0 && !loading && (
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest">
Пока никого нет
</p>
)}
</div>
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
import { useEffect, useState } from "react";
import { RefreshCw, Send, CheckCircle2, MessageSquare } from "lucide-react";
import {
fetchTickets,
fetchTicket,
replyToTicket,
closeTicket,
type SupportTicket,
type SupportTicketSummary,
type SupportMessage,
} from "./api";
type StatusFilter = "open" | "closed";
export default function TicketsPage() {
const [filter, setFilter] = useState<StatusFilter>("open");
const [list, setList] = useState<SupportTicketSummary[]>([]);
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [ticket, setTicket] = useState<SupportTicket | null>(null);
const [messages, setMessages] = useState<SupportMessage[]>([]);
const [replyText, setReplyText] = useState("");
const [busy, setBusy] = useState(false);
const loadList = async () => {
setLoading(true);
try {
setList(await fetchTickets(filter));
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadList();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter]);
const loadThread = async (id: string) => {
setSelectedId(id);
try {
const { ticket, messages } = await fetchTicket(id);
setTicket(ticket);
setMessages(messages);
} catch (e) {
console.error(e);
}
};
const handleReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedId || !replyText.trim()) return;
setBusy(true);
try {
await replyToTicket(selectedId, replyText);
setReplyText("");
await loadThread(selectedId);
await loadList();
} catch (e) {
console.error(e);
} finally {
setBusy(false);
}
};
const handleClose = async () => {
if (!selectedId) return;
setBusy(true);
try {
await closeTicket(selectedId);
setSelectedId(null);
setTicket(null);
setMessages([]);
await loadList();
} catch (e) {
console.error(e);
} finally {
setBusy(false);
}
};
return (
<div className="p-6 sm:p-10 max-w-6xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
Тикеты
</h1>
<button
onClick={loadList}
className="p-2 bg-white/5 hover:bg-white/10 rounded-full transition-colors"
>
<RefreshCw className={`w-4 h-4 text-secondary ${loading ? "animate-spin" : ""}`} />
</button>
</div>
<div className="flex gap-2">
{(["open", "closed"] as StatusFilter[]).map((s) => (
<button
key={s}
onClick={() => {
setFilter(s);
setSelectedId(null);
setTicket(null);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors ${
filter === s
? "bg-primary/10 text-primary border border-primary/30"
: "bg-white/5 text-muted-foreground hover:bg-white/10"
}`}
>
{s === "open" ? "Открытые" : "Закрытые"}
</button>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)] gap-6">
<div className="space-y-3">
{list.map((t) => (
<button
key={t.id}
onClick={() => loadThread(t.id)}
className={`w-full text-left bg-black/60 border rounded-xl p-4 transition-colors ${
selectedId === t.id
? "border-primary/40"
: "border-white/5 hover:border-white/10"
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-bold text-sm">
@{t.tg_username || t.tg_id || "аноним"}
</span>
<span className="text-[10px] text-muted-foreground/60 font-mono">
{new Date(t.updated_at).toLocaleString()}
</span>
</div>
{t.last_message_preview && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{t.last_message_preview}
</p>
)}
<p className="text-[10px] text-muted-foreground/60 mt-1">
{t.message_count} сообщ.
</p>
</button>
))}
{list.length === 0 && !loading && (
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest">
Тикетов нет
</p>
)}
</div>
<div className="bg-black/40 border border-white/10 rounded-2xl p-6 flex flex-col min-h-[400px]">
{!ticket ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
<MessageSquare className="w-5 h-5 mr-2" /> Выберите тикет слева
</div>
) : (
<>
<div className="flex items-center justify-between mb-4">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Тикет {ticket.status === "open" ? "открыт" : "закрыт"}
</span>
{ticket.status === "open" && (
<button
onClick={handleClose}
disabled={busy}
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 hover:bg-green-500/20 text-green-500 border border-green-500/20 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors disabled:opacity-40"
>
<CheckCircle2 className="w-3.5 h-3.5" /> Закрыть
</button>
)}
</div>
<div className="flex-1 space-y-3 overflow-y-auto max-h-[420px] pr-1">
{messages.map((m) => (
<div
key={m.id}
className={`max-w-[85%] rounded-xl p-3 text-sm ${
m.sender_role === "staff"
? "ml-auto bg-primary/10 border border-primary/20"
: "bg-white/5 border border-white/10"
}`}
>
<p className="whitespace-pre-wrap">{m.body}</p>
<p className="text-[10px] text-muted-foreground/60 font-mono mt-1">
{new Date(m.created_at).toLocaleString()}
</p>
</div>
))}
</div>
{ticket.status === "open" && (
<form onSubmit={handleReply} className="mt-4 flex items-end gap-2">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Ответ..."
rows={2}
className="flex-1 bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary resize-none"
/>
<button
type="submit"
disabled={busy || !replyText.trim()}
className="p-2.5 bg-primary/10 hover:bg-primary/20 text-primary border border-primary/30 rounded-lg transition-colors disabled:opacity-40"
>
<Send className="w-4 h-4" />
</button>
</form>
)}
</>
)}
</div>
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import { RefreshCw, Check, X, Banknote } from "lucide-react";
import { fetchWithdrawals, updateWithdrawal, type WithdrawalRequest } from "./api";
const STATUS_COLOR: Record<string, string> = {
pending: "text-yellow-500",
approved: "text-secondary",
paid: "text-green-500",
rejected: "text-red-500",
};
export default function WithdrawalsPage() {
const [list, setList] = useState<WithdrawalRequest[]>([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const load = async () => {
setLoading(true);
try {
setList(await fetchWithdrawals());
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const handleUpdate = async (id: string, status: "approved" | "rejected" | "paid") => {
setBusyId(id);
try {
await updateWithdrawal(id, status);
await load();
} catch (e) {
console.error(e);
} finally {
setBusyId(null);
}
};
return (
<div className="p-6 sm:p-10 max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
Заявки на вывод
</h1>
<button
onClick={load}
className="p-2 bg-white/5 hover:bg-white/10 rounded-full transition-colors"
>
<RefreshCw className={`w-4 h-4 text-secondary ${loading ? "animate-spin" : ""}`} />
</button>
</div>
<div className="space-y-3">
{list.map((w) => (
<div
key={w.id}
className="bg-black/60 border border-white/5 rounded-xl p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3"
>
<div>
<div className="flex items-center gap-2">
<Banknote className="w-4 h-4 text-muted-foreground" />
<span className="font-bold font-mono">
{(w.amount / 100).toFixed(2)} {w.currency}
</span>
<span className={`text-[10px] uppercase tracking-widest font-bold ${STATUS_COLOR[w.status]}`}>
{w.status}
</span>
</div>
{w.note && (
<p className="text-xs text-muted-foreground mt-1">{w.note}</p>
)}
<p className="text-[10px] text-muted-foreground/60 font-mono mt-1">
{new Date(w.created_at).toLocaleString()}
</p>
</div>
{w.status === "pending" && (
<div className="flex items-center gap-2">
<button
disabled={busyId === w.id}
onClick={() => handleUpdate(w.id, "approved")}
className="p-2 bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/20 rounded-lg transition-colors disabled:opacity-40"
title="Одобрить"
>
<Check className="w-4 h-4" />
</button>
<button
disabled={busyId === w.id}
onClick={() => handleUpdate(w.id, "rejected")}
className="p-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 rounded-lg transition-colors disabled:opacity-40"
title="Отклонить"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{w.status === "approved" && (
<button
disabled={busyId === w.id}
onClick={() => handleUpdate(w.id, "paid")}
className="px-3 py-2 bg-green-500/10 hover:bg-green-500/20 text-green-500 border border-green-500/20 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors disabled:opacity-40"
>
Отметить выплаченным
</button>
)}
</div>
))}
{list.length === 0 && !loading && (
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest">
Заявок нет
</p>
)}
</div>
</div>
);
}
+247 -1
View File
@@ -1,5 +1,31 @@
export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1"; export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
interface PublicConfig {
bot_username: string;
/// URL Grafana за auth-гейтом Caddy — `null`, пока GRAFANA_PUBLIC_URL не
/// задан на сервере (см. ObservabilityPage.tsx).
grafana_url: string | null;
}
let configPromise: Promise<PublicConfig> | null = null;
/// Юзернейм бота — намеренно не `import.meta.env.VITE_BOT_USERNAME`: этот SPA
/// собирается один раз в CI, и один и тот же статический бандл раздаётся и
/// прод-, и dev-стендом — build-time значение зашилось бы в файлы намертво и
/// не различалось бы между стендами (ровно так и вышло на практике: dev видел
/// прод-бота, у которого в BotFather другой домен — виджет логина отвечал
/// "Bot domain invalid"). `/api/v1/config` читает env на сервере заново на
/// каждый запрос, поэтому корректно отдаёт "свой" бот (и Grafana URL) для
/// каждого стенда.
export function getPublicConfig(): Promise<PublicConfig> {
if (!configPromise) {
configPromise = fetch(`${API_BASE}/config`)
.then((res) => res.json())
.catch(() => ({ bot_username: "ntrnr_vpn_bot", grafana_url: null }));
}
return configPromise;
}
// Сессия живёт только в HttpOnly cookie, которую ставит сервер — JS её не видит // Сессия живёт только в HttpOnly cookie, которую ставит сервер — JS её не видит
// и не должен: раньше здесь читался `document.cookie` в расчёте на токен, // и не должен: раньше здесь читался `document.cookie` в расчёте на токен,
// который сервер ставит как HttpOnly, поэтому чтение всегда возвращало null, а // который сервер ставит как HttpOnly, поэтому чтение всегда возвращало null, а
@@ -35,6 +61,13 @@ function toRequestInit(options: RequestInit): RequestInit {
/// Единая обёртка над fetch для авторизованных запросов: всегда с cookie, /// Единая обёртка над fetch для авторизованных запросов: всегда с cookie,
/// при первом 401 пробует silent-refresh и повторяет запрос один раз, при /// при первом 401 пробует silent-refresh и повторяет запрос один раз, при
/// провале — чистит сторону клиента и уводит на экран логина. /// провале — чистит сторону клиента и уводит на экран логина.
///
/// Редирект пропускается, если мы и так уже на `/` (экран логина): иначе
/// `window.location.href = "/"` там же, где мы уже находимся, всё равно
/// вызывает полную перезагрузку страницы — React-приложение перемонтируется,
/// Auth.tsx заново дёргает тот же `/users/me`, получает тот же 401 (для
/// анонимного визита это нормальное, ожидаемое состояние, а не сбой) — и
/// уходит в бесконечный цикл перезагрузок.
export async function apiFetch( export async function apiFetch(
path: string, path: string,
options: RequestInit = {}, options: RequestInit = {},
@@ -46,7 +79,13 @@ export async function apiFetch(
const refreshed = await refreshSession(); const refreshed = await refreshSession();
if (!refreshed) { if (!refreshed) {
window.location.href = "/"; // Админка (Owner/Moderator/Partner) логинится отдельно от обычных
// Ghost/Telegram-юзеров — редирект на "/" был бы неверным экраном для неё.
const isAdminPath = window.location.pathname.startsWith("/admin");
const loginPath = isAdminPath ? "/admin/login" : "/";
if (window.location.pathname !== loginPath) {
window.location.href = loginPath;
}
return res; return res;
} }
@@ -134,6 +173,213 @@ export const exchangeBootstrapToken = async (token: string): Promise<boolean> =>
return res.ok; return res.ok;
}; };
// --- Ролевая админка (Owner/Moderator/Partner) ---
export const adminLogin = async (username: string, password: string) => {
const res = await fetch(`${API_BASE}/auth/admin/login`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return parseOrThrow(res, "Login failed");
};
export interface StaffRole {
role: "owner" | "moderator" | "partner" | "support" | "user" | "admin";
can_manage_nodes: boolean;
username: string | null;
partner_code: string | null;
commission_percent: string | null;
}
export const fetchMyRole = async (): Promise<StaffRole> => {
const res = await apiFetch("/admin/staff/me");
return parseOrThrow(res, "Failed to fetch role");
};
export const createModerator = async (
username: string,
password: string,
canManageNodes: boolean,
) => {
const res = await apiFetch("/admin/staff/moderators", {
method: "POST",
body: JSON.stringify({
username,
password,
can_manage_nodes: canManageNodes,
}),
});
return parseOrThrow(res, "Failed to create moderator");
};
export const createSupport = async (username: string, password: string) => {
const res = await apiFetch("/admin/staff/support", {
method: "POST",
body: JSON.stringify({ username, password }),
});
return parseOrThrow(res, "Failed to create support account");
};
export const createPartner = async (
username: string,
password: string,
commissionPercent: number,
) => {
const res = await apiFetch("/admin/staff/partners", {
method: "POST",
body: JSON.stringify({
username,
password,
commission_percent: commissionPercent,
}),
});
return parseOrThrow(res, "Failed to create partner");
};
export interface StaffUser {
id: string;
username: string | null;
role: string;
can_manage_nodes: boolean;
commission_percent: string | null;
partner_code: string | null;
}
export const fetchStaffUsers = async (): Promise<StaffUser[]> => {
const res = await apiFetch("/admin/staff/staff-users");
return parseOrThrow(res, "Failed to fetch staff users");
};
export const setNodePermission = async (userId: string, canManageNodes: boolean) => {
const res = await apiFetch(`/admin/staff/users/${userId}/permissions`, {
method: "PATCH",
body: JSON.stringify({ can_manage_nodes: canManageNodes }),
});
return parseOrThrow(res, "Failed to update permissions");
};
export interface WithdrawalRequest {
id: string;
partner_id: string;
currency: string;
amount: number;
status: "pending" | "approved" | "rejected" | "paid";
note: string | null;
created_at: string;
processed_at: string | null;
processed_by: string | null;
}
export const fetchWithdrawals = async (): Promise<WithdrawalRequest[]> => {
const res = await apiFetch("/admin/staff/withdrawals");
return parseOrThrow(res, "Failed to fetch withdrawals");
};
export const updateWithdrawal = async (
id: string,
status: "approved" | "rejected" | "paid",
) => {
const res = await apiFetch(`/admin/staff/withdrawals/${id}`, {
method: "PATCH",
body: JSON.stringify({ status }),
});
return parseOrThrow(res, "Failed to update withdrawal");
};
export interface PartnerEarnings {
currency: string;
total_earned: number;
total_withdrawn: number;
pending_withdrawal: number;
available: number;
}
export interface MyEarnings {
earnings: PartnerEarnings[];
withdrawals: WithdrawalRequest[];
partner_code: string | null;
}
export const fetchMyEarnings = async (): Promise<MyEarnings> => {
const res = await apiFetch("/admin/partners/earnings");
return parseOrThrow(res, "Failed to fetch earnings");
};
export const requestWithdrawal = async (
currency: string,
amount: number,
note?: string,
) => {
const res = await apiFetch("/admin/partners/withdrawals", {
method: "POST",
body: JSON.stringify({ currency, amount, note }),
});
return parseOrThrow(res, "Failed to submit withdrawal request");
};
// --- Тикеты техподдержки (Owner/Moderator/Support) ---
export interface SupportTicketSummary {
id: string;
user_id: string;
tg_id: number | null;
tg_username: string | null;
status: "open" | "closed";
message_count: number;
last_message_preview: string | null;
created_at: string;
updated_at: string;
}
export interface SupportTicket {
id: string;
user_id: string;
status: "open" | "closed";
assigned_to: string | null;
created_at: string;
updated_at: string;
}
export interface SupportMessage {
id: string;
ticket_id: string;
sender_role: "user" | "staff";
sender_user_id: string | null;
body: string;
created_at: string;
}
export const fetchTickets = async (
status: "open" | "closed" | "all" = "open",
): Promise<SupportTicketSummary[]> => {
const res = await apiFetch(`/admin/support/tickets?status=${status}`);
return parseOrThrow(res, "Failed to fetch tickets");
};
export const fetchTicket = async (
id: string,
): Promise<{ ticket: SupportTicket; messages: SupportMessage[] }> => {
const res = await apiFetch(`/admin/support/tickets/${id}`);
return parseOrThrow(res, "Failed to fetch ticket");
};
export const replyToTicket = async (id: string, text: string) => {
const res = await apiFetch(`/admin/support/tickets/${id}/reply`, {
method: "POST",
body: JSON.stringify({ text }),
});
return parseOrThrow(res, "Failed to send reply");
};
export const closeTicket = async (id: string) => {
const res = await apiFetch(`/admin/support/tickets/${id}/close`, {
method: "PATCH",
});
return parseOrThrow(res, "Failed to close ticket");
};
export const logout = async () => { export const logout = async () => {
await fetch(`${API_BASE}/auth/logout`, { await fetch(`${API_BASE}/auth/logout`, {
method: "POST", method: "POST",
-17
View File
@@ -1,17 +0,0 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
version: 1
editable: true
- name: Loki
type: loki
access: proxy
url: http://loki:3100
version: 1
editable: true
-37
View File
@@ -1,37 +0,0 @@
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9096
common:
instance_addr: 127.0.0.1
path_prefix: /tmp/loki
storage:
filesystem:
chunks_directory: /tmp/loki/chunks
rules_directory: /tmp/loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
query_range:
results_cache:
cache:
embedded_cache:
enabled: true
max_size_mb: 100
schema_config:
configs:
- from: 2020-10-24
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
ruler:
alertmanager_url: http://localhost:9093
+35
View File
@@ -0,0 +1,35 @@
-- =====================================================================
-- ДИНАМИЧЕСКИЕ ЛИМИТЫ ТРАФИКА ПРОКСИ
-- =====================================================================
-- data_limit_bytes = NULL означает безлимит. Лимит читается прокси-сервером
-- на каждой проверке (POST /api/v1/internal/validate) и на каждом отчёте о
-- расходе (POST /api/v1/internal/usage) — админ меняет его в любой момент
-- через PATCH /api/v1/admin/users/{id}/limit без перезапуска прокси.
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_limit_bytes BIGINT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_used_bytes BIGINT NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_period_reset_at TIMESTAMPTZ;
-- Дефолтный лимит тарифа — проставляется юзеру в data_limit_bytes при покупке
-- подписки (см. AppRepository::buy_subscription), чтобы не заводить отдельный
-- админ-флоу на каждую покупку. NULL = тариф без предустановленного лимита.
ALTER TABLE plans ADD COLUMN IF NOT EXISTS default_limit_bytes BIGINT;
-- =====================================================================
-- MAGIC-LINK ВХОД ЧЕРЕЗ TELEGRAM-БОТА ДЛЯ ДЕСКТОП/МОБИЛЬНОГО ПРИЛОЖЕНИЯ
-- =====================================================================
-- auth_sessions была создана в 0001_init.sql, но 0002_refresh_sessions.sql
-- (репурпоз черновика Magic Link под refresh-токены) её DROP'нул и не
-- пересоздал — на любой БД, накатываемой с нуля (CI, sqlx::test, новый
-- инстанс), следующий ALTER TABLE падал на несуществующей таблице. CREATE
-- TABLE IF NOT EXISTS чинит fresh-инсталл; ALTER/CREATE INDEX ниже остаются
-- IF NOT EXISTS на случай окружений, где таблица уже существует в старом виде.
CREATE TABLE IF NOT EXISTS auth_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
auth_code VARCHAR(8) NOT NULL UNIQUE,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE auth_sessions ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
CREATE INDEX IF NOT EXISTS idx_auth_sessions_code ON auth_sessions(auth_code);
+68
View File
@@ -0,0 +1,68 @@
-- =====================================================================
-- РОЛЕВАЯ АДМИНКА: Owner / Moderator / Partner
-- =====================================================================
-- Раньше единственным способом получить админ-доступ была привязка к
-- ADMIN_TG_ID (см. AuthGuard::admin_guard) — один захардкоженный Telegram-
-- аккаунт на весь деплой. Теперь роль полноценно хранится в БД, у
-- Owner/Moderator/Partner есть логин+пароль (не Telegram/seed), а
-- ADMIN_TG_ID выпиливается из кода вместе с этой миграцией.
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
ALTER TABLE users
ADD CONSTRAINT users_role_check
CHECK (role IN ('user', 'owner', 'moderator', 'partner', 'admin'));
-- 'admin' оставлен в допустимых значениях только ради обратной
-- совместимости с уже существующими строками — новый код его не
-- присваивает нигде.
ALTER TABLE users ADD COLUMN IF NOT EXISTS username VARCHAR(50) UNIQUE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT;
-- Право модератора управлять нодами — переключается Owner'ом поштучно,
-- а не зашито в саму роль (см. AuthGuard::node_manage_guard).
ALTER TABLE users ADD COLUMN IF NOT EXISTS can_manage_nodes BOOLEAN NOT NULL DEFAULT FALSE;
-- Фиксированный процент партнёра с продаж приведённых им покупателей.
ALTER TABLE users ADD COLUMN IF NOT EXISTS commission_percent NUMERIC(5,2);
-- Короткий код для диплинка t.me/<bot>?start=<partner_code> — префикс "p_"
-- нужен боту, чтобы однозначно отличать партнёрский код от numeric
-- referrer-tg_id и от auth_sessions.auth_code (см. bot.rs::Command::Start).
ALTER TABLE users ADD COLUMN IF NOT EXISTS partner_code VARCHAR(20) UNIQUE;
-- Постоянная привязка покупателя к партнёру, выставляется один раз при
-- регистрации через диплинк (first-wins, см. set_referred_by_partner).
ALTER TABLE users ADD COLUMN IF NOT EXISTS referred_by_partner_id UUID REFERENCES users(id);
CREATE INDEX IF NOT EXISTS idx_users_referred_by_partner ON users(referred_by_partner_id)
WHERE referred_by_partner_id IS NOT NULL;
-- Леджер начислений — по одной записи на оплаченный инвойс приведённого
-- покупателя, а не бегущий итог на одной строке (нужна история для вывода
-- средств). Валюта — валюта самого инвойса, без конвертации (см. BillingService::confirm_payment).
CREATE TABLE IF NOT EXISTS partner_commissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
currency VARCHAR(10) NOT NULL,
amount BIGINT NOT NULL,
percent_at_time NUMERIC(5,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(invoice_id) -- защита от повторного начисления при retry confirm_payment
);
CREATE INDEX IF NOT EXISTS idx_partner_commissions_partner_currency
ON partner_commissions(partner_id, currency);
CREATE TABLE IF NOT EXISTS withdrawal_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
currency VARCHAR(10) NOT NULL,
amount BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected', 'paid')),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ,
processed_by UUID REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_withdrawal_requests_partner_status
ON withdrawal_requests(partner_id, status);
CREATE INDEX IF NOT EXISTS idx_withdrawal_requests_status ON withdrawal_requests(status);
+41
View File
@@ -0,0 +1,41 @@
-- =====================================================================
-- ТЕХПОДДЕРЖКА: роль Support + тикеты через Telegram-бота
-- =====================================================================
-- Единственный канал связи с поддержкой — бот (см. src/bot.rs). Кнопка
-- "Поддержка" переводит юзера в разовый режим ожидания сообщения
-- (awaiting_support_message), сбрасывается сразу после получения текста —
-- случайное сообщение мимо кнопки тикет не создаёт.
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
ALTER TABLE users
ADD CONSTRAINT users_role_check
CHECK (role IN ('user', 'owner', 'moderator', 'partner', 'support', 'admin'));
ALTER TABLE users ADD COLUMN IF NOT EXISTS awaiting_support_message BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS support_tickets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),
-- Информационно: кто последним ответил. Не enforced-назначение — любой
-- staff/support видит и отвечает на любой тикет.
assigned_to UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status, updated_at DESC);
-- Быстро найти "есть ли уже открытый тикет у юзера" — частичный индекс,
-- т.к. это единственный поиск по user_id, который реально нужен в горячем пути.
CREATE INDEX IF NOT EXISTS idx_support_tickets_user_open ON support_tickets(user_id)
WHERE status = 'open';
CREATE TABLE IF NOT EXISTS support_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
sender_role VARCHAR(10) NOT NULL CHECK (sender_role IN ('user', 'staff')),
sender_user_id UUID REFERENCES users(id),
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_support_messages_ticket ON support_messages(ticket_id, created_at);
+74
View File
@@ -0,0 +1,74 @@
-- Расширение регионального прайса: UAH/BYN/VND/UZS — по аналогии с уже
-- заведёнными USD/RUB/CNY/TRY/IRR (см. 0001_init.sql). Валюты выбраны по
-- уже сделанным в этой сессии локалям (uk/be/vi/uz).
--
-- Курс и скидка (~45% от чистой рыночной конвертации, тот же порядок, что
-- уже неявно заложен в RUB/CNY/TRY) — ПРИКИДКА для старта, не сверено с
-- реальными локальными конкурентами по каждому рынку. Легко поправить —
-- это просто UPDATE по этой же таблице, менять код не нужно.
--
-- VND/UZS — целыми единицами (без копеек/копий), та же логика, что уже у
-- IRR: у донга и сума нет ходовой разменной монеты, дробные суммы не имеют
-- смысла для конечного пользователя.
INSERT INTO plan_prices (plan_name, currency, amount) VALUES
-- =================================================================
-- УКРАИНА (UAH)
-- =================================================================
('GHOST_1M', 'UAH', 11900), -- 119.00 ₴
('GHOST_6M', 'UAH', 59900), -- 599.00 ₴
('GHOST_12M', 'UAH', 89900), -- 899.00 ₴
('NETRUNNER_1M', 'UAH', 21900), -- 219.00 ₴
('NETRUNNER_6M', 'UAH', 109900), -- 1 099.00 ₴
('NETRUNNER_12M', 'UAH', 149900), -- 1 499.00 ₴
('MAINFRAME_1M', 'UAH', 49900), -- 499.00 ₴
('MAINFRAME_6M', 'UAH', 229900), -- 2 299.00 ₴
('MAINFRAME_12M', 'UAH', 349900), -- 3 499.00 ₴
-- =================================================================
-- БЕЛАРУСЬ (BYN)
-- =================================================================
('GHOST_1M', 'BYN', 900), -- 9.00 Br
('GHOST_6M', 'BYN', 4400), -- 44.00 Br
('GHOST_12M', 'BYN', 6500), -- 65.00 Br
('NETRUNNER_1M', 'BYN', 1650), -- 16.50 Br
('NETRUNNER_6M', 'BYN', 8200), -- 82.00 Br
('NETRUNNER_12M', 'BYN', 10900), -- 109.00 Br
('MAINFRAME_1M', 'BYN', 3600), -- 36.00 Br
('MAINFRAME_6M', 'BYN', 17500), -- 175.00 Br
('MAINFRAME_12M', 'BYN', 26000), -- 260.00 Br
-- =================================================================
-- ВЬЕТНАМ (VND) — целыми донгами, без копеек (см. заголовок)
-- =================================================================
('GHOST_1M', 'VND', 69000), -- 69 000 ₫
('GHOST_6M', 'VND', 329000), -- 329 000 ₫
('GHOST_12M', 'VND', 499000), -- 499 000 ₫
('NETRUNNER_1M', 'VND', 125000), -- 125 000 ₫
('NETRUNNER_6M', 'VND', 619000), -- 619 000 ₫
('NETRUNNER_12M', 'VND', 829000), -- 829 000 ₫
('MAINFRAME_1M', 'VND', 279000), -- 279 000 ₫
('MAINFRAME_6M', 'VND', 1329000), -- 1 329 000 ₫
('MAINFRAME_12M', 'VND', 1999000), -- 1 999 000 ₫
-- =================================================================
-- УЗБЕКИСТАН (UZS) — целыми сумами, без копеек (см. заголовок)
-- =================================================================
('GHOST_1M', 'UZS', 35000), -- 35 000 сум
('GHOST_6M', 'UZS', 169000), -- 169 000 сум
('GHOST_12M', 'UZS', 249000), -- 249 000 сум
('NETRUNNER_1M', 'UZS', 63000), -- 63 000 сум
('NETRUNNER_6M', 'UZS', 319000), -- 319 000 сум
('NETRUNNER_12M', 'UZS', 419000), -- 419 000 сум
('MAINFRAME_1M', 'UZS', 139000), -- 139 000 сум
('MAINFRAME_6M', 'UZS', 669000), -- 669 000 сум
('MAINFRAME_12M', 'UZS', 999000) -- 999 000 сум
ON CONFLICT (plan_name, currency) DO UPDATE
SET amount = EXCLUDED.amount;
+22
View File
@@ -0,0 +1,22 @@
-- Старая IRR-цена (из 0001_init.sql) была рассчитана под курс риала в разы
-- крепче нынешнего — за прошедшее время из-за девальвации превратилась из
-- скидки в переплату (иранский пользователь платил на 65-186% БОЛЬШЕ, чем
-- американский, в реальном долларовом эквиваленте — см. обсуждение сессии).
--
-- Пересчитано по актуальному рыночному курсу (~1 820 500 ﷼/$, свободный
-- рынок, не официальный) с той же целевой скидкой ~55%, что и у остальных
-- региональных валют (RUB/CNY/TRY/UAH/BYN/VND/UZS — все в диапазоне 42-65%
-- от номинала). Получилась чистая формула: цена в риалах = цена в долларах
-- × 1 000 000 — легко проверить и поддерживать вручную при следующей
-- девальвации, не нужно каждый раз лезть в код.
UPDATE plan_prices SET amount = 5000000 WHERE plan_name = 'GHOST_1M' AND currency = 'IRR';
UPDATE plan_prices SET amount = 24000000 WHERE plan_name = 'GHOST_6M' AND currency = 'IRR';
UPDATE plan_prices SET amount = 36000000 WHERE plan_name = 'GHOST_12M' AND currency = 'IRR';
UPDATE plan_prices SET amount = 9000000 WHERE plan_name = 'NETRUNNER_1M' AND currency = 'IRR';
UPDATE plan_prices SET amount = 45000000 WHERE plan_name = 'NETRUNNER_6M' AND currency = 'IRR';
UPDATE plan_prices SET amount = 60000000 WHERE plan_name = 'NETRUNNER_12M' AND currency = 'IRR';
UPDATE plan_prices SET amount = 20000000 WHERE plan_name = 'MAINFRAME_1M' AND currency = 'IRR';
UPDATE plan_prices SET amount = 96000000 WHERE plan_name = 'MAINFRAME_6M' AND currency = 'IRR';
UPDATE plan_prices SET amount = 144000000 WHERE plan_name = 'MAINFRAME_12M' AND currency = 'IRR';
+17
View File
@@ -0,0 +1,17 @@
# Часть 1/2 — включается всегда, с самого первого деплоя (см.
# .gitea/workflows/deploy-nginx.yml). Обслуживает ACME-challenge для certbot
# и редиректит всё остальное на HTTPS. account-ssl.conf (сам бэкенд) деплой
# включает отдельно, только когда сертификат уже существует.
server {
listen 80;
listen [::]:80;
server_name account.netrunner-vpn.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
+26
View File
@@ -0,0 +1,26 @@
# Часть 2/2 — деплой (.gitea/workflows/deploy-nginx.yml) включает этот файл
# ТОЛЬКО когда на сервере уже есть сертификат
# /etc/letsencrypt/live/account.netrunner-vpn.com/ (иначе nginx -t упадёт).
#
# Реальный IP посетителя восстанавливается из CF-Connecting-IP —
# см. /etc/nginx/conf.d/cloudflare-real-ip.conf на самом сервере (общий для
# всех доменов на этом VPS, не часть этого репозитория) — без него
# rate-limiting (tower_governor) считал бы все запросы приходящими с одного
# IP Cloudflare, а не реальных клиентов.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name account.netrunner-vpn.com;
ssl_certificate /etc/letsencrypt/live/account.netrunner-vpn.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/account.netrunner-vpn.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-7
View File
@@ -1,7 +0,0 @@
global:
scrape_interval: 15s # Как часто опрашивать бэкенд
scrape_configs:
- job_name: "netrunner-backend"
static_configs:
- targets: ["app:8080"] # Адрес твоего Rust-бэкенда
+5 -1
View File
@@ -1,3 +1,7 @@
# Тонкий promtail — шлёт логи ЭТОГО хоста (app/migrate/redis) в центральный
# Loki на VPS с данными (см. netrunner-data/docker-compose.observability.yml).
# LOKI_PUSH_URL — публичный адрес того VPS, порт 3100, обязательно
# зафайрволенный там на IP этого хоста (см. шапку docker-compose.observability.yml).
server: server:
http_listen_port: 9080 http_listen_port: 9080
grpc_listen_port: 0 grpc_listen_port: 0
@@ -6,7 +10,7 @@ positions:
filename: /tmp/positions.yaml filename: /tmp/positions.yaml
clients: clients:
- url: http://loki:3100/loki/api/v1/push - url: ${LOKI_PUSH_URL}/loki/api/v1/push
scrape_configs: scrape_configs:
- job_name: docker_service_logs - job_name: docker_service_logs
+81 -5
View File
@@ -17,7 +17,9 @@ use axum::{
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder}; use metrics_exporter_prometheus::{Matcher, PrometheusBuilder};
use serde_json::json; use serde_json::json;
use std::{sync::Arc, time::Instant}; use std::{sync::Arc, time::Instant};
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer}; use tower_governor::{
governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer,
};
use tower_http::{ use tower_http::{
cors::CorsLayer, cors::CorsLayer,
services::{ServeDir, ServeFile}, services::{ServeDir, ServeFile},
@@ -28,8 +30,12 @@ use crate::db::repository::AppRepository;
use crate::modules::{ use crate::modules::{
auth::{controller as auth_ctrl, service::AuthService}, auth::{controller as auth_ctrl, service::AuthService},
billing::{controller as bill_ctrl, service::BillingService}, billing::{controller as bill_ctrl, service::BillingService},
geoip::service::GeoipService,
nodes::{controller as node_ctrl, service::NodeService}, nodes::{controller as node_ctrl, service::NodeService},
proxy_internal,
referrals::{controller as ref_ctrl, service::ReferralService}, referrals::{controller as ref_ctrl, service::ReferralService},
staff::{controller as staff_ctrl, service::StaffService},
support::{controller as support_ctrl, service::SupportService},
users::{controller as user_ctrl, service::UserService}, users::{controller as user_ctrl, service::UserService},
}; };
@@ -40,9 +46,24 @@ pub struct ApiState {
pub node_service: NodeService, pub node_service: NodeService,
pub referral_service: ReferralService, pub referral_service: ReferralService,
pub user_service: UserService, pub user_service: UserService,
pub staff_service: StaffService,
pub support_service: SupportService,
/// Страна-по-IP + грубая VPN/датацентр-эвристика — определяет валюту
/// региональной цены в `GET /plans` (см. modules::geoip). Не используется
/// в боте (Telegram Bot API не отдаёт IP пользователя вообще) — там
/// валюта определяется по `language_code`, см. bot.rs.
pub geoip_service: GeoipService,
pub jwt_secret: String, pub jwt_secret: String,
pub bot_token: String, pub bot_token: String,
pub admin_tg_id: i64, /// Юзернейм бота (без `@`) — отдаётся фронту через `GET /api/v1/config`.
/// Не публичный build-time env (`VITE_BOT_USERNAME`): фронт — статический
/// SPA-бандл, собираемый один раз в CI и одинаковый что для прод-, что для
/// dev-деплоя, значение зашилось бы намертво в конкретную сборку и не
/// подхватывало бы разные боты на разных стендах (ровно так и оказалось на
/// практике — dev-стенд показывал прод-бота, у которого домен в BotFather
/// не совпадает с dev-доменом → "Bot domain invalid"). Читается на сервере
/// заново на каждый запрос, поэтому корректно различается между стендами.
pub bot_username: String,
/// `Domain` для сессионных cookie. Пусто (по умолчанию, локальная разработка) → /// `Domain` для сессионных cookie. Пусто (по умолчанию, локальная разработка) →
/// cookie host-only. В проде — `.netrunner-vpn.com`, чтобы одна сессия работала /// cookie host-only. В проде — `.netrunner-vpn.com`, чтобы одна сессия работала
/// и на лендинге, и на ЛК (общий родительский домен, разные сабдомены). /// и на лендинге, и на ЛК (общий родительский домен, разные сабдомены).
@@ -58,6 +79,9 @@ pub struct ApiState {
/// (CSRF-проверка в `auth::guard`). Отдельно от `CORS_ALLOWED_ORIGINS`, т.к. туда /// (CSRF-проверка в `auth::guard`). Отдельно от `CORS_ALLOWED_ORIGINS`, т.к. туда
/// же попадают Bearer-клиенты (Tauri), для которых CSRF неактуален. /// же попадают Bearer-клиенты (Tauri), для которых CSRF неактуален.
pub csrf_allowed_origins: Vec<String>, pub csrf_allowed_origins: Vec<String>,
/// Общий секрет для `netrunner-proxy` (`X-Internal-Secret`) — не пользовательская
/// авторизация, см. `modules::proxy_internal::internal_secret_guard`.
pub proxy_internal_secret: String,
} }
impl Clone for ApiState { impl Clone for ApiState {
@@ -69,12 +93,16 @@ impl Clone for ApiState {
node_service: self.node_service.clone(), node_service: self.node_service.clone(),
referral_service: self.referral_service.clone(), referral_service: self.referral_service.clone(),
user_service: self.user_service.clone(), user_service: self.user_service.clone(),
staff_service: self.staff_service.clone(),
support_service: self.support_service.clone(),
geoip_service: self.geoip_service.clone(),
jwt_secret: self.jwt_secret.clone(), jwt_secret: self.jwt_secret.clone(),
bot_token: self.bot_token.clone(), bot_token: self.bot_token.clone(),
admin_tg_id: self.admin_tg_id, bot_username: self.bot_username.clone(),
cookie_domain: self.cookie_domain.clone(), cookie_domain: self.cookie_domain.clone(),
cookie_secure: self.cookie_secure, cookie_secure: self.cookie_secure,
csrf_allowed_origins: self.csrf_allowed_origins.clone(), csrf_allowed_origins: self.csrf_allowed_origins.clone(),
proxy_internal_secret: self.proxy_internal_secret.clone(),
} }
} }
} }
@@ -85,8 +113,15 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
let static_path = std::path::PathBuf::from(frontend_dir); let static_path = std::path::PathBuf::from(frontend_dir);
let index_path = static_path.join("index.html"); let index_path = static_path.join("index.html");
// SmartIpKeyExtractor читает x-forwarded-for/x-real-ip/forwarded (в этом
// порядке), падая на peer IP только если их нет. Без этого — дефолтный
// PeerIpKeyExtractor видел бы просто 127.0.0.1 (nginx проксирует локально)
// ОДИН И ТОТ ЖЕ для абсолютно всех посетителей сразу, и лимит превращался
// бы в глобальный на весь сервис, а не per-visitor (см. incident: 3 попытки
// логина от кого угодно — и /auth 429 для всех остальных до истечения окна).
let gov_conf = Arc::new( let gov_conf = Arc::new(
GovernorConfigBuilder::default() GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_millisecond(100) .per_millisecond(100)
.burst_size(10) .burst_size(10)
.finish() .finish()
@@ -95,6 +130,7 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
let seed_limit_conf = Arc::new( let seed_limit_conf = Arc::new(
GovernorConfigBuilder::default() GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_millisecond(5000) .per_millisecond(5000)
.burst_size(3) .burst_size(3)
.finish() .finish()
@@ -116,12 +152,38 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
auth_ctrl::router().layer(GovernorLayer::new(seed_limit_conf)), auth_ctrl::router().layer(GovernorLayer::new(seed_limit_conf)),
) )
.nest("/users", user_ctrl::router(state.clone())) .nest("/users", user_ctrl::router(state.clone()))
.nest("/billing", bill_ctrl::router(state.clone())) .nest(
"/billing",
bill_ctrl::router(state.clone()).merge(bill_ctrl::public_router()),
)
.nest("/nodes", node_ctrl::public_router(state.clone())) .nest("/nodes", node_ctrl::public_router(state.clone()))
.nest("/referrals", ref_ctrl::router(state.clone())) .nest("/referrals", ref_ctrl::router(state.clone()))
.route("/config", get(get_public_config))
.layer(GovernorLayer::new(gov_conf)); .layer(GovernorLayer::new(gov_conf));
let admin_routes_v1 = Router::new().nest("/nodes", node_ctrl::admin_router(state.clone())); let admin_routes_v1 = Router::new()
.nest("/nodes", node_ctrl::admin_router(state.clone()))
.nest("/users", user_ctrl::admin_router(state.clone()))
.nest("/staff", staff_ctrl::router(state.clone()))
.nest("/staff", staff_ctrl::owner_router(state.clone()))
.nest("/staff", staff_ctrl::staff_router(state.clone()))
.nest("/partners", staff_ctrl::partner_router(state.clone()))
.nest("/support", support_ctrl::router(state.clone()))
.nest(
"/observability",
staff_ctrl::observability_router(state.clone()),
);
// Внутренний контракт для прокси-нод — отдельный секрет, не auth_guard
// (прокси не пользовательская сессия) и не участвует в CORS ниже (никогда
// не вызывается из браузера).
let internal_routes = Router::new().nest(
"/internal",
proxy_internal::router().layer(middleware::from_fn_with_state(
state.clone(),
proxy_internal::internal_secret_guard,
)),
);
// Строгий CORS. Список разрешённых origin берём из env (через запятую), // Строгий CORS. Список разрешённых origin берём из env (через запятую),
// чтобы помимо веб-фронта пускать и десктоп/мобайл-приложение (Tauri webview). // чтобы помимо веб-фронта пускать и десктоп/мобайл-приложение (Tauri webview).
@@ -145,6 +207,7 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
Router::new() Router::new()
.nest("/api/v1", api_routes_v1) .nest("/api/v1", api_routes_v1)
.nest("/api/v1/admin", admin_routes_v1) .nest("/api/v1/admin", admin_routes_v1)
.nest("/api/v1", internal_routes)
// Liveness/readiness пробы для Docker/оркестратора и балансировщика. // Liveness/readiness пробы для Docker/оркестратора и балансировщика.
.route("/health", get(health_live)) .route("/health", get(health_live))
.route("/health/ready", get(health_ready)) .route("/health/ready", get(health_ready))
@@ -163,6 +226,19 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
.with_state(state) .with_state(state)
} }
/// Публичная (без auth_guard) рантайм-конфигурация для статического SPA —
/// см. `ApiState::bot_username` про то, почему это не build-time env фронта.
async fn get_public_config(State(state): State<ApiState>) -> impl IntoResponse {
// Публичный URL Grafana (за nginx + HTTP Basic Auth на VPS с данными,
// см. netrunner-data/nginx/) — тот же паттерн, что и
// bot_username выше: читается на сервере заново на каждый запрос, а не
// зашивается в SPA-бандл на этапе сборки, т.к. разный на разных стендах.
// `None`, пока GRAFANA_PUBLIC_URL не задан — ObservabilityPage.tsx в
// этом случае просто не показывает ссылку/iframe.
let grafana_url = std::env::var("GRAFANA_PUBLIC_URL").ok();
Json(json!({ "bot_username": state.bot_username, "grafana_url": grafana_url }))
}
/// Liveness: процесс жив и отвечает. Не трогает зависимости. /// Liveness: процесс жив и отвечает. Не трогает зависимости.
async fn health_live() -> impl IntoResponse { async fn health_live() -> impl IntoResponse {
(StatusCode::OK, Json(json!({ "status": "ok" }))) (StatusCode::OK, Json(json!({ "status": "ok" })))
+369 -26
View File
@@ -7,7 +7,9 @@
//! `POST /auth/exchange`. //! `POST /auth/exchange`.
use crate::modules::{ use crate::modules::{
auth::service::AuthService, billing::service::BillingService, users::service::UserService, auth::service::AuthService, billing::service::BillingService,
referrals::service::ReferralService, support::service::SupportService,
users::service::UserService,
}; };
use teloxide::{ use teloxide::{
prelude::*, prelude::*,
@@ -38,6 +40,8 @@ pub async fn run_bot(
auth: AuthService, auth: AuthService,
users: UserService, users: UserService,
billing: BillingService, billing: BillingService,
referrals: ReferralService,
support: SupportService,
cancel_token: CancellationToken, cancel_token: CancellationToken,
) { ) {
let config = BotConfig { let config = BotConfig {
@@ -48,18 +52,27 @@ pub async fn run_bot(
.expect("КРИТИЧЕСКАЯ ОШИБКА: WEB_APP_BASE_URL не задан!"), .expect("КРИТИЧЕСКАЯ ОШИБКА: WEB_APP_BASE_URL не задан!"),
}; };
let handler = Update::filter_message().branch( // Ветка команд (/start, /menu) — первая. Всё, что не распозналось как
dptree::entry() // команда, падает во вторую ветку: там же живёт разовый захват
.filter_command::<Command>() // свободного текста для тикетов поддержки (см. handle_free_text) —
.endpoint(handle_commands), // единственный канал создания тикета, кнопка "Поддержка" выставляет
); // User::awaiting_support_message перед этим.
let handler = Update::filter_message()
.branch(
dptree::entry()
.filter_command::<Command>()
.endpoint(handle_commands),
)
.branch(dptree::entry().endpoint(handle_free_text));
let callback_handler = Update::filter_callback_query().endpoint(handle_callbacks); let callback_handler = Update::filter_callback_query().endpoint(handle_callbacks);
let mut dispatcher = Dispatcher::builder( let mut dispatcher = Dispatcher::builder(
bot, bot,
dptree::entry().branch(handler).branch(callback_handler), dptree::entry().branch(handler).branch(callback_handler),
) )
.dependencies(dptree::deps![auth, users, billing, config]) .dependencies(dptree::deps![
auth, users, billing, referrals, support, config
])
.build(); .build();
tokio::select! { tokio::select! {
@@ -68,6 +81,52 @@ pub async fn run_bot(
} }
} }
/// Свободный текст вне команд/кнопок. Единственное осмысленное действие —
/// сообщение в поддержку, и только когда юзер только что нажал кнопку
/// "Поддержка" (см. `menu_support` в `handle_callbacks`) — иначе (как и
/// раньше, до этого хендлера) молча игнорируется, никакой регрессии для
/// случайных сообщений мимо кнопки.
#[instrument(skip(bot, users, support), fields(chat_id = msg.chat.id.0))]
async fn handle_free_text(
bot: Bot,
msg: Message,
users: UserService,
support: SupportService,
) -> ResponseResult<()> {
let Some(text) = msg.text() else {
return Ok(());
};
let tg_id = msg.chat.id.0;
let user = match users.get_user_profile(tg_id).await {
Ok(Some(u)) => u,
_ => return Ok(()),
};
if !user.awaiting_support_message {
return Ok(());
}
match support.handle_user_message(user.id, text).await {
Ok(()) => {
bot.send_message(
msg.chat.id,
"✅ Сообщение передано в поддержку. Мы ответим здесь же.",
)
.await?;
}
Err(e) => {
tracing::error!(error = ?e, tg_id, "Не удалось сохранить сообщение в поддержку");
bot.send_message(
msg.chat.id,
"Не удалось отправить сообщение, попробуйте ещё раз.",
)
.await?;
}
}
Ok(())
}
#[instrument(skip(bot, auth), fields(chat_id = msg.chat.id.0, username = ?msg.chat.username()))] #[instrument(skip(bot, auth), fields(chat_id = msg.chat.id.0, username = ?msg.chat.username()))]
async fn handle_commands( async fn handle_commands(
bot: Bot, bot: Bot,
@@ -82,14 +141,46 @@ async fn handle_commands(
let code = if ref_code.is_empty() { let code = if ref_code.is_empty() {
None None
} else { } else {
Some(ref_code) Some(ref_code.clone())
}; };
// Авторизация теперь в AuthService // Авторизация теперь в AuthService
let _ = auth let login_result = auth
.process_login(tg_id, msg.chat.username().map(String::from), code) .process_login(tg_id, msg.chat.username().map(String::from), code)
.await; .await;
// Стартовый параметр, который не парсится как tg_id реферера — три
// взаимоисключающих случая по формату: партнёрский код (префикс
// "p_" — см. StaffService::generate_partner_code), иначе
// auth_code из десктоп/мобильного приложения (magic-link вход,
// см. AuthService::create_telegram_login_code и
// POST /auth/telegram/session).
if !ref_code.is_empty() && ref_code.parse::<i64>().is_err() {
if ref_code.starts_with("p_") {
// Партнёрская привязка — постоянная, first-wins (см.
// set_referred_by_partner), безопасно вызывать даже для
// уже существующих юзеров, поэтому не гейтим на "новый юзер".
if let Ok(user) = &login_result {
if let Ok(Some(partner)) = auth.find_partner_by_code(&ref_code).await {
let _ = auth.attribute_partner_referral(partner.id, user.id).await;
}
}
} else if let Ok(user) = &login_result {
if auth
.confirm_telegram_login_code(&ref_code, user.id)
.await
.unwrap_or(false)
{
bot.send_message(
msg.chat.id,
"✅ Вход подтверждён. Вернитесь в приложение Netrunner.",
)
.await?;
return Ok(());
}
}
}
bot.send_message(msg.chat.id, main_menu_text()) bot.send_message(msg.chat.id, main_menu_text())
.parse_mode(ParseMode::Html) .parse_mode(ParseMode::Html)
.reply_markup(main_menu_keyboard()) .reply_markup(main_menu_keyboard())
@@ -105,15 +196,24 @@ async fn handle_commands(
Ok(()) Ok(())
} }
#[instrument(skip(bot, auth, users, billing, config), fields(user_id = q.from.id.0))] #[allow(clippy::too_many_arguments)]
#[instrument(skip(bot, auth, users, billing, referrals, support, config), fields(user_id = q.from.id.0))]
async fn handle_callbacks( async fn handle_callbacks(
bot: Bot, bot: Bot,
q: CallbackQuery, q: CallbackQuery,
auth: AuthService, auth: AuthService,
users: UserService, users: UserService,
billing: BillingService, billing: BillingService,
referrals: ReferralService,
support: SupportService,
config: BotConfig, config: BotConfig,
) -> ResponseResult<()> { ) -> ResponseResult<()> {
// Единственный доступный сигнал региона в боте — Bot API не отдаёт IP
// пользователя вообще (в отличие от сайта, см.
// billing::controller::list_plans_api). Грубее геолокации (язык клиента
// Telegram ≠ страна — диаспора, мультиязычные регионы), но лучше, чем
// показывать всем только USD.
let lang_code = q.from.language_code.clone();
if let (Some(data), Some(msg)) = (q.data, q.message) { if let (Some(data), Some(msg)) = (q.data, q.message) {
let tg_id = q.from.id.0 as i64; let tg_id = q.from.id.0 as i64;
info!(callback_data = %data, "Пользователь нажал кнопку в боте"); info!(callback_data = %data, "Пользователь нажал кнопку в боте");
@@ -129,12 +229,21 @@ async fn handle_callbacks(
), ),
_ => "📡 Статус: <b>НЕТ ПОДПИСКИ</b>".to_string(), _ => "📡 Статус: <b>НЕТ ПОДПИСКИ</b>".to_string(),
}; };
let traffic_status = match u.data_limit_bytes {
Some(limit) => format!(
"📶 Трафик: {} из {}",
format_bytes(u.data_used_bytes),
format_bytes(limit)
),
None => format!("📶 Трафик: {} (безлимит)", format_bytes(u.data_used_bytes)),
};
let text = format!( let text = format!(
"🤖 <b>ПРОФИЛЬ ОПЕРАТИВНИКА</b>\n\n👤 Логин: @{}\n💰 Баланс: <code>{} E$</code>\n🎟 Билеты: <code>{}</code>\n\n{}", "🤖 <b>ПРОФИЛЬ ОПЕРАТИВНИКА</b>\n\n👤 Логин: @{}\n💰 Баланс: <code>{} E$</code>\n🎟 Билеты: <code>{}</code>\n\n{}\n{}",
u.tg_username.unwrap_or_else(|| "Anon".into()), u.tg_username.unwrap_or_else(|| "Anon".into()),
u.balance, u.balance,
u.game_tickets, u.game_tickets,
sub_status sub_status,
traffic_status
); );
(text, profile_keyboard()) (text, profile_keyboard())
} }
@@ -159,11 +268,27 @@ async fn handle_callbacks(
"🖥 Открыть Терминал (WebApp)", "🖥 Открыть Терминал (WebApp)",
) )
.await, .await,
// Тарифы/Синдикат ведут в тот же ЛК (Profile.tsx уже рисует и оплату через // Выбор способа оплаты: TonConnect (как раньше, через ЛК/Profile.tsx)
// TonConnect, и реферальный блок на одной странице) — единый WebApp-мост // или прямо в чате через @CryptoBot (см. menu_pay_crypto).
// через bootstrap-токен, который фронт меняет на cookie-сессию через "menu_pay" => (
// POST /auth/exchange (см. App.tsx::useBootstrapTokenExchange). "💳 <b>ТАРИФНЫЙ ПЛАН</b>\n\nВыберите способ оплаты:".into(),
"menu_pay" => webapp_bridge_response( InlineKeyboardMarkup::new(vec![
vec![InlineKeyboardButton::callback(
"💠 TonConnect (кошелёк)",
"menu_pay_ton",
)],
vec![InlineKeyboardButton::callback(
"💎 Крипта через @CryptoBot",
"menu_pay_crypto",
)],
vec![InlineKeyboardButton::callback("🔙 Назад", "menu_main")],
]),
),
// Тот же ЛК (Profile.tsx уже рисует оплату через TonConnect и
// реферальный блок) — единый WebApp-мост через bootstrap-токен,
// который фронт меняет на cookie-сессию через POST /auth/exchange
// (см. App.tsx::useBootstrapTokenExchange).
"menu_pay_ton" => webapp_bridge_response(
&auth, &auth,
&users, &users,
&config, &config,
@@ -172,15 +297,220 @@ async fn handle_callbacks(
"💳 Выбрать тариф (WebApp)", "💳 Выбрать тариф (WebApp)",
) )
.await, .await,
"menu_ref" => webapp_bridge_response( // Оплата криптой прямо в чате, без WebApp/кошелька — юзер платит
&auth, // внутри @CryptoBot, возвращается и жмёт "Я оплатил". Показываем
&users, // цену в валюте региона (по language_code, см. выше) для
&config, // наглядности — САМА оплата остаётся в USD/USDT (см.
tg_id, // pay_plan-ветку ниже): CryptoBotProvider биллит 1:1 к
"🔗 <b>СИНДИКАТ</b>\n\nОткройте Терминал, чтобы увидеть свою реферальную ссылку и статистику.", // USD-цене тарифа, менять валюту списания нельзя, иначе при
"🔗 Открыть Синдикат (WebApp)", // показе, скажем, VND юзер спишет в разы больше/меньше нужного.
) "menu_pay_crypto" => match billing
.await, .list_plans_in_currency(
lang_code
.as_deref()
.and_then(crate::modules::geoip::currency::currency_for_telegram_lang)
.unwrap_or("USD"),
)
.await
{
Ok((display_currency, plans)) if !plans.is_empty() => {
let rows = plans
.chunks(1)
.map(|chunk| {
chunk
.iter()
.map(|(name, amount)| {
InlineKeyboardButton::callback(
format!(
"{}{}",
name,
crate::modules::geoip::currency::format_amount(
&display_currency,
*amount
)
),
format!("pay_plan:{}", name),
)
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let mut kb = rows;
kb.push(vec![InlineKeyboardButton::callback("🔙 Назад", "menu_pay")]);
(
"💎 <b>ОПЛАТА ЧЕРЕЗ CRYPTOBOT</b>\n\nВыберите тариф:".into(),
InlineKeyboardMarkup::new(kb),
)
}
Ok(_) => (
"Тарифы временно недоступны.".into(),
back_keyboard(),
),
Err(e) => {
tracing::error!(error = ?e, "Не удалось получить список тарифов");
("Ошибка при получении тарифов.".into(), back_keyboard())
}
},
data if data.starts_with("pay_plan:") => {
let plan_name = &data["pay_plan:".len()..];
match users.get_user_profile(tg_id).await {
Ok(Some(u)) => match billing.initiate_payment(&u, plan_name, "CRYPTOBOT", "USD").await {
Ok(checkout) => {
let invoice_id = checkout["invoice_id"].as_str().unwrap_or_default();
let cryptobot_invoice_id =
checkout["cryptobot_invoice_id"].as_i64().unwrap_or_default();
let pay_url = checkout["pay_url"].as_str().unwrap_or_default();
let kb = InlineKeyboardMarkup::new(vec![
vec![InlineKeyboardButton::url(
"💎 Оплатить в CryptoBot",
pay_url.parse().unwrap_or_else(|_| {
"https://t.me/CryptoBot".parse().unwrap()
}),
)],
vec![InlineKeyboardButton::callback(
"✅ Я оплатил, проверить",
format!("pay_check:{}:{}", invoice_id, cryptobot_invoice_id),
)],
vec![InlineKeyboardButton::callback("🔙 Назад", "menu_pay_crypto")],
]);
(
format!(
"💎 <b>ОПЛАТА: {}</b>\n\nОткройте ссылку и оплатите в @CryptoBot, затем вернитесь и нажмите «Я оплатил».",
plan_name
),
kb,
)
}
Err(e) => {
tracing::error!(error = ?e, tg_id, plan_name, "Не удалось создать инвойс CryptoBot");
(
"Не удалось создать счёт на оплату. Попробуйте позже.".into(),
back_keyboard(),
)
}
},
Ok(None) => (
"Оперативник не найден в базе данных.".into(),
back_keyboard(),
),
Err(e) => {
tracing::error!(error = ?e, tg_id, "КРИТИЧЕСКАЯ ОШИБКА при загрузке профиля для оплаты");
("Ошибка связи с ядром системы.".into(), back_keyboard())
}
}
}
data if data.starts_with("pay_check:") => {
let rest = &data["pay_check:".len()..];
let (invoice_id_str, cryptobot_invoice_id) =
rest.split_once(':').unwrap_or((rest, ""));
match (
uuid::Uuid::parse_str(invoice_id_str),
users.get_user_profile(tg_id).await,
) {
(Ok(invoice_id), Ok(Some(u))) => {
match billing
.confirm_payment(u.id, invoice_id, cryptobot_invoice_id)
.await
{
Ok(()) => (
"✅ Оплата подтверждена, тариф активирован!".into(),
back_keyboard(),
),
Err(e) => {
tracing::warn!(error = ?e, tg_id, "Оплата CryptoBot ещё не подтверждена");
(
"⏳ Оплата пока не найдена. Если вы уже оплатили — подождите немного и нажмите ещё раз.".into(),
InlineKeyboardMarkup::new(vec![
vec![InlineKeyboardButton::callback(
"🔄 Проверить снова",
data,
)],
vec![InlineKeyboardButton::callback("🔙 Назад", "menu_main")],
]),
)
}
}
}
_ => (
"Некорректные данные платежа.".into(),
back_keyboard(),
),
}
}
// Рефералка выводится нативно (без WebApp) — все данные уже есть
// на бэкенде (ReferralService::get_stats), а оплата (единственное,
// что реально требует браузера/кошелька) сюда не входит.
"menu_ref" => match users.get_user_profile(tg_id).await {
Ok(Some(u)) => match referrals.get_stats(u.id).await {
Ok((count, total_earned)) => (
format!(
"🔗 <b>СИНДИКАТ</b>\n\nВаша реферальная ссылка:\n<code>https://t.me/{}?start={}</code>\n\n👥 Приглашено: <b>{}</b>\n💰 Заработано: <code>{} E$</code>",
config.bot_username, tg_id, count, total_earned
),
back_keyboard(),
),
Err(e) => {
tracing::error!(error = ?e, tg_id, "Не удалось получить статистику рефералки");
("Ошибка при получении статистики.".into(), back_keyboard())
}
},
Ok(None) => (
"Оперативник не найден в базе данных.".into(),
back_keyboard(),
),
Err(e) => {
tracing::error!(error = ?e, tg_id, "КРИТИЧЕСКАЯ ОШИБКА при загрузке профиля для рефералки");
("Ошибка связи с ядром системы.".into(), back_keyboard())
}
},
// Единственный канал связи с поддержкой — переводит юзера в
// разовый режим ожидания (см. handle_free_text), следующее его
// текстовое сообщение станет тикетом/ответом в уже открытый.
"menu_support" => match users.get_user_profile(tg_id).await {
Ok(Some(u)) => {
if let Err(e) = support.set_awaiting(u.id).await {
tracing::error!(error = ?e, tg_id, "Не удалось выставить awaiting_support_message");
}
let mut text = "🆘 <b>ПОДДЕРЖКА</b>\n\nОпишите проблему одним сообщением — мы ответим здесь же.".to_string();
if let Ok(Some((_, messages))) = support.get_open_ticket_thread_for_user(u.id).await {
if !messages.is_empty() {
text.push_str("\n\n<b>Текущий тикет:</b>");
for m in messages.iter().rev().take(3).rev() {
let sender = if m.sender_role == "staff" { "Поддержка" } else { "Вы" };
text.push_str(&format!("\n\n<i>{}:</i> {}", sender, m.body));
}
}
}
(text, back_keyboard())
}
Ok(None) => (
"Оперативник не найден в базе данных.".into(),
back_keyboard(),
),
Err(e) => {
tracing::error!(error = ?e, tg_id, "КРИТИЧЕСКАЯ ОШИБКА при загрузке профиля для поддержки");
("Ошибка связи с ядром системы.".into(), back_keyboard())
}
},
// Код для входа в десктоп/мобильное приложение — см. докстринг
// AuthService::create_bot_login_code про то, почему это надёжнее
// deep-link-варианта (`?start=`).
"menu_app_login" => {
let username = q.from.username.clone();
match auth.create_bot_login_code(tg_id, username).await {
Ok(code) => (
format!(
"🔑 <b>ВХОД В ПРИЛОЖЕНИЕ</b>\n\nВаш код: <code>{}</code>\n\nВведите его в Netrunner-приложении на экране входа (вкладка Telegram). Код действует 5 минут.",
code
),
back_keyboard(),
),
Err(e) => {
tracing::error!(error = ?e, tg_id, "Не удалось создать код входа для приложения");
("Не удалось создать код, попробуйте ещё раз.".into(), back_keyboard())
}
}
}
_ => ("В разработке...".into(), back_keyboard()), _ => ("В разработке...".into(), back_keyboard()),
}; };
@@ -232,6 +562,11 @@ async fn webapp_bridge_response(
} }
} }
fn format_bytes(bytes: i64) -> String {
const GB: f64 = 1_000_000_000.0;
format!("{:.2} ГБ", bytes as f64 / GB)
}
fn main_menu_text() -> String { fn main_menu_text() -> String {
"<b>NETRUNNER OS</b>\nДобро пожаловать в систему управления узлами.".into() "<b>NETRUNNER OS</b>\nДобро пожаловать в систему управления узлами.".into()
} }
@@ -245,6 +580,14 @@ fn main_menu_keyboard() -> InlineKeyboardMarkup {
"🔗 Синдикат (Рефералы)", "🔗 Синдикат (Рефералы)",
"menu_ref", "menu_ref",
)], )],
vec![InlineKeyboardButton::callback(
"🔑 Код для входа в приложение",
"menu_app_login",
)],
vec![InlineKeyboardButton::callback(
"🆘 Поддержка",
"menu_support",
)],
]) ])
} }
fn profile_keyboard() -> InlineKeyboardMarkup { fn profile_keyboard() -> InlineKeyboardMarkup {
+97
View File
@@ -19,6 +19,100 @@ pub struct User {
pub has_used_trial: bool, pub has_used_trial: bool,
pub seed_hash: Option<String>, // Новое поле pub seed_hash: Option<String>, // Новое поле
pub last_hack_at: Option<chrono::DateTime<chrono::Utc>>, pub last_hack_at: Option<chrono::DateTime<chrono::Utc>>,
/// NULL = безлимит. Читается/пишется прокси через internal-эндпоинты.
pub data_limit_bytes: Option<i64>,
pub data_used_bytes: i64,
pub data_period_reset_at: Option<chrono::DateTime<chrono::Utc>>,
/// Логин для входа Owner/Moderator/Partner (не Telegram/seed). NULL у
/// обычных VPN-пользователей.
pub username: Option<String>,
#[serde(skip_serializing)]
pub password_hash: Option<String>,
/// Право модератора управлять нодами — переключается Owner'ом поштучно
/// (см. `AuthGuard::node_manage_guard`), не зашито в саму роль.
pub can_manage_nodes: bool,
/// Фиксированный процент партнёра с продаж приведённых им покупателей.
pub commission_percent: Option<rust_decimal::Decimal>,
/// Код диплинка `t.me/<bot>?start=<partner_code>` — префикс `p_`.
pub partner_code: Option<String>,
/// Партнёр, чей диплинк привёл этого пользователя (проставляется один
/// раз при регистрации, first-wins — см. `set_referred_by_partner`).
pub referred_by_partner_id: Option<Uuid>,
/// Разовый флаг "юзер нажал кнопку Поддержка в боте, следующее его
/// текстовое сообщение — тикет/ответ в тикет". Сбрасывается сразу после
/// получения текста (см. `support::service::SupportService::handle_user_message`).
pub awaiting_support_message: bool,
}
#[derive(Debug, Clone, FromRow, Serialize)]
pub struct SupportTicket {
pub id: Uuid,
pub user_id: Uuid,
pub status: String,
pub assigned_to: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, FromRow, Serialize)]
pub struct SupportMessage {
pub id: Uuid,
pub ticket_id: Uuid,
pub sender_role: String,
pub sender_user_id: Option<Uuid>,
pub body: String,
pub created_at: DateTime<Utc>,
}
/// Для списка тикетов в админке — один JOIN-запрос вместо N+1
/// (идентичность юзера + превью последнего сообщения).
#[derive(Debug, Clone, FromRow, Serialize)]
pub struct SupportTicketSummary {
pub id: Uuid,
pub user_id: Uuid,
pub tg_id: Option<i64>,
pub tg_username: Option<String>,
pub status: String,
pub message_count: i64,
pub last_message_preview: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, FromRow, Serialize)]
pub struct PartnerCommission {
pub id: Uuid,
pub partner_id: Uuid,
pub invoice_id: Uuid,
pub user_id: Uuid,
pub currency: String,
pub amount: i64,
pub percent_at_time: rust_decimal::Decimal,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, FromRow, Serialize)]
pub struct WithdrawalRequest {
pub id: Uuid,
pub partner_id: Uuid,
pub currency: String,
pub amount: i64,
pub status: String,
pub note: Option<String>,
pub created_at: DateTime<Utc>,
pub processed_at: Option<DateTime<Utc>>,
pub processed_by: Option<Uuid>,
}
/// Разбивка заработка/вывода партнёра по одной валюте — считается без
/// конвертации, партнёр видит несколько таких строк ("$45" + "₽690").
#[derive(Debug, Clone, Serialize)]
pub struct PartnerEarningsByCurrency {
pub currency: String,
pub total_earned: i64,
pub total_withdrawn: i64,
pub pending_withdrawal: i64,
pub available: i64,
} }
#[derive(Debug, Clone, FromRow)] #[derive(Debug, Clone, FromRow)]
@@ -86,6 +180,9 @@ pub struct ProfileData {
pub game_tickets: i32, pub game_tickets: i32,
pub subscription: Option<SubscriptionData>, pub subscription: Option<SubscriptionData>,
pub referrals: Vec<ReferralData>, pub referrals: Vec<ReferralData>,
/// `None` = безлимит. Расходуется прокси, читается на `VpnStats`-индикаторе приложения.
pub data_limit_bytes: Option<i64>,
pub data_used_bytes: i64,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
+799 -9
View File
@@ -16,6 +16,8 @@ pub struct InvoiceRecord {
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub plan_name: String, pub plan_name: String,
pub provider: String,
pub currency: String,
pub amount: i64, pub amount: i64,
pub status: String, pub status: String,
} }
@@ -39,6 +41,34 @@ pub trait AppRepository: Send + Sync {
async fn create_seed_user(&self, seed_hash: &str) -> Result<User, sqlx::Error>; async fn create_seed_user(&self, seed_hash: &str) -> Result<User, sqlx::Error>;
async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error>; async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error>;
// --- ЛИМИТЫ ТРАФИКА (используется прокси через internal-эндпоинты) ---
/// Атомарно прибавляет `delta_bytes` к счётчику расхода и возвращает
/// свежие `(data_used_bytes, data_limit_bytes)`. `None`, если юзера нет.
async fn apply_usage_delta(
&self,
user_id: Uuid,
delta_bytes: i64,
) -> Result<Option<(i64, Option<i64>)>, sqlx::Error>;
async fn set_data_limit(
&self,
user_id: Uuid,
limit_bytes: Option<i64>,
) -> Result<Option<User>, sqlx::Error>;
// --- MAGIC-LINK ВХОД ЧЕРЕЗ TELEGRAM-БОТА (см. modules::auth) ---
async fn create_auth_session(&self, auth_code: &str) -> Result<(), sqlx::Error>;
async fn get_auth_session(
&self,
auth_code: &str,
) -> Result<Option<(Option<Uuid>, bool, chrono::DateTime<chrono::Utc>)>, sqlx::Error>;
/// Помечает сессию подтверждённой; `false`, если код не найден, уже
/// подтверждён или истёк (защита от повторного использования/гонки).
async fn verify_auth_session(
&self,
auth_code: &str,
user_id: Uuid,
) -> Result<bool, sqlx::Error>;
// --- REFRESH-СЕССИИ (единая cookie-авторизация) --- // --- REFRESH-СЕССИИ (единая cookie-авторизация) ---
async fn create_refresh_session( async fn create_refresh_session(
&self, &self,
@@ -88,6 +118,21 @@ pub trait AppRepository: Send + Sync {
currency: &str, currency: &str,
) -> Result<Option<i64>, sqlx::Error>; ) -> Result<Option<i64>, sqlx::Error>;
/// Активные тарифы с USD-ценой (в центах) — для плиточного выбора тарифа
/// в боте (см. `bot.rs::menu_pay_crypto`). USD выбран как единственная
/// валюта, гарантированно заведённая на каждый план (см. сид в
/// `0001_init.sql`), а не потому что CryptoBot требует именно её.
async fn list_active_plans_usd(&self) -> Result<Vec<(String, i64)>, sqlx::Error>;
/// Обобщённая версия `list_active_plans_usd` — тарифы в произвольной
/// валюте (см. `modules::geoip` — валюта определяется по IP/языку).
/// Пустой Vec, если для этой валюты нет ни одной активной строки в
/// `plan_prices` (вызывающая сторона должна фолбэкнуться на USD).
async fn list_active_plans_by_currency(
&self,
currency: &str,
) -> Result<Vec<(String, i64)>, sqlx::Error>;
async fn create_invoice( async fn create_invoice(
&self, &self,
user_id: Uuid, user_id: Uuid,
@@ -124,6 +169,117 @@ pub trait AppRepository: Send + Sync {
async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error>; async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error>;
async fn apply_promo_code(&self, user_id: Uuid, code: &str) -> Result<i64, String>; async fn apply_promo_code(&self, user_id: Uuid, code: &str) -> Result<i64, String>;
// --- РОЛЕВАЯ АДМИНКА: OWNER / MODERATOR / PARTNER ---
/// Создаёт Owner/Moderator/Partner-аккаунт (логин+пароль, не Telegram/seed).
/// `can_manage_nodes` осмыслен только для "moderator", `commission_percent`/
/// `partner_code` — только для "partner" (для остальных ролей передавать `None`).
#[allow(clippy::too_many_arguments)]
async fn create_staff_user(
&self,
username: &str,
password_hash: &str,
role: &str,
can_manage_nodes: bool,
commission_percent: Option<rust_decimal::Decimal>,
partner_code: Option<&str>,
) -> Result<User, sqlx::Error>;
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error>;
async fn set_can_manage_nodes(
&self,
user_id: Uuid,
enabled: bool,
) -> Result<Option<User>, sqlx::Error>;
/// `role_filter` — `None` = все staff-роли (owner+moderator+partner).
async fn list_staff_users(&self, role_filter: Option<&str>) -> Result<Vec<User>, sqlx::Error>;
async fn find_partner_by_code(&self, code: &str) -> Result<Option<User>, sqlx::Error>;
/// Постоянная привязка покупателя к партнёру при первой регистрации по
/// диплинку. No-op (`Ok(false)`), если уже привязан — first-wins.
async fn set_referred_by_partner(
&self,
user_id: Uuid,
partner_id: Uuid,
) -> Result<bool, sqlx::Error>;
#[allow(clippy::too_many_arguments)]
async fn record_partner_commission(
&self,
partner_id: Uuid,
invoice_id: Uuid,
user_id: Uuid,
currency: &str,
amount: i64,
percent_at_time: rust_decimal::Decimal,
) -> Result<(), sqlx::Error>;
/// Разбивка заработка/вывода партнёра по валютам (без конвертации).
async fn get_partner_earnings(
&self,
partner_id: Uuid,
) -> Result<Vec<PartnerEarningsByCurrency>, sqlx::Error>;
async fn create_withdrawal_request(
&self,
partner_id: Uuid,
currency: &str,
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, sqlx::Error>;
/// Атомарная версия для записи: блокирует строку партнёра (`FOR UPDATE`) и
/// пересчитывает доступный остаток внутри той же транзакции перед вставкой
/// заявки — без этого несколько параллельных заявок на весь остаток могут
/// пройти одновременно и увести партнёра в минус (та же гонка, от которой
/// защищает `FOR UPDATE` в `apply_promo_code`). `Err("INSUFFICIENT_BALANCE")`
/// — запрошенная сумма больше доступного остатка.
async fn request_partner_withdrawal(
&self,
partner_id: Uuid,
currency: &str,
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, String>;
/// `status_filter` — `None` = все заявки (используется owner/moderator).
async fn list_withdrawal_requests(
&self,
status_filter: Option<&str>,
) -> Result<Vec<WithdrawalRequest>, sqlx::Error>;
async fn list_withdrawal_requests_for_partner(
&self,
partner_id: Uuid,
) -> Result<Vec<WithdrawalRequest>, sqlx::Error>;
async fn update_withdrawal_status(
&self,
id: Uuid,
new_status: &str,
processed_by: Uuid,
) -> Result<Option<WithdrawalRequest>, sqlx::Error>;
// --- ТЕХПОДДЕРЖКА (ТИКЕТЫ ЧЕРЕЗ БОТА) ---
/// Разовый флаг "юзер нажал кнопку Поддержка, следующее сообщение — тикет".
async fn set_awaiting_support(&self, user_id: Uuid, value: bool) -> Result<(), sqlx::Error>;
async fn get_open_ticket_for_user(
&self,
user_id: Uuid,
) -> Result<Option<SupportTicket>, sqlx::Error>;
async fn create_support_ticket(&self, user_id: Uuid) -> Result<SupportTicket, sqlx::Error>;
/// Добавляет сообщение и бампает `support_tickets.updated_at` в одной транзакции.
async fn add_support_message(
&self,
ticket_id: Uuid,
sender_role: &str,
sender_user_id: Option<Uuid>,
body: &str,
) -> Result<SupportMessage, sqlx::Error>;
/// `status_filter` — `None` = все тикеты (open первыми, затем по дате).
async fn list_tickets(
&self,
status_filter: Option<&str>,
) -> Result<Vec<SupportTicketSummary>, sqlx::Error>;
async fn get_ticket(&self, id: Uuid) -> Result<Option<SupportTicket>, sqlx::Error>;
async fn get_ticket_messages(
&self,
ticket_id: Uuid,
) -> Result<Vec<SupportMessage>, sqlx::Error>;
async fn close_ticket(&self, id: Uuid) -> Result<Option<SupportTicket>, sqlx::Error>;
/// Информационно: кто последним ответил — не enforced-назначение.
async fn set_ticket_assigned(&self, id: Uuid, staff_id: Uuid) -> Result<(), sqlx::Error>;
// --- VPN УЗЛЫ (CONTROL PLANE) --- // --- VPN УЗЛЫ (CONTROL PLANE) ---
/// Ноды в статусе `online` — то, что видят обычные юзеры (`/nodes`, `/routing`, `/stats`). /// Ноды в статусе `online` — то, что видят обычные юзеры (`/nodes`, `/routing`, `/stats`).
async fn get_active_nodes(&self) -> Result<Vec<VpnNode>, sqlx::Error>; async fn get_active_nodes(&self) -> Result<Vec<VpnNode>, sqlx::Error>;
@@ -175,7 +331,9 @@ impl AppRepository for PgRepository {
"INSERT INTO users (id, tg_id, tg_username) \ "INSERT INTO users (id, tg_id, tg_username) \
VALUES ($1, $2, $3) \ VALUES ($1, $2, $3) \
ON CONFLICT (tg_id) DO UPDATE SET tg_username = EXCLUDED.tg_username \ ON CONFLICT (tg_id) DO UPDATE SET tg_username = EXCLUDED.tg_username \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at" RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message"
) )
.bind(Uuid::new_v4()).bind(tg_id).bind(username).fetch_one(&self.pool).await .bind(Uuid::new_v4()).bind(tg_id).bind(username).fetch_one(&self.pool).await
} }
@@ -183,7 +341,9 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_user_by_id(&self, id: Uuid) -> Result<Option<User>, sqlx::Error> { async fn get_user_by_id(&self, id: Uuid) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>( sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at \ "SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \
FROM users WHERE id = $1" FROM users WHERE id = $1"
) )
.bind(id) .bind(id)
@@ -194,7 +354,9 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_user_by_tg_id(&self, tg_id: i64) -> Result<Option<User>, sqlx::Error> { async fn get_user_by_tg_id(&self, tg_id: i64) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>( sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at \ "SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \
FROM users WHERE tg_id = $1" FROM users WHERE tg_id = $1"
) )
.bind(tg_id) .bind(tg_id)
@@ -206,7 +368,9 @@ impl AppRepository for PgRepository {
async fn create_seed_user(&self, seed_hash: &str) -> Result<User, sqlx::Error> { async fn create_seed_user(&self, seed_hash: &str) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>( sqlx::query_as::<_, User>(
"INSERT INTO users (id, seed_hash) VALUES ($1, $2) \ "INSERT INTO users (id, seed_hash) VALUES ($1, $2) \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at" RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message"
) )
.bind(Uuid::new_v4()).bind(seed_hash).fetch_one(&self.pool).await .bind(Uuid::new_v4()).bind(seed_hash).fetch_one(&self.pool).await
} }
@@ -214,7 +378,9 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error> { async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>( sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at \ "SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \
FROM users WHERE seed_hash = $1" FROM users WHERE seed_hash = $1"
) )
.bind(hash) .bind(hash)
@@ -222,6 +388,84 @@ impl AppRepository for PgRepository {
.await .await
} }
#[instrument(skip(self))]
async fn apply_usage_delta(
&self,
user_id: Uuid,
delta_bytes: i64,
) -> Result<Option<(i64, Option<i64>)>, sqlx::Error> {
let row: Option<(i64, Option<i64>)> = sqlx::query_as(
"UPDATE users SET data_used_bytes = data_used_bytes + $1 \
WHERE id = $2 RETURNING data_used_bytes, data_limit_bytes",
)
.bind(delta_bytes)
.bind(user_id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
#[instrument(skip(self))]
async fn set_data_limit(
&self,
user_id: Uuid,
limit_bytes: Option<i64>,
) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"UPDATE users SET data_limit_bytes = $1 WHERE id = $2 \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message",
)
.bind(limit_bytes)
.bind(user_id)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn create_auth_session(&self, auth_code: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO auth_sessions (id, auth_code, expires_at) \
VALUES ($1, $2, NOW() + INTERVAL '5 minutes')",
)
.bind(Uuid::new_v4())
.bind(auth_code)
.execute(&self.pool)
.await?;
Ok(())
}
#[instrument(skip(self))]
async fn get_auth_session(
&self,
auth_code: &str,
) -> Result<Option<(Option<Uuid>, bool, chrono::DateTime<chrono::Utc>)>, sqlx::Error> {
sqlx::query_as(
"SELECT user_id, is_verified, expires_at FROM auth_sessions WHERE auth_code = $1",
)
.bind(auth_code)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn verify_auth_session(
&self,
auth_code: &str,
user_id: Uuid,
) -> Result<bool, sqlx::Error> {
let r = sqlx::query(
"UPDATE auth_sessions SET user_id = $1, is_verified = TRUE \
WHERE auth_code = $2 AND is_verified = FALSE AND expires_at > NOW()",
)
.bind(user_id)
.bind(auth_code)
.execute(&self.pool)
.await?;
Ok(r.rows_affected() > 0)
}
#[instrument(skip(self, token_hash))] #[instrument(skip(self, token_hash))]
async fn create_refresh_session( async fn create_refresh_session(
&self, &self,
@@ -295,10 +539,13 @@ impl AppRepository for PgRepository {
game_tickets: i32, game_tickets: i32,
subscription: Option<Json<SubscriptionData>>, subscription: Option<Json<SubscriptionData>>,
referrals: Json<Vec<ReferralData>>, referrals: Json<Vec<ReferralData>>,
data_limit_bytes: Option<i64>,
data_used_bytes: i64,
} }
let query = r#" let query = r#"
SELECT u.id, u.tg_username, u.role, u.balance, u.game_tickets, SELECT u.id, u.tg_username, u.role, u.balance, u.game_tickets,
u.data_limit_bytes, u.data_used_bytes,
(SELECT jsonb_build_object('plan_name', s.plan_name, 'expires_at', s.expires_at, 'status', s.status) (SELECT jsonb_build_object('plan_name', s.plan_name, 'expires_at', s.expires_at, 'status', s.status)
FROM public.subscriptions s WHERE s.user_id = u.id AND s.status != 'expired' LIMIT 1 FROM public.subscriptions s WHERE s.user_id = u.id AND s.status != 'expired' LIMIT 1
) AS "subscription", ) AS "subscription",
@@ -322,6 +569,8 @@ impl AppRepository for PgRepository {
game_tickets: r.game_tickets, game_tickets: r.game_tickets,
subscription: r.subscription.map(|json| json.0), subscription: r.subscription.map(|json| json.0),
referrals: r.referrals.0, referrals: r.referrals.0,
data_limit_bytes: r.data_limit_bytes,
data_used_bytes: r.data_used_bytes,
})) }))
} }
@@ -476,6 +725,34 @@ impl AppRepository for PgRepository {
Ok(res.map(|r| r.0)) Ok(res.map(|r| r.0))
} }
#[instrument(skip(self))]
async fn list_active_plans_usd(&self) -> Result<Vec<(String, i64)>, sqlx::Error> {
sqlx::query_as(
"SELECT pp.plan_name, pp.amount FROM plan_prices pp \
JOIN plans p ON p.name = pp.plan_name \
WHERE pp.currency = 'USD' AND pp.is_active = TRUE AND p.is_active = TRUE \
ORDER BY pp.amount ASC",
)
.fetch_all(&self.pool)
.await
}
#[instrument(skip(self))]
async fn list_active_plans_by_currency(
&self,
currency: &str,
) -> Result<Vec<(String, i64)>, sqlx::Error> {
sqlx::query_as(
"SELECT pp.plan_name, pp.amount FROM plan_prices pp \
JOIN plans p ON p.name = pp.plan_name \
WHERE pp.currency = $1 AND pp.is_active = TRUE AND p.is_active = TRUE \
ORDER BY pp.amount ASC",
)
.bind(currency)
.fetch_all(&self.pool)
.await
}
#[instrument(skip(self))] #[instrument(skip(self))]
async fn create_invoice( async fn create_invoice(
&self, &self,
@@ -504,7 +781,7 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_invoice(&self, invoice_id: Uuid) -> Result<Option<InvoiceRecord>, sqlx::Error> { async fn get_invoice(&self, invoice_id: Uuid) -> Result<Option<InvoiceRecord>, sqlx::Error> {
sqlx::query_as::<_, InvoiceRecord>( sqlx::query_as::<_, InvoiceRecord>(
"SELECT id, user_id, plan_name, amount, status FROM invoices WHERE id = $1", "SELECT id, user_id, plan_name, provider, currency, amount, status FROM invoices WHERE id = $1",
) )
.bind(invoice_id) .bind(invoice_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -514,7 +791,7 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_pending_invoices(&self) -> Result<Vec<InvoiceRecord>, sqlx::Error> { async fn get_pending_invoices(&self) -> Result<Vec<InvoiceRecord>, sqlx::Error> {
sqlx::query_as::<_, InvoiceRecord>( sqlx::query_as::<_, InvoiceRecord>(
"SELECT id, user_id, plan_name, amount, status FROM invoices \ "SELECT id, user_id, plan_name, provider, currency, amount, status FROM invoices \
WHERE status = 'pending' AND created_at > NOW() - INTERVAL '24 hours' \ WHERE status = 'pending' AND created_at > NOW() - INTERVAL '24 hours' \
ORDER BY created_at DESC LIMIT 100", ORDER BY created_at DESC LIMIT 100",
) )
@@ -567,6 +844,17 @@ impl AppRepository for PgRepository {
DO UPDATE SET status = 'active', expires_at = NOW() + INTERVAL '30 days', plan_name = EXCLUDED.plan_name") DO UPDATE SET status = 'active', expires_at = NOW() + INTERVAL '30 days', plan_name = EXCLUDED.plan_name")
.bind(user_id).bind(plan_name).execute(&mut *tx).await?; .bind(user_id).bind(plan_name).execute(&mut *tx).await?;
// Проставляем дефолтный лимит трафика тарифа (NULL тарифа = не трогать
// текущий лимит юзера — например, если админ уже выставил кастомный).
sqlx::query(
"UPDATE users SET data_limit_bytes = (SELECT default_limit_bytes FROM plans WHERE name = $2) \
WHERE id = $1 AND (SELECT default_limit_bytes FROM plans WHERE name = $2) IS NOT NULL",
)
.bind(user_id)
.bind(plan_name)
.execute(&mut *tx)
.await?;
tx.commit().await?; tx.commit().await?;
Ok(()) Ok(())
} }
@@ -609,7 +897,10 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn reward_referrer(&self, referred_id: Uuid, amount: i64) -> Result<bool, sqlx::Error> { async fn reward_referrer(&self, referred_id: Uuid, amount: i64) -> Result<bool, sqlx::Error> {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
let res: Option<(Uuid,)> = sqlx::query_as("UPDATE referrals SET status = 'rewarded', total_earned = total_earned + $1 WHERE referred_id = $2 RETURNING referrer_id") // WHERE status = 'pending' — разовый бонус за первую покупку приведённого
// юзера, а не revenue share навсегда: без этого условия UPDATE срабатывал
// при каждом вызове (т.е. при каждой последующей покупке того же юзера).
let res: Option<(Uuid,)> = sqlx::query_as("UPDATE referrals SET status = 'rewarded', total_earned = total_earned + $1 WHERE referred_id = $2 AND status = 'pending' RETURNING referrer_id")
.bind(amount).bind(referred_id).fetch_optional(&mut *tx).await?; .bind(amount).bind(referred_id).fetch_optional(&mut *tx).await?;
if let Some((r_id,)) = res { if let Some((r_id,)) = res {
@@ -628,7 +919,7 @@ impl AppRepository for PgRepository {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error> { async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error> {
let row: (Option<i64>, Option<i64>) = sqlx::query_as( let row: (Option<i64>, Option<i64>) = sqlx::query_as(
"SELECT COUNT(*), SUM(total_earned) FROM referrals WHERE referrer_id = $1", "SELECT COUNT(*), SUM(total_earned)::BIGINT FROM referrals WHERE referrer_id = $1",
) )
.bind(user_id) .bind(user_id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
@@ -710,6 +1001,505 @@ impl AppRepository for PgRepository {
Ok(reward) Ok(reward)
} }
// =====================================================================
// РОЛЕВАЯ АДМИНКА: OWNER / MODERATOR / PARTNER
// =====================================================================
#[instrument(skip(self, password_hash))]
async fn create_staff_user(
&self,
username: &str,
password_hash: &str,
role: &str,
can_manage_nodes: bool,
commission_percent: Option<rust_decimal::Decimal>,
partner_code: Option<&str>,
) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
"INSERT INTO users (id, username, password_hash, role, can_manage_nodes, commission_percent, partner_code) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message"
)
.bind(Uuid::new_v4())
.bind(username)
.bind(password_hash)
.bind(role)
.bind(can_manage_nodes)
.bind(commission_percent)
.bind(partner_code)
.fetch_one(&self.pool)
.await
}
#[instrument(skip(self))]
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \
FROM users WHERE username = $1"
)
.bind(username)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn set_can_manage_nodes(
&self,
user_id: Uuid,
enabled: bool,
) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"UPDATE users SET can_manage_nodes = $1 WHERE id = $2 \
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message"
)
.bind(enabled)
.bind(user_id)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn list_staff_users(&self, role_filter: Option<&str>) -> Result<Vec<User>, sqlx::Error> {
let roles: Vec<&str> = match role_filter {
Some(r) => vec![r],
None => vec!["owner", "moderator", "partner", "support"],
};
sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \
FROM users WHERE role = ANY($1) ORDER BY role, username"
)
.bind(&roles)
.fetch_all(&self.pool)
.await
}
#[instrument(skip(self))]
async fn find_partner_by_code(&self, code: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \
FROM users WHERE partner_code = $1 AND role = 'partner'"
)
.bind(code)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn set_referred_by_partner(
&self,
user_id: Uuid,
partner_id: Uuid,
) -> Result<bool, sqlx::Error> {
// first-wins: не перезаписывает уже проставленную привязку.
let result = sqlx::query(
"UPDATE users SET referred_by_partner_id = $1 \
WHERE id = $2 AND referred_by_partner_id IS NULL",
)
.bind(partner_id)
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
#[instrument(skip(self))]
async fn record_partner_commission(
&self,
partner_id: Uuid,
invoice_id: Uuid,
user_id: Uuid,
currency: &str,
amount: i64,
percent_at_time: rust_decimal::Decimal,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO partner_commissions (id, partner_id, invoice_id, user_id, currency, amount, percent_at_time) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
ON CONFLICT (invoice_id) DO NOTHING",
)
.bind(Uuid::new_v4())
.bind(partner_id)
.bind(invoice_id)
.bind(user_id)
.bind(currency)
.bind(amount)
.bind(percent_at_time)
.execute(&self.pool)
.await?;
Ok(())
}
#[instrument(skip(self))]
async fn get_partner_earnings(
&self,
partner_id: Uuid,
) -> Result<Vec<PartnerEarningsByCurrency>, sqlx::Error> {
// Три отдельных сгруппированных запроса и слияние в Rust — проще и
// надёжнее одного сложного JOIN, объёмы тут крошечные.
let earned_rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM partner_commissions \
WHERE partner_id = $1 GROUP BY currency",
)
.bind(partner_id)
.fetch_all(&self.pool)
.await?;
let withdrawn_rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
WHERE partner_id = $1 AND status IN ('approved', 'paid') GROUP BY currency",
)
.bind(partner_id)
.fetch_all(&self.pool)
.await?;
let pending_rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
WHERE partner_id = $1 AND status = 'pending' GROUP BY currency",
)
.bind(partner_id)
.fetch_all(&self.pool)
.await?;
fn entry_for(
map: &mut std::collections::BTreeMap<String, PartnerEarningsByCurrency>,
currency: String,
) -> &mut PartnerEarningsByCurrency {
map.entry(currency.clone())
.or_insert_with(|| PartnerEarningsByCurrency {
currency,
total_earned: 0,
total_withdrawn: 0,
pending_withdrawal: 0,
available: 0,
})
}
let mut by_currency: std::collections::BTreeMap<String, PartnerEarningsByCurrency> =
std::collections::BTreeMap::new();
for (currency, amount) in earned_rows {
entry_for(&mut by_currency, currency).total_earned = amount;
}
for (currency, amount) in withdrawn_rows {
entry_for(&mut by_currency, currency).total_withdrawn = amount;
}
for (currency, amount) in pending_rows {
entry_for(&mut by_currency, currency).pending_withdrawal = amount;
}
Ok(by_currency
.into_values()
.map(|mut e| {
e.available = e.total_earned - e.total_withdrawn - e.pending_withdrawal;
e
})
.collect())
}
#[instrument(skip(self))]
async fn create_withdrawal_request(
&self,
partner_id: Uuid,
currency: &str,
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, sqlx::Error> {
sqlx::query_as::<_, WithdrawalRequest>(
"INSERT INTO withdrawal_requests (id, partner_id, currency, amount, note) \
VALUES ($1, $2, $3, $4, $5) \
RETURNING id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by",
)
.bind(Uuid::new_v4())
.bind(partner_id)
.bind(currency)
.bind(amount)
.bind(note)
.fetch_one(&self.pool)
.await
}
#[instrument(skip(self))]
async fn request_partner_withdrawal(
&self,
partner_id: Uuid,
currency: &str,
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, String> {
let mut tx = self.pool.begin().await.map_err(|e| e.to_string())?;
// Блокируем строку партнёра — сериализует конкурентные заявки на вывод
// от одного и того же партнёра, пока эта транзакция не завершится.
sqlx::query("SELECT id FROM users WHERE id = $1 FOR UPDATE")
.bind(partner_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let earned: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount), 0)::BIGINT FROM partner_commissions \
WHERE partner_id = $1 AND currency = $2",
)
.bind(partner_id)
.bind(currency)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let withdrawn: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
WHERE partner_id = $1 AND currency = $2 AND status IN ('approved', 'paid')",
)
.bind(partner_id)
.bind(currency)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let pending: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
WHERE partner_id = $1 AND currency = $2 AND status = 'pending'",
)
.bind(partner_id)
.bind(currency)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let available = earned - withdrawn - pending;
if amount > available {
tx.rollback().await.map_err(|e| e.to_string())?;
return Err("INSUFFICIENT_BALANCE".to_string());
}
let req = sqlx::query_as::<_, WithdrawalRequest>(
"INSERT INTO withdrawal_requests (id, partner_id, currency, amount, note) \
VALUES ($1, $2, $3, $4, $5) \
RETURNING id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by",
)
.bind(Uuid::new_v4())
.bind(partner_id)
.bind(currency)
.bind(amount)
.bind(note)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(req)
}
#[instrument(skip(self))]
async fn list_withdrawal_requests(
&self,
status_filter: Option<&str>,
) -> Result<Vec<WithdrawalRequest>, sqlx::Error> {
match status_filter {
Some(status) => {
sqlx::query_as::<_, WithdrawalRequest>(
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
FROM withdrawal_requests WHERE status = $1 ORDER BY created_at DESC",
)
.bind(status)
.fetch_all(&self.pool)
.await
}
None => {
sqlx::query_as::<_, WithdrawalRequest>(
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
FROM withdrawal_requests ORDER BY (status = 'pending') DESC, created_at DESC",
)
.fetch_all(&self.pool)
.await
}
}
}
#[instrument(skip(self))]
async fn list_withdrawal_requests_for_partner(
&self,
partner_id: Uuid,
) -> Result<Vec<WithdrawalRequest>, sqlx::Error> {
sqlx::query_as::<_, WithdrawalRequest>(
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
FROM withdrawal_requests WHERE partner_id = $1 ORDER BY created_at DESC",
)
.bind(partner_id)
.fetch_all(&self.pool)
.await
}
#[instrument(skip(self))]
async fn update_withdrawal_status(
&self,
id: Uuid,
new_status: &str,
processed_by: Uuid,
) -> Result<Option<WithdrawalRequest>, sqlx::Error> {
sqlx::query_as::<_, WithdrawalRequest>(
"UPDATE withdrawal_requests SET status = $1, processed_at = NOW(), processed_by = $2 \
WHERE id = $3 \
RETURNING id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by",
)
.bind(new_status)
.bind(processed_by)
.bind(id)
.fetch_optional(&self.pool)
.await
}
// =====================================================================
// ТЕХПОДДЕРЖКА (ТИКЕТЫ ЧЕРЕЗ БОТА)
// =====================================================================
#[instrument(skip(self))]
async fn set_awaiting_support(&self, user_id: Uuid, value: bool) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE users SET awaiting_support_message = $1 WHERE id = $2")
.bind(value)
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(())
}
#[instrument(skip(self))]
async fn get_open_ticket_for_user(
&self,
user_id: Uuid,
) -> Result<Option<SupportTicket>, sqlx::Error> {
sqlx::query_as::<_, SupportTicket>(
"SELECT id, user_id, status, assigned_to, created_at, updated_at \
FROM support_tickets WHERE user_id = $1 AND status = 'open' \
ORDER BY created_at DESC LIMIT 1",
)
.bind(user_id)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn create_support_ticket(&self, user_id: Uuid) -> Result<SupportTicket, sqlx::Error> {
sqlx::query_as::<_, SupportTicket>(
"INSERT INTO support_tickets (id, user_id) VALUES ($1, $2) \
RETURNING id, user_id, status, assigned_to, created_at, updated_at",
)
.bind(Uuid::new_v4())
.bind(user_id)
.fetch_one(&self.pool)
.await
}
#[instrument(skip(self))]
async fn add_support_message(
&self,
ticket_id: Uuid,
sender_role: &str,
sender_user_id: Option<Uuid>,
body: &str,
) -> Result<SupportMessage, sqlx::Error> {
let mut tx = self.pool.begin().await?;
let msg = sqlx::query_as::<_, SupportMessage>(
"INSERT INTO support_messages (id, ticket_id, sender_role, sender_user_id, body) \
VALUES ($1, $2, $3, $4, $5) \
RETURNING id, ticket_id, sender_role, sender_user_id, body, created_at",
)
.bind(Uuid::new_v4())
.bind(ticket_id)
.bind(sender_role)
.bind(sender_user_id)
.bind(body)
.fetch_one(&mut *tx)
.await?;
sqlx::query("UPDATE support_tickets SET updated_at = NOW() WHERE id = $1")
.bind(ticket_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(msg)
}
#[instrument(skip(self))]
async fn list_tickets(
&self,
status_filter: Option<&str>,
) -> Result<Vec<SupportTicketSummary>, sqlx::Error> {
let query = "SELECT t.id, t.user_id, u.tg_id, u.tg_username, t.status, \
COUNT(m.id)::BIGINT AS message_count, \
(SELECT body FROM support_messages \
WHERE ticket_id = t.id ORDER BY created_at DESC LIMIT 1) AS last_message_preview, \
t.created_at, t.updated_at \
FROM support_tickets t \
JOIN users u ON u.id = t.user_id \
LEFT JOIN support_messages m ON m.ticket_id = t.id \
WHERE ($1::TEXT IS NULL OR t.status = $1) \
GROUP BY t.id, u.tg_id, u.tg_username \
ORDER BY (t.status = 'open') DESC, t.updated_at DESC";
sqlx::query_as::<_, SupportTicketSummary>(query)
.bind(status_filter)
.fetch_all(&self.pool)
.await
}
#[instrument(skip(self))]
async fn get_ticket(&self, id: Uuid) -> Result<Option<SupportTicket>, sqlx::Error> {
sqlx::query_as::<_, SupportTicket>(
"SELECT id, user_id, status, assigned_to, created_at, updated_at \
FROM support_tickets WHERE id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn get_ticket_messages(
&self,
ticket_id: Uuid,
) -> Result<Vec<SupportMessage>, sqlx::Error> {
sqlx::query_as::<_, SupportMessage>(
"SELECT id, ticket_id, sender_role, sender_user_id, body, created_at \
FROM support_messages WHERE ticket_id = $1 ORDER BY created_at ASC",
)
.bind(ticket_id)
.fetch_all(&self.pool)
.await
}
#[instrument(skip(self))]
async fn close_ticket(&self, id: Uuid) -> Result<Option<SupportTicket>, sqlx::Error> {
sqlx::query_as::<_, SupportTicket>(
"UPDATE support_tickets SET status = 'closed', updated_at = NOW() \
WHERE id = $1 \
RETURNING id, user_id, status, assigned_to, created_at, updated_at",
)
.bind(id)
.fetch_optional(&self.pool)
.await
}
#[instrument(skip(self))]
async fn set_ticket_assigned(&self, id: Uuid, staff_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE support_tickets SET assigned_to = $1 WHERE id = $2")
.bind(staff_id)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
// ===================================================================== // =====================================================================
// VPN УЗЛЫ (CONTROL PLANE) // VPN УЗЛЫ (CONTROL PLANE)
// ===================================================================== // =====================================================================
+106 -10
View File
@@ -17,8 +17,9 @@ use netrunner_control_plane::api::{create_router, ApiState};
use netrunner_control_plane::bot; use netrunner_control_plane::bot;
use netrunner_control_plane::db::repository::{AppRepository, PgRepository}; use netrunner_control_plane::db::repository::{AppRepository, PgRepository};
use netrunner_control_plane::modules::{ use netrunner_control_plane::modules::{
auth::service::AuthService, billing::service::BillingService, nodes::service::NodeService, auth::service::AuthService, billing::service::BillingService, geoip::service::GeoipService,
referrals::service::ReferralService, users::service::UserService, nodes::service::NodeService, referrals::service::ReferralService, staff::service::StaffService,
support::service::SupportService, users::service::UserService,
}; };
use netrunner_control_plane::tasks; use netrunner_control_plane::tasks;
@@ -27,6 +28,12 @@ use netrunner_control_plane::tasks;
struct Args { struct Args {
#[arg(long)] #[arg(long)]
migrate: bool, migrate: bool,
/// Одноразовое создание первого Owner-аккаунта (логин+пароль, не
/// Telegram) — прогоняется руками на VPS: `--create-owner --username admin`.
#[arg(long)]
create_owner: bool,
#[arg(long)]
username: Option<String>,
} }
fn init_tracing() { fn init_tracing() {
@@ -149,6 +156,49 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(()); return Ok(());
} }
// Бутстрап первого Owner'а — тоже требует только DATABASE_URL, тот же
// one-shot паттерн, что и --migrate. Пароль вводится скрыто в консоли
// (не через env/аргумент), чтобы не осесть в истории шелла.
if args.create_owner {
let username = args
.username
.clone()
.ok_or("--create-owner требует --username <имя>")?;
info!("🔐 Создание владельца (Owner): {}", username);
let password = rpassword::prompt_password("Пароль для владельца: ")?;
let password_confirm = rpassword::prompt_password("Повторите пароль: ")?;
if password != password_confirm {
return Err("Пароли не совпадают.".into());
}
if password.len() < 8 {
return Err("Пароль должен быть не короче 8 символов.".into());
}
let hash = AuthService::hash_password(&password)
.map_err(|e| format!("Ошибка хеширования пароля: {:?}", e))?;
let repo = PgRepository::new(pool.clone());
let user = repo
.create_staff_user(&username, &hash, "owner", false, None, None)
.await
.map_err(|e| {
if let sqlx::Error::Database(db_err) = &e {
if db_err.constraint() == Some("users_username_key") {
return format!("Логин '{}' уже занят.", username);
}
}
format!("Ошибка создания владельца: {e}")
})?;
info!("✅ Владелец создан: id={}, username={}", user.id, username);
println!(
"Владелец '{}' успешно создан. Войдите через /admin/login на фронтенде.",
username
);
return Ok(());
}
// --- Полный запуск сервера: проверяем все обязательные ресурсы --- // --- Полный запуск сервера: проверяем все обязательные ресурсы ---
ensure_master_ssh_key()?; ensure_master_ssh_key()?;
@@ -163,18 +213,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"КРИТИЧЕСКАЯ ОШИБКА: BOT_ENABLED=true, но TELOXIDE_TOKEN не задан в .env!".into(), "КРИТИЧЕСКАЯ ОШИБКА: BOT_ENABLED=true, но TELOXIDE_TOKEN не задан в .env!".into(),
); );
} }
// Тот же бот, что читает bot.rs при старте — но здесь ещё и отдаётся фронту
// через GET /api/v1/config (см. ApiState::bot_username), не как VITE_-переменная,
// зашиваемая в SPA-бандл на этапе сборки в CI.
let bot_username =
std::env::var("BOT_USERNAME").unwrap_or_else(|_| "ntrnr_vpn_bot".to_string());
let jwt_secret = let jwt_secret =
std::env::var("JWT_SECRET").expect("КРИТИЧЕСКАЯ ОШИБКА: JWT_SECRET не задан в .env!"); std::env::var("JWT_SECRET").expect("КРИТИЧЕСКАЯ ОШИБКА: JWT_SECRET не задан в .env!");
let frontend_dir = let frontend_dir =
std::env::var("FRONTEND_DIR").expect("КРИТИЧЕСКАЯ ОШИБКА: FRONTEND_DIR не задан в .env!"); std::env::var("FRONTEND_DIR").expect("КРИТИЧЕСКАЯ ОШИБКА: FRONTEND_DIR не задан в .env!");
let port = std::env::var("PORT").expect("КРИТИЧЕСКАЯ ОШИБКА: PORT не задан в .env!"); let port = std::env::var("PORT").expect("КРИТИЧЕСКАЯ ОШИБКА: PORT не задан в .env!");
// Считываем ADMIN_TG_ID один раз при старте сервера
let admin_tg_id = std::env::var("ADMIN_TG_ID")
.expect("КРИТИЧЕСКАЯ ОШИБКА: ADMIN_TG_ID не задан в .env!")
.parse::<i64>()
.expect("ADMIN_TG_ID должен быть числом");
info!("🚀 Запуск Netrunner Control Plane v2.0 (Modular Architecture)"); info!("🚀 Запуск Netrunner Control Plane v2.0 (Modular Architecture)");
// Домен для Set-Cookie: пусто локально (host-only cookie), в проде // Домен для Set-Cookie: пусто локально (host-only cookie), в проде
@@ -193,6 +242,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Только для стендов без TLS, никогда не для прода!" Только для стендов без TLS, никогда не для прода!"
); );
} }
// Общий секрет, которым делятся все прокси-ноды при вызовах internal-эндпоинтов
// (`POST /internal/validate`, `POST /internal/usage`) — не пользовательская
// авторизация, поэтому отдельный env, не JWT_SECRET.
let proxy_internal_secret = std::env::var("PROXY_INTERNAL_SECRET")
.expect("КРИТИЧЕСКАЯ ОШИБКА: PROXY_INTERNAL_SECRET не задан в .env!");
let csrf_allowed_origins: Vec<String> = std::env::var("CSRF_ALLOWED_ORIGINS") let csrf_allowed_origins: Vec<String> = std::env::var("CSRF_ALLOWED_ORIGINS")
.unwrap_or_else(|_| { .unwrap_or_else(|_| {
"https://netrunner-vpn.com,https://account.netrunner-vpn.com".to_string() "https://netrunner-vpn.com,https://account.netrunner-vpn.com".to_string()
@@ -234,6 +289,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone())); let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let cancel_token = CancellationToken::new(); let cancel_token = CancellationToken::new();
// DB-IP Lite (не MaxMind — тот же формат .mmdb, но без регистрации/ключа,
// см. modules::geoip) + X4BNet lists_vpn (открытый список VPN/датацентр
// CIDR-диапазонов). Дефолты указывают прямо на публичные раздачи — можно
// переопределить, если когда-нибудь понадобится своё зеркало.
let geoip_db_url_template = std::env::var("GEOIP_DB_URL_TEMPLATE").unwrap_or_else(|_| {
"https://download.db-ip.com/free/dbip-country-lite-{}.mmdb.gz".to_string()
});
let vpn_list_url = std::env::var("VPN_LIST_URL").unwrap_or_else(|_| {
"https://raw.githubusercontent.com/X4BNet/lists_vpn/main/output/vpn/ipv4.txt".to_string()
});
let geoip_service = GeoipService::new(geoip_db_url_template, vpn_list_url);
let state = ApiState { let state = ApiState {
repo: repo.clone(), repo: repo.clone(),
auth_service: AuthService::new(repo.clone()), auth_service: AuthService::new(repo.clone()),
@@ -241,12 +308,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
node_service: NodeService::new(repo.clone()), node_service: NodeService::new(repo.clone()),
referral_service: ReferralService::new(repo.clone()), referral_service: ReferralService::new(repo.clone()),
user_service: UserService::new(repo.clone()), user_service: UserService::new(repo.clone()),
staff_service: StaffService::new(repo.clone()),
support_service: SupportService::new(repo.clone()),
geoip_service: geoip_service.clone(),
jwt_secret, jwt_secret,
bot_token: bot_token.clone(), bot_token: bot_token.clone(),
admin_tg_id, bot_username,
cookie_domain, cookie_domain,
cookie_secure, cookie_secure,
csrf_allowed_origins, csrf_allowed_origins,
proxy_internal_secret,
}; };
let app = create_router(state.clone(), &frontend_dir); let app = create_router(state.clone(), &frontend_dir);
@@ -266,6 +337,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let bot_auth = state.auth_service.clone(); let bot_auth = state.auth_service.clone();
let bot_users = state.user_service.clone(); let bot_users = state.user_service.clone();
let bot_billing = state.billing_service.clone(); let bot_billing = state.billing_service.clone();
let bot_referrals = state.referral_service.clone();
let bot_support = state.support_service.clone();
let bot_token_for_task = bot_token.clone(); let bot_token_for_task = bot_token.clone();
let bot_handle = tokio::spawn(async move { let bot_handle = tokio::spawn(async move {
@@ -276,6 +349,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
bot_auth, bot_auth,
bot_users, bot_users,
bot_billing, bot_billing,
bot_referrals,
bot_support,
bot_cancel_token, bot_cancel_token,
) )
.await; .await;
@@ -286,12 +361,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
}); });
// Первая загрузка баз GeoIP/VPN-списка — не блокирует старт HTTP-сервера
// (сеть может быть недоступна/медленной на старте контейнера), но
// запускается сразу, не через сутки ожидания первого тика в
// run_background_tasks.
let startup_geoip = geoip_service.clone();
tokio::spawn(async move {
if let Err(e) = startup_geoip.refresh_geoip_db().await {
tracing::warn!(
"GeoIP: первичная загрузка базы страна-по-IP не удалась: {}",
e
);
}
if let Err(e) = startup_geoip.refresh_vpn_list().await {
tracing::warn!(
"GeoIP: первичная загрузка списка VPN-диапазонов не удалась: {}",
e
);
}
});
let tasks_token = cancel_token.clone(); let tasks_token = cancel_token.clone();
let tasks_billing = state.billing_service.clone(); let tasks_billing = state.billing_service.clone();
let tasks_repo = repo.clone(); let tasks_repo = repo.clone();
let tasks_geoip = geoip_service.clone();
let tasks_handle = tokio::spawn(async move { let tasks_handle = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await; tokio::time::sleep(std::time::Duration::from_secs(2)).await;
tasks::run_background_tasks(tasks_billing, tasks_repo, tasks_token).await; tasks::run_background_tasks(tasks_billing, tasks_repo, tasks_geoip, tasks_token).await;
}); });
// === КООРДИНАТОР АВАРИЙНОГО И СТАНДАРТНОГО ШУТДАУНА === // === КООРДИНАТОР АВАРИЙНОГО И СТАНДАРТНОГО ШУТДАУНА ===
+6 -3
View File
@@ -47,9 +47,12 @@
cookie**, дополнительно проверяет `Origin`/`Referer` против `CSRF_ALLOWED_ORIGINS` — это cookie**, дополнительно проверяет `Origin`/`Referer` против `CSRF_ALLOWED_ORIGINS` — это
CSRF-защита. Bearer-запросы её не проходят и не должны: подделать `Authorization`-заголовок CSRF-защита. Bearer-запросы её не проходят и не должны: подделать `Authorization`-заголовок
с чужого origin браузер не даст, поэтому CSRF для них неактуален. с чужого origin браузер не даст, поэтому CSRF для них неактуален.
- `admin_guard` — должен идти **после** `auth_guard` в цепочке слоёв (читает юзера из - `owner_guard` / `staff_guard` / `node_manage_guard` / `partner_guard` — ролевые гварды
extensions). Пропускает, если `tg_id` юзера совпадает с `ADMIN_TG_ID` из `.env` или его админки (Owner/Moderator/Partner), должны идти **после** `auth_guard` в цепочке слоёв
`role == "admin"`. (читают роль юзера из extensions, второй запрос к БД не нужен). Без завязки на Telegram —
роль назначается через `staff`-модуль (создание Owner — CLI `--create-owner`, дальше
Owner/Moderator создают друг друга через API). `node_manage_guard` — Owner всегда, либо
Moderator с выданным правом `can_manage_nodes`.
## Sybil-защита ## Sybil-защита
+87 -3
View File
@@ -3,7 +3,11 @@ use crate::{api::ApiState, error::AppError};
use axum::http::header::{COOKIE, SET_COOKIE, USER_AGENT}; use axum::http::header::{COOKIE, SET_COOKIE, USER_AGENT};
use axum::http::HeaderMap; use axum::http::HeaderMap;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::{extract::State, routing::post, Json, Router}; use axum::{
extract::{Path, State},
routing::{get, post},
Json, Router,
};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use tracing::instrument; use tracing::instrument;
@@ -29,11 +33,20 @@ pub struct ExchangePayload {
pub token: String, pub token: String,
} }
#[derive(Deserialize, Debug)]
pub struct AdminLoginPayload {
pub username: String,
pub password: String,
}
pub fn router() -> Router<ApiState> { pub fn router() -> Router<ApiState> {
Router::new() Router::new()
.route("/telegram", post(auth_telegram_api)) .route("/telegram", post(auth_telegram_api))
.route("/telegram/session", post(create_telegram_session_api))
.route("/telegram/session/{code}", get(get_telegram_session_api))
.route("/seed/generate", post(generate_seed_api)) .route("/seed/generate", post(generate_seed_api))
.route("/seed/login", post(login_seed_api)) .route("/seed/login", post(login_seed_api))
.route("/admin/login", post(admin_login_api))
.route("/exchange", post(exchange_bootstrap_api)) .route("/exchange", post(exchange_bootstrap_api))
.route("/refresh", post(refresh_api)) .route("/refresh", post(refresh_api))
.route("/logout", post(logout_api)) .route("/logout", post(logout_api))
@@ -156,6 +169,46 @@ async fn auth_telegram_api(
Ok((out_headers, Json(json!({ "status": "success" })))) Ok((out_headers, Json(json!({ "status": "success" }))))
} }
/// Приложение (десктоп/мобильный, без домена для Telegram Login Widget) создаёт
/// код и открывает `deep_link` в системном Telegram — юзер подтверждает вход
/// командой `/start <auth_code>` в боте (см. `bot.rs::handle_commands`), а
/// приложение тем временем поллит `GET .../session/{code}`.
async fn create_telegram_session_api(
State(state): State<ApiState>,
) -> Result<impl IntoResponse, AppError> {
let auth_code = state.auth_service.create_telegram_login_code().await?;
let deep_link = format!("https://t.me/{}?start={}", state.bot_username, auth_code);
Ok(Json(
json!({ "auth_code": auth_code, "deep_link": deep_link }),
))
}
#[instrument(skip(state, headers))]
async fn get_telegram_session_api(
State(state): State<ApiState>,
Path(code): Path<String>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
let session = state
.auth_service
.poll_telegram_login(&code, &state.jwt_secret, user_agent(&headers))
.await?;
let Some(session) = session else {
return Ok(with_headers(
HeaderMap::new(),
Json(json!({ "status": "pending" })),
));
};
let mut out_headers = HeaderMap::new();
set_session_cookies(&mut out_headers, &state, &session);
Ok(with_headers(
out_headers,
Json(json!({ "status": "success", "token": session.access_token })),
))
}
#[axum::debug_handler] #[axum::debug_handler]
async fn generate_seed_api( async fn generate_seed_api(
State(state): State<ApiState>, State(state): State<ApiState>,
@@ -171,9 +224,11 @@ async fn generate_seed_api(
let mut out_headers = HeaderMap::new(); let mut out_headers = HeaderMap::new();
set_session_cookies(&mut out_headers, &state, &session); set_session_cookies(&mut out_headers, &state, &session);
// Cookie — для веб-фронта; access_token в теле — для Tauri-приложения
// (кросс-origin custom scheme, Lax-cookie туда не долетают, см. `src/lib/api.ts`).
Ok(( Ok((
out_headers, out_headers,
Json(json!({ "status": "success", "seed": seed })), Json(json!({ "status": "success", "seed": seed, "token": session.access_token })),
)) ))
} }
@@ -196,7 +251,36 @@ async fn login_seed_api(
let mut out_headers = HeaderMap::new(); let mut out_headers = HeaderMap::new();
set_session_cookies(&mut out_headers, &state, &session); set_session_cookies(&mut out_headers, &state, &session);
Ok((out_headers, Json(json!({ "status": "success" })))) Ok((
out_headers,
Json(json!({ "status": "success", "token": session.access_token })),
))
}
/// Логин Owner/Moderator/Partner по логину+паролю — отдельная админка,
/// не завязанная на Telegram/seed (см. `AuthService::login_staff`).
async fn admin_login_api(
State(state): State<ApiState>,
headers: HeaderMap,
Json(payload): Json<AdminLoginPayload>,
) -> Result<impl IntoResponse, AppError> {
let session = state
.auth_service
.login_staff(
&payload.username,
&payload.password,
&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", "token": session.access_token })),
))
} }
/// Обменивает короткоживущий bootstrap-токен (см. `bot.rs`, кнопки /// Обменивает короткоживущий bootstrap-токен (см. `bot.rs`, кнопки
+330 -17
View File
@@ -95,27 +95,340 @@ pub async fn auth_guard(
Ok(next.run(req).await) Ok(next.run(req).await)
} }
/// Проверяет права администратора. // Ролевые гварды админки (Owner/Moderator/Partner) — заменяют старый
pub async fn admin_guard( // `admin_guard`, который держался на одном захардкоженном ADMIN_TG_ID.
State(state): State<ApiState>, // Роль читается из уже инжектнутого auth_guard'ом User, второй запрос к БД
// не нужен.
fn current_user(req: &Request) -> Result<&User, AppError> {
req.extensions()
.get::<User>()
.ok_or_else(|| AppError::Unauthorized("Context lost: Auth required".into()))
}
fn deny(user: &User) -> AppError {
warn!(
"SECURITY_ALERT: Unauthorized admin access attempt by user_id={} role={:?}",
user.id, user.role
);
AppError::Unauthorized("Insufficient clearance level".into())
}
/// Только Owner.
pub async fn owner_guard(
State(_state): State<ApiState>,
req: Request, req: Request,
next: Next, next: Next,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let user = req let user = current_user(&req)?;
.extensions() if user.role == "owner" {
.get::<User>()
.ok_or_else(|| AppError::Unauthorized("Context lost: Auth required".into()))?;
// Используем закешированный в памяти ID
if user.tg_id == Some(state.admin_tg_id) || user.role == "admin" {
Ok(next.run(req).await) Ok(next.run(req).await)
} else { } else {
warn!( Err(deny(user))
"SECURITY_ALERT: Unauthorized admin access attempt by @{:?}", }
user.tg_username }
);
Err(AppError::Unauthorized( /// Owner или Moderator (любой, независимо от `can_manage_nodes`).
"Insufficient clearance level".into(), pub async fn staff_guard(
)) State(_state): State<ApiState>,
req: Request,
next: Next,
) -> Result<Response, AppError> {
let user = current_user(&req)?;
if user.role == "owner" || user.role == "moderator" {
Ok(next.run(req).await)
} else {
Err(deny(user))
}
}
/// Owner ИЛИ Moderator с выданным правом `can_manage_nodes` — гейт для
/// управления нодами.
pub async fn node_manage_guard(
State(_state): State<ApiState>,
req: Request,
next: Next,
) -> Result<Response, AppError> {
let user = current_user(&req)?;
if user.role == "owner" || (user.role == "moderator" && user.can_manage_nodes) {
Ok(next.run(req).await)
} else {
Err(deny(user))
}
}
/// Owner, Moderator ИЛИ Support — доступ к тикетам техподдержки. Шире
/// `staff_guard` (тот не пускает Support).
pub async fn support_access_guard(
State(_state): State<ApiState>,
req: Request,
next: Next,
) -> Result<Response, AppError> {
let user = current_user(&req)?;
if user.role == "owner" || user.role == "moderator" || user.role == "support" {
Ok(next.run(req).await)
} else {
Err(deny(user))
}
}
/// Только Partner.
pub async fn partner_guard(
State(_state): State<ApiState>,
req: Request,
next: Next,
) -> Result<Response, AppError> {
let user = current_user(&req)?;
if user.role == "partner" {
Ok(next.run(req).await)
} else {
Err(deny(user))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::{AppRepository, PgRepository};
use crate::modules::auth::service::AuthService;
use crate::modules::billing::service::BillingService;
use crate::modules::nodes::service::NodeService;
use crate::modules::referrals::service::ReferralService;
use crate::modules::staff::service::StaffService;
use crate::modules::users::service::UserService;
use axum::{
body::Body,
http::{Request as HttpRequest, StatusCode},
middleware,
routing::get,
Extension, Router,
};
use sqlx::PgPool;
use std::sync::Arc;
use tower::ServiceExt;
fn build_state(pool: PgPool) -> ApiState {
// BillingService::new паникует без этой переменной окружения; для
// тестов ролевых гвардов её конкретное значение не имеет значения.
std::env::set_var("TON_MASTER_WALLET", "UQtest_wallet_for_guard_tests");
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
ApiState {
repo: repo.clone(),
auth_service: AuthService::new(repo.clone()),
billing_service: BillingService::new(repo.clone(), None),
node_service: NodeService::new(repo.clone()),
referral_service: ReferralService::new(repo.clone()),
user_service: UserService::new(repo.clone()),
staff_service: StaffService::new(repo.clone()),
support_service: crate::modules::support::service::SupportService::new(repo.clone()),
geoip_service: crate::modules::geoip::service::GeoipService::new(
"https://example.invalid/{}.mmdb.gz".into(),
"https://example.invalid/vpn.txt".into(),
),
jwt_secret: "test-secret".into(),
bot_token: "test-bot-token".into(),
bot_username: "test_bot".into(),
cookie_domain: "".into(),
cookie_secure: false,
csrf_allowed_origins: vec![],
proxy_internal_secret: "test-internal-secret".into(),
}
}
fn make_user(role: &str, can_manage_nodes: bool) -> User {
User {
id: uuid::Uuid::new_v4(),
tg_id: None,
tg_username: None,
role: role.into(),
balance: 0,
game_tickets: 0,
has_used_trial: false,
seed_hash: None,
last_hack_at: None,
data_limit_bytes: None,
data_used_bytes: 0,
data_period_reset_at: None,
username: None,
password_hash: None,
can_manage_nodes,
commission_percent: None,
partner_code: None,
referred_by_partner_id: None,
awaiting_support_message: false,
}
}
/// Строит одноразовый роутер с единственным guard'ом под тестом, инжектит
/// заданного пользователя напрямую в extensions (минуя настоящий auth_guard/JWT
/// — гварды читают только Extension<User>, см. `current_user`) и возвращает
/// итоговый HTTP-статус.
async fn status_for<G, Fut>(state: ApiState, user: User, guard: G) -> StatusCode
where
G: Fn(State<ApiState>, Request, Next) -> Fut + Clone + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<Response, AppError>> + Send + 'static,
{
let app = Router::new()
.route("/", get(|| async { StatusCode::OK }))
.route_layer(middleware::from_fn_with_state(state.clone(), guard))
.layer(Extension(user))
.with_state(state);
let resp = app
.oneshot(HttpRequest::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
resp.status()
}
#[sqlx::test]
async fn test_owner_guard_matrix(pool: PgPool) {
let state = build_state(pool);
assert_eq!(
status_for(state.clone(), make_user("owner", false), owner_guard).await,
StatusCode::OK
);
assert_eq!(
status_for(state.clone(), make_user("moderator", true), owner_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("partner", false), owner_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("user", false), owner_guard).await,
StatusCode::UNAUTHORIZED
);
}
#[sqlx::test]
async fn test_staff_guard_matrix(pool: PgPool) {
let state = build_state(pool);
assert_eq!(
status_for(state.clone(), make_user("owner", false), staff_guard).await,
StatusCode::OK
);
assert_eq!(
status_for(state.clone(), make_user("moderator", false), staff_guard).await,
StatusCode::OK
);
assert_eq!(
status_for(state.clone(), make_user("partner", false), staff_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("user", false), staff_guard).await,
StatusCode::UNAUTHORIZED
);
}
#[sqlx::test]
async fn test_node_manage_guard_matrix(pool: PgPool) {
let state = build_state(pool);
assert_eq!(
status_for(state.clone(), make_user("owner", false), node_manage_guard).await,
StatusCode::OK,
"owner управляет нодами всегда, независимо от can_manage_nodes"
);
assert_eq!(
status_for(
state.clone(),
make_user("moderator", true),
node_manage_guard
)
.await,
StatusCode::OK
);
assert_eq!(
status_for(
state.clone(),
make_user("moderator", false),
node_manage_guard
)
.await,
StatusCode::UNAUTHORIZED,
"модератор без выданного права can_manage_nodes не должен проходить"
);
assert_eq!(
status_for(
state.clone(),
make_user("partner", false),
node_manage_guard
)
.await,
StatusCode::UNAUTHORIZED
);
}
#[sqlx::test]
async fn test_partner_guard_matrix(pool: PgPool) {
let state = build_state(pool);
assert_eq!(
status_for(state.clone(), make_user("partner", false), partner_guard).await,
StatusCode::OK
);
assert_eq!(
status_for(state.clone(), make_user("owner", false), partner_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("moderator", true), partner_guard).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(state.clone(), make_user("user", false), partner_guard).await,
StatusCode::UNAUTHORIZED
);
}
#[sqlx::test]
async fn test_support_access_guard_matrix(pool: PgPool) {
let state = build_state(pool);
assert_eq!(
status_for(
state.clone(),
make_user("owner", false),
support_access_guard
)
.await,
StatusCode::OK
);
assert_eq!(
status_for(
state.clone(),
make_user("moderator", false),
support_access_guard
)
.await,
StatusCode::OK
);
assert_eq!(
status_for(
state.clone(),
make_user("support", false),
support_access_guard
)
.await,
StatusCode::OK
);
assert_eq!(
status_for(
state.clone(),
make_user("partner", false),
support_access_guard
)
.await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status_for(
state.clone(),
make_user("user", false),
support_access_guard
)
.await,
StatusCode::UNAUTHORIZED
);
} }
} }
+7 -3
View File
@@ -1,9 +1,13 @@
//! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе) //! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе),
//! и JWT-гварды (`auth_guard`, `admin_guard`). См. README.md рядом с этим файлом. //! логин Owner/Moderator/Partner/Support по паролю, и ролевые JWT-гварды (`auth_guard` +
//! `owner_guard`/`staff_guard`/`node_manage_guard`/`partner_guard`/`support_access_guard`).
//! См. README.md рядом.
pub mod controller; pub mod controller;
pub mod guard; pub mod guard;
pub mod service; pub mod service;
pub use guard::{admin_guard, auth_guard}; pub use guard::{
auth_guard, node_manage_guard, owner_guard, partner_guard, staff_guard, support_access_guard,
};
pub use service::{AuthService, Claims}; pub use service::{AuthService, Claims};
+473
View File
@@ -34,6 +34,7 @@ pub struct Claims {
/// Пара токенов, выдаваемая при входе: короткий access-JWT (кладётся в /// Пара токенов, выдаваемая при входе: короткий access-JWT (кладётся в
/// `Authorization`/cookie на каждый запрос) и непрозрачный refresh-токен /// `Authorization`/cookie на каждый запрос) и непрозрачный refresh-токен
/// (только в отдельной cookie, только на `/auth/refresh`). /// (только в отдельной cookie, только на `/auth/refresh`).
#[derive(Debug)]
pub struct Session { pub struct Session {
pub access_token: String, pub access_token: String,
pub refresh_token: String, pub refresh_token: String,
@@ -239,6 +240,176 @@ impl AuthService {
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent) self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
.await .await
} }
// --- Логин Owner/Moderator/Partner по паролю (не Telegram/seed) ---
// Отдельная схема хеширования от `hash_seed_argon2`: там общая на все
// seed'ы фиксированная соль из ARGON_SALT — приемлемо для
// высокоэнтропийных сгенерированных seed'ов, но не для человеческих
// паролей (одна утечка ARGON_SALT — и все пароли колются разом одной
// radeoduga-таблицей). Здесь — самодостаточная PHC-строка со случайной
// солью на каждый пароль (`argon2::password_hash`).
/// Хеширует пароль со случайной солью. Используется и при создании
/// Owner/Moderator/Partner (`staff` controller), и в CLI-бутстрапе
/// (`--create-owner`) — важно, чтобы схема была идентичной везде.
pub fn hash_password(password: &str) -> Result<String, AppError> {
use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AppError::Internal(format!("Ошибка хеширования пароля: {e}")))
}
/// Логин Owner/Moderator/Partner по логину+паролю. Одинаковая ошибка на
/// "нет юзера" и "неверный пароль" — без user enumeration.
#[instrument(skip(self, password, jwt_secret))]
pub async fn login_staff(
&self,
username: &str,
password: &str,
jwt_secret: &str,
user_agent: Option<&str>,
) -> Result<Session, AppError> {
use argon2::password_hash::{PasswordHash, PasswordVerifier};
let generic_err = || AppError::Unauthorized("Invalid credentials".into());
let user = self
.repo
.get_user_by_username(username)
.await?
.ok_or_else(generic_err)?;
let hash = user.password_hash.as_deref().ok_or_else(generic_err)?;
let parsed_hash = PasswordHash::new(hash).map_err(|_| generic_err())?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.map_err(|_| generic_err())?;
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
.await
}
/// Ищет партнёра по коду диплинка (`t.me/<bot>?start=p_XXXXXX`).
pub async fn find_partner_by_code(&self, code: &str) -> Result<Option<User>, AppError> {
self.repo
.find_partner_by_code(code)
.await
.map_err(AppError::Database)
}
/// Привязывает покупателя к партнёру (first-wins, no-op если уже привязан).
pub async fn attribute_partner_referral(
&self,
partner_id: Uuid,
buyer_id: Uuid,
) -> Result<bool, AppError> {
self.repo
.set_referred_by_partner(buyer_id, partner_id)
.await
.map_err(AppError::Database)
}
// --- Magic-link вход через Telegram-бота для десктоп/мобильного приложения ---
// Приложение не может встроить Telegram Login Widget (нет домена/WebView-моста
// как у веб-ЛК), поэтому вместо него: приложение создаёт auth_code и открывает
// `t.me/<bot>?start=<auth_code>` (см. `bot.rs::handle_commands`), а само поллит
// `GET /auth/telegram/session/{code}`, пока бот не подтвердит код.
/// Создаёт ожидающую подтверждения сессию, возвращает код для deep-link.
pub async fn create_telegram_login_code(&self) -> Result<String, AppError> {
let auth_code: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect();
self.repo
.create_auth_session(&auth_code)
.await
.map_err(AppError::Database)?;
Ok(auth_code)
}
/// Вызывается ботом на `/start <auth_code>`: если код существует, ещё не
/// подтверждён и не истёк — привязывает его к вошедшему в боте юзеру.
pub async fn confirm_telegram_login_code(
&self,
auth_code: &str,
user_id: Uuid,
) -> Result<bool, AppError> {
self.repo
.verify_auth_session(auth_code, user_id)
.await
.map_err(AppError::Database)
}
/// Код для входа, который генерирует **сам бот** по кнопке в меню — в
/// отличие от `create_telegram_login_code` (deep-link из приложения),
/// боту не нужен отдельный шаг подтверждения: сам факт диалога уже
/// удостоверяет tg_id, поэтому код создаётся сразу подтверждённым. Юзер
/// вручную вводит его в приложении. Deep-link-флоу ненадёжен для юзеров,
/// уже открывавших бота раньше — Telegram не пересылает `?start=` заново
/// для уже начатых чатов, только пустой `/start`.
pub async fn create_bot_login_code(
&self,
tg_id: i64,
username: Option<String>,
) -> Result<String, AppError> {
let user = self.process_login(tg_id, username, None).await?;
let auth_code: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect();
self.repo
.create_auth_session(&auth_code)
.await
.map_err(AppError::Database)?;
self.repo
.verify_auth_session(&auth_code, user.id)
.await
.map_err(AppError::Database)?;
Ok(auth_code)
}
/// Опрашивается приложением. `Ok(None)` — код существует, ждём подтверждения
/// в боте; `Err(Unauthorized)` — код не найден или истёк.
#[instrument(skip(self, jwt_secret))]
pub async fn poll_telegram_login(
&self,
auth_code: &str,
jwt_secret: &str,
user_agent: Option<&str>,
) -> Result<Option<Session>, AppError> {
let (user_id, is_verified, expires_at) = self
.repo
.get_auth_session(auth_code)
.await
.map_err(AppError::Database)?
.ok_or_else(|| AppError::Unauthorized("Invalid auth code".into()))?;
if expires_at < Utc::now() {
return Err(AppError::Unauthorized("Auth code expired".into()));
}
if !is_verified {
return Ok(None);
}
let user_id = user_id
.ok_or_else(|| AppError::Internal("Verified auth session missing user_id".into()))?;
let user = self
.repo
.get_user_by_id(user_id)
.await
.map_err(AppError::Database)?
.ok_or(AppError::UserNotFound)?;
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
.await
.map(Some)
}
// --- Логика Telegram (без изменений, кроме вызова генерации токена) --- // --- Логика Telegram (без изменений, кроме вызова генерации токена) ---
// Сигнатура повторяет поля payload-а Telegram Login Widget — это намеренно. // Сигнатура повторяет поля payload-а Telegram Login Widget — это намеренно.
@@ -321,7 +492,309 @@ impl AuthService {
.create_referral(referrer.id, new_user_id) .create_referral(referrer.id, new_user_id)
.await .await
.ok(); .ok();
metrics::counter!("referral_signups_total").increment(1);
} }
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::PgRepository;
use sqlx::PgPool;
const SECRET: &str = "test-jwt-secret";
const SALT: &str = "test-seed-salt-min-16-chars";
// --- Ротация refresh-токена и обнаружение переиспользования ---
#[sqlx::test]
async fn test_refresh_session_rotates_and_revokes_old_token(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
let session = svc
.issue_session(user.id, Some(1), SECRET, None)
.await
.unwrap();
let rotated = svc
.refresh_session(&session.refresh_token, SECRET, None)
.await
.expect("первая ротация должна пройти");
assert_ne!(rotated.refresh_token, session.refresh_token);
// Старый refresh-токен теперь отозван — повторное предъявление отклоняется.
let err = svc
.refresh_session(&session.refresh_token, SECRET, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[sqlx::test]
async fn test_refresh_reuse_after_rotation_revokes_all_sessions(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
let session = svc
.issue_session(user.id, Some(1), SECRET, None)
.await
.unwrap();
let rotated = svc
.refresh_session(&session.refresh_token, SECRET, None)
.await
.unwrap();
// Переиспользование уже отозванного (старого) токена — сигнал компрометации.
let err = svc
.refresh_session(&session.refresh_token, SECRET, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
// Даже свежая пара, выданная легитимной ротацией ДО обнаружения реюза,
// должна была быть отозвана целиком (revoke_all_refresh_sessions).
let err2 = svc
.refresh_session(&rotated.refresh_token, SECRET, None)
.await
.unwrap_err();
assert!(matches!(err2, AppError::Unauthorized(_)));
}
#[sqlx::test]
async fn test_refresh_session_expired_rejected(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
let raw_token = "manually-crafted-refresh-token";
let token_hash = AuthService::hash_refresh_token(raw_token);
repo.create_refresh_session(
user.id,
&token_hash,
None,
Utc::now() - chrono::Duration::days(1),
)
.await
.unwrap();
let err = svc
.refresh_session(raw_token, SECRET, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
// --- login_staff: без user enumeration ---
#[sqlx::test]
async fn test_login_staff_success(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo.clone());
let hash = AuthService::hash_password("correct horse battery staple").unwrap();
repo.create_staff_user("owner1", &hash, "owner", true, None, None)
.await
.unwrap();
svc.login_staff("owner1", "correct horse battery staple", SECRET, None)
.await
.expect("верный логин/пароль должны пройти");
}
#[sqlx::test]
async fn test_login_staff_generic_error_no_enumeration(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo.clone());
let hash = AuthService::hash_password("correct horse battery staple").unwrap();
repo.create_staff_user("owner1", &hash, "owner", true, None, None)
.await
.unwrap();
// Обычный Telegram-юзер без username/password_hash вообще.
repo.upsert_user(42, None).await.unwrap();
let err_wrong_pass = svc
.login_staff("owner1", "wrong password", SECRET, None)
.await
.unwrap_err();
let err_unknown_user = svc
.login_staff("nobody", "whatever", SECRET, None)
.await
.unwrap_err();
let msg = |e: AppError| match e {
AppError::Unauthorized(m) => m,
other => panic!("ожидался Unauthorized, получено {other:?}"),
};
// Обе ошибки должны быть текстуально неотличимы — иначе можно перебором
// узнать, существует ли username.
assert_eq!(msg(err_wrong_pass), msg(err_unknown_user));
}
#[test]
fn test_hash_password_roundtrip() {
use argon2::password_hash::{PasswordHash, PasswordVerifier};
let hash = AuthService::hash_password("hunter2").unwrap();
let parsed = PasswordHash::new(&hash).unwrap();
assert!(Argon2::default()
.verify_password(b"hunter2", &parsed)
.is_ok());
assert!(Argon2::default()
.verify_password(b"wrong", &parsed)
.is_err());
}
// --- Ghost Protocol: seed-вход ---
#[sqlx::test]
async fn test_register_and_login_seed_roundtrip(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo.clone());
let (seed, _session) = svc.register_seed(SALT, SECRET, None).await.unwrap();
svc.login_seed(&seed, SALT, SECRET, None)
.await
.expect("вход по только что сгенерированному seed должен пройти");
let err = svc
.login_seed("totally-wrong-seed-value", SALT, SECRET, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
// --- Telegram Login Widget: HMAC-подпись ---
fn compute_valid_tg_hash(
bot_token: &str,
id: i64,
username: Option<&str>,
auth_date: i64,
) -> String {
let mut data_check_arr = vec![format!("auth_date={}", auth_date), format!("id={}", id)];
if let Some(v) = username {
data_check_arr.push(format!("username={}", v));
}
data_check_arr.sort();
let data_check_string = data_check_arr.join("\n");
let mut hasher = Sha256::new();
hasher.update(bot_token.trim().as_bytes());
let secret_key = hasher.finalize();
let mut mac = Hmac::<Sha256>::new_from_slice(&secret_key).unwrap();
mac.update(data_check_string.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
#[sqlx::test]
async fn test_verify_tg_widget_auth_accepts_valid_signature(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo);
let bot_token = "123:ABC-fake-bot-token";
let auth_date = Utc::now().timestamp();
let hash = compute_valid_tg_hash(bot_token, 555, Some("neo"), auth_date);
svc.verify_tg_widget_auth(
bot_token,
555,
None,
None,
Some("neo"),
None,
auth_date,
&hash,
)
.expect("валидная подпись должна приниматься");
}
#[sqlx::test]
async fn test_verify_tg_widget_auth_rejects_tampered_signature(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo);
let bot_token = "123:ABC-fake-bot-token";
let auth_date = Utc::now().timestamp();
let hash = compute_valid_tg_hash(bot_token, 555, Some("neo"), auth_date);
// Тот же хэш, но id подделан относительно того, что реально подписано.
let err = svc
.verify_tg_widget_auth(
bot_token,
666,
None,
None,
Some("neo"),
None,
auth_date,
&hash,
)
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[sqlx::test]
async fn test_verify_tg_widget_auth_rejects_expired_session(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo);
let bot_token = "123:ABC-fake-bot-token";
let auth_date = Utc::now().timestamp() - 90_000; // > 86400 секунд назад
let hash = compute_valid_tg_hash(bot_token, 555, Some("neo"), auth_date);
let err = svc
.verify_tg_widget_auth(
bot_token,
555,
None,
None,
Some("neo"),
None,
auth_date,
&hash,
)
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
// --- process_login: привязка реферала только для новых юзеров, без self-ref ---
#[sqlx::test]
async fn test_process_login_applies_referral_for_new_user_only(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = AuthService::new(repo.clone());
let referrer = repo.upsert_user(100, None).await.unwrap();
// Новый юзер с валидным реферальным кодом (tg_id реферера) -> связь создаётся.
svc.process_login(200, None, Some("100".to_string()))
.await
.unwrap();
let (count, _) = repo.get_referral_stats(referrer.id).await.unwrap();
assert_eq!(count, 1);
// Самореферал игнорируется.
svc.process_login(300, None, Some("300".to_string()))
.await
.unwrap();
let (self_ref_count, _) = repo
.get_referral_stats(repo.get_user_by_tg_id(300).await.unwrap().unwrap().id)
.await
.unwrap();
assert_eq!(self_ref_count, 0);
// Повторный логин уже существующего юзера с ref_code — не создаёт новую связь
// (is_new уже false на момент проверки).
svc.process_login(200, None, Some("100".to_string()))
.await
.unwrap();
let (count_after, _) = repo.get_referral_stats(referrer.id).await.unwrap();
assert_eq!(
count_after, 1,
"повторный логин не должен задваивать реферальную связь"
);
}
}
+79 -3
View File
@@ -1,14 +1,21 @@
use axum::{ use axum::{
extract::{Extension, State}, extract::{ConnectInfo, Extension, State},
http::HeaderMap,
middleware, middleware,
routing::post, routing::{get, post},
Json, Router, Json, Router,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::net::{IpAddr, SocketAddr};
use uuid::Uuid; use uuid::Uuid;
use crate::{api::ApiState, db::models::User, error::AppError, modules::auth::guard::auth_guard}; use crate::{
api::ApiState,
db::models::User,
error::AppError,
modules::{auth::guard::auth_guard, geoip::currency::currency_for_country},
};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct InitiatePaymentPayload { pub struct InitiatePaymentPayload {
@@ -23,6 +30,13 @@ pub struct ConfirmPaymentPayload {
pub external_tx_id: String, pub external_tx_id: String,
} }
/// Без auth_guard — цены должны быть видны анонимному посетителю сайта
/// (см. netrunner-landing/components/sections/Pricing.tsx), не только
/// залогиненным.
pub fn public_router() -> Router<ApiState> {
Router::new().route("/plans", get(list_plans_api))
}
pub fn router(state: ApiState) -> Router<ApiState> { pub fn router(state: ApiState) -> Router<ApiState> {
Router::new() Router::new()
.route("/pay/initiate", post(initiate_payment_api)) .route("/pay/initiate", post(initiate_payment_api))
@@ -31,6 +45,68 @@ pub fn router(state: ApiState) -> Router<ApiState> {
.route_layer(middleware::from_fn_with_state(state, auth_guard)) .route_layer(middleware::from_fn_with_state(state, auth_guard))
} }
/// IP реального посетителя — из `X-Real-IP` (его ставит nginx на проде, см.
/// netrunner-data/nginx/*, netrunner-backend/nginx/*, восстановлен из
/// `CF-Connecting-IP` через `set_real_ip_from`/`real_ip_header`), иначе
/// `X-Forwarded-For` (первый адрес в цепочке), иначе — реальный TCP-пир
/// (для локальной разработки без nginx впереди).
fn extract_client_ip(headers: &HeaderMap, connect_info: &SocketAddr) -> IpAddr {
if let Some(real_ip) = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.trim().parse().ok())
{
return real_ip;
}
if let Some(forwarded) = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse().ok())
{
return forwarded;
}
connect_info.ip()
}
/// Тарифы в валюте, определённой по региону посетителя. Если IP похож на
/// VPN/датацентр (см. `geoip_service.is_vpn`) — намеренно НЕ используем
/// региональную цену, отдаём базовую USD (иначе покупка "из региона" через
/// любой публичный VPN обесценивала бы всю схему региональных скидок).
async fn list_plans_api(
State(state): State<ApiState>,
headers: HeaderMap,
ConnectInfo(connect_info): ConnectInfo<SocketAddr>,
) -> Result<Json<Value>, AppError> {
let ip = extract_client_ip(&headers, &connect_info);
let is_vpn = state.geoip_service.is_vpn(ip).await;
let country = state.geoip_service.country_for_ip(ip).await;
let currency = if is_vpn {
"USD"
} else {
country
.as_deref()
.and_then(currency_for_country)
.unwrap_or("USD")
};
let (resolved_currency, plans) = state
.billing_service
.list_plans_in_currency(currency)
.await?;
Ok(Json(json!({
"currency": resolved_currency,
"detected_country": country,
"vpn_detected": is_vpn,
"plans": plans.into_iter().map(|(name, amount)| json!({
"plan": name,
"amount": amount,
})).collect::<Vec<_>>(),
})))
}
/// Шаг 1: Запрашиваем у провайдера данные для оплаты (адрес, ссылку и т.д.) /// Шаг 1: Запрашиваем у провайдера данные для оплаты (адрес, ссылку и т.д.)
async fn initiate_payment_api( async fn initiate_payment_api(
Extension(user): Extension<User>, Extension(user): Extension<User>,
+148
View File
@@ -0,0 +1,148 @@
//! Провайдер [@CryptoBot](https://t.me/CryptoBot) (Crypto Pay API) — оплата
//! криптой прямо в Telegram, без TonConnect-кошелька: юзер получает ссылку на
//! инвойс внутри CryptoBot, платит там, возвращается и жмёт "Я оплатил".
//!
//! Биллим в USDT 1:1 к USD-цене тарифа (см. `BillingService::initiate_payment` —
//! в отличие от TON, курс не нужен, `invoice.amount` уже приходит в USD-центах
//! из `plan_prices`). Своего вебхука пока нет — статус подтверждается либо
//! ручным нажатием (`verify_payment` бьёт по конкретному `invoice_id`), либо
//! никогда автоматически не подтягивается фоновой сверкой (в отличие от TON,
//! `get_recent_transactions` не переопределён — см. README рядом с `mod.rs`).
//!
//! Документация API: <https://help.crypt.bot/crypto-pay-api>.
use super::{InvoiceData, PaymentProvider};
use crate::error::AppError;
use async_trait::async_trait;
use serde_json::{json, Value};
/// Актив, в котором выставляется инвойс. USDT — самый ликвидный и стабильный
/// (1:1 к USD, без своей волатильности) из поддерживаемых Crypto Pay API.
const ASSET: &str = "USDT";
pub struct CryptoBotProvider {
pub api_token: String,
/// `https://pay.crypt.bot/api` (mainnet) или `https://testnet-pay.crypt.bot/api`.
pub base_url: String,
}
impl CryptoBotProvider {
fn client(&self) -> Result<reqwest::Client, AppError> {
use reqwest::header::{HeaderMap, HeaderValue};
let mut headers = HeaderMap::new();
headers.insert(
"Crypto-Pay-API-Token",
HeaderValue::from_str(&self.api_token).map_err(|e| {
AppError::Internal(format!("Некорректный CRYPTOBOT_API_TOKEN: {e}"))
})?,
);
reqwest::Client::builder()
.default_headers(headers)
.build()
.map_err(|e| AppError::Internal(format!("Ошибка создания HTTP-клиента: {e}")))
}
}
#[async_trait]
impl PaymentProvider for CryptoBotProvider {
fn provider_id(&self) -> &'static str {
"CRYPTOBOT"
}
async fn create_checkout_session(&self, invoice: &InvoiceData) -> Result<Value, AppError> {
// invoice.amount — USD-центы (см. docstring модуля), USDT биллится 1:1.
let amount_usdt = format!("{:.2}", invoice.amount as f64 / 100.0);
let res: Value = self
.client()?
.post(format!("{}/createInvoice", self.base_url))
.json(&json!({
"asset": ASSET,
"amount": amount_usdt,
"description": "Netrunner VPN — подписка",
"payload": invoice.invoice_id.to_string(),
}))
.send()
.await
.map_err(|e| AppError::Internal(format!("Ошибка сети CryptoBot API: {e}")))?
.json()
.await
.map_err(|e| AppError::Internal(format!("Ошибка парсинга ответа CryptoBot: {e}")))?;
if res["ok"].as_bool() != Some(true) {
return Err(AppError::Internal(format!(
"CryptoBot createInvoice отказал: {}",
res["error"]
)));
}
let result = &res["result"];
Ok(json!({
// Наш собственный ID (не CryptoBot'а) — чтобы вызывающий код мог
// позже дёрнуть `confirm_payment(invoice_id, ...)`.
"invoice_id": invoice.invoice_id,
"cryptobot_invoice_id": result["invoice_id"],
"pay_url": result["bot_invoice_url"].as_str().or_else(|| result["pay_url"].as_str()),
}))
}
/// `external_tx_id` здесь — не хэш транзакции, а собственный `invoice_id`
/// CryptoBot (полученный из `create_checkout_session`, см. `bot.rs::pay_check`
/// — он приходит в callback-данных, отдельно в БД не хранится).
async fn verify_payment(
&self,
external_tx_id: &str,
expected_amount: i64,
expected_invoice_id: &str,
) -> Result<bool, AppError> {
let res: Value = self
.client()?
.get(format!("{}/getInvoices", self.base_url))
.query(&[("invoice_ids", external_tx_id)])
.send()
.await
.map_err(|e| AppError::Internal(format!("Ошибка сети CryptoBot API: {e}")))?
.json()
.await
.map_err(|e| AppError::Internal(format!("Ошибка парсинга ответа CryptoBot: {e}")))?;
if res["ok"].as_bool() != Some(true) {
// Ответ API "не ok" (сбой/рейт-лимит/недоступность CryptoBot) — не то
// же самое, что "инвойс не найден/не оплачен". Раньше это тоже
// схлопывалось в `Ok(false)`, а confirm_payment необратимо помечает
// инвойс `failed` на любое `false` — реальная оплата терялась
// навсегда при простом временном сбое CryptoBot API. `Err` оставляет
// инвойс `pending`, юзер может нажать "Я оплатил" ещё раз.
return Err(AppError::Internal(format!(
"CryptoBot getInvoices вернул ошибку: {}",
res["error"]
)));
}
let Some(item) = res["result"]["items"].as_array().and_then(|a| a.first()) else {
return Ok(false);
};
let status = item["status"].as_str().unwrap_or("");
let payload = item["payload"].as_str().unwrap_or("");
let paid_amount: f64 = item["amount"]
.as_str()
.and_then(|s| s.parse().ok())
.unwrap_or(0.0);
let expected_usdt = expected_amount as f64 / 100.0;
if status == "paid" && payload == expected_invoice_id && paid_amount >= expected_usdt - 0.01
{
tracing::info!("CryptoBot invoice {} verified!", external_tx_id);
Ok(true)
} else {
tracing::warn!(
"CryptoBot mismatch. Expected invoice: {}, got payload: {}, status: {}",
expected_invoice_id,
payload,
status
);
Ok(false)
}
}
}
+1
View File
@@ -7,6 +7,7 @@ use async_trait::async_trait;
use serde_json::Value; use serde_json::Value;
use uuid::Uuid; use uuid::Uuid;
pub mod cryptobot;
pub mod ton; pub mod ton;
pub struct InvoiceData { pub struct InvoiceData {
+12 -1
View File
@@ -62,7 +62,18 @@ impl PaymentProvider for TonProvider {
.map_err(|e| AppError::Internal(format!("Ошибка запроса к TON API: {}", e)))?; .map_err(|e| AppError::Internal(format!("Ошибка запроса к TON API: {}", e)))?;
if !res.status().is_success() { if !res.status().is_success() {
return Ok(false); // Транзакция не найдена или еще не подтверждена // Неуспешный HTTP-статус (429/5xx/недоступность TonAPI) — это НЕ то
// же самое, что "транзакция не найдена/невалидна". Раньше оба случая
// схлопывались в `Ok(false)`, а confirm_payment на любое `false`
// необратимо помечает инвойс `failed` — реальный платёж, наткнувшийся
// на временный сбой TonAPI именно в момент подтверждения, терялся
// навсегда: `reconcile_pending_payments` подбирает только `pending`,
// не `failed`. Возвращаем `Err`, чтобы `confirm_payment` НЕ трогал
// статус инвойса и юзер/сверка могли повторить попытку позже.
return Err(AppError::Internal(format!(
"TonAPI недоступен или транзакция ещё не проиндексирована: HTTP {}",
res.status()
)));
} }
let data: Value = res let data: Value = res
+515 -43
View File
@@ -40,6 +40,23 @@ impl Clone for BillingService {
"TON", "TON",
Box::new(super::providers::ton::TonProvider { master_wallet }), Box::new(super::providers::ton::TonProvider { master_wallet }),
); );
// Опционален: без токена оплата через CryptoBot просто вернёт "provider
// not supported" (см. `initiate_payment`), а не уронит инстанс при
// старте — в отличие от обязательных секретов вроде TON_MASTER_WALLET.
if let Ok(api_token) = std::env::var("CRYPTOBOT_API_TOKEN") {
let base_url = if std::env::var("CRYPTOBOT_TESTNET").as_deref() == Ok("true") {
"https://testnet-pay.crypt.bot/api".to_string()
} else {
"https://pay.crypt.bot/api".to_string()
};
providers.insert(
"CRYPTOBOT",
Box::new(super::providers::cryptobot::CryptoBotProvider {
api_token,
base_url,
}),
);
}
Self { Self {
repo: self.repo.clone(), repo: self.repo.clone(),
@@ -60,6 +77,23 @@ impl BillingService {
"TON", "TON",
Box::new(super::providers::ton::TonProvider { master_wallet }), Box::new(super::providers::ton::TonProvider { master_wallet }),
); );
// Опционален: без токена оплата через CryptoBot просто вернёт "provider
// not supported" (см. `initiate_payment`), а не уронит инстанс при
// старте — в отличие от обязательных секретов вроде TON_MASTER_WALLET.
if let Ok(api_token) = std::env::var("CRYPTOBOT_API_TOKEN") {
let base_url = if std::env::var("CRYPTOBOT_TESTNET").as_deref() == Ok("true") {
"https://testnet-pay.crypt.bot/api".to_string()
} else {
"https://pay.crypt.bot/api".to_string()
};
providers.insert(
"CRYPTOBOT",
Box::new(super::providers::cryptobot::CryptoBotProvider {
api_token,
base_url,
}),
);
}
Self { Self {
repo, repo,
@@ -68,6 +102,38 @@ impl BillingService {
} }
} }
/// Активные тарифы с USD-ценой — для плиточного выбора тарифа в боте.
pub async fn list_plans_usd(&self) -> Result<Vec<(String, i64)>, AppError> {
self.repo
.list_active_plans_usd()
.await
.map_err(AppError::Database)
}
/// Тарифы в конкретной валюте — для `GET /plans` (см.
/// `billing::controller::list_plans_api`) и мультивалютного бота
/// (`bot.rs`). Если для этой валюты в `plan_prices` нет ни одной активной
/// строки (валюта определилась, но регион не заведён — маловероятно,
/// т.к. валюта берётся из той же карты, что и миграция, но на всякий
/// случай) — тихо фолбэкается на USD, а не отдаёт пустой список.
pub async fn list_plans_in_currency(
&self,
currency: &str,
) -> Result<(String, Vec<(String, i64)>), AppError> {
if currency != "USD" {
let regional = self
.repo
.list_active_plans_by_currency(currency)
.await
.map_err(AppError::Database)?;
if !regional.is_empty() {
return Ok((currency.to_string(), regional));
}
}
let usd = self.list_plans_usd().await?;
Ok(("USD".to_string(), usd))
}
/// Курс TON к валюте из кеша; `None` при промахе/недоступности Redis — /// Курс TON к валюте из кеша; `None` при промахе/недоступности Redis —
/// вызывающий код должен сходить за свежим значением и заполнить кеш сам. /// вызывающий код должен сходить за свежим значением и заполнить кеш сам.
async fn get_cached_ton_rate(&self, currency_lower: &str) -> Option<rust_decimal::Decimal> { async fn get_cached_ton_rate(&self, currency_lower: &str) -> Option<rust_decimal::Decimal> {
@@ -115,57 +181,69 @@ impl BillingService {
AppError::BusinessLogic("Тариф недоступен для данного региона".into()) AppError::BusinessLogic("Тариф недоступен для данного региона".into())
})?; })?;
// --- КУРС TON: сначала кеш (Redis, TTL 60с), иначе синхронный запрос к TonAPI --- // Курс TON нужен только самому TON-провайдеру — блокчейн понимает
let currency_lower = currency.to_lowercase(); // только nanoTON, а не фиат. Остальные провайдеры (CryptoBot: USDT
let currency_upper = currency.to_uppercase(); // 1:1 к USD; в будущем карточные эквайринги) биллятся напрямую в
// валюте `plan_prices`, без пересчёта.
let invoice_amount = if provider_name == "TON" {
// --- КУРС TON: сначала кеш (Redis, TTL 60с), иначе синхронный запрос к TonAPI ---
let currency_lower = currency.to_lowercase();
let currency_upper = currency.to_uppercase();
let ton_rate = if let Some(cached) = self.get_cached_ton_rate(&currency_lower).await { let ton_rate = if let Some(cached) = self.get_cached_ton_rate(&currency_lower).await {
cached 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
};
// ----------------------------------------
Self::calculate_ton_invoice(fiat_amount_minimal, ton_rate)
.map_err(AppError::BusinessLogic)?
} else { } else {
let client = reqwest::Client::new(); fiat_amount_minimal
// 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)
.map_err(AppError::BusinessLogic)?;
let invoice_id = self let invoice_id = self
.repo .repo
.create_invoice(user.id, plan_name, provider_name, currency, nanotons) .create_invoice(user.id, plan_name, provider_name, currency, invoice_amount)
.await?; .await?;
metrics::counter!("payments_total", "provider" => provider_name.to_string(), "status" => "initiated")
.increment(1);
let invoice_data = InvoiceData { let invoice_data = InvoiceData {
invoice_id, invoice_id,
amount: nanotons, amount: invoice_amount,
currency: currency.into(), currency: currency.into(),
}; };
@@ -199,8 +277,18 @@ impl BillingService {
return Ok(()); return Ok(());
} }
// 2. Получаем провайдера // 2. Получаем провайдера, которым был выставлен именно этот инвойс —
let provider = self.providers.get("TON").unwrap(); // раньше здесь был захардкожен "TON", что ломало подтверждение любого
// другого провайдера (см. `invoice.provider`, заполняется в `create_invoice`).
let provider = self
.providers
.get(invoice.provider.as_str())
.ok_or_else(|| {
AppError::Internal(format!(
"Провайдер {} не сконфигурирован на этом инстансе",
invoice.provider
))
})?;
// 3. Провайдер лезет в блокчейн и проверяет транзакцию // 3. Провайдер лезет в блокчейн и проверяет транзакцию
let is_valid = provider let is_valid = provider
@@ -211,6 +299,8 @@ impl BillingService {
self.repo self.repo
.update_invoice_status(invoice_id, "failed", Some(external_tx_id)) .update_invoice_status(invoice_id, "failed", Some(external_tx_id))
.await?; .await?;
metrics::counter!("payments_total", "provider" => invoice.provider.clone(), "status" => "failed")
.increment(1);
return Err(AppError::BusinessLogic( return Err(AppError::BusinessLogic(
"Payment verification failed".into(), "Payment verification failed".into(),
)); ));
@@ -220,6 +310,8 @@ impl BillingService {
self.repo self.repo
.update_invoice_status(invoice_id, "paid", Some(external_tx_id)) .update_invoice_status(invoice_id, "paid", Some(external_tx_id))
.await?; .await?;
metrics::counter!("payments_total", "provider" => invoice.provider.clone(), "status" => "paid")
.increment(1);
// 5. АКТИВИРУЕМ ТАРИФ // 5. АКТИВИРУЕМ ТАРИФ
self.repo self.repo
@@ -233,6 +325,74 @@ impl BillingService {
.reward_referrer(invoice.user_id, revshare_bonus) .reward_referrer(invoice.user_id, revshare_bonus)
.await; .await;
// 7. Партнёрская комиссия — независимо от рефералки выше, отдельная
// система (см. modules::staff). Считаем от фиатной цены тарифа В
// ВАЛЮТЕ ИНВОЙСА через get_plan_price, а НЕ от invoice.amount:
// invoice.amount для TON — это nanoTON (+2% буфер на конвертацию),
// не фиат; для CryptoBot это фиат-центы 1:1, но полагаться на то,
// каким провайдером оплачен именно этот инвойс, неверно —
// get_plan_price(plan_name, currency) корректен одинаково для всех
// провайдеров. Ошибка здесь не должна ронять подтверждение платежа
// (та же resilience, что у reward_referrer выше).
if let Err(e) = self.record_partner_commission_if_referred(&invoice).await {
tracing::warn!(
"Не удалось начислить партнёрскую комиссию по инвойсу {}: {:?}",
invoice.id,
e
);
}
Ok(())
}
/// См. комментарий в шаге 7 `confirm_payment` — вынесено отдельным
/// методом ради `?`-раннего-выхода без загрязнения основной функции.
async fn record_partner_commission_if_referred(
&self,
invoice: &crate::db::repository::InvoiceRecord,
) -> Result<(), AppError> {
let Some(buyer) = self.repo.get_user_by_id(invoice.user_id).await? else {
return Ok(());
};
let Some(partner_id) = buyer.referred_by_partner_id else {
return Ok(());
};
let Some(partner) = self.repo.get_user_by_id(partner_id).await? else {
return Ok(());
};
let Some(percent) = partner.commission_percent else {
return Ok(()); // партнёр без выставленного процента — молча пропускаем
};
let Some(fiat_amount_minimal) = self
.repo
.get_plan_price(&invoice.plan_name, &invoice.currency)
.await?
else {
return Ok(()); // тариф/валюта более не активны — пропускаем
};
use rust_decimal::prelude::{FromPrimitive, ToPrimitive};
let commission = (rust_decimal::Decimal::from_i64(fiat_amount_minimal).unwrap_or_default()
* percent
/ rust_decimal::Decimal::from(100))
.round()
.to_i64()
.unwrap_or(0);
if commission > 0 {
self.repo
.record_partner_commission(
partner_id,
invoice.id,
invoice.user_id,
&invoice.currency,
commission,
percent,
)
.await?;
metrics::counter!("partner_commission_total", "currency" => invoice.currency.clone())
.increment(commission as u64);
}
Ok(()) Ok(())
} }
@@ -356,3 +516,315 @@ impl BillingService {
Ok(nanotons) Ok(nanotons)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::PgRepository;
use crate::modules::billing::providers::{InvoiceData, PaymentProvider};
use async_trait::async_trait;
use rust_decimal::Decimal;
use sqlx::PgPool;
// --- calculate_ton_invoice: чистая арифметика, без БД ---
#[test]
fn test_calculate_ton_invoice_applies_2_percent_buffer() {
// $10.00 (1000 центов) по курсу 2 USD/TON -> 5 TON чистых -> +2% буфер -> 5.1 TON
let rate = Decimal::new(2, 0);
let nanotons = BillingService::calculate_ton_invoice(1000, rate).unwrap();
assert_eq!(nanotons, 5_100_000_000);
}
#[test]
fn test_calculate_ton_invoice_zero_rate_errors() {
let err = BillingService::calculate_ton_invoice(1000, Decimal::ZERO).unwrap_err();
assert!(err.contains("не может быть нулевым"));
}
// --- confirm_payment: владение, идемпотентность, маршрутизация провайдера ---
struct FakeProvider {
id: &'static str,
verify_result: bool,
}
#[async_trait]
impl PaymentProvider for FakeProvider {
fn provider_id(&self) -> &'static str {
self.id
}
async fn create_checkout_session(
&self,
_invoice: &InvoiceData,
) -> Result<serde_json::Value, AppError> {
Ok(serde_json::json!({}))
}
async fn verify_payment(
&self,
_external_tx_id: &str,
_expected_amount: i64,
_expected_invoice_id: &str,
) -> Result<bool, AppError> {
Ok(self.verify_result)
}
}
fn service_with_fake_provider(
repo: Arc<dyn AppRepository>,
verify_result: bool,
) -> BillingService {
let mut providers: HashMap<&'static str, Box<dyn PaymentProvider>> = HashMap::new();
providers.insert(
"TEST",
Box::new(FakeProvider {
id: "TEST",
verify_result,
}),
);
BillingService {
repo,
providers,
redis: None,
}
}
#[sqlx::test]
async fn test_confirm_payment_rejects_non_owner(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let owner = repo.upsert_user(1, None).await.unwrap();
let stranger = repo.upsert_user(2, None).await.unwrap();
let invoice_id = repo
.create_invoice(owner.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
let err = svc
.confirm_payment(stranger.id, invoice_id, "tx1")
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
// Инвойс не должен был измениться.
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
assert_eq!(invoice.status, "pending");
}
#[sqlx::test]
async fn test_confirm_payment_idempotent_when_already_paid(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
// verify_result=false: если бы идемпотентность не сработала, второй вызов
// упал бы на верификации — но код обязан выйти раньше, на шаге "already paid".
let svc = service_with_fake_provider(repo.clone(), false);
let user = repo.upsert_user(1, None).await.unwrap();
let invoice_id = repo
.create_invoice(user.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
repo.update_invoice_status(invoice_id, "paid", Some("tx0"))
.await
.unwrap();
svc.confirm_payment(user.id, invoice_id, "tx1")
.await
.expect("повторное подтверждение уже оплаченного инвойса должно быть no-op");
}
#[sqlx::test]
async fn test_confirm_payment_unknown_provider_errors(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let user = repo.upsert_user(1, None).await.unwrap();
// Инвойс выставлен провайдером, которого нет в HashMap этого инстанса —
// регрессионный тест на баг "confirm_payment жёстко ходил в TON независимо
// от invoice.provider".
let invoice_id = repo
.create_invoice(user.id, "GHOST_1M", "NOT_CONFIGURED", "USD", 500)
.await
.unwrap();
let err = svc
.confirm_payment(user.id, invoice_id, "tx1")
.await
.unwrap_err();
assert!(matches!(err, AppError::Internal(_)));
}
#[sqlx::test]
async fn test_confirm_payment_failed_verification_marks_invoice_failed(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), false);
let user = repo.upsert_user(1, None).await.unwrap();
let invoice_id = repo
.create_invoice(user.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
let err = svc
.confirm_payment(user.id, invoice_id, "tx1")
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
assert_eq!(invoice.status, "failed");
// Подписка не должна была активироваться.
assert!(repo.get_subscription(user.id).await.unwrap().is_none());
}
#[sqlx::test]
async fn test_confirm_payment_success_activates_subscription_and_rewards_referrer(
pool: PgPool,
) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let referrer = repo.upsert_user(1, None).await.unwrap();
let buyer = repo.upsert_user(2, None).await.unwrap();
repo.create_referral(referrer.id, buyer.id).await.unwrap();
let invoice_id = repo
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500) // $5.00
.await
.unwrap();
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.expect("успешная верификация должна пройти");
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
assert_eq!(invoice.status, "paid");
let sub = repo
.get_subscription(buyer.id)
.await
.unwrap()
.expect("подписка должна активироваться");
assert_eq!(sub.plan_name, "GHOST_1M");
assert_eq!(sub.status, "active");
// 10% от суммы инвойса (500) = 50, зачисляется рефереру на баланс.
let referrer_updated = repo.get_user_by_id(referrer.id).await.unwrap().unwrap();
assert_eq!(referrer_updated.balance, 50);
}
// --- Партнёрская комиссия: считается от get_plan_price, не от invoice.amount ---
#[sqlx::test]
async fn test_partner_commission_uses_plan_price_not_invoice_amount(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let partner = repo
.create_staff_user(
"partner1",
"hash",
"partner",
false,
Some(Decimal::new(10, 0)), // 10%
Some("p_abc123"),
)
.await
.unwrap();
let buyer = repo.upsert_user(2, None).await.unwrap();
repo.set_referred_by_partner(buyer.id, partner.id)
.await
.unwrap();
// invoice.amount = 999999 — намеренно НЕ совпадает с реальной ценой тарифа
// (симулирует TON-инвойс, где amount — nanoTON, а не фиат), чтобы доказать,
// что комиссия считается через get_plan_price("GHOST_1M", "USD") = 500,
// а не от этого числа.
let invoice_id = repo
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 999_999)
.await
.unwrap();
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.unwrap();
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
let usd_earnings = earnings.iter().find(|e| e.currency == "USD").unwrap();
// 10% от 500 (реальная цена GHOST_1M/USD) = 50, а НЕ 10% от 999999.
assert_eq!(usd_earnings.total_earned, 50);
}
#[sqlx::test]
async fn test_partner_commission_idempotent_on_duplicate_confirm(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
let partner = repo
.create_staff_user(
"partner1",
"hash",
"partner",
false,
Some(Decimal::new(10, 0)),
Some("p_abc123"),
)
.await
.unwrap();
let buyer = repo.upsert_user(2, None).await.unwrap();
repo.set_referred_by_partner(buyer.id, partner.id)
.await
.unwrap();
let invoice_id = repo
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.unwrap();
// Второй вызов — инвойс уже "paid", подтверждение — no-op (см. тест выше),
// поэтому record_partner_commission_if_referred не должен вызваться снова.
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.unwrap();
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
let usd_earnings = earnings.iter().find(|e| e.currency == "USD").unwrap();
assert_eq!(
usd_earnings.total_earned, 50,
"комиссия не должна начислиться дважды"
);
}
#[sqlx::test]
async fn test_partner_commission_skipped_without_percent(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = service_with_fake_provider(repo.clone(), true);
// Партнёр без выставленного процента — комиссия не должна начисляться,
// и confirm_payment не должен упасть.
let partner = repo
.create_staff_user("partner1", "hash", "partner", false, None, Some("p_abc123"))
.await
.unwrap();
let buyer = repo.upsert_user(2, None).await.unwrap();
repo.set_referred_by_partner(buyer.id, partner.id)
.await
.unwrap();
let invoice_id = repo
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
svc.confirm_payment(buyer.id, invoice_id, "tx1")
.await
.unwrap();
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
assert!(earnings.is_empty());
}
}
+100
View File
@@ -0,0 +1,100 @@
//! Статические таблицы "страна/язык → валюта". Не БД — список валют вообще
//! завязан на то, что реально заведено в `plan_prices` (см. `migrations/
//! 0001_init.sql` + `0007_regional_pricing.sql`), т.е. это код, а не
//! конфигурация — добавление новой валюты всё равно требует миграции.
/// ISO 3166-1 alpha-2 страна → код валюты в `plan_prices.currency`.
/// Нет записи — значит для этой страны своей региональной цены нет,
/// вызывающая сторона обязана фолбэкнуться на `"USD"`.
pub fn currency_for_country(country_code: &str) -> Option<&'static str> {
match country_code {
"RU" => Some("RUB"),
"CN" => Some("CNY"),
"TR" => Some("TRY"),
"IR" => Some("IRR"),
"UA" => Some("UAH"),
"BY" => Some("BYN"),
"VN" => Some("VND"),
"UZ" => Some("UZS"),
_ => None,
}
}
/// Telegram `language_code` → валюта — единственный доступный сигнал в
/// боте (Bot API не отдаёт IP пользователя вообще, см. `mod.rs`). Грубее
/// геолокации (язык ≠ страна: диаспора, мультиязычные регионы), но лучше,
/// чем everyone-USD.
pub fn currency_for_telegram_lang(lang_code: &str) -> Option<&'static str> {
// Telegram присылает "ru", "uk", иногда с региональным суффиксом
// ("en-US") — берём только первичный подтег.
let primary = lang_code.split(['-', '_']).next().unwrap_or(lang_code);
match primary {
"ru" => Some("RUB"),
"zh" => Some("CNY"),
"tr" => Some("TRY"),
"fa" => Some("IRR"),
"uk" => Some("UAH"),
"be" => Some("BYN"),
"vi" => Some("VND"),
"uz" => Some("UZS"),
_ => None,
}
}
/// Символ валюты для отображения (бот, потенциально фронт).
pub fn currency_symbol(currency: &str) -> &'static str {
match currency {
"USD" => "$",
"RUB" => "",
"CNY" => "¥",
"TRY" => "",
"IRR" => "",
"UAH" => "",
"BYN" => "Br",
"VND" => "",
"UZS" => "сум",
_ => "$",
}
}
/// IRR/VND/UZS хранятся в `plan_prices.amount` целыми единицами (не центами/
/// копейками) — см. комментарий в migrations/0007_regional_pricing.sql: у
/// этих валют нет ходовой разменной монеты, дробные суммы бессмысленны для
/// пользователя.
fn is_whole_unit_currency(currency: &str) -> bool {
matches!(currency, "IRR" | "VND" | "UZS")
}
/// `amount` — как хранится в `plan_prices` (центы/копейки для большинства
/// валют, целые единицы для IRR/VND/UZS, см. `is_whole_unit_currency`).
/// Возвращает готовую для показа строку с символом валюты, БЕЗ значка
/// тысячных разделителей (это I18n-забота фронта/бота, если понадобится).
pub fn format_amount(currency: &str, amount: i64) -> String {
let symbol = currency_symbol(currency);
if is_whole_unit_currency(currency) {
format!("{} {}", amount, symbol)
} else {
format!("{}{:.2}", symbol, amount as f64 / 100.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn country_lookup_known_and_unknown() {
assert_eq!(currency_for_country("RU"), Some("RUB"));
assert_eq!(currency_for_country("UZ"), Some("UZS"));
assert_eq!(currency_for_country("US"), None);
assert_eq!(currency_for_country(""), None);
}
#[test]
fn telegram_lang_strips_region_suffix() {
assert_eq!(currency_for_telegram_lang("ru"), Some("RUB"));
assert_eq!(currency_for_telegram_lang("ru-RU"), Some("RUB"));
assert_eq!(currency_for_telegram_lang("en-US"), None);
assert_eq!(currency_for_telegram_lang("en"), None);
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Определение страны по IP (self-hosted DB-IP Lite, формат `.mmdb` — тот же,
//! что у MaxMind, но без регистрации/лицензионного ключа, см. README ниже) и
//! грубая эвристика "это VPN/датацентр?" по открытому списку CIDR-диапазонов
//! (X4BNet `lists_vpn`, тоже без ключей/оплаты).
//!
//! Обе базы скачиваются в рантайме (см. `service::GeoipService::refresh_*`),
//! не коммитятся в репозиторий — только пути/URL в конфиге. Периодическое
//! обновление — фоновая задача в `tasks.rs`, не здесь.
//!
//! Region-based pricing работает только там, где есть IP посетителя (сайт,
//! `GET /plans` — см. `modules::billing::controller`) — у Telegram-бота IP
//! юзера не видно вообще (ограничение самого Bot API), поэтому там валюта
//! определяется по `language_code` (см. `currency::currency_for_telegram_lang`).
pub mod currency;
pub mod service;
+143
View File
@@ -0,0 +1,143 @@
//! Держит в памяти два периодически обновляемых источника:
//! - `.mmdb`-база страна-по-IP (DB-IP Lite, тот же формат, что MaxMind
//! GeoLite2, но без регистрации/лицензионного ключа — просто прямая
//! ссылка на скачивание, обновляется у них раз в месяц).
//! - список CIDR-диапазонов известных VPN/датацентров (X4BNet `lists_vpn`,
//! тоже открытый, без ключей) — грубая эвристика, не идеальная (см. их
//! же README), но достаточная для "не давать региональную скидку через
//! очевидный VPN", не для полноценного антифрода.
//!
//! Обновление обеих баз — фоновая периодическая задача (см. `tasks.rs`), не
//! часть этого файла: сервис только хранит текущее состояние и умеет его
//! перезаписать (`refresh_*`), вызывающая сторона решает, когда дёргать.
use flate2::read::GzDecoder;
use ipnet::IpNet;
use serde::Deserialize;
use std::io::Read;
use std::net::IpAddr;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{info, warn};
#[derive(Deserialize)]
struct MmdbCountryLookup<'a> {
#[serde(borrow)]
country: Option<MmdbCountryCode<'a>>,
}
#[derive(Deserialize)]
struct MmdbCountryCode<'a> {
iso_code: Option<&'a str>,
}
#[derive(Clone)]
pub struct GeoipService {
reader: Arc<RwLock<Option<maxminddb::Reader<Vec<u8>>>>>,
vpn_ranges: Arc<RwLock<Vec<IpNet>>>,
/// Шаблон URL с одним `{}` под "YYYY-MM" — DB-IP публикует файл с датой
/// в имени, единой "latest"-ссылки у них нет.
geoip_url_template: String,
vpn_list_url: String,
}
impl GeoipService {
pub fn new(geoip_url_template: String, vpn_list_url: String) -> Self {
Self {
reader: Arc::new(RwLock::new(None)),
vpn_ranges: Arc::new(RwLock::new(Vec::new())),
geoip_url_template,
vpn_list_url,
}
}
/// ISO 3166-1 alpha-2 код страны по IP, `None` — база ещё не загрузилась
/// (первые секунды после старта) или IP не нашёлся (приватный диапазон,
/// пробел в данных DB-IP).
pub async fn country_for_ip(&self, ip: IpAddr) -> Option<String> {
let guard = self.reader.read().await;
let reader = guard.as_ref()?;
let lookup: MmdbCountryLookup = reader.lookup(ip).ok()?;
lookup
.country
.and_then(|c| c.iso_code)
.map(|s| s.to_string())
}
/// Грубая проверка "похоже на VPN/датацентр" — see module doc про точность.
pub async fn is_vpn(&self, ip: IpAddr) -> bool {
let guard = self.vpn_ranges.read().await;
guard.iter().any(|net| net.contains(&ip))
}
/// Скачивает и перезагружает `.mmdb`. Пробует текущий месяц, затем
/// предыдущий (файл за новый месяц у DB-IP иногда появляется с
/// задержкой в первые дни) — до первого успешного скачивания.
pub async fn refresh_geoip_db(&self) -> Result<(), String> {
let now = chrono::Utc::now();
for months_back in 0..=1i64 {
let date = now - chrono::Duration::days(31 * months_back);
let month_tag = date.format("%Y-%m").to_string();
let url = self.geoip_url_template.replace("{}", &month_tag);
match Self::try_download_mmdb(&url).await {
Ok(reader) => {
*self.reader.write().await = Some(reader);
info!(url = %url, "GeoIP: база страна-по-IP обновлена");
return Ok(());
}
Err(e) => {
warn!(url = %url, error = %e, "GeoIP: не удалось скачать за этот месяц, пробую предыдущий");
}
}
}
Err("GeoIP: не удалось скачать .mmdb ни за текущий, ни за прошлый месяц".into())
}
async fn try_download_mmdb(url: &str) -> Result<maxminddb::Reader<Vec<u8>>, String> {
let resp = reqwest::get(url)
.await
.map_err(|e| format!("HTTP-запрос: {e}"))?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
let compressed = resp
.bytes()
.await
.map_err(|e| format!("тело ответа: {e}"))?;
let mut decoder = GzDecoder::new(&compressed[..]);
let mut decompressed = Vec::new();
decoder
.read_to_end(&mut decompressed)
.map_err(|e| format!("gzip-распаковка: {e}"))?;
maxminddb::Reader::from_source(decompressed).map_err(|e| format!("парсинг .mmdb: {e}"))
}
/// Скачивает и перезагружает список CIDR-диапазонов VPN/датацентров.
pub async fn refresh_vpn_list(&self) -> Result<(), String> {
let resp = reqwest::get(&self.vpn_list_url)
.await
.map_err(|e| format!("HTTP-запрос: {e}"))?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
let text = resp.text().await.map_err(|e| format!("тело ответа: {e}"))?;
let ranges: Vec<IpNet> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.filter_map(|l| l.parse().ok())
.collect();
if ranges.is_empty() {
return Err("список VPN-диапазонов пуст после парсинга — подозрительно, не перезаписываю текущий".into());
}
let count = ranges.len();
*self.vpn_ranges.write().await = ranges;
info!(count, "GeoIP: список VPN/датацентр-диапазонов обновлён");
Ok(())
}
}
+4
View File
@@ -3,6 +3,10 @@
pub mod auth; pub mod auth;
pub mod billing; pub mod billing;
pub mod geoip;
pub mod nodes; pub mod nodes;
pub mod proxy_internal;
pub mod referrals; pub mod referrals;
pub mod staff;
pub mod support;
pub mod users; pub mod users;
+5 -3
View File
@@ -14,7 +14,7 @@ use crate::{
db::models::{CreateNodePayload, User, VpnNode}, db::models::{CreateNodePayload, User, VpnNode},
error::AppError, error::AppError,
modules::{ modules::{
auth::{admin_guard, auth_guard}, auth::{auth_guard, node_manage_guard},
nodes::service::NodeService, nodes::service::NodeService,
}, },
}; };
@@ -41,8 +41,10 @@ pub fn admin_router(state: ApiState) -> Router<ApiState> {
.route("/", get(list_all_nodes).post(add_node)) .route("/", get(list_all_nodes).post(add_node))
.route("/{id}", delete(remove_node)) .route("/{id}", delete(remove_node))
.route("/{id}/restart", post(restart_node)) .route("/{id}/restart", post(restart_node))
// Теперь admin_guard использует from_fn_with_state .route_layer(middleware::from_fn_with_state(
.route_layer(middleware::from_fn_with_state(state.clone(), admin_guard)) state.clone(),
node_manage_guard,
))
.route_layer(middleware::from_fn_with_state(state.clone(), auth_guard)) .route_layer(middleware::from_fn_with_state(state.clone(), auth_guard))
} }
+45 -8
View File
@@ -86,11 +86,24 @@ impl NodeService {
ssh_password: &str, ssh_password: &str,
) -> Result<VpnNode, AppError> { ) -> Result<VpnNode, AppError> {
let node = self.repo.add_vpn_node_pending(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()); self.spawn_provisioning(
node.id,
node.ip_address.clone(),
ssh_password.to_string(),
node.port,
node.sni_domain.clone(),
);
Ok(node) Ok(node)
} }
fn spawn_provisioning(&self, node_id: Uuid, ip_address: String, password: String) { fn spawn_provisioning(
&self,
node_id: Uuid,
ip_address: String,
password: String,
port: i32,
sni_domain: Option<String>,
) {
let repo = self.repo.clone(); let repo = self.repo.clone();
let semaphore = self.deploy_semaphore.clone(); let semaphore = self.deploy_semaphore.clone();
@@ -101,7 +114,7 @@ impl NodeService {
}; };
let provision_result = tokio::task::spawn_blocking(move || { let provision_result = tokio::task::spawn_blocking(move || {
Self::provision_node_via_ssh(&ip_address, &password) Self::provision_node_via_ssh(&ip_address, &password, port, sni_domain.as_deref())
}) })
.await; .await;
@@ -123,7 +136,12 @@ impl NodeService {
}); });
} }
fn provision_node_via_ssh(ip: &str, password: &str) -> Result<(), AppError> { fn provision_node_via_ssh(
ip: &str,
password: &str,
port: i32,
sni_domain: Option<&str>,
) -> Result<(), AppError> {
use std::fs; use std::fs;
// 1. Просто считываем уже гарантированно созданный на старте сервера ключ // 1. Просто считываем уже гарантированно созданный на старте сервера ключ
@@ -135,9 +153,28 @@ impl NodeService {
let sess = Self::connect_ssh(ip, "root", password)?; let sess = Self::connect_ssh(ip, "root", password)?;
// 3. Подготавливаем команды автоматизации // 3. Подготавливаем команды автоматизации
let gh_token = std::env::var("GHCR_TOKEN").map_err(|_| { let gh_token = std::env::var("GT_REGISTRY_TOKEN").map_err(|_| {
AppError::Internal("GHCR_TOKEN не задан — провижининг ноды невозможен".into()) AppError::Internal("GT_REGISTRY_TOKEN не задан — провижининг ноды невозможен".into())
})?; })?;
// Секрет и публичный URL этого же бэкенда — нода должна ходить именно
// сюда для проверки токенов/лимитов (--require-auth), а не работать
// без авторизации, как было раньше при отсутствии этих переменных.
let proxy_internal_secret = std::env::var("PROXY_INTERNAL_SECRET").map_err(|_| {
AppError::Internal(
"PROXY_INTERNAL_SECRET не задан — провижининг ноды с --require-auth невозможен"
.into(),
)
})?;
let backend_url = std::env::var("WEB_APP_BASE_URL").map_err(|_| {
AppError::Internal("WEB_APP_BASE_URL не задан — провижининг ноды невозможен".into())
})?;
// Домен-декой конкретно этой ноды — если админ не указал при добавлении,
// используем тот же дефолт, что и сам netrunner-proxy без --decoy-host
// (netrunner_core::net::DEFAULT_DECOY_HOST в том, отдельном репозитории —
// здесь недоступен как зависимость, поэтому продублирован явно).
const DEFAULT_DECOY_HOST: &str = "www.debian.org";
let sni_domain = sni_domain.unwrap_or(DEFAULT_DECOY_HOST);
// Если скрипт инициализации отсутствует — жёстко падаем, а не деплоим // Если скрипт инициализации отсутствует — жёстко падаем, а не деплоим
// полуживую ноду заглушкой `echo`. // полуживую ноду заглушкой `echo`.
let init_script = fs::read_to_string("templates/init_node.sh").map_err(|e| { let init_script = fs::read_to_string("templates/init_node.sh").map_err(|e| {
@@ -161,8 +198,8 @@ impl NodeService {
); );
let full_command = format!( let full_command = format!(
"NODE_IP='{}' GH_TOKEN='{}'\n{}\n{}", "NODE_IP='{}' GH_TOKEN='{}' NODE_PORT='{}' SNI_DOMAIN='{}' BACKEND_URL='{}' PROXY_INTERNAL_SECRET='{}'\n{}\n{}",
ip, gh_token, init_script, harden_script ip, gh_token, port, sni_domain, backend_url, proxy_internal_secret, init_script, harden_script
); );
Self::exec_ssh_command(&sess, &full_command)?; Self::exec_ssh_command(&sess, &full_command)?;
+273
View File
@@ -0,0 +1,273 @@
//! Внутренний контракт для `netrunner-proxy`: проверка JWT-токена клиента и учёт
//! расхода трафика. Это не пользовательская сессия — прокси не логинится как юзер,
//! поэтому вместо `auth_guard`/cookie здесь общий секрет (`X-Internal-Secret`,
//! см. [`internal_secret_guard`]), которым делятся все прокси-ноды и бэкенд.
use crate::{api::ApiState, error::AppError};
use axum::{
extract::{Request, State},
middleware::Next,
response::Response,
routing::post,
Json, Router,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub async fn internal_secret_guard(
State(state): State<ApiState>,
req: Request,
next: Next,
) -> Result<Response, AppError> {
let provided = req
.headers()
.get("X-Internal-Secret")
.and_then(|v| v.to_str().ok());
if provided != Some(state.proxy_internal_secret.as_str()) {
return Err(AppError::Unauthorized("Invalid internal secret".into()));
}
Ok(next.run(req).await)
}
pub fn router() -> Router<ApiState> {
Router::new()
.route("/validate", post(validate_api))
.route("/usage", post(usage_api))
}
#[derive(Deserialize)]
struct ValidatePayload {
token: String,
}
#[derive(Serialize, Debug)]
struct ValidateResponse {
user_id: Uuid,
limit_bytes: Option<i64>,
used_bytes: i64,
}
/// Декодирует access-JWT тем же путём, что и `auth::guard::auth_guard`, но без
/// cookie/CSRF-логики (прокси всегда шлёт Bearer в теле, не в заголовках браузера).
async fn validate_api(
State(state): State<ApiState>,
Json(payload): Json<ValidatePayload>,
) -> Result<Json<ValidateResponse>, AppError> {
let token_data = jsonwebtoken::decode::<crate::modules::auth::service::Claims>(
&payload.token,
&jsonwebtoken::DecodingKey::from_secret(state.jwt_secret.as_bytes()),
&jsonwebtoken::Validation::default(),
)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
let user_id = 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)?;
Ok(Json(ValidateResponse {
user_id: user.id,
limit_bytes: user.data_limit_bytes,
used_bytes: user.data_used_bytes,
}))
}
#[derive(Deserialize)]
struct UsagePayload {
user_id: Uuid,
delta_bytes: i64,
}
#[derive(Serialize, Debug)]
struct UsageResponse {
used_bytes: i64,
limit_bytes: Option<i64>,
over_limit: bool,
}
/// Прибавляет репортнутую прокси дельту трафика к счётчику юзера и сразу
/// отвечает, не превышен ли лимит — прокси разрывает сессию в тот же тик, если
/// `over_limit == true`, не делая отдельный round-trip.
async fn usage_api(
State(state): State<ApiState>,
Json(payload): Json<UsagePayload>,
) -> Result<Json<UsageResponse>, AppError> {
let (used_bytes, limit_bytes) = state
.repo
.apply_usage_delta(payload.user_id, payload.delta_bytes)
.await?
.ok_or(AppError::UserNotFound)?;
let over_limit = limit_bytes.is_some_and(|limit| used_bytes >= limit);
Ok(Json(UsageResponse {
used_bytes,
limit_bytes,
over_limit,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::{AppRepository, PgRepository};
use crate::modules::auth::service::{AuthService, Claims};
use crate::modules::billing::service::BillingService;
use crate::modules::nodes::service::NodeService;
use crate::modules::referrals::service::ReferralService;
use crate::modules::staff::service::StaffService;
use crate::modules::users::service::UserService;
use jsonwebtoken::{encode, EncodingKey, Header};
use sqlx::PgPool;
use std::sync::Arc;
const SECRET: &str = "test-jwt-secret";
fn build_state(pool: PgPool) -> ApiState {
std::env::set_var(
"TON_MASTER_WALLET",
"UQtest_wallet_for_proxy_internal_tests",
);
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
ApiState {
repo: repo.clone(),
auth_service: AuthService::new(repo.clone()),
billing_service: BillingService::new(repo.clone(), None),
node_service: NodeService::new(repo.clone()),
referral_service: ReferralService::new(repo.clone()),
user_service: UserService::new(repo.clone()),
staff_service: StaffService::new(repo.clone()),
support_service: crate::modules::support::service::SupportService::new(repo.clone()),
geoip_service: crate::modules::geoip::service::GeoipService::new(
"https://example.invalid/{}.mmdb.gz".into(),
"https://example.invalid/vpn.txt".into(),
),
jwt_secret: SECRET.into(),
bot_token: "test-bot-token".into(),
bot_username: "test_bot".into(),
cookie_domain: "".into(),
cookie_secure: false,
csrf_allowed_origins: vec![],
proxy_internal_secret: "test-internal-secret".into(),
}
}
fn sign_token(user_id: uuid::Uuid) -> String {
let claims = Claims {
sub: user_id.to_string(),
tg_id: None,
exp: (chrono::Utc::now() + chrono::Duration::minutes(15)).timestamp() as usize,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(SECRET.as_bytes()),
)
.unwrap()
}
#[sqlx::test]
async fn test_validate_api_rejects_garbage_token(pool: PgPool) {
let state = build_state(pool);
let err = validate_api(
State(state),
Json(ValidatePayload {
token: "not-a-real-jwt".into(),
}),
)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[sqlx::test]
async fn test_validate_api_accepts_valid_token_and_returns_limits(pool: PgPool) {
let state = build_state(pool.clone());
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let user = repo.upsert_user(1, None).await.unwrap();
repo.set_data_limit(user.id, Some(1_000_000)).await.unwrap();
let token = sign_token(user.id);
let resp = validate_api(State(state), Json(ValidatePayload { token }))
.await
.unwrap();
assert_eq!(resp.user_id, user.id);
assert_eq!(resp.limit_bytes, Some(1_000_000));
assert_eq!(resp.used_bytes, 0);
}
#[sqlx::test]
async fn test_usage_api_unknown_user_not_found(pool: PgPool) {
let state = build_state(pool);
let err = usage_api(
State(state),
Json(UsagePayload {
user_id: uuid::Uuid::new_v4(),
delta_bytes: 100,
}),
)
.await
.unwrap_err();
assert!(matches!(err, AppError::UserNotFound));
}
#[sqlx::test]
async fn test_usage_api_over_limit_boundary(pool: PgPool) {
let state = build_state(pool.clone());
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let user = repo.upsert_user(1, None).await.unwrap();
repo.set_data_limit(user.id, Some(1000)).await.unwrap();
// 999 из 1000 — ещё не превышен лимит.
let resp1 = usage_api(
State(state.clone()),
Json(UsagePayload {
user_id: user.id,
delta_bytes: 999,
}),
)
.await
.unwrap();
assert_eq!(resp1.used_bytes, 999);
assert!(!resp1.over_limit);
// Ровно 1000 из 1000 — лимит уже считается превышенным (>=, не >).
let resp2 = usage_api(
State(state),
Json(UsagePayload {
user_id: user.id,
delta_bytes: 1,
}),
)
.await
.unwrap();
assert_eq!(resp2.used_bytes, 1000);
assert!(resp2.over_limit);
}
#[sqlx::test]
async fn test_usage_api_unlimited_user_never_over_limit(pool: PgPool) {
let state = build_state(pool.clone());
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let user = repo.upsert_user(1, None).await.unwrap();
// data_limit_bytes остаётся NULL — безлимит.
let resp = usage_api(
State(state),
Json(UsagePayload {
user_id: user.id,
delta_bytes: 999_999_999,
}),
)
.await
.unwrap();
assert!(!resp.over_limit);
assert_eq!(resp.limit_bytes, None);
}
}
+254
View File
@@ -0,0 +1,254 @@
//! HTTP-роуты ролевой админки. Named-guard'ы (`owner_guard`/`staff_guard`/
//! `partner_guard`) уже проверяют роль до входа в хендлер — сами хендлеры
//! читают текущего юзера только через `Extension<User>`.
use axum::{
extract::{Extension, Path, State},
http::StatusCode,
middleware,
routing::{get, patch, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::{
api::ApiState,
db::models::User,
error::AppError,
modules::auth::{auth_guard, owner_guard, partner_guard, staff_guard},
};
/// Любой залогиненный видит свою роль/права — нужно фронту, чтобы решить,
/// что показать и не пора ли редиректить на `/admin/login`.
pub fn router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/me", get(get_my_role))
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
/// Не подключён ни к чему в проде: изначально задумывался как гейт для
/// Caddy `forward_auth` перед Grafana, но эту схему заменили на nginx +
/// HTTP Basic Auth прямо на VPS с данными (см. netrunner-data/nginx/) —
/// проще и не зависит от того, задеплоен ли уже этот бэкенд. Эндпоинт
/// оставлен на случай, если позже понадобится реальный SSO поверх
/// basic-auth. Тело ответа не важно, важен только статус: 200/401.
/// Бизнес-метрики (выручка, число юзеров) чувствительны — Support/Partner
/// сюда не допускаются, поэтому `staff_guard`, а не `support_access_guard`.
pub fn observability_router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/check", get(|| async { StatusCode::OK }))
.route_layer(middleware::from_fn_with_state(state.clone(), staff_guard))
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
/// Только Owner: создание модераторов/саппортов, выдача модераторам права
/// на управление нодами.
pub fn owner_router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/moderators", post(create_moderator))
.route("/support", post(create_support))
.route("/users/{id}/permissions", patch(set_permissions))
.route_layer(middleware::from_fn_with_state(state.clone(), owner_guard))
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
/// Owner + Moderator: создание партнёров, список staff-юзеров, заявки на вывод.
pub fn staff_router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/partners", post(create_partner))
.route("/staff-users", get(list_staff))
.route("/withdrawals", get(list_withdrawals))
.route("/withdrawals/{id}", patch(update_withdrawal))
.route_layer(middleware::from_fn_with_state(state.clone(), staff_guard))
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
/// Только Partner: свой заработок и заявки на вывод.
pub fn partner_router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/withdrawals", post(request_withdrawal))
.route("/earnings", get(get_my_earnings))
.route_layer(middleware::from_fn_with_state(state.clone(), partner_guard))
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
async fn get_my_role(Extension(user): Extension<User>) -> Json<Value> {
Json(json!({
"role": user.role,
"can_manage_nodes": user.can_manage_nodes,
"username": user.username,
"partner_code": user.partner_code,
"commission_percent": user.commission_percent,
}))
}
#[derive(Deserialize)]
pub struct CreateModeratorPayload {
pub username: String,
pub password: String,
pub can_manage_nodes: bool,
}
async fn create_moderator(
State(state): State<ApiState>,
Json(p): Json<CreateModeratorPayload>,
) -> Result<Json<Value>, AppError> {
let user = state
.staff_service
.create_moderator(&p.username, &p.password, p.can_manage_nodes)
.await?;
Ok(Json(json!({
"id": user.id,
"username": user.username,
"role": user.role,
"can_manage_nodes": user.can_manage_nodes,
})))
}
#[derive(Deserialize)]
pub struct CreateSupportPayload {
pub username: String,
pub password: String,
}
async fn create_support(
State(state): State<ApiState>,
Json(p): Json<CreateSupportPayload>,
) -> Result<Json<Value>, AppError> {
let user = state
.staff_service
.create_support(&p.username, &p.password)
.await?;
Ok(Json(json!({
"id": user.id,
"username": user.username,
"role": user.role,
})))
}
#[derive(Deserialize)]
pub struct CreatePartnerPayload {
pub username: String,
pub password: String,
pub commission_percent: rust_decimal::Decimal,
}
async fn create_partner(
State(state): State<ApiState>,
Json(p): Json<CreatePartnerPayload>,
) -> Result<Json<Value>, AppError> {
let user = state
.staff_service
.create_partner(&p.username, &p.password, p.commission_percent)
.await?;
Ok(Json(json!({
"id": user.id,
"username": user.username,
"partner_code": user.partner_code,
"commission_percent": user.commission_percent,
})))
}
#[derive(Deserialize)]
pub struct SetPermissionsPayload {
pub can_manage_nodes: bool,
}
async fn set_permissions(
State(state): State<ApiState>,
Path(id): Path<Uuid>,
Json(p): Json<SetPermissionsPayload>,
) -> Result<Json<Value>, AppError> {
let user = state
.staff_service
.set_can_manage_nodes(id, p.can_manage_nodes)
.await?
.ok_or(AppError::UserNotFound)?;
Ok(Json(json!({
"id": user.id,
"can_manage_nodes": user.can_manage_nodes,
})))
}
async fn list_staff(State(state): State<ApiState>) -> Result<Json<Vec<Value>>, AppError> {
let users = state.staff_service.list_staff().await?;
Ok(Json(
users
.into_iter()
.map(|u| {
json!({
"id": u.id,
"username": u.username,
"role": u.role,
"can_manage_nodes": u.can_manage_nodes,
"commission_percent": u.commission_percent,
"partner_code": u.partner_code,
})
})
.collect(),
))
}
async fn list_withdrawals(State(state): State<ApiState>) -> Result<Json<Value>, AppError> {
let list = state.staff_service.list_all_withdrawals().await?;
Ok(Json(json!(list)))
}
#[derive(Deserialize)]
pub struct UpdateWithdrawalPayload {
pub status: String,
}
async fn update_withdrawal(
Extension(user): Extension<User>,
State(state): State<ApiState>,
Path(id): Path<Uuid>,
Json(p): Json<UpdateWithdrawalPayload>,
) -> Result<Json<Value>, AppError> {
if !["approved", "rejected", "paid"].contains(&p.status.as_str()) {
return Err(AppError::BusinessLogic("Недопустимый статус.".into()));
}
let req = state
.staff_service
.update_withdrawal(id, &p.status, user.id)
.await?
.ok_or_else(|| AppError::NotFound("Заявка не найдена".into()))?;
Ok(Json(json!(req)))
}
async fn get_my_earnings(
Extension(user): Extension<User>,
State(state): State<ApiState>,
) -> Result<Json<Value>, AppError> {
let earnings = state.staff_service.get_earnings(user.id).await?;
let withdrawals = state
.staff_service
.list_withdrawals_for_partner(user.id)
.await?;
Ok(Json(json!({
"earnings": earnings,
"withdrawals": withdrawals,
"partner_code": user.partner_code,
})))
}
#[derive(Deserialize)]
pub struct RequestWithdrawalPayload {
pub currency: String,
pub amount: i64,
pub note: Option<String>,
}
async fn request_withdrawal(
Extension(user): Extension<User>,
State(state): State<ApiState>,
Json(p): Json<RequestWithdrawalPayload>,
) -> Result<Json<Value>, AppError> {
let req = state
.staff_service
.request_withdrawal(user.id, &p.currency, p.amount, p.note.as_deref())
.await?;
Ok(Json(json!(req)))
}
+8
View File
@@ -0,0 +1,8 @@
//! Ролевая админка: Owner создаёт Moderator/Partner, Moderator тоже может
//! создавать Partner. Модератору отдельно выдаётся право на управление
//! нодами (`can_manage_nodes`). Партнёры видят свой заработок и подают
//! заявки на вывод; Owner/Moderator эти заявки рассматривают. См. README.md
//! рядом с этим файлом и Context в плане `idempotent-foraging-crane.md`.
pub mod controller;
pub mod service;
+328
View File
@@ -0,0 +1,328 @@
//! Бизнес-логика ролевой админки: создание Owner/Moderator/Partner,
//! переключение права на управление нодами, заработок и заявки на вывод
//! партнёров. Начисление самой комиссии живёт в
//! `billing::service::confirm_payment` (отдельно от рефералки) — здесь
//! только чтение/создание записей.
use crate::{
db::models::{PartnerEarningsByCurrency, User, WithdrawalRequest},
db::repository::AppRepository,
error::AppError,
modules::auth::service::AuthService,
};
use rand::Rng;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Clone)]
pub struct StaffService {
repo: Arc<dyn AppRepository>,
}
/// Короткий код диплинка `t.me/<bot>?start=<partner_code>` — префикс `p_`
/// нужен боту, чтобы однозначно отличать партнёрский код от numeric
/// referrer-tg_id и от `auth_sessions.auth_code` (см. `bot.rs::Command::Start`).
fn generate_partner_code() -> String {
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::thread_rng();
let code: String = (0..6)
.map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
.collect();
format!("p_{code}")
}
impl StaffService {
pub fn new(repo: Arc<dyn AppRepository>) -> Self {
Self { repo }
}
pub async fn create_moderator(
&self,
username: &str,
password: &str,
can_manage_nodes: bool,
) -> Result<User, AppError> {
let hash = AuthService::hash_password(password)?;
self.repo
.create_staff_user(username, &hash, "moderator", can_manage_nodes, None, None)
.await
.map_err(|e| map_create_error(e, "модератора"))
}
/// Аккаунт саппорта — видит только тикеты (см. `support_access_guard`),
/// `can_manage_nodes`/`commission_percent`/`partner_code` неприменимы.
pub async fn create_support(&self, username: &str, password: &str) -> Result<User, AppError> {
let hash = AuthService::hash_password(password)?;
self.repo
.create_staff_user(username, &hash, "support", false, None, None)
.await
.map_err(|e| map_create_error(e, "саппорта"))
}
pub async fn create_partner(
&self,
username: &str,
password: &str,
commission_percent: rust_decimal::Decimal,
) -> Result<User, AppError> {
let hash = AuthService::hash_password(password)?;
let code = generate_partner_code();
self.repo
.create_staff_user(
username,
&hash,
"partner",
false,
Some(commission_percent),
Some(&code),
)
.await
.map_err(|e| map_create_error(e, "партнёра"))
}
pub async fn set_can_manage_nodes(
&self,
user_id: Uuid,
enabled: bool,
) -> Result<Option<User>, AppError> {
self.repo
.set_can_manage_nodes(user_id, enabled)
.await
.map_err(AppError::Database)
}
pub async fn list_staff(&self) -> Result<Vec<User>, AppError> {
self.repo
.list_staff_users(None)
.await
.map_err(AppError::Database)
}
pub async fn list_all_withdrawals(&self) -> Result<Vec<WithdrawalRequest>, AppError> {
self.repo
.list_withdrawal_requests(None)
.await
.map_err(AppError::Database)
}
pub async fn update_withdrawal(
&self,
id: Uuid,
new_status: &str,
processed_by: Uuid,
) -> Result<Option<WithdrawalRequest>, AppError> {
self.repo
.update_withdrawal_status(id, new_status, processed_by)
.await
.map_err(AppError::Database)
}
pub async fn get_earnings(
&self,
partner_id: Uuid,
) -> Result<Vec<PartnerEarningsByCurrency>, AppError> {
self.repo
.get_partner_earnings(partner_id)
.await
.map_err(AppError::Database)
}
pub async fn list_withdrawals_for_partner(
&self,
partner_id: Uuid,
) -> Result<Vec<WithdrawalRequest>, AppError> {
self.repo
.list_withdrawal_requests_for_partner(partner_id)
.await
.map_err(AppError::Database)
}
/// Партнёр подаёт заявку на вывод — проверяем, что запрошенная сумма не
/// превышает доступный остаток именно в этой валюте (без конвертации).
/// Проверка баланса и вставка заявки — одна транзакция с блокировкой
/// строки партнёра (`request_partner_withdrawal`), иначе несколько
/// параллельных заявок на весь остаток могут пройти одновременно.
pub async fn request_withdrawal(
&self,
partner_id: Uuid,
currency: &str,
amount: i64,
note: Option<&str>,
) -> Result<WithdrawalRequest, AppError> {
if amount <= 0 {
return Err(AppError::BusinessLogic(
"Сумма вывода должна быть положительной.".into(),
));
}
self.repo
.request_partner_withdrawal(partner_id, currency, amount, note)
.await
.map_err(|e| match e.as_str() {
"INSUFFICIENT_BALANCE" => AppError::BusinessLogic(
"Запрошенная сумма превышает доступный остаток в этой валюте.".into(),
),
_ => AppError::Internal(format!("Ошибка обработки заявки на вывод: {e}")),
})
}
}
fn map_create_error(e: sqlx::Error, kind: &str) -> AppError {
if let sqlx::Error::Database(db_err) = &e {
if db_err.constraint() == Some("users_username_key") {
return AppError::BusinessLogic(format!(
"Логин уже занят, выберите другой для {kind}."
));
}
}
AppError::Database(e)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::PgRepository;
use rust_decimal::Decimal;
use sqlx::PgPool;
#[sqlx::test]
async fn test_create_moderator_duplicate_username_is_friendly_error(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = StaffService::new(repo);
svc.create_moderator("mod1", "pass1", false).await.unwrap();
let err = svc
.create_moderator("mod1", "different-pass", true)
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
}
#[sqlx::test]
async fn test_create_partner_duplicate_username_is_friendly_error(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = StaffService::new(repo);
svc.create_partner("p1", "pass1", Decimal::new(10, 0))
.await
.unwrap();
let err = svc
.create_partner("p1", "different-pass", Decimal::new(20, 0))
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
}
async fn seed_partner_with_earnings(repo: &Arc<dyn AppRepository>, earned: i64) -> Uuid {
let partner = repo
.create_staff_user(
"partner1",
"hash",
"partner",
false,
Some(Decimal::new(10, 0)),
Some("p_abc123"),
)
.await
.unwrap();
let buyer = repo.upsert_user(999, None).await.unwrap();
let invoice_id = repo
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
.await
.unwrap();
repo.record_partner_commission(
partner.id,
invoice_id,
buyer.id,
"USD",
earned,
Decimal::new(10, 0),
)
.await
.unwrap();
partner.id
}
#[sqlx::test]
async fn test_request_withdrawal_rejects_non_positive_amount(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = StaffService::new(repo.clone());
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
let err = svc
.request_withdrawal(partner_id, "USD", 0, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
let err2 = svc
.request_withdrawal(partner_id, "USD", -50, None)
.await
.unwrap_err();
assert!(matches!(err2, AppError::BusinessLogic(_)));
}
#[sqlx::test]
async fn test_request_withdrawal_rejects_over_balance(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = StaffService::new(repo.clone());
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
let err = svc
.request_withdrawal(partner_id, "USD", 1001, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
}
#[sqlx::test]
async fn test_request_withdrawal_succeeds_within_balance_and_updates_available(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = StaffService::new(repo.clone());
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
svc.request_withdrawal(partner_id, "USD", 400, None)
.await
.expect("400 из доступных 1000 должно пройти");
let earnings = svc.get_earnings(partner_id).await.unwrap();
let usd = earnings.iter().find(|e| e.currency == "USD").unwrap();
assert_eq!(usd.total_earned, 1000);
assert_eq!(usd.pending_withdrawal, 400);
assert_eq!(usd.available, 600);
}
/// Регрессионный тест на гонку: `request_partner_withdrawal` обязан
/// блокировать строку партнёра (`FOR UPDATE`), иначе несколько параллельных
/// заявок на весь остаток проходят одновременно и суммарно превышают
/// доступный баланс партнёра (было воспроизведено до фикса — все 10 из 10
/// параллельных заявок проходили).
#[sqlx::test]
async fn test_concurrent_withdrawal_requests_do_not_overdraw_balance(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
let num_requests = 10;
let mut handles = Vec::new();
for _ in 0..num_requests {
let repo_clone: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let svc = StaffService::new(repo_clone);
handles.push(tokio::spawn(async move {
svc.request_withdrawal(partner_id, "USD", 1000, None).await
}));
}
let mut success_count = 0;
for h in handles {
if h.await.unwrap().is_ok() {
success_count += 1;
}
}
assert_eq!(
success_count, 1,
"без блокировки строки несколько параллельных заявок на весь остаток \
могли пройти одновременно и увести партнёра в минус — request_withdrawal \
нужен FOR UPDATE по аналогии с apply_promo_code"
);
}
}
+103
View File
@@ -0,0 +1,103 @@
//! HTTP-роуты тикетов техподдержки. Все — под `support_access_guard`
//! (Owner/Moderator/Support), см. `modules::auth::support_access_guard`.
use axum::{
extract::{Extension, Path, Query, State},
middleware,
routing::{get, patch, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::{
api::ApiState,
db::models::User,
error::AppError,
modules::auth::{auth_guard, support_access_guard},
};
pub fn router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/tickets", get(list_tickets))
.route("/tickets/{id}", get(get_ticket))
.route("/tickets/{id}/reply", post(reply))
.route("/tickets/{id}/close", patch(close))
.route_layer(middleware::from_fn_with_state(
state.clone(),
support_access_guard,
))
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
#[derive(Deserialize)]
pub struct ListTicketsQuery {
pub status: Option<String>,
}
async fn list_tickets(
State(state): State<ApiState>,
Query(q): Query<ListTicketsQuery>,
) -> Result<Json<Value>, AppError> {
let status_filter = match q.status.as_deref() {
Some("all") | None => None,
Some(s) => Some(s),
};
let tickets = state.support_service.list_tickets(status_filter).await?;
Ok(Json(json!(tickets)))
}
async fn get_ticket(
State(state): State<ApiState>,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, AppError> {
let (ticket, messages) = state
.support_service
.get_ticket_with_messages(id)
.await?
.ok_or_else(|| AppError::NotFound("Тикет не найден".into()))?;
Ok(Json(json!({
"ticket": ticket,
"messages": messages,
})))
}
#[derive(Deserialize)]
pub struct ReplyPayload {
pub text: String,
}
async fn reply(
Extension(staff): Extension<User>,
State(state): State<ApiState>,
Path(id): Path<Uuid>,
Json(p): Json<ReplyPayload>,
) -> Result<Json<Value>, AppError> {
let ticket = state
.repo
.get_ticket(id)
.await?
.ok_or_else(|| AppError::NotFound("Тикет не найден".into()))?;
let ticket_user = state.repo.get_user_by_id(ticket.user_id).await?;
state
.support_service
.reply_as_staff(
id,
staff.id,
&p.text,
&state.bot_token,
ticket_user.and_then(|u| u.tg_id),
)
.await?;
Ok(Json(json!({ "ok": true })))
}
async fn close(
State(state): State<ApiState>,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, AppError> {
let ticket = state.support_service.close_ticket(id).await?;
Ok(Json(json!(ticket)))
}
+8
View File
@@ -0,0 +1,8 @@
//! Тикеты техподдержки — единственный канал создания: кнопка "Поддержка" в
//! Telegram-боте (см. `crate::bot`). Ответ саппорта — из веб-админки,
//! доставляется юзеру обратно в тот же Telegram-чат. См. `service.rs`.
pub mod controller;
pub mod service;
pub use service::SupportService;
+268
View File
@@ -0,0 +1,268 @@
//! Бизнес-логика тикетов техподдержки. Единственный канал создания
//! тикета/сообщения — бот (`bot.rs`), см. `handle_user_message`; ответ
//! саппорта приходит из веб-админки (`support::controller::reply`) и
//! доставляется юзеру обратно в тот же Telegram-чат через `reply_as_staff`.
use crate::{
db::models::{SupportMessage, SupportTicket, SupportTicketSummary},
db::repository::AppRepository,
error::AppError,
};
use std::sync::Arc;
use teloxide::{payloads::SendMessageSetters, prelude::Requester, types::ChatId, Bot};
use uuid::Uuid;
#[derive(Clone)]
pub struct SupportService {
repo: Arc<dyn AppRepository>,
}
impl SupportService {
pub fn new(repo: Arc<dyn AppRepository>) -> Self {
Self { repo }
}
/// Кнопка "Поддержка" в боте — переводит юзера в разовый режим ожидания
/// сообщения. Сбрасывается сам в `handle_user_message`.
pub async fn set_awaiting(&self, user_id: Uuid) -> Result<(), AppError> {
self.repo
.set_awaiting_support(user_id, true)
.await
.map_err(AppError::Database)
}
/// Вызывается ботом ТОЛЬКО когда `user.awaiting_support_message == true`
/// (проверка — на вызывающей стороне, см. `bot.rs`). Переиспользует уже
/// открытый тикет юзера, если есть, иначе создаёт новый.
pub async fn handle_user_message(&self, user_id: Uuid, text: &str) -> Result<(), AppError> {
let ticket = match self
.repo
.get_open_ticket_for_user(user_id)
.await
.map_err(AppError::Database)?
{
Some(t) => t,
None => {
let t = self
.repo
.create_support_ticket(user_id)
.await
.map_err(AppError::Database)?;
metrics::counter!("support_tickets_total", "status" => "created").increment(1);
t
}
};
self.repo
.add_support_message(ticket.id, "user", Some(user_id), text)
.await
.map_err(AppError::Database)?;
// Сбрасываем флаг только после успешной записи сообщения — если БД
// недоступна, следующее сообщение юзера не потеряется молча.
self.repo
.set_awaiting_support(user_id, false)
.await
.map_err(AppError::Database)
}
pub async fn list_tickets(
&self,
status_filter: Option<&str>,
) -> Result<Vec<SupportTicketSummary>, AppError> {
self.repo
.list_tickets(status_filter)
.await
.map_err(AppError::Database)
}
/// Для кнопки "Поддержка" в боте — показать контекст уже открытого
/// тикета юзера, если есть (последние сообщения треда).
pub async fn get_open_ticket_thread_for_user(
&self,
user_id: Uuid,
) -> Result<Option<(SupportTicket, Vec<SupportMessage>)>, AppError> {
let Some(ticket) = self
.repo
.get_open_ticket_for_user(user_id)
.await
.map_err(AppError::Database)?
else {
return Ok(None);
};
let messages = self
.repo
.get_ticket_messages(ticket.id)
.await
.map_err(AppError::Database)?;
Ok(Some((ticket, messages)))
}
pub async fn get_ticket_with_messages(
&self,
ticket_id: Uuid,
) -> Result<Option<(SupportTicket, Vec<SupportMessage>)>, AppError> {
let Some(ticket) = self
.repo
.get_ticket(ticket_id)
.await
.map_err(AppError::Database)?
else {
return Ok(None);
};
let messages = self
.repo
.get_ticket_messages(ticket_id)
.await
.map_err(AppError::Database)?;
Ok(Some((ticket, messages)))
}
/// Саппорт отвечает из админки — сохраняет сообщение и пытается
/// доставить его юзеру в Telegram. Ошибка отправки в Telegram (юзер
/// заблокировал бота, сетевой сбой у Telegram API и т.п.) не роняет
/// ответ целиком — сообщение уже сохранено в БД, саппорт увидит его в
/// треде даже если push не дошёл (та же resilience, что у расчёта
/// партнёрской комиссии в `BillingService::confirm_payment`).
pub async fn reply_as_staff(
&self,
ticket_id: Uuid,
staff_id: Uuid,
text: &str,
bot_token: &str,
user_tg_id: Option<i64>,
) -> Result<(), AppError> {
self.repo
.add_support_message(ticket_id, "staff", Some(staff_id), text)
.await
.map_err(AppError::Database)?;
self.repo
.set_ticket_assigned(ticket_id, staff_id)
.await
.map_err(AppError::Database)?;
match user_tg_id {
Some(tg_id) => {
let bot = Bot::new(bot_token);
let message = format!("💬 <b>Ответ поддержки:</b>\n\n{}", text);
if let Err(e) = bot
.send_message(ChatId(tg_id), message)
.parse_mode(teloxide::types::ParseMode::Html)
.await
{
tracing::warn!(error = ?e, ticket_id = %ticket_id, "Не удалось доставить ответ поддержки в Telegram — сообщение сохранено в БД");
}
}
None => {
tracing::warn!(ticket_id = %ticket_id, "У юзера тикета нет tg_id — ответ сохранён, но доставить в Telegram некуда");
}
}
Ok(())
}
pub async fn close_ticket(&self, ticket_id: Uuid) -> Result<SupportTicket, AppError> {
let ticket = self
.repo
.close_ticket(ticket_id)
.await
.map_err(AppError::Database)?
.ok_or_else(|| AppError::NotFound("Тикет не найден".into()))?;
metrics::counter!("support_tickets_total", "status" => "closed").increment(1);
Ok(ticket)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::PgRepository;
use sqlx::PgPool;
#[sqlx::test]
async fn test_handle_user_message_creates_ticket_and_resets_flag(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = SupportService::new(repo.clone());
let user = repo.upsert_user(111, None).await.unwrap();
svc.set_awaiting(user.id).await.unwrap();
svc.handle_user_message(user.id, "не подключается на iOS")
.await
.unwrap();
let user_after = repo.get_user_by_id(user.id).await.unwrap().unwrap();
assert!(
!user_after.awaiting_support_message,
"флаг должен сброситься сразу после получения сообщения"
);
let ticket = repo
.get_open_ticket_for_user(user.id)
.await
.unwrap()
.expect("должен появиться открытый тикет");
let messages = repo.get_ticket_messages(ticket.id).await.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender_role, "user");
assert_eq!(messages[0].body, "не подключается на iOS");
}
#[sqlx::test]
async fn test_handle_user_message_reuses_open_ticket(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = SupportService::new(repo.clone());
let user = repo.upsert_user(222, None).await.unwrap();
svc.handle_user_message(user.id, "первое сообщение")
.await
.unwrap();
let first_ticket = repo
.get_open_ticket_for_user(user.id)
.await
.unwrap()
.unwrap();
svc.handle_user_message(user.id, "второе сообщение, тот же тикет")
.await
.unwrap();
let second_ticket = repo
.get_open_ticket_for_user(user.id)
.await
.unwrap()
.unwrap();
assert_eq!(
first_ticket.id, second_ticket.id,
"второе сообщение должно уйти в уже открытый тикет, а не создать новый"
);
let messages = repo.get_ticket_messages(first_ticket.id).await.unwrap();
assert_eq!(messages.len(), 2);
}
#[sqlx::test]
async fn test_handle_user_message_creates_new_ticket_if_previous_closed(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = SupportService::new(repo.clone());
let user = repo.upsert_user(333, None).await.unwrap();
svc.handle_user_message(user.id, "первое обращение")
.await
.unwrap();
let first_ticket = repo
.get_open_ticket_for_user(user.id)
.await
.unwrap()
.unwrap();
repo.close_ticket(first_ticket.id).await.unwrap();
svc.handle_user_message(user.id, "новое обращение")
.await
.unwrap();
let second_ticket = repo
.get_open_ticket_for_user(user.id)
.await
.unwrap()
.expect("должен появиться новый открытый тикет");
assert_ne!(first_ticket.id, second_ticket.id);
}
}
+44 -3
View File
@@ -1,14 +1,20 @@
use axum::{ use axum::{
extract::{Extension, State}, extract::{Extension, Path, State},
middleware, middleware,
routing::{get, post}, routing::{get, patch, post},
Json, Router, Json, Router,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tracing::instrument; use tracing::instrument;
use uuid::Uuid;
use crate::{api::ApiState, db::models::User, error::AppError, modules::auth::guard::auth_guard}; use crate::{
api::ApiState,
db::models::User,
error::AppError,
modules::auth::{guard::auth_guard, owner_guard},
};
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct HackResultPayload { pub struct HackResultPayload {
@@ -26,6 +32,41 @@ pub fn router(state: ApiState) -> Router<ApiState> {
.route_layer(middleware::from_fn_with_state(state, auth_guard)) .route_layer(middleware::from_fn_with_state(state, auth_guard))
} }
#[derive(Deserialize)]
pub struct SetLimitPayload {
/// `None`/`null` — снять лимит (безлимит).
pub data_limit_bytes: Option<i64>,
}
/// Секретный роутер для Nexus (Admin) — динамический лимит трафика на юзера,
/// читается прокси-нодами на каждой проверке (`internal/validate`, `internal/usage`),
/// поэтому вступает в силу без перезапуска/передеплоя прокси.
pub fn admin_router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/{id}/limit", patch(set_user_limit))
.route_layer(middleware::from_fn_with_state(state.clone(), owner_guard))
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
#[instrument(skip(state, payload))]
async fn set_user_limit(
State(state): State<ApiState>,
Path(id): Path<Uuid>,
Json(payload): Json<SetLimitPayload>,
) -> Result<Json<Value>, AppError> {
let user = state
.repo
.set_data_limit(id, payload.data_limit_bytes)
.await?
.ok_or(AppError::UserNotFound)?;
Ok(Json(json!({
"status": "success",
"user_id": user.id,
"data_limit_bytes": user.data_limit_bytes,
})))
}
#[instrument(skip(state, user), fields(tg_id = user.tg_id))] #[instrument(skip(state, user), fields(tg_id = user.tg_id))]
async fn get_me( async fn get_me(
Extension(user): Extension<User>, Extension(user): Extension<User>,
+142
View File
@@ -9,6 +9,7 @@ use uuid::Uuid;
/// Ответ на `POST /users/hack/start`: доска, которую сервер только что /// Ответ на `POST /users/hack/start`: доска, которую сервер только что
/// сгенерировал и сохранил — клиент обязан играть строго на ней. /// сгенерировал и сохранил — клиент обязан играть строго на ней.
#[derive(Debug)]
pub struct HackSessionStart { pub struct HackSessionStart {
pub session_id: Uuid, pub session_id: Uuid,
pub matrix: Vec<Vec<String>>, pub matrix: Vec<Vec<String>>,
@@ -123,3 +124,144 @@ impl UserService {
Ok(reward) Ok(reward)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::db::repository::PgRepository;
use sqlx::PgPool;
async fn grant_tickets(pool: &PgPool, user_id: Uuid, tickets: i32) {
sqlx::query("UPDATE users SET game_tickets = $1 WHERE id = $2")
.bind(tickets)
.bind(user_id)
.execute(pool)
.await
.unwrap();
}
#[sqlx::test]
async fn test_start_hack_session_no_tickets_errors(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
let svc = UserService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
let err = svc.start_hack_session(user.id).await.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
}
#[sqlx::test]
async fn test_start_hack_session_too_frequent_errors(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let svc = UserService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
grant_tickets(&pool, user.id, 5).await;
svc.start_hack_session(user.id)
.await
.expect("первая игра должна пройти");
let err = svc.start_hack_session(user.id).await.unwrap_err();
assert!(
matches!(err, AppError::BusinessLogic(_)),
"повторный старт раньше 45с должен блокироваться rate-limit'ом"
);
}
#[sqlx::test]
async fn test_start_hack_session_decrements_ticket_and_creates_session(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let svc = UserService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
grant_tickets(&pool, user.id, 3).await;
let session = svc.start_hack_session(user.id).await.unwrap();
assert_eq!(session.matrix.len(), cyberhack::GRID_SIZE);
assert_eq!(session.targets.len(), 3);
let updated = repo.get_user_by_id(user.id).await.unwrap().unwrap();
assert_eq!(updated.game_tickets, 2);
}
#[sqlx::test]
async fn test_submit_hack_session_double_submit_rejected(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let svc = UserService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
grant_tickets(&pool, user.id, 3).await;
let session = svc.start_hack_session(user.id).await.unwrap();
svc.submit_hack_session(user.id, session.session_id, vec![])
.await
.expect("первая сдача (даже пустым путём) должна пройти");
let err = svc
.submit_hack_session(user.id, session.session_id, vec![])
.await
.unwrap_err();
assert!(
matches!(err, AppError::BusinessLogic(_)),
"повторная сдача уже использованной сессии должна отклоняться"
);
}
#[sqlx::test]
async fn test_submit_hack_session_wrong_user_rejected(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let svc = UserService::new(repo.clone());
let owner = repo.upsert_user(1, None).await.unwrap();
let stranger = repo.upsert_user(2, None).await.unwrap();
grant_tickets(&pool, owner.id, 3).await;
let session = svc.start_hack_session(owner.id).await.unwrap();
let err = svc
.submit_hack_session(stranger.id, session.session_id, vec![])
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
}
#[sqlx::test]
async fn test_submit_hack_session_rejects_illegal_path_without_awarding_balance(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let svc = UserService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
grant_tickets(&pool, user.id, 3).await;
let session = svc.start_hack_session(user.id).await.unwrap();
// Первый ход обязан быть в ряду 0 (active_index стартует в 0) — (1, 0) нелегален.
let illegal_path = vec![(1, 0)];
let err = svc
.submit_hack_session(user.id, session.session_id, illegal_path)
.await
.unwrap_err();
assert!(matches!(err, AppError::BusinessLogic(_)));
let unchanged = repo.get_user_by_id(user.id).await.unwrap().unwrap();
assert_eq!(
unchanged.balance, 0,
"нелегальный путь не должен начислять награду"
);
}
#[sqlx::test]
async fn test_submit_hack_session_end_to_end_credits_returned_reward(pool: PgPool) {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let svc = UserService::new(repo.clone());
let user = repo.upsert_user(1, None).await.unwrap();
grant_tickets(&pool, user.id, 3).await;
let session = svc.start_hack_session(user.id).await.unwrap();
// Первый ход всегда легален в ряду 0, независимо от содержимого доски.
let path = vec![(0, 0)];
let reward = svc
.submit_hack_session(user.id, session.session_id, path)
.await
.expect("легальный (пусть и не выигрышный) путь не должен отклоняться");
let updated = repo.get_user_by_id(user.id).await.unwrap().unwrap();
assert_eq!(
updated.balance, reward,
"начисленный баланс должен совпадать с возвращённой суммой награды"
);
}
}
+33 -1
View File
@@ -3,10 +3,13 @@
//! подписки active -> grace -> expired и сверяет блокчейн на предмет платежей, чьё //! подписки active -> grace -> expired и сверяет блокчейн на предмет платежей, чьё
//! подтверждение потерялось (см. `BillingService::reconcile_pending_payments`); //! подтверждение потерялось (см. `BillingService::reconcile_pending_payments`);
//! - раз в `NODE_HEALTH_CHECK_INTERVAL` секунд (по умолчанию 60) пингует все VPN-ноды //! - раз в `NODE_HEALTH_CHECK_INTERVAL` секунд (по умолчанию 60) пингует все VPN-ноды
//! по TCP и переоценивает их статус `online`/`offline` (см. `run_node_health_check`). //! по TCP и переоценивает их статус `online`/`offline` (см. `run_node_health_check`);
//! - раз в `GEOIP_REFRESH_INTERVAL` секунд (по умолчанию раз в сутки) перекачивает
//! базу страна-по-IP и список VPN/датацентр-диапазонов (см. `modules::geoip`).
use crate::db::repository::AppRepository; use crate::db::repository::AppRepository;
use crate::modules::billing::service::BillingService; use crate::modules::billing::service::BillingService;
use crate::modules::geoip::service::GeoipService;
use std::{env, net::SocketAddr, sync::Arc, time::Duration}; use std::{env, net::SocketAddr, sync::Arc, time::Duration};
use tokio::{net::TcpStream, time}; use tokio::{net::TcpStream, time};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -17,6 +20,7 @@ const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(3);
pub async fn run_background_tasks( pub async fn run_background_tasks(
billing: BillingService, billing: BillingService,
repo: Arc<dyn AppRepository>, repo: Arc<dyn AppRepository>,
geoip: GeoipService,
cancel_token: CancellationToken, cancel_token: CancellationToken,
) { ) {
let interval_secs = env::var("SUBSCRIPTION_CHECK_INTERVAL") let interval_secs = env::var("SUBSCRIPTION_CHECK_INTERVAL")
@@ -27,6 +31,10 @@ pub async fn run_background_tasks(
.unwrap_or_else(|_| "60".to_string()) .unwrap_or_else(|_| "60".to_string())
.parse::<u64>() .parse::<u64>()
.unwrap_or(60); .unwrap_or(60);
let geoip_refresh_interval_secs = env::var("GEOIP_REFRESH_INTERVAL")
.unwrap_or_else(|_| "86400".to_string())
.parse::<u64>()
.unwrap_or(86400);
info!( info!(
"🔄 Фоновый биллинг-воркер запущен (интервал: {} сек)", "🔄 Фоновый биллинг-воркер запущен (интервал: {} сек)",
@@ -36,9 +44,16 @@ pub async fn run_background_tasks(
"🩺 Health-check воркер VPN-нод запущен (интервал: {} сек)", "🩺 Health-check воркер VPN-нод запущен (интервал: {} сек)",
health_check_interval_secs health_check_interval_secs
); );
info!(
"🌍 GeoIP-воркер запущен (интервал: {} сек)",
geoip_refresh_interval_secs
);
let mut billing_interval = time::interval(Duration::from_secs(interval_secs)); let mut billing_interval = time::interval(Duration::from_secs(interval_secs));
let mut health_interval = time::interval(Duration::from_secs(health_check_interval_secs)); let mut health_interval = time::interval(Duration::from_secs(health_check_interval_secs));
// Первый тик срабатывает сразу (не ждёт полный интервал) — иначе первые
// сутки после старта сервиса /plans работал бы вообще без базы GeoIP.
let mut geoip_interval = time::interval(Duration::from_secs(geoip_refresh_interval_secs));
loop { loop {
tokio::select! { tokio::select! {
@@ -63,6 +78,14 @@ pub async fn run_background_tasks(
_ = health_interval.tick() => { _ = health_interval.tick() => {
run_node_health_check(&repo).await; run_node_health_check(&repo).await;
} }
_ = geoip_interval.tick() => {
if let Err(e) = geoip.refresh_geoip_db().await {
error!("GeoIP: ошибка обновления базы страна-по-IP: {}", e);
}
if let Err(e) = geoip.refresh_vpn_list().await {
error!("GeoIP: ошибка обновления списка VPN-диапазонов: {}", e);
}
}
_ = cancel_token.cancelled() => { break; } _ = cancel_token.cancelled() => { break; }
} }
} }
@@ -82,6 +105,9 @@ async fn run_node_health_check(repo: &Arc<dyn AppRepository>) {
} }
}; };
let total = nodes.len();
let mut online_count: u64 = 0;
for node in nodes { for node in nodes {
if node.status == "provisioning" { if node.status == "provisioning" {
continue; continue;
@@ -102,6 +128,9 @@ async fn run_node_health_check(repo: &Arc<dyn AppRepository>) {
}; };
let new_status = if is_reachable { "online" } else { "offline" }; let new_status = if is_reachable { "online" } else { "offline" };
if is_reachable {
online_count += 1;
}
if new_status != node.status { if new_status != node.status {
info!(node_id = %node.id, name = %node.name, from = %node.status, to = %new_status, "Смена статуса ноды по health-check"); info!(node_id = %node.id, name = %node.name, from = %node.status, to = %new_status, "Смена статуса ноды по health-check");
} }
@@ -110,4 +139,7 @@ async fn run_node_health_check(repo: &Arc<dyn AppRepository>) {
error!(node_id = %node.id, error = ?e, "Health-check: не удалось обновить статус ноды"); error!(node_id = %node.id, error = ?e, "Health-check: не удалось обновить статус ноды");
} }
} }
metrics::gauge!("nodes_online").set(online_count as f64);
metrics::gauge!("nodes_total").set(total as f64);
} }
+21 -7
View File
@@ -10,13 +10,21 @@ if ! command -v docker &> /dev/null; then
sh get-docker.sh sh get-docker.sh
fi fi
echo "[*] Step 3: Logging into ghcr.io via Control Plane token..." echo "[*] Step 3: Logging into Gitea Registry via Control Plane token..."
echo "$GH_TOKEN" | docker login ghcr.io -u nineap --password-stdin echo "$GH_TOKEN" | docker login gitea.netrunner-vpn.com -u nineap --password-stdin
echo "[*] Step 4: Pulling the latest Netrunner image..." echo "[*] Step 4: Pulling the latest Netrunner image..."
docker pull ghcr.io/nineap/netrunner-proxy:latest docker pull gitea.netrunner-vpn.com/nineap/netrunner-proxy:latest
echo "[*] Step 5: Starting Netrunner Proxy at port 443..." echo "[*] Step 5: Starting Netrunner Proxy at port ${NODE_PORT}..."
# NODE_PORT/SNI_DOMAIN/BACKEND_URL/PROXY_INTERNAL_SECRET подставляет
# NodeService::provision_node_via_ssh (см. src/modules/nodes/service.rs) —
# конкретные для ЭТОЙ ноды значения, а не общий дефолт на все ноды разом.
# Раньше control-plane URL был захардкожен прямо тут на конкретный IP, а
# --decoy-host/--require-auth/--health-port вообще не передавались — нода
# всегда стартовала с дефолтным декоем (www.debian.org, одинаковым для всех
# нод сразу) и БЕЗ проверки токена/лимитов трафика, независимо от того, что
# админ вводил при добавлении ноды.
docker run -d \ docker run -d \
--name netrunner-proxy \ --name netrunner-proxy \
--restart always \ --restart always \
@@ -25,9 +33,15 @@ docker run -d \
--cap-add NET_RAW \ --cap-add NET_RAW \
--cap-add DAC_OVERRIDE \ --cap-add DAC_OVERRIDE \
--device /dev/net/tun:/dev/net/tun \ --device /dev/net/tun:/dev/net/tun \
-e CONTROL_PLANE_URL=http://147.45.43.70:8080 \ -e PROXY_INTERNAL_SECRET="$PROXY_INTERNAL_SECRET" \
-e NODE_IP=$NODE_IP \ gitea.netrunner-vpn.com/nineap/netrunner-proxy:latest \
ghcr.io/nineap/netrunner-proxy:latest ./netrunner-proxy \
--port "$NODE_PORT" \
--decoy-host "$SNI_DOMAIN" \
--require-auth \
--backend-url "$BACKEND_URL" \
--health-port 9091 \
--metrics-port 9093
echo "[*] Step 6: Starting Watchtower for auto-updates..." echo "[*] Step 6: Starting Watchtower for auto-updates..."
docker run -d \ docker run -d \
+38
View File
@@ -128,3 +128,41 @@ async fn test_apply_promo_code_race_condition(pool: PgPool) {
let current_uses: i32 = promo_record.get("current_uses"); let current_uses: i32 = promo_record.get("current_uses");
assert_eq!(current_uses, 1); assert_eq!(current_uses, 1);
} }
// =====================================================================
// ТЕСТ 4: Реферальная награда — одноразовый бонус за первую покупку.
// `reward_referrer` фильтрует по `status = 'pending'`, поэтому повторная
// покупка того же приведённого юзера (см. BillingService::confirm_payment)
// не должна начислять рефереру ещё раз.
// =====================================================================
#[sqlx::test]
async fn test_reward_referrer_is_one_time_bonus_not_repeated(pool: PgPool) {
let repo = PgRepository::new(pool);
let referrer = repo.upsert_user(1, None).await.unwrap();
let referred = repo.upsert_user(2, None).await.unwrap();
repo.create_referral(referrer.id, referred.id)
.await
.unwrap();
let first = repo.reward_referrer(referred.id, 100).await.unwrap();
assert!(first, "первое начисление должно найти строку referrals");
let after_first = repo.get_user_by_id(referrer.id).await.unwrap().unwrap();
assert_eq!(after_first.balance, 100);
// Вторая "покупка" того же приведённого юзера — начисление НЕ должно
// сработать повторно, статус уже 'rewarded'.
let second = repo.reward_referrer(referred.id, 50).await.unwrap();
assert!(
!second,
"повторное начисление должно быть заблокировано статусом 'rewarded'"
);
let after_second = repo.get_user_by_id(referrer.id).await.unwrap().unwrap();
assert_eq!(
after_second.balance, 100,
"баланс не должен измениться повторно"
);
let (_, total_earned) = repo.get_referral_stats(referrer.id).await.unwrap();
assert_eq!(total_earned, 100);
}