Compare commits
19 Commits
e6c81bf8fd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d465167f4 | |||
| d91aef012e | |||
| a89721fbee | |||
| a84d5dc820 | |||
| 403a6127b1 | |||
| c617c05233 | |||
| 51788f73ee | |||
| 4e998d789b | |||
| 864d4bc852 | |||
| 583682f330 | |||
| 91e214dad2 | |||
| 4f6bba6885 | |||
| d543fb5585 | |||
| 3feb2f431c | |||
| ebc618743a | |||
| 379f2a26cf | |||
| 875f679fc3 | |||
| b902a28dae | |||
| 591643d364 |
+10
-3
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 }}
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
|
||||||
|
|||||||
@@ -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" /> Доходы
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
global:
|
|
||||||
scrape_interval: 15s # Как часто опрашивать бэкенд
|
|
||||||
|
|
||||||
scrape_configs:
|
|
||||||
- job_name: "netrunner-backend"
|
|
||||||
static_configs:
|
|
||||||
- targets: ["app:8080"] # Адрес твоего Rust-бэкенда
|
|
||||||
+5
-1
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
// === КООРДИНАТОР АВАРИЙНОГО И СТАНДАРТНОГО ШУТДАУНА ===
|
// === КООРДИНАТОР АВАРИЙНОГО И СТАНДАРТНОГО ШУТДАУНА ===
|
||||||
|
|||||||
@@ -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-защита
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//! Тикеты техподдержки — единственный канал создания: кнопка "Поддержка" в
|
||||||
|
//! Telegram-боте (см. `crate::bot`). Ответ саппорта — из веб-админки,
|
||||||
|
//! доставляется юзеру обратно в тот же Telegram-чат. См. `service.rs`.
|
||||||
|
|
||||||
|
pub mod controller;
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
|
pub use service::SupportService;
|
||||||
@@ -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
@@ -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
@@ -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 \
|
||||||
|
|||||||
Reference in New Issue
Block a user