Compare commits

..

19 Commits

Author SHA1 Message Date
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
51 changed files with 2651 additions and 272 deletions
+10 -3
View File
@@ -1,6 +1,9 @@
# ===================================================================== # =====================================================================
# 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)
@@ -28,7 +31,11 @@ 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 # Опционально: без него оплата через @CryptoBot просто вернёт "provider not
# supported", остальное продолжает работать как раньше. Токен берётся у # supported", остальное продолжает работать как раньше. Токен берётся у
+4 -1
View File
@@ -21,9 +21,12 @@ 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 # Опционально: без него оплата через @CryptoBot вернёт "provider not
# supported", остальное работает как обычно. Токен — у @CryptoBot: /pay -> # supported", остальное работает как обычно. Токен — у @CryptoBot: /pay ->
# Create App -> Payment Token. # Create App -> Payment Token.
+81 -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,32 @@ 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 # Опционально: без него оплата через @CryptoBot вернёт "provider not
# supported", остальные провайдеры продолжают работать. Токен — у @CryptoBot: # supported", остальные провайдеры продолжают работать. Токен — у @CryptoBot:
# /pay -> Create App -> Payment Token. # /pay -> Create App -> Payment Token.
@@ -39,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 ===
# ===================================================================== # =====================================================================
@@ -70,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
+26 -20
View File
@@ -1,9 +1,13 @@
# Копия .github/workflows/build.yml для Gitea Actions. Отличие от оригинала: # Копия .github/workflows/build.yml для Gitea Actions. Отличие от оригинала:
# логин на ghcr.io использует secrets.GHCR_TOKEN (настоящий GitHub PAT с # реестр — встроенный Container Registry самой Gitea (gitea.netrunner-vpn.com),
# правами write:packages), а не secrets.GITHUB_TOKEN — автотокен Gitea валиден # не ghcr.io — секрет secrets.GITHUB_TOKEN там валиден только на самом
# только для самого Gitea (API/пакеты этого инстанса), на ghcr.io им не # GitHub. Логин — токеном Gitea с правом write:package
# залогиниться. Секрет GHCR_TOKEN нужно завести отдельно в настройках этого # (secrets.GT_REGISTRY_TOKEN), один и тот же токен используется во всех
# репозитория/организации в Gitea. # репозиториях, где собираются образы (netrunner-backend/netrunner-landing/
# netrunner-proxy) — пакеты в Gitea принадлежат аккаунту, не конкретному
# репозиторию, заводить по секрету на каждый репозиторий не нужно, но
# значение нужно продублировать в Settings каждого из них (Gitea не даёт
# организационных секретов на уровне всего аккаунта для personal-репозиториев).
name: Build & Push Image name: Build & Push Image
on: on:
@@ -13,12 +17,12 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
REGISTRY: ghcr.io REGISTRY: gitea.netrunner-vpn.com
IMAGE_NAME: ${{ github.repository_owner }}/netrunner-control-plane IMAGE_NAME: nineap/netrunner-control-plane
jobs: jobs:
build: build:
name: Build and push to GHCR name: Build and push to Gitea Registry
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
@@ -30,12 +34,12 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to GHCR - name: Log in to Gitea Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }} password: ${{ secrets.GT_REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels) - name: Extract metadata (tags, labels)
id: meta id: meta
@@ -54,9 +58,13 @@ jobs:
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
# Кеш слоёв между прогонами — повторные сборки в разы быстрее. # Кеш слоёв между прогонами — type=gha (GitHub Actions Cache) не
cache-from: type=gha # работает на self-hosted Gitea Actions раннере (act_runner не
cache-to: type=gha,mode=max # реализует этот прокси-сервис, сборка виснет и падает по таймауту
# на попытке экспортировать кеш) — вместо этого кеш кладём в тот же
# 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 на # Кастомная сборка Caddy (модуль caddy-dns/cloudflare, нужен для TLS на
# dev-стенде — см. Caddyfile.dev). Компилировать xcaddy'ем прямо на VPS # dev-стенде — см. Caddyfile.dev). Компилировать xcaddy'ем прямо на VPS
@@ -76,12 +84,12 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to GHCR - name: Log in to Gitea Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }} password: ${{ secrets.GT_REGISTRY_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -90,9 +98,7 @@ jobs:
file: ./Dockerfile.caddy file: ./Dockerfile.caddy
push: true push: true
# Захардкожено, не ${{ github.repository_owner }}: он резолвится в # Захардкожено, не ${{ github.repository_owner }}: он резолвится в
# "nineAp" (смешанный регистр), Docker такие теги отклоняет — та же # "nineAp" (смешанный регистр), Docker такие теги отклоняет.
# причина, по которой deploy.yml тоже хардкодит "nineap" для
# основного образа вместо репозиторного контекста.
tags: ${{ env.REGISTRY }}/nineap/netrunner-caddy-cloudflare:latest tags: ${{ env.REGISTRY }}/nineap/netrunner-caddy-cloudflare:latest
cache-from: type=gha,scope=caddy cache-from: type=registry,ref=${{ env.REGISTRY }}/nineap/netrunner-caddy-cloudflare:cache
cache-to: type=gha,mode=max,scope=caddy cache-to: type=registry,ref=${{ env.REGISTRY }}/nineap/netrunner-caddy-cloudflare:cache,mode=max
+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."
+123 -16
View File
@@ -1,5 +1,6 @@
# Копия .github/workflows/deploy.yml для Gitea Actions. secrets.GHCR_TOKEN # Копия .github/workflows/deploy.yml для Gitea Actions. Образы тянутся из
# здесь уже был настоящим PAT (не автотокеном), менять не пришлось. # встроенного Container Registry самой Gitea (gitea.netrunner-vpn.com), не
# ghcr.io — secrets.GT_REGISTRY_TOKEN, не GHCR_TOKEN/GITHUB_TOKEN.
# Триггер workflow_run (авто-деплой после успешной сборки) поддерживается не # Триггер workflow_run (авто-деплой после успешной сборки) поддерживается не
# во всех версиях Gitea Actions — если deploy-dev не срабатывает автоматически # во всех версиях Gitea Actions — если deploy-dev не срабатывает автоматически
# после build, проверить версию Gitea или временно дергать деплой вручную # после build, проверить версию Gitea или временно дергать деплой вручную
@@ -9,6 +10,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"]
@@ -17,7 +23,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
image_tag: image_tag:
description: "Тег образа в GHCR для прод-деплоя" description: "Тег образа в Gitea Registry для прод-деплоя"
required: true required: true
default: "latest" default: "latest"
@@ -32,34 +38,65 @@ jobs:
ref: ${{ github.event.workflow_run.head_sha }} ref: ${{ github.event.workflow_run.head_sha }}
# Копируем compose-файл + Caddyfile (примонтированный конфиг, не образ — # Копируем compose-файл + Caddyfile (примонтированный конфиг, не образ —
# сам Caddy-образ, как и образ бэкенда, дев-сервер тянет из GHCR, собран # сам Caddy-образ, как и образ бэкенда, дев-сервер тянет из Gitea Registry,
# в build.yml::build-caddy. Собирать xcaddy прямо на VPS один раз уже не # собран в build.yml::build-caddy. Собирать xcaddy прямо на VPS один раз уже не
# уложилось в таймаут SSH-деплоя и уронило весь прогон). # уложилось в таймаут 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,Caddyfile.dev" 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,GT_REGISTRY_TOKEN"
script: | script: |
set -e set -e
cd /root/netrunner-core cd /root/netrunner-core
export IMAGE="ghcr.io/nineap/netrunner-control-plane:latest" # .env — статические значения (никогда не меняются между деплоями)
export CADDY_IMAGE="ghcr.io/nineap/netrunner-caddy-cloudflare:latest" # зашиты здесь же литералом, секреты/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 "🚀 Deploying ${IMAGE} to DEV"
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin 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 pull
docker compose -f docker-compose.dev-remote.yml up -d --remove-orphans docker compose -f docker-compose.dev-remote.yml up -d --remove-orphans
@@ -67,26 +104,83 @@ 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 }}
GT_REGISTRY_TOKEN: ${{ secrets.GT_REGISTRY_TOKEN }}
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,GT_REGISTRY_TOKEN"
script: | script: |
set -e set -e
cd /root/netrunner-core cd /root/netrunner-core
export IMAGE="ghcr.io/nineap/netrunner-control-plane:${{ github.event.inputs.image_tag }}" 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 "🚀 Deploying ${IMAGE}"
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin echo "${{ secrets.GT_REGISTRY_TOKEN }}" | docker login gitea.netrunner-vpn.com -u ${{ github.actor }} --password-stdin
# Тянем свежий образ, прогоняем миграции (one-shot) и поднимаем сервисы. # Тянем свежий образ, прогоняем миграции (one-shot) и поднимаем сервисы.
docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml pull
@@ -96,3 +190,16 @@ 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 }}
GT_REGISTRY_TOKEN: ${{ secrets.GT_REGISTRY_TOKEN }}
+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."
+103 -6
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"]
@@ -32,23 +37,50 @@ jobs:
- 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,Caddyfile.dev" 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" export CADDY_IMAGE="ghcr.io/nineap/netrunner-caddy-cloudflare:latest"
echo "🚀 Deploying ${IMAGE} to DEV" echo "🚀 Deploying ${IMAGE} to DEV"
@@ -61,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}"
@@ -90,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 }}
Generated
+65
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,9 +2229,12 @@ 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",
@@ -3451,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"
+8 -1
View File
@@ -40,10 +40,17 @@ argon2 = "0.5"
rpassword = "7.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
+3 -3
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:
@@ -85,7 +85,7 @@ services:
# компиляция xcaddy (Go, тянет много модулей) на этой машине один раз уже # компиляция xcaddy (Go, тянет много модулей) на этой машине один раз уже
# не уложилась в таймаут SSH-деплоя и уронила весь прогон. # не уложилась в таймаут SSH-деплоя и уронила весь прогон.
caddy: caddy:
image: ${CADDY_IMAGE:-ghcr.io/nineap/netrunner-caddy-cloudflare:latest} image: ${CADDY_IMAGE:-gitea.netrunner-vpn.com/nineap/netrunner-caddy-cloudflare:latest}
container_name: netrunner-caddy-dev container_name: netrunner-caddy-dev
restart: always restart: always
ports: ports:
+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:
+19 -3
View File
@@ -1,12 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { Loader2, Server, Users, Wallet, DollarSign, LogOut } from "lucide-react"; import { Loader2, Server, Users, Wallet, DollarSign, LogOut, LifeBuoy, Activity } from "lucide-react";
import { fetchMyRole, logout, type StaffRole } from "./api"; import { fetchMyRole, logout, type StaffRole } from "./api";
const STAFF_ROLES = ["owner", "moderator", "partner"]; const STAFF_ROLES = ["owner", "moderator", "partner", "support"];
export default function AdminLayout() { export default function AdminLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [role, setRole] = useState<StaffRole | null>(null); const [role, setRole] = useState<StaffRole | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -17,6 +18,11 @@ export default function AdminLayout() {
navigate("/admin/login"); navigate("/admin/login");
return; return;
} }
// Support не видит "Ноды" (индекс-роут /admin) — после логина сразу
// на единственный раздел, который ему доступен.
if (r.role === "support" && location.pathname === "/admin") {
navigate("/admin/tickets", { replace: true });
}
setRole(r); setRole(r);
}) })
.catch(() => navigate("/admin/login")) .catch(() => navigate("/admin/login"))
@@ -66,6 +72,16 @@ export default function AdminLayout() {
<Wallet className="w-4 h-4" /> Выводы <Wallet className="w-4 h-4" /> Выводы
</NavLink> </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" && ( {role.role === "partner" && (
<NavLink to="/admin/earnings" className={navClass}> <NavLink to="/admin/earnings" className={navClass}>
<DollarSign className="w-4 h-4" /> Доходы <DollarSign className="w-4 h-4" /> Доходы
+4
View File
@@ -12,6 +12,8 @@ const AdminLayout = lazy(() => import("./AdminLayout"));
const StaffPage = lazy(() => import("./StaffPage")); const StaffPage = lazy(() => import("./StaffPage"));
const WithdrawalsPage = lazy(() => import("./WithdrawalsPage")); const WithdrawalsPage = lazy(() => import("./WithdrawalsPage"));
const EarningsPage = lazy(() => import("./EarningsPage")); 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..." },
@@ -94,6 +96,8 @@ export default function App() {
<Route path="staff" element={<StaffPage />} /> <Route path="staff" element={<StaffPage />} />
<Route path="withdrawals" element={<WithdrawalsPage />} /> <Route path="withdrawals" element={<WithdrawalsPage />} />
<Route path="earnings" element={<EarningsPage />} /> <Route path="earnings" element={<EarningsPage />} />
<Route path="tickets" element={<TicketsPage />} />
<Route path="observability" element={<ObservabilityPage />} />
</Route> </Route>
</Routes> </Routes>
</AuthHandler> </AuthHandler>
+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>
);
}
+52
View File
@@ -4,6 +4,7 @@ import { UserPlus, RefreshCw } from "lucide-react";
import { import {
createModerator, createModerator,
createPartner, createPartner,
createSupport,
fetchStaffUsers, fetchStaffUsers,
setNodePermission, setNodePermission,
type StaffRole, type StaffRole,
@@ -23,6 +24,9 @@ export default function StaffPage() {
const [partnerPassword, setPartnerPassword] = useState(""); const [partnerPassword, setPartnerPassword] = useState("");
const [partnerPercent, setPartnerPercent] = useState("10"); const [partnerPercent, setPartnerPercent] = useState("10");
const [supportUsername, setSupportUsername] = useState("");
const [supportPassword, setSupportPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const load = async () => { const load = async () => {
@@ -68,6 +72,19 @@ export default function StaffPage() {
} }
}; };
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) => { const handleTogglePermission = async (userId: string, current: boolean) => {
try { try {
await setNodePermission(userId, !current); await setNodePermission(userId, !current);
@@ -135,6 +152,41 @@ export default function StaffPage() {
</form> </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 <form
onSubmit={handleCreatePartner} onSubmit={handleCreatePartner}
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3" className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
+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>
);
}
+82 -5
View File
@@ -1,6 +1,13 @@
export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1"; export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
let configPromise: Promise<{ bot_username: string }> | null = null; 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 /// Юзернейм бота — намеренно не `import.meta.env.VITE_BOT_USERNAME`: этот SPA
/// собирается один раз в CI, и один и тот же статический бандл раздаётся и /// собирается один раз в CI, и один и тот же статический бандл раздаётся и
@@ -8,12 +15,13 @@ let configPromise: Promise<{ bot_username: string }> | null = null;
/// не различалось бы между стендами (ровно так и вышло на практике: dev видел /// не различалось бы между стендами (ровно так и вышло на практике: dev видел
/// прод-бота, у которого в BotFather другой домен — виджет логина отвечал /// прод-бота, у которого в BotFather другой домен — виджет логина отвечал
/// "Bot domain invalid"). `/api/v1/config` читает env на сервере заново на /// "Bot domain invalid"). `/api/v1/config` читает env на сервере заново на
/// каждый запрос, поэтому корректно отдаёт "свой" бот для каждого стенда. /// каждый запрос, поэтому корректно отдаёт "свой" бот (и Grafana URL) для
export function getPublicConfig(): Promise<{ bot_username: string }> { /// каждого стенда.
export function getPublicConfig(): Promise<PublicConfig> {
if (!configPromise) { if (!configPromise) {
configPromise = fetch(`${API_BASE}/config`) configPromise = fetch(`${API_BASE}/config`)
.then((res) => res.json()) .then((res) => res.json())
.catch(() => ({ bot_username: "ntrnr_vpn_bot" })); .catch(() => ({ bot_username: "ntrnr_vpn_bot", grafana_url: null }));
} }
return configPromise; return configPromise;
} }
@@ -178,7 +186,7 @@ export const adminLogin = async (username: string, password: string) => {
}; };
export interface StaffRole { export interface StaffRole {
role: "owner" | "moderator" | "partner" | "user" | "admin"; role: "owner" | "moderator" | "partner" | "support" | "user" | "admin";
can_manage_nodes: boolean; can_manage_nodes: boolean;
username: string | null; username: string | null;
partner_code: string | null; partner_code: string | null;
@@ -206,6 +214,14 @@ export const createModerator = async (
return parseOrThrow(res, "Failed to create moderator"); 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 ( export const createPartner = async (
username: string, username: string,
password: string, password: string,
@@ -303,6 +319,67 @@ export const requestWithdrawal = async (
return parseOrThrow(res, "Failed to submit withdrawal request"); 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
+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
+28 -3
View File
@@ -28,10 +28,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, proxy_internal,
referrals::{controller as ref_ctrl, service::ReferralService}, referrals::{controller as ref_ctrl, service::ReferralService},
staff::{controller as staff_ctrl, service::StaffService}, 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},
}; };
@@ -43,6 +45,12 @@ pub struct ApiState {
pub referral_service: ReferralService, pub referral_service: ReferralService,
pub user_service: UserService, pub user_service: UserService,
pub staff_service: StaffService, 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,
/// Юзернейм бота (без `@`) — отдаётся фронту через `GET /api/v1/config`. /// Юзернейм бота (без `@`) — отдаётся фронту через `GET /api/v1/config`.
@@ -84,6 +92,8 @@ impl Clone for ApiState {
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(), 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(),
bot_username: self.bot_username.clone(), bot_username: self.bot_username.clone(),
@@ -132,7 +142,10 @@ 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)) .route("/config", get(get_public_config))
@@ -144,7 +157,12 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
.nest("/staff", staff_ctrl::router(state.clone())) .nest("/staff", staff_ctrl::router(state.clone()))
.nest("/staff", staff_ctrl::owner_router(state.clone())) .nest("/staff", staff_ctrl::owner_router(state.clone()))
.nest("/staff", staff_ctrl::staff_router(state.clone())) .nest("/staff", staff_ctrl::staff_router(state.clone()))
.nest("/partners", staff_ctrl::partner_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 // Внутренний контракт для прокси-нод — отдельный секрет, не auth_guard
// (прокси не пользовательская сессия) и не участвует в CORS ниже (никогда // (прокси не пользовательская сессия) и не участвует в CORS ниже (никогда
@@ -201,7 +219,14 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
/// Публичная (без auth_guard) рантайм-конфигурация для статического SPA — /// Публичная (без auth_guard) рантайм-конфигурация для статического SPA —
/// см. `ApiState::bot_username` про то, почему это не build-time env фронта. /// см. `ApiState::bot_username` про то, почему это не build-time env фронта.
async fn get_public_config(State(state): State<ApiState>) -> impl IntoResponse { async fn get_public_config(State(state): State<ApiState>) -> impl IntoResponse {
Json(json!({ "bot_username": state.bot_username })) // Публичный 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: процесс жив и отвечает. Не трогает зависимости.
+128 -10
View File
@@ -8,7 +8,8 @@
use crate::modules::{ use crate::modules::{
auth::service::AuthService, billing::service::BillingService, auth::service::AuthService, billing::service::BillingService,
referrals::service::ReferralService, users::service::UserService, referrals::service::ReferralService, support::service::SupportService,
users::service::UserService,
}; };
use teloxide::{ use teloxide::{
prelude::*, prelude::*,
@@ -40,6 +41,7 @@ pub async fn run_bot(
users: UserService, users: UserService,
billing: BillingService, billing: BillingService,
referrals: ReferralService, referrals: ReferralService,
support: SupportService,
cancel_token: CancellationToken, cancel_token: CancellationToken,
) { ) {
let config = BotConfig { let config = BotConfig {
@@ -50,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) — первая. Всё, что не распозналось как
// команда, падает во вторую ветку: там же живёт разовый захват
// свободного текста для тикетов поддержки (см. handle_free_text) —
// единственный канал создания тикета, кнопка "Поддержка" выставляет
// User::awaiting_support_message перед этим.
let handler = Update::filter_message()
.branch(
dptree::entry() dptree::entry()
.filter_command::<Command>() .filter_command::<Command>()
.endpoint(handle_commands), .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, referrals, config]) .dependencies(dptree::deps![
auth, users, billing, referrals, support, config
])
.build(); .build();
tokio::select! { tokio::select! {
@@ -70,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,
@@ -139,7 +196,8 @@ async fn handle_commands(
Ok(()) Ok(())
} }
#[instrument(skip(bot, auth, users, billing, referrals, 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,
@@ -147,8 +205,15 @@ async fn handle_callbacks(
users: UserService, users: UserService,
billing: BillingService, billing: BillingService,
referrals: ReferralService, 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, "Пользователь нажал кнопку в боте");
@@ -233,17 +298,37 @@ async fn handle_callbacks(
) )
.await, .await,
// Оплата криптой прямо в чате, без WebApp/кошелька — юзер платит // Оплата криптой прямо в чате, без WebApp/кошелька — юзер платит
// внутри @CryptoBot, возвращается и жмёт "Я оплатил". // внутри @CryptoBot, возвращается и жмёт "Я оплатил". Показываем
"menu_pay_crypto" => match billing.list_plans_usd().await { // цену в валюте региона (по language_code, см. выше) для
Ok(plans) if !plans.is_empty() => { // наглядности — САМА оплата остаётся в USD/USDT (см.
// pay_plan-ветку ниже): CryptoBotProvider биллит 1:1 к
// USD-цене тарифа, менять валюту списания нельзя, иначе при
// показе, скажем, VND юзер спишет в разы больше/меньше нужного.
"menu_pay_crypto" => match billing
.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 let rows = plans
.chunks(1) .chunks(1)
.map(|chunk| { .map(|chunk| {
chunk chunk
.iter() .iter()
.map(|(name, cents)| { .map(|(name, amount)| {
InlineKeyboardButton::callback( InlineKeyboardButton::callback(
format!("{} — ${:.2}", name, *cents as f64 / 100.0), format!(
"{}{}",
name,
crate::modules::geoip::currency::format_amount(
&display_currency,
*amount
)
),
format!("pay_plan:{}", name), format!("pay_plan:{}", name),
) )
}) })
@@ -378,6 +463,35 @@ async fn handle_callbacks(
("Ошибка связи с ядром системы.".into(), back_keyboard()) ("Ошибка связи с ядром системы.".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 про то, почему это надёжнее // AuthService::create_bot_login_code про то, почему это надёжнее
// deep-link-варианта (`?start=`). // deep-link-варианта (`?start=`).
@@ -470,6 +584,10 @@ fn main_menu_keyboard() -> InlineKeyboardMarkup {
"🔑 Код для входа в приложение", "🔑 Код для входа в приложение",
"menu_app_login", "menu_app_login",
)], )],
vec![InlineKeyboardButton::callback(
"🆘 Поддержка",
"menu_support",
)],
]) ])
} }
fn profile_keyboard() -> InlineKeyboardMarkup { fn profile_keyboard() -> InlineKeyboardMarkup {
+39
View File
@@ -38,6 +38,45 @@ pub struct User {
/// Партнёр, чей диплинк привёл этого пользователя (проставляется один /// Партнёр, чей диплинк привёл этого пользователя (проставляется один
/// раз при регистрации, first-wins — см. `set_referred_by_partner`). /// раз при регистрации, first-wins — см. `set_referred_by_partner`).
pub referred_by_partner_id: Option<Uuid>, 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)] #[derive(Debug, Clone, FromRow, Serialize)]
+209 -12
View File
@@ -124,6 +124,15 @@ pub trait AppRepository: Send + Sync {
/// `0001_init.sql`), а не потому что CryptoBot требует именно её. /// `0001_init.sql`), а не потому что CryptoBot требует именно её.
async fn list_active_plans_usd(&self) -> Result<Vec<(String, i64)>, sqlx::Error>; 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,
@@ -241,6 +250,36 @@ pub trait AppRepository: Send + Sync {
processed_by: Uuid, processed_by: Uuid,
) -> Result<Option<WithdrawalRequest>, sqlx::Error>; ) -> 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>;
@@ -294,7 +333,7 @@ impl AppRepository for PgRepository {
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, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id" 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
} }
@@ -304,7 +343,7 @@ impl AppRepository for PgRepository {
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, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \ 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)
@@ -317,7 +356,7 @@ impl AppRepository for PgRepository {
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, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \ 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)
@@ -331,7 +370,7 @@ impl AppRepository for PgRepository {
"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, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id" 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
} }
@@ -341,7 +380,7 @@ impl AppRepository for PgRepository {
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, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \ 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)
@@ -376,7 +415,7 @@ impl AppRepository for PgRepository {
"UPDATE users SET data_limit_bytes = $1 WHERE id = $2 \ "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, \ 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, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id", username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message",
) )
.bind(limit_bytes) .bind(limit_bytes)
.bind(user_id) .bind(user_id)
@@ -698,6 +737,22 @@ impl AppRepository for PgRepository {
.await .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,
@@ -965,7 +1020,7 @@ impl AppRepository for PgRepository {
VALUES ($1, $2, $3, $4, $5, $6, $7) \ 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, \ 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, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id" username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message"
) )
.bind(Uuid::new_v4()) .bind(Uuid::new_v4())
.bind(username) .bind(username)
@@ -983,7 +1038,7 @@ impl AppRepository for PgRepository {
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, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \ username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \
FROM users WHERE username = $1" FROM users WHERE username = $1"
) )
.bind(username) .bind(username)
@@ -1001,7 +1056,7 @@ impl AppRepository for PgRepository {
"UPDATE users SET can_manage_nodes = $1 WHERE id = $2 \ "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, \ 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, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id" username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message"
) )
.bind(enabled) .bind(enabled)
.bind(user_id) .bind(user_id)
@@ -1013,12 +1068,12 @@ impl AppRepository for PgRepository {
async fn list_staff_users(&self, role_filter: Option<&str>) -> Result<Vec<User>, sqlx::Error> { async fn list_staff_users(&self, role_filter: Option<&str>) -> Result<Vec<User>, sqlx::Error> {
let roles: Vec<&str> = match role_filter { let roles: Vec<&str> = match role_filter {
Some(r) => vec![r], Some(r) => vec![r],
None => vec!["owner", "moderator", "partner"], None => vec!["owner", "moderator", "partner", "support"],
}; };
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, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \ 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" FROM users WHERE role = ANY($1) ORDER BY role, username"
) )
.bind(&roles) .bind(&roles)
@@ -1031,7 +1086,7 @@ impl AppRepository for PgRepository {
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, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \ 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'" FROM users WHERE partner_code = $1 AND role = 'partner'"
) )
.bind(code) .bind(code)
@@ -1303,6 +1358,148 @@ impl AppRepository for PgRepository {
.await .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)
// ===================================================================== // =====================================================================
+41 -3
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, staff::service::StaffService, 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;
@@ -288,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()),
@@ -296,6 +309,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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()), 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(),
bot_username, bot_username,
@@ -323,6 +338,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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_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 {
@@ -334,6 +350,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
bot_users, bot_users,
bot_billing, bot_billing,
bot_referrals, bot_referrals,
bot_support,
bot_cancel_token, bot_cancel_token,
) )
.await; .await;
@@ -344,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-защита
+71
View File
@@ -157,6 +157,21 @@ pub async fn node_manage_guard(
} }
} }
/// 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. /// Только Partner.
pub async fn partner_guard( pub async fn partner_guard(
State(_state): State<ApiState>, State(_state): State<ApiState>,
@@ -205,6 +220,11 @@ mod tests {
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()), 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(), jwt_secret: "test-secret".into(),
bot_token: "test-bot-token".into(), bot_token: "test-bot-token".into(),
bot_username: "test_bot".into(), bot_username: "test_bot".into(),
@@ -235,6 +255,7 @@ mod tests {
commission_percent: None, commission_percent: None,
partner_code: None, partner_code: None,
referred_by_partner_id: None, referred_by_partner_id: None,
awaiting_support_message: false,
} }
} }
@@ -360,4 +381,54 @@ mod tests {
StatusCode::UNAUTHORIZED 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
);
}
} }
+6 -3
View File
@@ -1,10 +1,13 @@
//! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе), //! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе),
//! логин Owner/Moderator/Partner по паролю, и ролевые JWT-гварды (`auth_guard` + //! логин Owner/Moderator/Partner/Support по паролю, и ролевые JWT-гварды (`auth_guard` +
//! `owner_guard`/`staff_guard`/`node_manage_guard`/`partner_guard`). См. README.md рядом. //! `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::{auth_guard, node_manage_guard, owner_guard, partner_guard, staff_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};
+1
View File
@@ -492,6 +492,7 @@ 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(())
} }
+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>,
+32
View File
@@ -110,6 +110,30 @@ impl BillingService {
.map_err(AppError::Database) .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> {
@@ -214,6 +238,8 @@ impl BillingService {
.repo .repo
.create_invoice(user.id, plan_name, provider_name, currency, invoice_amount) .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,
@@ -273,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(),
)); ));
@@ -282,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
@@ -360,6 +390,8 @@ impl BillingService {
percent, percent,
) )
.await?; .await?;
metrics::counter!("partner_commission_total", "currency" => invoice.currency.clone())
.increment(commission as u64);
} }
Ok(()) Ok(())
} }
+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(())
}
}
+2
View File
@@ -3,8 +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 proxy_internal;
pub mod referrals; pub mod referrals;
pub mod staff; pub mod staff;
pub mod support;
pub mod users; pub mod users;
+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)?;
+5
View File
@@ -143,6 +143,11 @@ mod tests {
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()), 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(), jwt_secret: SECRET.into(),
bot_token: "test-bot-token".into(), bot_token: "test-bot-token".into(),
bot_username: "test_bot".into(), bot_username: "test_bot".into(),
+40 -1
View File
@@ -4,6 +4,7 @@
use axum::{ use axum::{
extract::{Extension, Path, State}, extract::{Extension, Path, State},
http::StatusCode,
middleware, middleware,
routing::{get, patch, post}, routing::{get, patch, post},
Json, Router, Json, Router,
@@ -27,10 +28,27 @@ 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))
} }
/// Только Owner: создание модераторов, выдача им права на управление нодами. /// Не подключён ни к чему в проде: изначально задумывался как гейт для
/// 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> { pub fn owner_router(state: ApiState) -> Router<ApiState> {
Router::new() Router::new()
.route("/moderators", post(create_moderator)) .route("/moderators", post(create_moderator))
.route("/support", post(create_support))
.route("/users/{id}/permissions", patch(set_permissions)) .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.clone(), owner_guard))
.route_layer(middleware::from_fn_with_state(state, auth_guard)) .route_layer(middleware::from_fn_with_state(state, auth_guard))
@@ -89,6 +107,27 @@ async fn create_moderator(
}))) })))
} }
#[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)] #[derive(Deserialize)]
pub struct CreatePartnerPayload { pub struct CreatePartnerPayload {
pub username: String, pub username: String,
+10
View File
@@ -49,6 +49,16 @@ impl StaffService {
.map_err(|e| map_create_error(e, "модератора")) .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( pub async fn create_partner(
&self, &self,
username: &str, username: &str,
+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);
}
}
+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 \