Compare commits
8 Commits
v0.10.0-beta
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 23f5e782f8 | |||
| cbb2133991 | |||
| 30866bb244 | |||
| fd22714c9b | |||
| 5e3229e998 | |||
| d0dc8d5822 | |||
| 65495ee2bf | |||
| 7d63603150 |
+23
-8
@@ -7,14 +7,29 @@ BOT_DEBUG=false
|
|||||||
# Telegram user ID администратора (узнать: @userinfobot или @getidsbot)
|
# Telegram user ID администратора (узнать: @userinfobot или @getidsbot)
|
||||||
TELEGRAM_ADMIN_ID=123456789
|
TELEGRAM_ADMIN_ID=123456789
|
||||||
|
|
||||||
# Remnawave — панель 1 (https://docs.rw/)
|
# --- Remnawave (официальные имена: https://docs.rw/docs/install/subscription-page/bundled) ---
|
||||||
REMNAWAVE_PANEL_NAME=Панель 1
|
REMNAWAVE_PANEL_NAME=Панель 1
|
||||||
|
# URL панели: https://panel.example.com или http://remnawave:3000 (внутри Docker-сети)
|
||||||
REMNAWAVE_PANEL_URL=https://panel.example.com
|
REMNAWAVE_PANEL_URL=https://panel.example.com
|
||||||
# Settings → API Tokens в панели Remnawave
|
# API-токен: Remnawave Settings → API Tokens (Authorization: Bearer)
|
||||||
REMNAWAVE_API_TOKEN=your_api_token_here
|
REMNAWAVE_API_TOKEN=API_TOKEN_FROM_REMNAWAVE
|
||||||
# Опционально, если перед панелью стоит Caddy с X-Api-Key
|
# Если используется Caddy with security — X-Api-Key к панели
|
||||||
REMNAWAVE_CADDY_TOKEN=
|
CADDY_AUTH_API_TOKEN=
|
||||||
# Публичная страница подписки (Subscription Page), для проверки доступности
|
# Опционально: Subscription Page (например https://sub.example.com)
|
||||||
REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com
|
REMNAWAVE_SUBSCRIPTION_URL=
|
||||||
|
|
||||||
# Docker Compose читает этот файл как .env (скопируйте: cp .env.example .env)
|
# PostgreSQL (должен совпадать с POSTGRES_* ниже; install.sh сгенерирует автоматически)
|
||||||
|
POSTGRES_USER=tgvpn
|
||||||
|
POSTGRES_PASSWORD=change_me_strong_password
|
||||||
|
POSTGRES_DB=tgvpn
|
||||||
|
DATABASE_URL=postgres://tgvpn:change_me_strong_password@db:5432/tgvpn?sslmode=disable
|
||||||
|
|
||||||
|
# Срок подписки: для /config у пользователей бота
|
||||||
|
TRIAL_USER_DAYS=1
|
||||||
|
# Для /admin user (создание админом)
|
||||||
|
DEFAULT_USER_DAYS=1
|
||||||
|
# UUID сквадов из панели (/admin squads), через запятую для internal
|
||||||
|
DEFAULT_EXTERNAL_SQUAD_UUID=
|
||||||
|
DEFAULT_INTERNAL_SQUAD_UUIDS=
|
||||||
|
|
||||||
|
# Docker Compose: cp .env.example .env
|
||||||
|
|||||||
+36
-1
@@ -2,6 +2,41 @@
|
|||||||
|
|
||||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
|
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
|
||||||
|
- `/config` и кнопка «Получить конфиг» — trial-подписка на `TRIAL_USER_DAYS` (по умолчанию 1 день), создание пользователя в Remnawave и ссылка на подписку
|
||||||
|
- `install.sh` — интерактивный установщик на Linux-сервер (опрос параметров, `.env`, Docker)
|
||||||
|
- PostgreSQL 16 в Docker Compose (`DATABASE_URL`)
|
||||||
|
- Создание пользователей Remnawave: `/admin user`, `/admin user <логин> [дней]`
|
||||||
|
- Назначение сквадов: external + internal (`/admin assign <логин>`, мастер с кнопками)
|
||||||
|
- `/admin squads` — список сквадов из API
|
||||||
|
- Сохранение VPN-пользователей и состояния мастера в БД
|
||||||
|
|
||||||
|
## [0.20.0] — 2026-05-21
|
||||||
|
|
||||||
|
### Изменено
|
||||||
|
|
||||||
|
- Конфигурация Remnawave приведена к [официальной документации](https://docs.rw/docs/install/subscription-page/bundled):
|
||||||
|
- `REMNAWAVE_PANEL_URL` — URL панели и API (`/api/...`)
|
||||||
|
- `REMNAWAVE_API_TOKEN` — `Authorization: Bearer`
|
||||||
|
- `CADDY_AUTH_API_TOKEN` — `X-Api-Key` (вместо `REMNAWAVE_CADDY_TOKEN`, старое имя поддерживается)
|
||||||
|
- Удалён `REMNAWAVE_API_URL` (отдельный URL API в Remnawave не используется)
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
|
||||||
|
- `/admin check`: отчёт без Markdown — URL и имена переменных отображаются корректно
|
||||||
|
- Страница подписки (`REMNAWAVE_SUBSCRIPTION_URL`) — опциональная проверка, не ошибка если не задана
|
||||||
|
- Подсказка при HTTP 502: различие домена панели (`panel.*`) и подписки (`sub.*`)
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
|
||||||
|
- Раздел в README: Remnawave API (по официальной документации)
|
||||||
|
- Пример `curl` для проверки API с сервера
|
||||||
|
|
||||||
|
[0.20.0]: #
|
||||||
|
|
||||||
## [0.10.0-beta] — 2026-05-21
|
## [0.10.0-beta] — 2026-05-21
|
||||||
|
|
||||||
Первый публичный beta-релиз Telegram-бота для VPN на базе [Remnawave](https://docs.rw/).
|
Первый публичный beta-релиз Telegram-бота для VPN на базе [Remnawave](https://docs.rw/).
|
||||||
@@ -32,4 +67,4 @@
|
|||||||
- `internal/config` — загрузка конфигурации
|
- `internal/config` — загрузка конфигурации
|
||||||
- `internal/remnawave` — HTTP-клиент и health-check панели
|
- `internal/remnawave` — HTTP-клиент и health-check панели
|
||||||
|
|
||||||
[0.10.0-beta]: https://git.evilfox.cc/test/tgvpn/releases/tag/v0.10.0-beta
|
[0.10.0-beta]: #
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
# tgvpn
|
# tgvpn
|
||||||
|
|
||||||
**Версия:** [0.10.0-beta](CHANGELOG.md) · [Релизы](https://git.evilfox.cc/test/tgvpn/releases)
|
**Версия:** [0.20.0](CHANGELOG.md)
|
||||||
|
|
||||||
Telegram-бот на Go (базовое приветствие; далее — VPN-функции).
|
Telegram-бот на Go для управления VPN через панель [Remnawave](https://docs.rw/): проверка панели, создание пользователей, назначение сквадов. Данные хранятся в **PostgreSQL**.
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
- [Требования](#требования)
|
||||||
|
- [Установщик (рекомендуется)](#установщик-на-сервере)
|
||||||
|
- [Быстрый старт](#быстрый-старт-docker-compose)
|
||||||
|
- [PostgreSQL](#postgresql)
|
||||||
|
- [Развёртывание на VPS](#развёртывание-на-vps-linux)
|
||||||
|
- [Обновление бота](#обновление-бота)
|
||||||
|
- [Переменные окружения](#переменные-окружения)
|
||||||
|
- [Админ-меню](#админ-меню-в-боте)
|
||||||
|
- [Устранение неполадок](#устранение-неполадок)
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
@@ -10,6 +22,7 @@ Telegram-бот на Go (базовое приветствие; далее — V
|
|||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| Docker | 24+ |
|
| Docker | 24+ |
|
||||||
| Docker Compose | v2 (`docker compose`) |
|
| Docker Compose | v2 (`docker compose`) |
|
||||||
|
| PostgreSQL | 16+ (в compose включён) |
|
||||||
| Токен бота | [@BotFather](https://t.me/BotFather) |
|
| Токен бота | [@BotFather](https://t.me/BotFather) |
|
||||||
| Сеть | Исходящий HTTPS к `api.telegram.org` (порт 443) |
|
| Сеть | Исходящий HTTPS к `api.telegram.org` (порт 443) |
|
||||||
|
|
||||||
@@ -17,12 +30,69 @@ Telegram-бот на Go (базовое приветствие; далее — V
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Установщик на сервере
|
||||||
|
|
||||||
|
Интерактивный скрипт запросит все параметры, создаст `.env` и запустит Docker.
|
||||||
|
|
||||||
|
### Требования на сервере
|
||||||
|
|
||||||
|
- Linux (Ubuntu 22.04/24.04, Debian 12)
|
||||||
|
- `curl`, `git` (для клонирования)
|
||||||
|
- Права `sudo` (для установки Docker при необходимости)
|
||||||
|
|
||||||
|
### Установка одной командой
|
||||||
|
|
||||||
|
Если репозиторий уже на сервере:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tgvpn
|
||||||
|
chmod +x install.sh
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Или скачайте скрипт и укажите каталог `/opt/tgvpn`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/tgvpn
|
||||||
|
cd /opt/tgvpn
|
||||||
|
git clone <URL-вашего-репозитория> .
|
||||||
|
chmod +x install.sh
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Что спрашивает установщик
|
||||||
|
|
||||||
|
| Блок | Параметры |
|
||||||
|
|------|-----------|
|
||||||
|
| Telegram | `BOT_TOKEN`, `TELEGRAM_ADMIN_ID`, `BOT_DEBUG` |
|
||||||
|
| Remnawave | URL панели, API token, Caddy token, subscription URL |
|
||||||
|
| PostgreSQL | пользователь, база, пароль (можно сгенерировать случайный) |
|
||||||
|
| VPN | срок по умолчанию, UUID сквадов (опционально) |
|
||||||
|
| Система | каталог установки, URL git (если не из текущей папки) |
|
||||||
|
|
||||||
|
После завершения: `docker compose up -d --build`, проверка `docker compose ps`.
|
||||||
|
|
||||||
|
### Переменные окружения для PostgreSQL в compose
|
||||||
|
|
||||||
|
В `.env` должны совпадать `POSTGRES_PASSWORD` и пароль в `DATABASE_URL`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
POSTGRES_USER=tgvpn
|
||||||
|
POSTGRES_PASSWORD=ваш_сильный_пароль
|
||||||
|
POSTGRES_DB=tgvpn
|
||||||
|
DATABASE_URL=postgres://tgvpn:ваш_сильный_пароль@db:5432/tgvpn?sslmode=disable
|
||||||
|
```
|
||||||
|
|
||||||
|
Установщик заполняет это автоматически.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Быстрый старт (Docker Compose)
|
## Быстрый старт (Docker Compose)
|
||||||
|
|
||||||
### 1. Клонирование
|
### 1. Клонирование
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.evilfox.cc/test/tgvpn.git
|
git clone <URL-вашего-репозитория>
|
||||||
cd tgvpn
|
cd tgvpn
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -42,12 +112,16 @@ REMNAWAVE_PANEL_NAME=Панель 1
|
|||||||
REMNAWAVE_PANEL_URL=https://panel.example.com
|
REMNAWAVE_PANEL_URL=https://panel.example.com
|
||||||
REMNAWAVE_API_TOKEN=токен_из_панели
|
REMNAWAVE_API_TOKEN=токен_из_панели
|
||||||
REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com
|
REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com
|
||||||
|
DATABASE_URL=postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable
|
||||||
|
DEFAULT_USER_DAYS=30
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Важно:** файл `.env` не попадает в git и не копируется в образ. Compose передаёт переменные в контейнер при старте.
|
> **Важно:** файл `.env` не попадает в git и не копируется в образ. Compose передаёт переменные в контейнер при старте.
|
||||||
|
|
||||||
### 3. Сборка и запуск
|
### 3. Сборка и запуск
|
||||||
|
|
||||||
|
Поднимаются два сервиса: **PostgreSQL** (`db`) и **бот** (`bot`). Бот стартует только после готовности БД.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
@@ -55,23 +129,164 @@ docker compose up -d --build
|
|||||||
### 4. Проверка
|
### 4. Проверка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# логи (должно быть: «бот @имя_бота запущен»)
|
# статус (db — healthy, bot — running)
|
||||||
docker compose logs -f bot
|
|
||||||
|
|
||||||
# статус контейнера
|
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|
||||||
|
# логи бота (должно быть: «postgres ok», «бот @имя запущен»)
|
||||||
|
docker compose logs --tail=30 bot
|
||||||
|
|
||||||
|
# логи PostgreSQL
|
||||||
|
docker compose logs --tail=20 db
|
||||||
```
|
```
|
||||||
|
|
||||||
В Telegram откройте бота и отправьте `/start`.
|
В Telegram: `/start` → кнопка «Получить конфиг» или `/config` (trial на `TRIAL_USER_DAYS`, по умолчанию 1 день). От админа — `/admin squads`, `/admin user`.
|
||||||
|
|
||||||
### 5. Остановка
|
### 5. Остановка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# остановить контейнеры (данные БД сохраняются в volume pgdata)
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
|
# удалить и данные БД (осторожно!)
|
||||||
|
docker compose down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## PostgreSQL
|
||||||
|
|
||||||
|
Бот **не работает без PostgreSQL**: при старте проверяется `DATABASE_URL`, применяется миграция, далее идёт работа с БД.
|
||||||
|
|
||||||
|
### Роль базы данных
|
||||||
|
|
||||||
|
| Таблица | Назначение |
|
||||||
|
|---------|------------|
|
||||||
|
| `telegram_users` | Пользователи Telegram, зашедшие в бота (`/start`) |
|
||||||
|
| `vpn_users` | Созданные в Remnawave аккаунты: UUID, логин, сквады, срок |
|
||||||
|
| `admin_wizard` | Состояние мастера админа (создание пользователя, назначение сквадов) |
|
||||||
|
|
||||||
|
Миграции лежат в `internal/db/migrations/` и применяются **автоматически** при каждом запуске бота (`CREATE TABLE IF NOT EXISTS`).
|
||||||
|
|
||||||
|
### Схема в Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
db: # PostgreSQL 16, volume pgdata
|
||||||
|
bot: # ждёт healthy у db, затем стартует
|
||||||
|
```
|
||||||
|
|
||||||
|
Параметры по умолчанию (см. `docker-compose.yml`):
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|----------|
|
||||||
|
| Хост (внутри compose) | `db` |
|
||||||
|
| Порт | `5432` |
|
||||||
|
| База | `tgvpn` |
|
||||||
|
| Пользователь | `tgvpn` |
|
||||||
|
| Пароль | `tgvpn` |
|
||||||
|
|
||||||
|
Строка подключения для бота:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable
|
||||||
|
```
|
||||||
|
|
||||||
|
Формат URL (общий вид):
|
||||||
|
|
||||||
|
```
|
||||||
|
postgres://USER:PASSWORD@HOST:PORT/DATABASE?sslmode=disable
|
||||||
|
```
|
||||||
|
|
||||||
|
### Подключение к БД вручную
|
||||||
|
|
||||||
|
Из каталога проекта:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# интерактивная консоль psql внутри контейнера
|
||||||
|
docker compose exec db psql -U tgvpn -d tgvpn
|
||||||
|
```
|
||||||
|
|
||||||
|
Полезные запросы:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- все VPN-пользователи, созданные через бота
|
||||||
|
SELECT remnawave_username, remnawave_uuid, expire_at, created_at FROM vpn_users;
|
||||||
|
|
||||||
|
-- активные мастера админа
|
||||||
|
SELECT admin_telegram_id, step, updated_at FROM admin_wizard;
|
||||||
|
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
### Продакшен: смена пароля БД
|
||||||
|
|
||||||
|
1. Задайте сильный пароль в `docker-compose.yml` (секция `db.environment`) **или** вынесите в `.env`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me_strong}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Обновите `DATABASE_URL` в `.env` бота с тем же паролем.
|
||||||
|
|
||||||
|
3. Пересоздайте стек:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
> Если меняете пароль у уже существующего volume `pgdata`, может понадобиться сброс volume или `ALTER USER` внутри старой БД.
|
||||||
|
|
||||||
|
### Внешний PostgreSQL (без контейнера `db`)
|
||||||
|
|
||||||
|
Если БД на отдельном сервере или managed Postgres:
|
||||||
|
|
||||||
|
1. Создайте базу и пользователя с правами `CREATE`, `SELECT`, `INSERT`, `UPDATE`, `DELETE`.
|
||||||
|
2. В `.env` укажите реальный URL:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgres://user:password@postgres.example.com:5432/tgvpn?sslmode=require
|
||||||
|
```
|
||||||
|
|
||||||
|
3. В `docker-compose.yml` закомментируйте или удалите сервис `db` и `depends_on` у `bot`.
|
||||||
|
4. Убедитесь, что с хоста бота есть сетевой доступ к порту `5432`.
|
||||||
|
|
||||||
|
### Резервное копирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# дамп в файл (дата в имени)
|
||||||
|
docker compose exec -T db pg_dump -U tgvpn tgvpn > backup_$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# восстановление (на пустую или новую БД)
|
||||||
|
cat backup_20260101.sql | docker compose exec -T db psql -U tgvpn -d tgvpn
|
||||||
|
```
|
||||||
|
|
||||||
|
Рекомендуется настроить cron на VPS для ежедневных дампов.
|
||||||
|
|
||||||
|
### Проверка после деплоя
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
# tgvpn-db running (healthy)
|
||||||
|
# tgvpn-bot running
|
||||||
|
|
||||||
|
docker compose logs bot | grep -i postgres
|
||||||
|
# ожидается успешный старт без «ping postgres» / «apply migration» ошибок
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ошибки PostgreSQL
|
||||||
|
|
||||||
|
| Симптом | Решение |
|
||||||
|
|---------|---------|
|
||||||
|
| `DATABASE_URL не задан` | Добавьте переменную в `.env` |
|
||||||
|
| `connect postgres` / `ping postgres` | Проверьте, что `db` в состоянии `healthy`: `docker compose ps` |
|
||||||
|
| Бот стартует раньше БД | В compose уже есть `depends_on: condition: service_healthy` — обновите compose |
|
||||||
|
| `password authentication failed` | Совпадение пароля в `POSTGRES_PASSWORD` и в `DATABASE_URL` |
|
||||||
|
| Пустые таблицы после создания user | Смотрите логи бота и ответ Remnawave API; запись в `vpn_users` идёт после успешного `POST /api/users` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Развёртывание на VPS (Linux)
|
## Развёртывание на VPS (Linux)
|
||||||
|
|
||||||
Ниже — пошаговая установка на чистый сервер (Ubuntu 22.04/24.04, Debian 12). Аналогично на других дистрибутивах с Docker.
|
Ниже — пошаговая установка на чистый сервер (Ubuntu 22.04/24.04, Debian 12). Аналогично на других дистрибутивах с Docker.
|
||||||
@@ -115,7 +330,7 @@ docker compose version
|
|||||||
sudo mkdir -p /opt/tgvpn
|
sudo mkdir -p /opt/tgvpn
|
||||||
sudo chown $USER:$USER /opt/tgvpn
|
sudo chown $USER:$USER /opt/tgvpn
|
||||||
cd /opt/tgvpn
|
cd /opt/tgvpn
|
||||||
git clone https://git.evilfox.cc/test/tgvpn.git .
|
git clone <URL-вашего-репозитория> .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Шаг 4. Настройка `.env`
|
### Шаг 4. Настройка `.env`
|
||||||
@@ -125,7 +340,9 @@ cp .env.example .env
|
|||||||
nano .env # или vim / vi
|
nano .env # или vim / vi
|
||||||
```
|
```
|
||||||
|
|
||||||
Укажите реальный `BOT_TOKEN`. Для продакшена оставьте `BOT_DEBUG=false`.
|
Укажите реальный `BOT_TOKEN`, `DATABASE_URL` (в compose для VPS обычно оставляют `postgres://tgvpn:...@db:5432/...`), Remnawave и при необходимости UUID сквадов (`DEFAULT_*`).
|
||||||
|
|
||||||
|
Для продакшена смените пароль PostgreSQL — см. [PostgreSQL → Продакшен](#продакшен-смена-пароля-бд).
|
||||||
|
|
||||||
Права на секреты:
|
Права на секреты:
|
||||||
|
|
||||||
@@ -144,6 +361,7 @@ docker compose up -d --build
|
|||||||
```bash
|
```bash
|
||||||
docker compose ps
|
docker compose ps
|
||||||
docker compose logs --tail=50 bot
|
docker compose logs --tail=50 bot
|
||||||
|
docker compose logs --tail=20 db
|
||||||
```
|
```
|
||||||
|
|
||||||
### Шаг 6. Автозапуск после перезагрузки сервера
|
### Шаг 6. Автозапуск после перезагрузки сервера
|
||||||
@@ -260,7 +478,7 @@ docker image prune -f
|
|||||||
| `git pull` конфликтует с локальными правками | `git stash` → `git pull` → `git stash pop` или сбросить локальные изменения: `git checkout -- .` |
|
| `git pull` конфликтует с локальными правками | `git stash` → `git pull` → `git stash pop` или сбросить локальные изменения: `git checkout -- .` |
|
||||||
| Бот не стартует после pull | `docker compose logs bot` — часто не хватает новой переменной в `.env` |
|
| Бот не стартует после pull | `docker compose logs bot` — часто не хватает новой переменной в `.env` |
|
||||||
| Старый код в контейнере | Обязательно `--build`: `docker compose up -d --build` |
|
| Старый код в контейнере | Обязательно `--build`: `docker compose up -d --build` |
|
||||||
| Нет доступа к git | Проверьте SSH/HTTPS-доступ к `git.evilfox.cc` |
|
| Нет доступа к git | Проверьте SSH/HTTPS-доступ к вашему git-серверу |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -272,7 +490,7 @@ docker image prune -f
|
|||||||
2. В PowerShell:
|
2. В PowerShell:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git clone https://git.evilfox.cc/test/tgvpn.git
|
git clone <URL-вашего-репозитория>
|
||||||
cd tgvpn
|
cd tgvpn
|
||||||
Copy-Item .env.example .env
|
Copy-Item .env.example .env
|
||||||
# отредактируйте .env — вставьте BOT_TOKEN
|
# отредактируйте .env — вставьте BOT_TOKEN
|
||||||
@@ -284,9 +502,31 @@ docker compose logs -f bot
|
|||||||
|
|
||||||
## Локальная разработка (без Docker)
|
## Локальная разработка (без Docker)
|
||||||
|
|
||||||
|
Нужен запущенный PostgreSQL 16+ (локально или только контейнер БД):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# вариант: только БД в Docker, бот на хосте
|
||||||
|
docker compose up -d db
|
||||||
|
```
|
||||||
|
|
||||||
|
В `.env` для локального бота укажите:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgres://tgvpn:tgvpn@localhost:5432/tgvpn?sslmode=disable
|
||||||
|
```
|
||||||
|
|
||||||
|
Проброс порта в `docker-compose.yml` (если ещё нет):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
db:
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# укажите BOT_TOKEN в .env
|
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -306,12 +546,24 @@ go build -o bot .
|
|||||||
| `BOT_TOKEN` | да | Токен от @BotFather |
|
| `BOT_TOKEN` | да | Токен от @BotFather |
|
||||||
| `TELEGRAM_ADMIN_ID` | да | Числовой Telegram user ID администратора (например, [@userinfobot](https://t.me/userinfobot)) |
|
| `TELEGRAM_ADMIN_ID` | да | Числовой Telegram user ID администратора (например, [@userinfobot](https://t.me/userinfobot)) |
|
||||||
| `REMNAWAVE_PANEL_NAME` | нет | Название панели в админ-меню (по умолчанию «Панель 1») |
|
| `REMNAWAVE_PANEL_NAME` | нет | Название панели в админ-меню (по умолчанию «Панель 1») |
|
||||||
| `REMNAWAVE_PANEL_URL` | да | URL панели Remnawave, например `https://vpn.example.com` |
|
| `REMNAWAVE_PANEL_URL` | да | URL панели — сюда же идут запросы API (`/api/...`). Пример: `https://panel.example.com` ([док](https://docs.rw/docs/install/subscription-page/bundled)) |
|
||||||
| `REMNAWAVE_API_TOKEN` | да | API-токен: панель → **Settings → API Tokens** ([документация](https://docs.rw/)) |
|
| `REMNAWAVE_API_TOKEN` | да | Токен из **Remnawave Settings → API Tokens**, заголовок `Authorization: Bearer` |
|
||||||
| `REMNAWAVE_CADDY_TOKEN` | нет | Доп. заголовок `X-Api-Key`, если панель за Caddy |
|
| `CADDY_AUTH_API_TOKEN` | нет | `X-Api-Key`, если включён Caddy with security (как в оф. `.env` subscription-page) |
|
||||||
| `REMNAWAVE_SUBSCRIPTION_URL` | нет* | URL страницы подписки для проверки в `/admin check` (*рекомендуется) |
|
| `REMNAWAVE_SUBSCRIPTION_URL` | нет | Опционально: домен Subscription Page (`sub.*`), отдельная проверка |
|
||||||
|
| `DATABASE_URL` | да | PostgreSQL, в compose: `postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable` |
|
||||||
|
| `TRIAL_USER_DAYS` | нет | Срок trial-конфига для `/config` (по умолчанию 1) |
|
||||||
|
| `DEFAULT_USER_DAYS` | нет | Срок при создании админом `/admin user` (по умолчанию 1) |
|
||||||
|
| `DEFAULT_EXTERNAL_SQUAD_UUID` | нет | External squad по умолчанию при быстром создании |
|
||||||
|
| `DEFAULT_INTERNAL_SQUAD_UUIDS` | нет | Internal squads через запятую |
|
||||||
| `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) |
|
| `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) |
|
||||||
|
|
||||||
|
### Команды для пользователей
|
||||||
|
|
||||||
|
- `/start` — приветствие и кнопка получения конфига
|
||||||
|
- `/config` — создать пользователя в Remnawave на `TRIAL_USER_DAYS` (если активная подписка уже есть — вернёт существующую ссылку)
|
||||||
|
|
||||||
|
Нужны `DEFAULT_EXTERNAL_SQUAD_UUID` и `DEFAULT_INTERNAL_SQUAD_UUIDS` — те же сквады, что для быстрого `/admin user`.
|
||||||
|
|
||||||
### Админ-меню в боте
|
### Админ-меню в боте
|
||||||
|
|
||||||
Только пользователь с `TELEGRAM_ADMIN_ID`:
|
Только пользователь с `TELEGRAM_ADMIN_ID`:
|
||||||
@@ -319,7 +571,36 @@ go build -o bot .
|
|||||||
- `/admin` — админ-меню (панель 1, Remnawave)
|
- `/admin` — админ-меню (панель 1, Remnawave)
|
||||||
- `/admin check` — полная проверка: веб панели, API (статистика, users, nodes), подписка (settings + API), страница подписки
|
- `/admin check` — полная проверка: веб панели, API (статистика, users, nodes), подписка (settings + API), страница подписки
|
||||||
- `/admin config` — конфиг панели в боте
|
- `/admin config` — конфиг панели в боте
|
||||||
- Кнопки снизу (после `/start`): «Проверить панель», «Конфиг панели»
|
- `/admin user` — мастер создания пользователя в Remnawave + назначение сквадов
|
||||||
|
- `/admin user <логин> [дней]` — быстрое создание (сквады из `DEFAULT_*` в `.env`)
|
||||||
|
- `/admin squads` — список internal/external squads
|
||||||
|
- `/admin assign <логин>` — назначить сквады существующему пользователю
|
||||||
|
- Кнопки: «Создать пользователя», «Сквады», «Проверить панель», «Конфиг»
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remnawave API (по официальной документации)
|
||||||
|
|
||||||
|
Как в [Bundled Subscription Page](https://docs.rw/docs/install/subscription-page/bundled):
|
||||||
|
|
||||||
|
```env
|
||||||
|
REMNAWAVE_PANEL_URL=https://panel.example.com
|
||||||
|
REMNAWAVE_API_TOKEN=API_TOKEN_FROM_REMNAWAVE
|
||||||
|
CADDY_AUTH_API_TOKEN=
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Отдельного `REMNAWAVE_API_URL` нет** — API всегда на том же хосте, что и панель: `{REMNAWAVE_PANEL_URL}/api/...`
|
||||||
|
- Авторизация: `Authorization: Bearer {REMNAWAVE_API_TOKEN}`
|
||||||
|
- Внутри Docker-сети Remnawave: `REMNAWAVE_PANEL_URL=http://remnawave:3000`
|
||||||
|
- Домен `sub.*` — это Subscription Page, не панель; для API используйте `panel.*`
|
||||||
|
|
||||||
|
Пример проверки с сервера:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-H "Authorization: Bearer $REMNAWAVE_API_TOKEN" \
|
||||||
|
"$REMNAWAVE_PANEL_URL/api/system/stats/recap"
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -335,10 +616,16 @@ docker compose logs -f bot
|
|||||||
# последние 100 строк логов
|
# последние 100 строк логов
|
||||||
docker compose logs --tail=100 bot
|
docker compose logs --tail=100 bot
|
||||||
|
|
||||||
# зайти в контейнер (обычно не нужно)
|
# зайти в контейнер бота
|
||||||
docker compose exec bot sh
|
docker compose exec bot sh
|
||||||
|
|
||||||
# удалить контейнер (образ останется)
|
# консоль PostgreSQL
|
||||||
|
docker compose exec db psql -U tgvpn -d tgvpn
|
||||||
|
|
||||||
|
# дамп базы
|
||||||
|
docker compose exec -T db pg_dump -U tgvpn tgvpn > backup.sql
|
||||||
|
|
||||||
|
# удалить контейнеры (данные БД в volume сохранятся)
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
# удалить контейнер и неиспользуемые образы проекта
|
# удалить контейнер и неиспользуемые образы проекта
|
||||||
@@ -351,8 +638,9 @@ docker compose down --rmi local
|
|||||||
|
|
||||||
- Бот использует **long polling**: входящие запросы на ваш сервер **не нужны**, порты открывать не требуется.
|
- Бот использует **long polling**: входящие запросы на ваш сервер **не нужны**, порты открывать не требуется.
|
||||||
- Нужен только **исходящий** доступ к `https://api.telegram.org`.
|
- Нужен только **исходящий** доступ к `https://api.telegram.org`.
|
||||||
- Не коммитьте `.env` в git. Не публикуйте `BOT_TOKEN`.
|
- Не коммитьте `.env` в git. Не публикуйте `BOT_TOKEN` и пароль БД.
|
||||||
- Контейнер запускается от непривилегированного пользователя `bot` (UID 10001).
|
- PostgreSQL доступен **только внутри docker-сети** (порт наружу не проброшен по умолчанию).
|
||||||
|
- Контейнер бота запускается от непривилегированного пользователя `bot` (UID 10001).
|
||||||
|
|
||||||
Если позже добавите **webhook**, понадобится reverse proxy (nginx/Caddy), TLS и открытый порт 443 — это описывается отдельно при появлении функции.
|
Если позже добавите **webhook**, понадобится reverse proxy (nginx/Caddy), TLS и открытый порт 443 — это описывается отдельно при появлении функции.
|
||||||
|
|
||||||
@@ -381,6 +669,15 @@ docker compose logs bot # ошибки сети, токена
|
|||||||
- Убедитесь, что на сервере нет блокировки Telegram (firewall, провайдер).
|
- Убедитесь, что на сервере нет блокировки Telegram (firewall, провайдер).
|
||||||
- Проверьте: `curl -I https://api.telegram.org` с хоста.
|
- Проверьте: `curl -I https://api.telegram.org` с хоста.
|
||||||
|
|
||||||
|
### API возвращает 502, веб-панель — 200
|
||||||
|
|
||||||
|
Частая причина: в `REMNAWAVE_PANEL_URL` указан домен **страницы подписки** (`sub.example.com`), а не **админ-панели** (`panel.example.com`).
|
||||||
|
|
||||||
|
1. Укажите URL **панели** (не sub): `REMNAWAVE_PANEL_URL=https://panel.example.com`
|
||||||
|
2. Токен API: `REMNAWAVE_API_TOKEN=...` (Settings → API Tokens)
|
||||||
|
3. Страницу подписки — опционально: `REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com`
|
||||||
|
4. Проверьте на сервере: `docker compose ps` (Remnawave Panel запущен), логи reverse proxy
|
||||||
|
|
||||||
### Контейнер постоянно перезапускается
|
### Контейнер постоянно перезапускается
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -389,6 +686,10 @@ docker compose logs --tail=200 bot
|
|||||||
|
|
||||||
Чаще всего — пустой `BOT_TOKEN` или ошибка при старте.
|
Чаще всего — пустой `BOT_TOKEN` или ошибка при старте.
|
||||||
|
|
||||||
|
### `DATABASE_URL не задан` / ошибки PostgreSQL
|
||||||
|
|
||||||
|
См. раздел [PostgreSQL → Ошибки](#ошибки-postgresql).
|
||||||
|
|
||||||
### Нет доступа к `docker` без sudo
|
### Нет доступа к `docker` без sudo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -404,11 +705,14 @@ sudo usermod -aG docker $USER
|
|||||||
tgvpn/
|
tgvpn/
|
||||||
├── main.go
|
├── main.go
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── bot/ # обработчики Telegram, админ-меню
|
│ ├── bot/ # Telegram, админ-меню, создание пользователей
|
||||||
│ ├── config/ # переменные окружения
|
│ ├── config/ # переменные окружения
|
||||||
│ └── remnawave/ # клиент API панели
|
│ ├── db/ # PostgreSQL: подключение, миграции, репозитории
|
||||||
|
│ │ └── migrations/ # SQL-миграции (001_init.sql)
|
||||||
|
│ └── remnawave/ # API панели (users, squads)
|
||||||
├── Dockerfile # multi-stage сборка
|
├── Dockerfile # multi-stage сборка
|
||||||
├── docker-compose.yml # оркестрация
|
├── install.sh # интерактивный установщик на сервер
|
||||||
|
├── docker-compose.yml # bot + PostgreSQL (volume pgdata)
|
||||||
├── .env.example # шаблон переменных
|
├── .env.example # шаблон переменных
|
||||||
├── .dockerignore
|
├── .dockerignore
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
@@ -420,4 +724,4 @@ tgvpn/
|
|||||||
|
|
||||||
## Репозиторий
|
## Репозиторий
|
||||||
|
|
||||||
https://git.evilfox.cc/test/tgvpn.git
|
Укажите URL вашего приватного git-репозитория при клонировании.
|
||||||
|
|||||||
+25
-2
@@ -1,4 +1,22 @@
|
|||||||
services:
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: tgvpn-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-tgvpn}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tgvpn}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-tgvpn}
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U tgvpn -d tgvpn"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -6,9 +24,14 @@ services:
|
|||||||
image: tgvpn-bot:latest
|
image: tgvpn-bot:latest
|
||||||
container_name: tgvpn-bot
|
container_name: tgvpn-bot
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
BOT_DEBUG: ${BOT_DEBUG:-false}
|
BOT_DEBUG: ${BOT_DEBUG:-false}
|
||||||
# Long polling — исходящие HTTPS к api.telegram.org
|
DATABASE_URL: ${DATABASE_URL:-postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable}
|
||||||
# ports не нужны, пока нет webhook
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
module telegramvpn
|
module telegramvpn
|
||||||
|
|
||||||
go 1.22
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,30 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
+283
@@ -0,0 +1,283 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Интерактивная установка tgvpn на Linux-сервер (Docker + PostgreSQL)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-/opt/tgvpn}"
|
||||||
|
REPO_URL=""
|
||||||
|
USE_CURRENT_DIR=false
|
||||||
|
SKIP_DOCKER_INSTALL=false
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${CYAN}[*]${NC} $*"; }
|
||||||
|
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||||
|
fail() { echo -e "${RED}[✗]${NC} $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
prompt() {
|
||||||
|
local label="$1"
|
||||||
|
local default="${2:-}"
|
||||||
|
local val
|
||||||
|
if [[ -n "$default" ]]; then
|
||||||
|
read -r -p "$label [$default]: " val
|
||||||
|
echo "${val:-$default}"
|
||||||
|
else
|
||||||
|
read -r -p "$label: " val
|
||||||
|
echo "$val"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_required() {
|
||||||
|
local label="$1"
|
||||||
|
local val=""
|
||||||
|
while [[ -z "$val" ]]; do
|
||||||
|
read -r -p "$label: " val
|
||||||
|
[[ -z "$val" ]] && warn "Поле обязательно."
|
||||||
|
done
|
||||||
|
echo "$val"
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_secret() {
|
||||||
|
local label="$1"
|
||||||
|
local val=""
|
||||||
|
while [[ -z "$val" ]]; do
|
||||||
|
read -r -s -p "$label: " val
|
||||||
|
echo ""
|
||||||
|
[[ -z "$val" ]] && warn "Поле обязательно."
|
||||||
|
done
|
||||||
|
echo "$val"
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_yn() {
|
||||||
|
local label="$1"
|
||||||
|
local default="${2:-y}"
|
||||||
|
local hint="Y/n"
|
||||||
|
[[ "$default" == "n" ]] && hint="y/N"
|
||||||
|
local ans
|
||||||
|
read -r -p "$label [$hint]: " ans
|
||||||
|
ans="${ans:-$default}"
|
||||||
|
[[ "$ans" =~ ^[Yy] ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
need_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_docker() {
|
||||||
|
need_cmd docker && docker compose version >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
install_docker() {
|
||||||
|
info "Установка Docker (официальный репозиторий)..."
|
||||||
|
if need_cmd apt-get; then
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y ca-certificates curl
|
||||||
|
sudo install -m 0755 -d /etc/apt/keyrings
|
||||||
|
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
|
||||||
|
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||||
|
$(. /etc/os-release && echo "${VERSION_CODENAME}") stable" | \
|
||||||
|
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||||
|
fi
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
sudo usermod -aG docker "$USER" 2>/dev/null || true
|
||||||
|
ok "Docker установлен. Если команда docker требует sudo — перелогиньтесь или выполните: newgrp docker"
|
||||||
|
else
|
||||||
|
fail "Автоустановка Docker поддерживается только для Debian/Ubuntu (apt). Установите Docker вручную: https://docs.docker.com/engine/install/"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_env() {
|
||||||
|
local env_file="$1"
|
||||||
|
cat > "$env_file" <<EOF
|
||||||
|
# Сгенерировано install.sh $(date -Iseconds)
|
||||||
|
BOT_TOKEN=${BOT_TOKEN}
|
||||||
|
BOT_DEBUG=${BOT_DEBUG}
|
||||||
|
TELEGRAM_ADMIN_ID=${TELEGRAM_ADMIN_ID}
|
||||||
|
|
||||||
|
REMNAWAVE_PANEL_NAME=${REMNAWAVE_PANEL_NAME}
|
||||||
|
REMNAWAVE_PANEL_URL=${REMNAWAVE_PANEL_URL}
|
||||||
|
REMNAWAVE_API_TOKEN=${REMNAWAVE_API_TOKEN}
|
||||||
|
CADDY_AUTH_API_TOKEN=${CADDY_AUTH_API_TOKEN}
|
||||||
|
REMNAWAVE_SUBSCRIPTION_URL=${REMNAWAVE_SUBSCRIPTION_URL}
|
||||||
|
|
||||||
|
POSTGRES_USER=${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB=${POSTGRES_DB}
|
||||||
|
DATABASE_URL=${DATABASE_URL}
|
||||||
|
|
||||||
|
TRIAL_USER_DAYS=${TRIAL_USER_DAYS}
|
||||||
|
DEFAULT_USER_DAYS=${DEFAULT_USER_DAYS}
|
||||||
|
DEFAULT_EXTERNAL_SQUAD_UUID=${DEFAULT_EXTERNAL_SQUAD_UUID}
|
||||||
|
DEFAULT_INTERNAL_SQUAD_UUIDS=${DEFAULT_INTERNAL_SQUAD_UUIDS}
|
||||||
|
EOF
|
||||||
|
chmod 600 "$env_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
banner() {
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo " tgvpn — установщик Telegram-бота"
|
||||||
|
echo " Remnawave + PostgreSQL + Docker"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_db_password() {
|
||||||
|
if [[ "$POSTGRES_PASSWORD" =~ [:@/?#\[\] ] ]]; then
|
||||||
|
warn "В пароле PostgreSQL есть спецсимволы. Рекомендуется сгенерировать пароль (буквы/цифры)."
|
||||||
|
if ! prompt_yn "Продолжить с этим паролем?" "n"; then
|
||||||
|
fail "Укажите другой пароль или сгенерируйте автоматически."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
banner
|
||||||
|
|
||||||
|
local script_dir
|
||||||
|
script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
if [[ -f "$script_dir/docker-compose.yml" ]] && [[ -f "$script_dir/main.go" ]]; then
|
||||||
|
if prompt_yn "Запустить установку из текущей папки ($script_dir)?" "y"; then
|
||||||
|
USE_CURRENT_DIR=true
|
||||||
|
INSTALL_DIR="$script_dir"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! $USE_CURRENT_DIR; then
|
||||||
|
INSTALL_DIR="$(prompt "Каталог установки" "$INSTALL_DIR")"
|
||||||
|
REPO_URL="$(prompt "URL git-репозитория (пусто — только каталог, код уже должен быть там)" "")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "=== Telegram ==="
|
||||||
|
BOT_TOKEN="$(prompt_secret "BOT_TOKEN (от @BotFather)")"
|
||||||
|
TELEGRAM_ADMIN_ID="$(prompt_required "TELEGRAM_ADMIN_ID (числовой ID от @userinfobot)")"
|
||||||
|
if [[ ! "$TELEGRAM_ADMIN_ID" =~ ^[0-9]+$ ]]; then
|
||||||
|
fail "TELEGRAM_ADMIN_ID должен быть числом"
|
||||||
|
fi
|
||||||
|
if prompt_yn "Включить BOT_DEBUG (подробные логи)?" "n"; then
|
||||||
|
BOT_DEBUG=true
|
||||||
|
else
|
||||||
|
BOT_DEBUG=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "=== Remnawave ==="
|
||||||
|
REMNAWAVE_PANEL_NAME="$(prompt "Название панели в боте" "Панель 1")"
|
||||||
|
REMNAWAVE_PANEL_URL="$(prompt_required "REMNAWAVE_PANEL_URL (https://panel.example.com)")"
|
||||||
|
if [[ ! "$REMNAWAVE_PANEL_URL" =~ ^https?:// ]]; then
|
||||||
|
fail "URL панели должен начинаться с http:// или https://"
|
||||||
|
fi
|
||||||
|
REMNAWAVE_PANEL_URL="${REMNAWAVE_PANEL_URL%/}"
|
||||||
|
REMNAWAVE_API_TOKEN="$(prompt_secret "REMNAWAVE_API_TOKEN (Settings → API Tokens)")"
|
||||||
|
CADDY_AUTH_API_TOKEN="$(prompt "CADDY_AUTH_API_TOKEN (опционально, Enter — пропустить)" "")"
|
||||||
|
REMNAWAVE_SUBSCRIPTION_URL="$(prompt "REMNAWAVE_SUBSCRIPTION_URL (опционально)" "")"
|
||||||
|
if [[ -n "$REMNAWAVE_SUBSCRIPTION_URL" ]]; then
|
||||||
|
REMNAWAVE_SUBSCRIPTION_URL="${REMNAWAVE_SUBSCRIPTION_URL%/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "=== PostgreSQL ==="
|
||||||
|
POSTGRES_USER="$(prompt "Пользователь БД" "tgvpn")"
|
||||||
|
POSTGRES_DB="$(prompt "Имя базы" "tgvpn")"
|
||||||
|
if prompt_yn "Сгенерировать случайный пароль PostgreSQL?" "y"; then
|
||||||
|
POSTGRES_PASSWORD="$(openssl rand -hex 16 2>/dev/null || head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 24)"
|
||||||
|
ok "Пароль сгенерирован (сохранён в .env)"
|
||||||
|
else
|
||||||
|
POSTGRES_PASSWORD="$(prompt_secret "POSTGRES_PASSWORD")"
|
||||||
|
fi
|
||||||
|
validate_db_password
|
||||||
|
DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "=== Пользователи VPN (по умолчанию) ==="
|
||||||
|
TRIAL_USER_DAYS="$(prompt "Срок trial-конфига для пользователей бота (/config), дней" "1")"
|
||||||
|
DEFAULT_USER_DAYS="$(prompt "Срок при создании админом (/admin user), дней" "1")"
|
||||||
|
DEFAULT_EXTERNAL_SQUAD_UUID="$(prompt "DEFAULT_EXTERNAL_SQUAD_UUID (опционально)" "")"
|
||||||
|
DEFAULT_INTERNAL_SQUAD_UUIDS="$(prompt "DEFAULT_INTERNAL_SQUAD_UUIDS через запятую (опционально)" "")"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if ! check_docker; then
|
||||||
|
warn "Docker или Docker Compose не найдены."
|
||||||
|
if prompt_yn "Установить Docker автоматически (Ubuntu/Debian)?" "y"; then
|
||||||
|
install_docker
|
||||||
|
if ! check_docker; then
|
||||||
|
warn "После установки может потребоваться перелогин. Запустите скрипт снова или: sudo docker compose ..."
|
||||||
|
SKIP_DOCKER_INSTALL=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "Нужны Docker и плагин compose. Установите вручную и запустите скрипт снова."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "Docker и Docker Compose найдены"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "=== Установка файлов ==="
|
||||||
|
|
||||||
|
if ! $USE_CURRENT_DIR; then
|
||||||
|
sudo mkdir -p "$INSTALL_DIR"
|
||||||
|
sudo chown "$USER:$USER" "$INSTALL_DIR"
|
||||||
|
if [[ -n "$REPO_URL" ]]; then
|
||||||
|
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||||
|
info "Обновление репозитория..."
|
||||||
|
git -C "$INSTALL_DIR" pull --ff-only || warn "git pull не выполнен"
|
||||||
|
else
|
||||||
|
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
elif [[ ! -f "$INSTALL_DIR/docker-compose.yml" ]]; then
|
||||||
|
fail "В $INSTALL_DIR нет docker-compose.yml. Укажите URL репозитория или запустите скрипт из папки проекта."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
|
||||||
|
if [[ -f .env ]]; then
|
||||||
|
if ! prompt_yn ".env уже существует. Перезаписать?" "n"; then
|
||||||
|
fail "Установка отменена. Отредактируйте .env вручную."
|
||||||
|
fi
|
||||||
|
cp .env ".env.bak.$(date +%s)" 2>/dev/null || true
|
||||||
|
warn "Старая копия: .env.bak.*"
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_env ".env"
|
||||||
|
ok "Создан .env"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "=== Запуск контейнеров ==="
|
||||||
|
DC="docker compose"
|
||||||
|
if ! docker compose version >/dev/null 2>&1; then
|
||||||
|
DC="sudo docker compose"
|
||||||
|
fi
|
||||||
|
|
||||||
|
$DC pull db 2>/dev/null || true
|
||||||
|
$DC up -d --build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
sleep 3
|
||||||
|
$DC ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ok "Установка завершена!"
|
||||||
|
echo ""
|
||||||
|
echo " Каталог: $INSTALL_DIR"
|
||||||
|
echo " Логи бота: $DC logs -f bot"
|
||||||
|
echo " Логи БД: $DC logs -f db"
|
||||||
|
echo " Перезапуск: $DC up -d --build"
|
||||||
|
echo ""
|
||||||
|
echo " В Telegram (аккаунт админа $TELEGRAM_ADMIN_ID):"
|
||||||
|
echo " /start"
|
||||||
|
echo " /admin check"
|
||||||
|
echo " /admin squads"
|
||||||
|
echo " /admin user"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telegramvpn/internal/db"
|
||||||
|
"telegramvpn/internal/remnawave"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,36}$`)
|
||||||
|
|
||||||
|
func (h *Handler) handleAdminUsersSubcommand(chatID, adminID int64, args []string) {
|
||||||
|
switch {
|
||||||
|
case len(args) == 0 || args[0] == "help":
|
||||||
|
h.sendText(chatID, "Пользователи Remnawave:\n\n"+
|
||||||
|
"/admin user — мастер создания\n"+
|
||||||
|
"/admin user <логин> [дней] — быстрое создание\n"+
|
||||||
|
"/admin squads — список сквадов\n"+
|
||||||
|
"/admin assign <логин> — назначить сквады (мастер)\n"+
|
||||||
|
"/admin cancel — отменить мастер")
|
||||||
|
case args[0] == "cancel", args[0] == "отмена":
|
||||||
|
_ = h.database.ClearWizard(context.Background(), adminID)
|
||||||
|
h.sendText(chatID, "Мастер отменён.")
|
||||||
|
case args[0] == "squads", args[0] == "сквады":
|
||||||
|
h.sendSquadsList(chatID)
|
||||||
|
case args[0] == "user", args[0] == "пользователь":
|
||||||
|
if len(args) >= 2 {
|
||||||
|
days := h.cfg.DefaultUserDays
|
||||||
|
if len(args) >= 3 {
|
||||||
|
if d, err := strconv.Atoi(args[2]); err == nil && d > 0 {
|
||||||
|
days = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.quickCreateUser(chatID, adminID, args[1], days)
|
||||||
|
} else {
|
||||||
|
h.startUserWizard(chatID, adminID)
|
||||||
|
}
|
||||||
|
case args[0] == "assign", args[0] == "сквад":
|
||||||
|
if len(args) < 2 {
|
||||||
|
h.sendText(chatID, "Укажите логин: /admin assign username")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.startAssignWizard(chatID, adminID, args[1])
|
||||||
|
default:
|
||||||
|
h.sendAdminHelp(chatID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) startUserWizard(chatID, adminID int64) {
|
||||||
|
ctx := context.Background()
|
||||||
|
data := db.WizardData{"mode": "create"}
|
||||||
|
_ = h.database.SetWizard(ctx, adminID, db.StepAwaitUsername, data)
|
||||||
|
h.sendText(chatID, "Создание пользователя.\n\nВведите логин (3–36 символов, a-z, 0-9, _, -):\n\n/admin cancel — отмена")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) startAssignWizard(chatID, adminID int64, username string) {
|
||||||
|
ctx := context.Background()
|
||||||
|
data := db.WizardData{
|
||||||
|
"mode": "assign",
|
||||||
|
"username": username,
|
||||||
|
}
|
||||||
|
_ = h.database.SetWizard(ctx, adminID, db.StepPickExternalSquad, data)
|
||||||
|
h.sendExternalSquadPicker(chatID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleWizardMessage(chatID, adminID int64, text string) bool {
|
||||||
|
ctx := context.Background()
|
||||||
|
w, err := h.database.GetWizard(ctx, adminID)
|
||||||
|
if err != nil || w == nil || w.Step == db.StepIdle || w.Step == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch w.Step {
|
||||||
|
case db.StepAwaitUsername:
|
||||||
|
if !usernameRe.MatchString(text) {
|
||||||
|
h.sendText(chatID, "Неверный логин. Допустимы: a-z, 0-9, _, - (3–36 символов).")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
w.Data.Set("username", text)
|
||||||
|
_ = h.database.SetWizard(ctx, adminID, db.StepAwaitDays, w.Data)
|
||||||
|
h.sendText(chatID, fmt.Sprintf("Срок подписки в днях (по умолчанию %d):", h.cfg.DefaultUserDays))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case db.StepAwaitDays:
|
||||||
|
days := h.cfg.DefaultUserDays
|
||||||
|
if text != "" {
|
||||||
|
if d, err := strconv.Atoi(text); err != nil || d <= 0 {
|
||||||
|
h.sendText(chatID, "Введите число дней больше 0.")
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
days = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Data.Set("days", days)
|
||||||
|
_ = h.database.SetWizard(ctx, adminID, db.StepPickExternalSquad, w.Data)
|
||||||
|
h.sendExternalSquadPicker(chatID, w.Data)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleWizardCallback(cq *tgbotapi.CallbackQuery) bool {
|
||||||
|
if !strings.HasPrefix(cq.Data, "wz:") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
chatID := cq.Message.Chat.ID
|
||||||
|
adminID := cq.From.ID
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
w, err := h.database.GetWizard(ctx, adminID)
|
||||||
|
if err != nil || w == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(cq.Data, ":")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parts[1] {
|
||||||
|
case "ext":
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if parts[2] == "skip" {
|
||||||
|
w.Data.Set("external_squad", "")
|
||||||
|
} else {
|
||||||
|
w.Data.Set("external_squad", parts[2])
|
||||||
|
}
|
||||||
|
_ = h.database.SetWizard(ctx, adminID, db.StepPickInternalSquads, w.Data)
|
||||||
|
h.sendInternalSquadPicker(chatID, w.Data)
|
||||||
|
|
||||||
|
case "int":
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if parts[2] == "done" {
|
||||||
|
_ = h.database.SetWizard(ctx, adminID, db.StepConfirm, w.Data)
|
||||||
|
h.sendConfirm(chatID, w.Data)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
w.Data.ToggleUUID("internal_squads", parts[2])
|
||||||
|
_ = h.database.SetWizard(ctx, adminID, db.StepPickInternalSquads, w.Data)
|
||||||
|
h.sendInternalSquadPicker(chatID, w.Data)
|
||||||
|
|
||||||
|
case "ok":
|
||||||
|
h.finishWizard(chatID, adminID, w.Data)
|
||||||
|
|
||||||
|
case "no":
|
||||||
|
_ = h.database.ClearWizard(ctx, adminID)
|
||||||
|
h.sendText(chatID, "Отменено.")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) sendSquadsList(chatID int64) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ext, err1 := h.panel.ListExternalSquads(ctx)
|
||||||
|
ints, err2 := h.panel.ListInternalSquads(ctx)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("Сквады Remnawave:\n\n")
|
||||||
|
|
||||||
|
b.WriteString("External:\n")
|
||||||
|
if err1 != nil {
|
||||||
|
b.WriteString(" ошибка: " + err1.Error() + "\n")
|
||||||
|
} else if len(ext) == 0 {
|
||||||
|
b.WriteString(" (пусто)\n")
|
||||||
|
} else {
|
||||||
|
for _, s := range ext {
|
||||||
|
b.WriteString(fmt.Sprintf(" • %s\n %s\n", s.Name, s.UUID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\nInternal:\n")
|
||||||
|
if err2 != nil {
|
||||||
|
b.WriteString(" ошибка: " + err2.Error() + "\n")
|
||||||
|
} else if len(ints) == 0 {
|
||||||
|
b.WriteString(" (пусто)\n")
|
||||||
|
} else {
|
||||||
|
for _, s := range ints {
|
||||||
|
b.WriteString(fmt.Sprintf(" • %s\n %s\n", s.Name, s.UUID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.sendText(chatID, b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) sendExternalSquadPicker(chatID int64, data db.WizardData) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
squads, err := h.panel.ListExternalSquads(ctx)
|
||||||
|
if err != nil {
|
||||||
|
h.sendText(chatID, "Не удалось загрузить external squads: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows [][]tgbotapi.InlineKeyboardButton
|
||||||
|
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("⏭ Без external squad", "wz:ext:skip"),
|
||||||
|
))
|
||||||
|
for _, s := range squads {
|
||||||
|
label := s.Name
|
||||||
|
if len(label) > 40 {
|
||||||
|
label = label[:40]
|
||||||
|
}
|
||||||
|
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData(label, "wz:ext:"+s.UUID),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
msg := tgbotapi.NewMessage(chatID, "Выберите External Squad:")
|
||||||
|
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(rows...)
|
||||||
|
h.send(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) sendInternalSquadPicker(chatID int64, data db.WizardData) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
squads, err := h.panel.ListInternalSquads(ctx)
|
||||||
|
if err != nil {
|
||||||
|
h.sendText(chatID, "Не удалось загрузить internal squads: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := map[string]bool{}
|
||||||
|
for _, id := range data.StringSlice("internal_squads") {
|
||||||
|
selected[id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows [][]tgbotapi.InlineKeyboardButton
|
||||||
|
for _, s := range squads {
|
||||||
|
mark := "☐"
|
||||||
|
if selected[s.UUID] {
|
||||||
|
mark = "☑"
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("%s %s", mark, s.Name)
|
||||||
|
if len(label) > 60 {
|
||||||
|
label = label[:60]
|
||||||
|
}
|
||||||
|
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData(label, "wz:int:"+s.UUID),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("✅ Готово", "wz:int:done"),
|
||||||
|
))
|
||||||
|
|
||||||
|
msg := tgbotapi.NewMessage(chatID, "Выберите Internal Squads (можно несколько), затем «Готово»:")
|
||||||
|
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(rows...)
|
||||||
|
h.send(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) sendConfirm(chatID int64, data db.WizardData) {
|
||||||
|
ext := data.String("external_squad")
|
||||||
|
ints := data.StringSlice("internal_squads")
|
||||||
|
text := fmt.Sprintf(
|
||||||
|
"Подтвердите:\n\nЛогин: %s\nДней: %d\nExternal: %s\nInternal: %d шт.\n",
|
||||||
|
data.String("username"), data.Int("days"), squadLabel(ext), len(ints),
|
||||||
|
)
|
||||||
|
if data.String("mode") == "assign" {
|
||||||
|
text = fmt.Sprintf(
|
||||||
|
"Назначить сквады пользователю %s\n\nExternal: %s\nInternal: %d шт.\n",
|
||||||
|
data.String("username"), squadLabel(ext), len(ints),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
msg := tgbotapi.NewMessage(chatID, text)
|
||||||
|
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(
|
||||||
|
tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("✅ Да", "wz:ok"),
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("❌ Нет", "wz:no"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
h.send(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) finishWizard(chatID, adminID int64, data db.WizardData) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ext := data.String("external_squad")
|
||||||
|
var extPtr *string
|
||||||
|
if ext != "" {
|
||||||
|
extPtr = &ext
|
||||||
|
}
|
||||||
|
ints := data.StringSlice("internal_squads")
|
||||||
|
if len(ints) == 0 && len(h.cfg.DefaultInternalSquadUUIDs) > 0 {
|
||||||
|
ints = h.cfg.DefaultInternalSquadUUIDs
|
||||||
|
}
|
||||||
|
if extPtr == nil && h.cfg.DefaultExternalSquadUUID != "" {
|
||||||
|
e := h.cfg.DefaultExternalSquadUUID
|
||||||
|
extPtr = &e
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := data.String("mode")
|
||||||
|
if mode == "assign" {
|
||||||
|
u, err := h.panel.AssignSquads(ctx, remnawave.AssignSquadsInput{
|
||||||
|
Username: data.String("username"),
|
||||||
|
ExternalSquadUUID: extPtr,
|
||||||
|
ActiveInternalSquads: ints,
|
||||||
|
})
|
||||||
|
_ = h.database.ClearWizard(ctx, adminID)
|
||||||
|
if err != nil {
|
||||||
|
h.sendText(chatID, "Ошибка назначения сквадов: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.sendText(chatID, fmt.Sprintf("✅ Сквады назначены пользователю %s\nUUID: %s", u.Username, u.UUID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
days := data.Int("days")
|
||||||
|
if days <= 0 {
|
||||||
|
days = h.cfg.DefaultUserDays
|
||||||
|
}
|
||||||
|
var tgID *int64
|
||||||
|
// при создании из мастера админом telegramId не обязателен
|
||||||
|
|
||||||
|
u, err := h.panel.CreateUser(ctx, remnawave.CreateUserInput{
|
||||||
|
Username: data.String("username"),
|
||||||
|
ExpireAt: db.DefaultExpireAt(days),
|
||||||
|
TelegramID: tgID,
|
||||||
|
ExternalSquadUUID: extPtr,
|
||||||
|
ActiveInternalSquads: ints,
|
||||||
|
Description: "created via tgvpn bot",
|
||||||
|
})
|
||||||
|
_ = h.database.ClearWizard(ctx, adminID)
|
||||||
|
if err != nil {
|
||||||
|
h.sendText(chatID, "Ошибка создания: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vpn := db.VPNUser{
|
||||||
|
RemnawaveUUID: u.UUID,
|
||||||
|
RemnawaveUsername: u.Username,
|
||||||
|
ExternalSquadUUID: extPtr,
|
||||||
|
InternalSquadUUIDs: ints,
|
||||||
|
ExpireAt: &u.ExpireAt,
|
||||||
|
}
|
||||||
|
if err := h.database.SaveVPNUser(ctx, vpn); err != nil {
|
||||||
|
log.Printf("save vpn user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := fmt.Sprintf("✅ Пользователь создан\n\nЛогин: %s\nUUID: %s\nИстекает: %s",
|
||||||
|
u.Username, u.UUID, u.ExpireAt.Format("2006-01-02"))
|
||||||
|
if u.SubscriptionURL != "" {
|
||||||
|
text += "\nПодписка: " + u.SubscriptionURL
|
||||||
|
} else if u.ShortUUID != "" && h.cfg.RemnawaveSubscription != "" {
|
||||||
|
text += "\nПодписка: " + h.cfg.RemnawaveSubscription + "/" + u.ShortUUID
|
||||||
|
}
|
||||||
|
h.sendText(chatID, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) quickCreateUser(chatID, adminID int64, username string, days int) {
|
||||||
|
if !usernameRe.MatchString(username) {
|
||||||
|
h.sendText(chatID, "Неверный логин.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var extPtr *string
|
||||||
|
if h.cfg.DefaultExternalSquadUUID != "" {
|
||||||
|
e := h.cfg.DefaultExternalSquadUUID
|
||||||
|
extPtr = &e
|
||||||
|
}
|
||||||
|
ints := h.cfg.DefaultInternalSquadUUIDs
|
||||||
|
|
||||||
|
u, err := h.panel.CreateUser(ctx, remnawave.CreateUserInput{
|
||||||
|
Username: username,
|
||||||
|
ExpireAt: db.DefaultExpireAt(days),
|
||||||
|
ExternalSquadUUID: extPtr,
|
||||||
|
ActiveInternalSquads: ints,
|
||||||
|
Description: "created via tgvpn bot",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.sendText(chatID, "Ошибка: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.database.SaveVPNUser(ctx, db.VPNUser{
|
||||||
|
RemnawaveUUID: u.UUID,
|
||||||
|
RemnawaveUsername: u.Username,
|
||||||
|
ExternalSquadUUID: extPtr,
|
||||||
|
InternalSquadUUIDs: ints,
|
||||||
|
ExpireAt: &u.ExpireAt,
|
||||||
|
})
|
||||||
|
h.sendText(chatID, fmt.Sprintf("✅ %s создан до %s", u.Username, u.ExpireAt.Format("2006-01-02")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func squadLabel(uuid string) string {
|
||||||
|
if uuid == "" {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
if len(uuid) > 12 {
|
||||||
|
return uuid[:8] + "…"
|
||||||
|
}
|
||||||
|
return uuid
|
||||||
|
}
|
||||||
+166
-97
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"telegramvpn/internal/config"
|
"telegramvpn/internal/config"
|
||||||
|
"telegramvpn/internal/db"
|
||||||
"telegramvpn/internal/remnawave"
|
"telegramvpn/internal/remnawave"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
@@ -19,30 +20,33 @@ type Handler struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
api *tgbotapi.BotAPI
|
api *tgbotapi.BotAPI
|
||||||
panel *remnawave.Client
|
panel *remnawave.Client
|
||||||
|
database *db.DB
|
||||||
admin int64
|
admin int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI) *Handler {
|
func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI, database *db.DB) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
api: api,
|
api: api,
|
||||||
panel: remnawave.NewClient(cfg.RemnawaveURL, cfg.RemnawaveToken, cfg.RemnawaveCaddy),
|
panel: remnawave.NewClient(cfg.RemnawavePanelURL, cfg.RemnawaveAPIToken, cfg.CaddyAuthAPIToken),
|
||||||
|
database: database,
|
||||||
admin: cfg.TelegramAdminID,
|
admin: cfg.TelegramAdminID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) RegisterCommands() {
|
func (h *Handler) RegisterCommands() {
|
||||||
commands := []tgbotapi.BotCommand{
|
public := []tgbotapi.BotCommand{
|
||||||
{Command: "start", Description: "Начать"},
|
{Command: "start", Description: "Начать"},
|
||||||
{Command: "admin", Description: "Админ-меню Remnawave (панель 1)"},
|
{Command: "config", Description: "Получить VPN-конфиг"},
|
||||||
}
|
}
|
||||||
|
if _, err := h.api.Request(tgbotapi.NewSetMyCommands(public...)); err != nil {
|
||||||
|
log.Printf("команды (все пользователи): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := append(public, tgbotapi.BotCommand{Command: "admin", Description: "Админ-меню"})
|
||||||
scope := tgbotapi.BotCommandScope{Type: "chat", ChatID: h.admin}
|
scope := tgbotapi.BotCommandScope{Type: "chat", ChatID: h.admin}
|
||||||
cfg := tgbotapi.SetMyCommandsConfig{
|
if _, err := h.api.Request(tgbotapi.SetMyCommandsConfig{Commands: admin, Scope: &scope}); err != nil {
|
||||||
Commands: commands,
|
log.Printf("команды (админ): %v", err)
|
||||||
Scope: &scope,
|
|
||||||
}
|
|
||||||
if _, err := h.api.Request(cfg); err != nil {
|
|
||||||
log.Printf("не удалось зарегистрировать команды для админа: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,26 +65,36 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case text == "/start":
|
case text == "/start":
|
||||||
h.sendStart(chatID, userID, update.Message.From.FirstName)
|
h.sendStart(chatID, userID, update.Message.From.FirstName, update.Message.From.UserName)
|
||||||
|
case text == "/config", text == "/getconfig":
|
||||||
|
h.handleUserConfig(chatID, userID)
|
||||||
case strings.HasPrefix(text, "/admin"):
|
case strings.HasPrefix(text, "/admin"):
|
||||||
h.handleAdminCommand(chatID, userID, text)
|
h.handleAdminCommand(chatID, userID, text)
|
||||||
case strings.HasPrefix(text, "/"):
|
case strings.HasPrefix(text, "/"):
|
||||||
h.sendText(chatID, "Неизвестная команда. Для начала — /start")
|
h.sendText(chatID, "Неизвестная команда. Для начала — /start")
|
||||||
default:
|
default:
|
||||||
if h.isAdmin(userID) {
|
if h.isAdmin(userID) && h.handleWizardMessage(chatID, userID, text) {
|
||||||
|
return
|
||||||
|
}
|
||||||
switch text {
|
switch text {
|
||||||
case "📋 Конфиг панели":
|
case userHomeLabel(), "/menu":
|
||||||
h.sendPanelConfig(chatID)
|
h.sendUserMenu(chatID, userID, update.Message.From.FirstName, update.Message.From.UserName)
|
||||||
return
|
return
|
||||||
case "🔌 Проверить панель":
|
case adminPanelLabel(), "🛠 Админ-меню":
|
||||||
h.sendPanelCheck(chatID)
|
if h.isAdmin(userID) {
|
||||||
return
|
h.sendAdminMenu(chatID)
|
||||||
case "◀️ Выйти из админки":
|
}
|
||||||
h.sendText(chatID, "Админ-меню закрыто. /admin — снова открыть.")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.isUserConfigButtonText(text) {
|
||||||
|
h.handleUserConfig(chatID, userID)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
h.sendText(chatID, "Напишите /start, чтобы начать.")
|
// Старые подписи reply-клавиатуры (если остались у пользователя)
|
||||||
|
if h.isAdmin(userID) && h.handleLegacyAdminReply(chatID, userID, text) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.sendText(chatID, "Напишите /start или нажмите 🏠 Главная в меню.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +115,8 @@ func (h *Handler) handleAdminCommand(chatID, userID int64, text string) {
|
|||||||
h.sendPanelCheck(chatID)
|
h.sendPanelCheck(chatID)
|
||||||
case "config", "конфиг":
|
case "config", "конфиг":
|
||||||
h.sendPanelConfig(chatID)
|
h.sendPanelConfig(chatID)
|
||||||
|
case "user", "пользователь", "squads", "сквады", "assign", "сквад", "cancel", "отмена", "help":
|
||||||
|
h.handleAdminUsersSubcommand(chatID, userID, args[2:])
|
||||||
default:
|
default:
|
||||||
h.sendAdminHelp(chatID)
|
h.sendAdminHelp(chatID)
|
||||||
}
|
}
|
||||||
@@ -112,20 +128,54 @@ func (h *Handler) handleCallback(cq *tgbotapi.CallbackQuery) {
|
|||||||
log.Printf("callback answer: %v", err)
|
log.Printf("callback answer: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !h.isAdmin(cq.From.ID) {
|
chatID := cq.Message.Chat.ID
|
||||||
h.editOrSend(cq.Message.Chat.ID, cq.Message.MessageID, "Нет доступа.")
|
userID := cq.From.ID
|
||||||
|
|
||||||
|
switch cq.Data {
|
||||||
|
case cbUserConfig:
|
||||||
|
h.handleUserConfig(chatID, userID)
|
||||||
|
return
|
||||||
|
case cbUserHome:
|
||||||
|
h.sendUserMenu(chatID, userID, cq.From.FirstName, cq.From.UserName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(cq.Data, "wz:") {
|
||||||
|
if !h.isAdmin(userID) {
|
||||||
|
h.callbackDenied(cq)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.handleWizardCallback(cq) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.isAdmin(userID) {
|
||||||
|
h.callbackDenied(cq)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch cq.Data {
|
switch cq.Data {
|
||||||
case "admin:config":
|
case cbAdminUser:
|
||||||
h.sendPanelConfig(cq.Message.Chat.ID)
|
h.startUserWizard(chatID, userID)
|
||||||
case "admin:check":
|
case cbAdminSquads:
|
||||||
h.sendPanelCheck(cq.Message.Chat.ID)
|
h.sendSquadsList(chatID)
|
||||||
case "admin:menu":
|
case cbAdminConfig:
|
||||||
h.sendAdminMenu(cq.Message.Chat.ID)
|
h.sendPanelConfig(chatID)
|
||||||
|
case cbAdminCheck:
|
||||||
|
h.sendPanelCheck(chatID)
|
||||||
|
case cbAdminMenu:
|
||||||
|
h.sendAdminMenu(chatID)
|
||||||
default:
|
default:
|
||||||
h.editOrSend(cq.Message.Chat.ID, cq.Message.MessageID, "Неизвестное действие.")
|
h.editOrSend(chatID, cq.Message.MessageID, "Неизвестное действие.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) callbackDenied(cq *tgbotapi.CallbackQuery) {
|
||||||
|
cb := tgbotapi.NewCallback(cq.ID, "Нет доступа")
|
||||||
|
cb.ShowAlert = true
|
||||||
|
if _, err := h.api.Request(cb); err != nil {
|
||||||
|
log.Printf("callback alert: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,65 +183,120 @@ func (h *Handler) isAdmin(userID int64) bool {
|
|||||||
return userID == h.admin
|
return userID == h.admin
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) sendStart(chatID, userID int64, firstName string) {
|
func (h *Handler) sendStart(chatID, userID int64, firstName, tgUsername string) {
|
||||||
|
h.sendUserMenu(chatID, userID, firstName, tgUsername)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) sendUserMenu(chatID, userID int64, firstName, tgUsername string) {
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = h.database.UpsertTelegramUser(ctx, userID, tgUsername, firstName)
|
||||||
|
|
||||||
|
h.dismissReplyKeyboard(chatID)
|
||||||
|
|
||||||
name := firstName
|
name := firstName
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = "друг"
|
name = "друг"
|
||||||
}
|
}
|
||||||
text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот на базе панели Remnawave.", name)
|
days := trialDays(h.cfg)
|
||||||
|
text := fmt.Sprintf(
|
||||||
|
"👋 Привет, %s!\n\n"+
|
||||||
|
"🔐 Trial VPN — %d дн.\n"+
|
||||||
|
"Нажмите кнопку ниже или /config\n\n"+
|
||||||
|
"Импорт: V2rayNG, Hiddify, Streisand и др.",
|
||||||
|
name, days,
|
||||||
|
)
|
||||||
if h.isAdmin(userID) {
|
if h.isAdmin(userID) {
|
||||||
text += "\n\n/admin — админ-меню\n/admin check — проверка API и подписки"
|
text += "\n\nВы администратор: кнопка «🛠 Админ-панель» или /admin"
|
||||||
}
|
}
|
||||||
msg := tgbotapi.NewMessage(chatID, text)
|
msg := tgbotapi.NewMessage(chatID, text)
|
||||||
if h.isAdmin(userID) {
|
msg.ReplyMarkup = userMenuKeyboard(h.cfg, userID, h.admin)
|
||||||
msg.ReplyMarkup = adminReplyKeyboard()
|
|
||||||
}
|
|
||||||
h.send(msg)
|
h.send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) dismissReplyKeyboard(chatID int64) {
|
||||||
|
rm := tgbotapi.NewMessage(chatID, "\u200b")
|
||||||
|
rm.ReplyMarkup = tgbotapi.NewRemoveKeyboard(true)
|
||||||
|
rm.DisableNotification = true
|
||||||
|
if _, err := h.api.Send(rm); err != nil {
|
||||||
|
log.Printf("remove reply keyboard: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) isUserConfigButtonText(text string) bool {
|
||||||
|
return text == userConfigLabel(h.cfg) ||
|
||||||
|
strings.HasPrefix(text, "🔐 ") ||
|
||||||
|
strings.HasPrefix(text, "📲 Получить конфиг")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleLegacyAdminReply(chatID, userID int64, text string) bool {
|
||||||
|
switch text {
|
||||||
|
case "📋 Конфиг панели", "⚙️ Настройки":
|
||||||
|
h.sendPanelConfig(chatID)
|
||||||
|
case "🔌 Проверить панель", "🔌 Проверка API":
|
||||||
|
h.sendPanelCheck(chatID)
|
||||||
|
case "👤 Создать пользователя", "👤 Новый пользователь":
|
||||||
|
h.startUserWizard(chatID, userID)
|
||||||
|
case "📡 Сквады":
|
||||||
|
h.sendSquadsList(chatID)
|
||||||
|
case "◀️ Выйти из админки":
|
||||||
|
h.sendUserMenu(chatID, userID, "", "")
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) sendAdminMenu(chatID int64) {
|
func (h *Handler) sendAdminMenu(chatID int64) {
|
||||||
text := fmt.Sprintf(
|
text := fmt.Sprintf(
|
||||||
"🛠 *Админ-меню* — %s\n\n"+
|
"🛠 *Админ-панель* — %s\n\n"+
|
||||||
"Команды:\n"+
|
"• /admin check — проверка API\n"+
|
||||||
"• /admin — это меню\n"+
|
"• /admin user — новый пользователь\n"+
|
||||||
"• /admin check — проверка панели, API и подписки\n"+
|
"• /admin squads — сквады\n"+
|
||||||
"• /admin config — конфиг панели\n\n"+
|
"• /admin assign — назначить сквады\n\n"+
|
||||||
"Или кнопки ниже.",
|
"🏠 Главная — меню пользователя",
|
||||||
escapeMarkdown(h.cfg.RemnawaveName),
|
escapeMarkdown(h.cfg.RemnawaveName),
|
||||||
)
|
)
|
||||||
msg := tgbotapi.NewMessage(chatID, text)
|
msg := tgbotapi.NewMessage(chatID, text)
|
||||||
msg.ParseMode = "Markdown"
|
msg.ParseMode = "Markdown"
|
||||||
msg.ReplyMarkup = adminInlineKeyboard()
|
msg.ReplyMarkup = adminMenuKeyboard()
|
||||||
h.send(msg)
|
h.send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) sendAdminHelp(chatID int64) {
|
func (h *Handler) sendAdminHelp(chatID int64) {
|
||||||
h.sendText(chatID, "Неизвестный аргумент.\n\n/admin — меню\n/admin check — проверка\n/admin config — конфиг")
|
h.sendText(chatID, "Команды:\n/admin — меню\n/admin check\n/admin config\n/admin user\n/admin squads\n/admin assign <логин>")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) sendPanelConfig(chatID int64) {
|
func (h *Handler) sendPanelConfig(chatID int64) {
|
||||||
subURL := h.cfg.RemnawaveSubscription
|
subURL := h.cfg.RemnawaveSubscription
|
||||||
if subURL == "" {
|
if subURL == "" {
|
||||||
subURL = "не задан"
|
subURL = "не задана (опционально)"
|
||||||
|
}
|
||||||
|
caddy := h.cfg.CaddyAuthAPIToken
|
||||||
|
if caddy == "" {
|
||||||
|
caddy = "не задан"
|
||||||
|
} else {
|
||||||
|
caddy = maskSecret(caddy)
|
||||||
}
|
}
|
||||||
text := fmt.Sprintf(
|
text := fmt.Sprintf(
|
||||||
"⚙️ *%s* (Remnawave)\n\n"+
|
"⚙️ %s (Remnawave)\n\n"+
|
||||||
"• URL панели: `%s`\n"+
|
"REMNAWAVE_PANEL_URL:\n%s\n"+
|
||||||
"• URL подписки: `%s`\n"+
|
"(API: %s/api/... + Bearer REMNAWAVE_API_TOKEN)\n\n"+
|
||||||
"• API token: `%s`\n"+
|
"REMNAWAVE_SUBSCRIPTION_URL (опц.):\n%s\n\n"+
|
||||||
"• Caddy token: %s\n\n"+
|
"REMNAWAVE_API_TOKEN: %s\n"+
|
||||||
"Токен API: панель → *Settings → API Tokens*.\n"+
|
"CADDY_AUTH_API_TOKEN: %s\n\n"+
|
||||||
"Документация: %s",
|
"Токен: Remnawave Settings → API Tokens\n"+
|
||||||
escapeMarkdown(h.cfg.RemnawaveName),
|
"Док: %s",
|
||||||
escapeMarkdown(h.cfg.RemnawaveURL),
|
h.cfg.RemnawaveName,
|
||||||
escapeMarkdown(subURL),
|
h.cfg.RemnawavePanelURL,
|
||||||
escapeMarkdown(maskSecret(h.cfg.RemnawaveToken)),
|
h.cfg.RemnawavePanelURL,
|
||||||
caddyStatus(h.cfg.RemnawaveCaddy),
|
subURL,
|
||||||
docsURL,
|
maskSecret(h.cfg.RemnawaveAPIToken),
|
||||||
|
caddy,
|
||||||
|
"https://docs.rw/docs/install/subscription-page/bundled",
|
||||||
)
|
)
|
||||||
msg := tgbotapi.NewMessage(chatID, text)
|
msg := tgbotapi.NewMessage(chatID, text)
|
||||||
msg.ParseMode = "Markdown"
|
msg.ReplyMarkup = adminContextKeyboard()
|
||||||
msg.ReplyMarkup = adminInlineKeyboard()
|
|
||||||
h.send(msg)
|
h.send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,48 +307,12 @@ func (h *Handler) sendPanelCheck(chatID int64) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
report := h.panel.FullCheck(ctx, h.cfg.RemnawaveSubscription)
|
report := h.panel.FullCheck(ctx, h.cfg.RemnawaveSubscription)
|
||||||
text := remnawave.FormatReport(
|
text := remnawave.FormatReport(report, h.cfg.RemnawaveName)
|
||||||
report,
|
|
||||||
escapeMarkdown(h.cfg.RemnawaveName),
|
|
||||||
escapeMarkdown(h.cfg.RemnawaveURL),
|
|
||||||
)
|
|
||||||
|
|
||||||
msg := tgbotapi.NewMessage(chatID, text)
|
msg := tgbotapi.NewMessage(chatID, text)
|
||||||
msg.ParseMode = "Markdown"
|
msg.ReplyMarkup = adminContextKeyboard()
|
||||||
msg.ReplyMarkup = adminInlineKeyboard()
|
|
||||||
if err := h.sendReturnErr(msg); err != nil {
|
|
||||||
msg.ParseMode = ""
|
|
||||||
h.send(msg)
|
h.send(msg)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup {
|
|
||||||
return tgbotapi.NewInlineKeyboardMarkup(
|
|
||||||
tgbotapi.NewInlineKeyboardRow(
|
|
||||||
tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить (API+подписка)", "admin:check"),
|
|
||||||
tgbotapi.NewInlineKeyboardButtonData("📋 Конфиг", "admin:config"),
|
|
||||||
),
|
|
||||||
tgbotapi.NewInlineKeyboardRow(
|
|
||||||
tgbotapi.NewInlineKeyboardButtonURL("📖 Документация", docsURL),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func adminReplyKeyboard() tgbotapi.ReplyKeyboardMarkup {
|
|
||||||
return tgbotapi.ReplyKeyboardMarkup{
|
|
||||||
Keyboard: [][]tgbotapi.KeyboardButton{
|
|
||||||
{
|
|
||||||
tgbotapi.NewKeyboardButton("🔌 Проверить панель"),
|
|
||||||
tgbotapi.NewKeyboardButton("📋 Конфиг панели"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tgbotapi.NewKeyboardButton("◀️ Выйти из админки"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ResizeKeyboard: true,
|
|
||||||
OneTimeKeyboard: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) sendText(chatID int64, text string) {
|
func (h *Handler) sendText(chatID int64, text string) {
|
||||||
h.send(tgbotapi.NewMessage(chatID, text))
|
h.send(tgbotapi.NewMessage(chatID, text))
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"telegramvpn/internal/config"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cbUserConfig = "user:config"
|
||||||
|
cbUserHome = "user:home"
|
||||||
|
cbAdminMenu = "admin:menu"
|
||||||
|
cbAdminUser = "admin:user"
|
||||||
|
cbAdminSquads = "admin:squads"
|
||||||
|
cbAdminCheck = "admin:check"
|
||||||
|
cbAdminConfig = "admin:config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func trialDays(cfg *config.Config) int {
|
||||||
|
d := cfg.TrialUserDays
|
||||||
|
if d <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func userConfigLabel(cfg *config.Config) string {
|
||||||
|
return fmt.Sprintf("🔐 Подключить VPN · %d дн.", trialDays(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func userHomeLabel() string {
|
||||||
|
return "🏠 Главная"
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminPanelLabel() string {
|
||||||
|
return "🛠 Админ-панель"
|
||||||
|
}
|
||||||
|
|
||||||
|
// userMenuKeyboard — главное меню пользователя (и выход из админки).
|
||||||
|
func userMenuKeyboard(cfg *config.Config, userID, adminID int64) tgbotapi.InlineKeyboardMarkup {
|
||||||
|
rows := [][]tgbotapi.InlineKeyboardButton{
|
||||||
|
tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData(userConfigLabel(cfg), cbUserConfig),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if userID == adminID {
|
||||||
|
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData(adminPanelLabel(), cbAdminMenu),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return tgbotapi.NewInlineKeyboardMarkup(rows...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// configResultKeyboard — после выдачи конфига: ссылка + возврат в меню.
|
||||||
|
func configResultKeyboard(cfg *config.Config, userID, adminID int64, subscriptionURL string) tgbotapi.InlineKeyboardMarkup {
|
||||||
|
var rows [][]tgbotapi.InlineKeyboardButton
|
||||||
|
if subscriptionURL != "" {
|
||||||
|
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonURL("🔗 Открыть подписку", subscriptionURL),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
rows = append(rows,
|
||||||
|
tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData(userConfigLabel(cfg), cbUserConfig),
|
||||||
|
),
|
||||||
|
tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData(userHomeLabel(), cbUserHome),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if userID == adminID {
|
||||||
|
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData(adminPanelLabel(), cbAdminMenu),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return tgbotapi.NewInlineKeyboardMarkup(rows...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminMenuKeyboard — панель администратора.
|
||||||
|
func adminMenuKeyboard() tgbotapi.InlineKeyboardMarkup {
|
||||||
|
return tgbotapi.NewInlineKeyboardMarkup(
|
||||||
|
tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("👤 Новый пользователь", cbAdminUser),
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("📡 Сквады", cbAdminSquads),
|
||||||
|
),
|
||||||
|
tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("🔌 Проверка API", cbAdminCheck),
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("⚙️ Настройки", cbAdminConfig),
|
||||||
|
),
|
||||||
|
tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonURL("📖 Remnawave Docs", docsURL),
|
||||||
|
),
|
||||||
|
tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData(userHomeLabel(), cbUserHome),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminContextKeyboard — под ответами админки (проверка, конфиг и т.д.).
|
||||||
|
func adminContextKeyboard() tgbotapi.InlineKeyboardMarkup {
|
||||||
|
return tgbotapi.NewInlineKeyboardMarkup(
|
||||||
|
tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("🛠 В админ-панель", cbAdminMenu),
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData(userHomeLabel(), cbUserHome),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telegramvpn/internal/db"
|
||||||
|
"telegramvpn/internal/remnawave"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) handleUserConfig(chatID, telegramID int64) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
days := h.cfg.TrialUserDays
|
||||||
|
if days <= 0 {
|
||||||
|
days = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := h.database.GetVPNByTelegramID(ctx, telegramID)
|
||||||
|
if err != nil {
|
||||||
|
h.sendText(chatID, "Ошибка базы данных. Попробуйте позже.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing != nil && existing.ExpireAt != nil && existing.ExpireAt.After(time.Now()) {
|
||||||
|
link := h.resolveSubscriptionLink(ctx, existing.RemnawaveUsername, telegramID)
|
||||||
|
h.sendConfigMessage(chatID, telegramID, days, existing.RemnawaveUsername, *existing.ExpireAt, link)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
panelUser, err := h.panel.GetUserByTelegramID(ctx, telegramID)
|
||||||
|
if err == nil && panelUser != nil && panelUser.ExpireAt.After(time.Now()) {
|
||||||
|
link := panelUser.SubscriptionURL
|
||||||
|
if link == "" {
|
||||||
|
link = h.subscriptionLink(panelUser.ShortUUID)
|
||||||
|
}
|
||||||
|
_ = h.saveVPNFromPanel(ctx, telegramID, panelUser)
|
||||||
|
h.sendConfigMessage(chatID, telegramID, days, panelUser.Username, panelUser.ExpireAt, link)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := fmt.Sprintf("u%d", telegramID)
|
||||||
|
var extPtr *string
|
||||||
|
if h.cfg.DefaultExternalSquadUUID != "" {
|
||||||
|
e := h.cfg.DefaultExternalSquadUUID
|
||||||
|
extPtr = &e
|
||||||
|
}
|
||||||
|
ints := h.cfg.DefaultInternalSquadUUIDs
|
||||||
|
tgID := telegramID
|
||||||
|
|
||||||
|
u, err := h.panel.CreateUser(ctx, remnawave.CreateUserInput{
|
||||||
|
Username: username,
|
||||||
|
ExpireAt: db.DefaultExpireAt(days),
|
||||||
|
TelegramID: &tgID,
|
||||||
|
ExternalSquadUUID: extPtr,
|
||||||
|
ActiveInternalSquads: ints,
|
||||||
|
Description: fmt.Sprintf("trial %d day via tgvpn bot", days),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.sendText(chatID, "Не удалось создать доступ: "+err.Error()+"\n\nПопробуйте позже или напишите администратору.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.saveVPNFromPanel(ctx, telegramID, u); err != nil {
|
||||||
|
log.Printf("save vpn user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
link := u.SubscriptionURL
|
||||||
|
if link == "" {
|
||||||
|
link = h.subscriptionLink(u.ShortUUID)
|
||||||
|
}
|
||||||
|
h.sendConfigMessage(chatID, telegramID, days, u.Username, u.ExpireAt, link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) saveVPNFromPanel(ctx context.Context, telegramID int64, u *remnawave.PanelUser) error {
|
||||||
|
if u == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var ext *string
|
||||||
|
if h.cfg.DefaultExternalSquadUUID != "" {
|
||||||
|
e := h.cfg.DefaultExternalSquadUUID
|
||||||
|
ext = &e
|
||||||
|
}
|
||||||
|
return h.database.SaveVPNUser(ctx, db.VPNUser{
|
||||||
|
TelegramID: &telegramID,
|
||||||
|
RemnawaveUUID: u.UUID,
|
||||||
|
RemnawaveUsername: u.Username,
|
||||||
|
ExternalSquadUUID: ext,
|
||||||
|
InternalSquadUUIDs: h.cfg.DefaultInternalSquadUUIDs,
|
||||||
|
ExpireAt: &u.ExpireAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) resolveSubscriptionLink(ctx context.Context, username string, telegramID int64) string {
|
||||||
|
if u, err := h.panel.GetUserByUsername(ctx, username); err == nil && u != nil {
|
||||||
|
if u.SubscriptionURL != "" {
|
||||||
|
return u.SubscriptionURL
|
||||||
|
}
|
||||||
|
return h.subscriptionLink(u.ShortUUID)
|
||||||
|
}
|
||||||
|
if u, err := h.panel.GetUserByTelegramID(ctx, telegramID); err == nil && u != nil {
|
||||||
|
if u.SubscriptionURL != "" {
|
||||||
|
return u.SubscriptionURL
|
||||||
|
}
|
||||||
|
return h.subscriptionLink(u.ShortUUID)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) subscriptionLink(shortUUID string) string {
|
||||||
|
if shortUUID != "" && h.cfg.RemnawaveSubscription != "" {
|
||||||
|
return h.cfg.RemnawaveSubscription + "/" + shortUUID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) sendConfigMessage(chatID, telegramID int64, days int, username string, expireAt time.Time, link string) {
|
||||||
|
text := fmt.Sprintf(
|
||||||
|
"✅ VPN готов\n\n"+
|
||||||
|
"⏱ Срок: %d дн. (до %s)\n"+
|
||||||
|
"👤 Логин: %s\n",
|
||||||
|
days,
|
||||||
|
expireAt.Local().Format("02.01.2006 15:04"),
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
if link != "" {
|
||||||
|
text += "\n🔗 Ссылка подписки — кнопка ниже или:\n" + link
|
||||||
|
} else {
|
||||||
|
text += "\n\n⚠️ Ссылка не настроена — REMNAWAVE_SUBSCRIPTION_URL в .env"
|
||||||
|
}
|
||||||
|
msg := tgbotapi.NewMessage(chatID, text)
|
||||||
|
msg.ReplyMarkup = configResultKeyboard(h.cfg, telegramID, h.admin, link)
|
||||||
|
h.send(msg)
|
||||||
|
}
|
||||||
|
|
||||||
+58
-11
@@ -7,15 +7,23 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// См. официальную схему env: https://docs.rw/docs/install/subscription-page/bundled
|
||||||
|
// REMNAWAVE_PANEL_URL + REMNAWAVE_API_TOKEN (+ опционально CADDY_AUTH_API_TOKEN)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
BotToken string
|
BotToken string
|
||||||
BotDebug bool
|
BotDebug bool
|
||||||
TelegramAdminID int64
|
TelegramAdminID int64
|
||||||
RemnawaveName string
|
RemnawaveName string
|
||||||
RemnawaveURL string
|
RemnawavePanelURL string
|
||||||
RemnawaveToken string
|
RemnawaveAPIToken string
|
||||||
RemnawaveCaddy string
|
CaddyAuthAPIToken string
|
||||||
RemnawaveSubscription string
|
RemnawaveSubscription string
|
||||||
|
DatabaseURL string
|
||||||
|
DefaultUserDays int
|
||||||
|
TrialUserDays int
|
||||||
|
DefaultExternalSquadUUID string
|
||||||
|
DefaultInternalSquadUUIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -31,15 +39,15 @@ func Load() (*Config, error) {
|
|||||||
|
|
||||||
panelURL := strings.TrimRight(strings.TrimSpace(os.Getenv("REMNAWAVE_PANEL_URL")), "/")
|
panelURL := strings.TrimRight(strings.TrimSpace(os.Getenv("REMNAWAVE_PANEL_URL")), "/")
|
||||||
if panelURL == "" {
|
if panelURL == "" {
|
||||||
return nil, fmt.Errorf("REMNAWAVE_PANEL_URL не задан")
|
return nil, fmt.Errorf("REMNAWAVE_PANEL_URL не задан (URL панели, см. https://docs.rw/docs/install/subscription-page/bundled)")
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(panelURL, "http://") && !strings.HasPrefix(panelURL, "https://") {
|
if !strings.HasPrefix(panelURL, "http://") && !strings.HasPrefix(panelURL, "https://") {
|
||||||
return nil, fmt.Errorf("REMNAWAVE_PANEL_URL должен начинаться с http:// или https://")
|
return nil, fmt.Errorf("REMNAWAVE_PANEL_URL должен быть с http:// или https:// (как в документации Remnawave)")
|
||||||
}
|
}
|
||||||
|
|
||||||
panelToken := strings.TrimSpace(os.Getenv("REMNAWAVE_API_TOKEN"))
|
apiToken := strings.TrimSpace(os.Getenv("REMNAWAVE_API_TOKEN"))
|
||||||
if panelToken == "" {
|
if apiToken == "" {
|
||||||
return nil, fmt.Errorf("REMNAWAVE_API_TOKEN не задан (создайте в панели: Settings → API Tokens)")
|
return nil, fmt.Errorf("REMNAWAVE_API_TOKEN не задан (Remnawave Settings → API Tokens)")
|
||||||
}
|
}
|
||||||
|
|
||||||
name := strings.TrimSpace(os.Getenv("REMNAWAVE_PANEL_NAME"))
|
name := strings.TrimSpace(os.Getenv("REMNAWAVE_PANEL_NAME"))
|
||||||
@@ -47,19 +55,58 @@ func Load() (*Config, error) {
|
|||||||
name = "Панель 1"
|
name = "Панель 1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
caddy := strings.TrimSpace(os.Getenv("CADDY_AUTH_API_TOKEN"))
|
||||||
|
if caddy == "" {
|
||||||
|
caddy = strings.TrimSpace(os.Getenv("REMNAWAVE_CADDY_TOKEN")) // устаревшее имя
|
||||||
|
}
|
||||||
|
|
||||||
subURL := strings.TrimRight(strings.TrimSpace(os.Getenv("REMNAWAVE_SUBSCRIPTION_URL")), "/")
|
subURL := strings.TrimRight(strings.TrimSpace(os.Getenv("REMNAWAVE_SUBSCRIPTION_URL")), "/")
|
||||||
if subURL != "" && !strings.HasPrefix(subURL, "http://") && !strings.HasPrefix(subURL, "https://") {
|
if subURL != "" && !strings.HasPrefix(subURL, "http://") && !strings.HasPrefix(subURL, "https://") {
|
||||||
return nil, fmt.Errorf("REMNAWAVE_SUBSCRIPTION_URL должен начинаться с http:// или https://")
|
return nil, fmt.Errorf("REMNAWAVE_SUBSCRIPTION_URL должен начинаться с http:// или https://")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dbURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||||
|
if dbURL == "" {
|
||||||
|
return nil, fmt.Errorf("DATABASE_URL не задан")
|
||||||
|
}
|
||||||
|
|
||||||
|
days := 1
|
||||||
|
if v := strings.TrimSpace(os.Getenv("DEFAULT_USER_DAYS")); v != "" {
|
||||||
|
if d, err := strconv.Atoi(v); err == nil && d > 0 {
|
||||||
|
days = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trialDays := days
|
||||||
|
if v := strings.TrimSpace(os.Getenv("TRIAL_USER_DAYS")); v != "" {
|
||||||
|
if d, err := strconv.Atoi(v); err == nil && d > 0 {
|
||||||
|
trialDays = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var internalSquads []string
|
||||||
|
if v := strings.TrimSpace(os.Getenv("DEFAULT_INTERNAL_SQUAD_UUIDS")); v != "" {
|
||||||
|
for _, part := range strings.Split(v, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
internalSquads = append(internalSquads, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
BotToken: token,
|
BotToken: token,
|
||||||
BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"),
|
BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"),
|
||||||
TelegramAdminID: adminID,
|
TelegramAdminID: adminID,
|
||||||
RemnawaveName: name,
|
RemnawaveName: name,
|
||||||
RemnawaveURL: panelURL,
|
RemnawavePanelURL: panelURL,
|
||||||
RemnawaveToken: panelToken,
|
RemnawaveAPIToken: apiToken,
|
||||||
RemnawaveCaddy: strings.TrimSpace(os.Getenv("REMNAWAVE_CADDY_TOKEN")),
|
CaddyAuthAPIToken: caddy,
|
||||||
RemnawaveSubscription: subURL,
|
RemnawaveSubscription: subURL,
|
||||||
|
DatabaseURL: dbURL,
|
||||||
|
DefaultUserDays: days,
|
||||||
|
TrialUserDays: trialDays,
|
||||||
|
DefaultExternalSquadUUID: strings.TrimSpace(os.Getenv("DEFAULT_EXTERNAL_SQUAD_UUID")),
|
||||||
|
DefaultInternalSquadUUIDs: internalSquads,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
type DB struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Connect(ctx context.Context, databaseURL string) (*DB, error) {
|
||||||
|
cfg, err := pgxpool.ParseConfig(databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse database url: %w", err)
|
||||||
|
}
|
||||||
|
cfg.MaxConns = 10
|
||||||
|
cfg.MinConns = 1
|
||||||
|
|
||||||
|
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect postgres: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("ping postgres: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d := &DB{pool: pool}
|
||||||
|
if err := d.migrate(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) Close() {
|
||||||
|
d.pool.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) migrate(ctx context.Context) error {
|
||||||
|
data, err := migrationsFS.ReadFile("migrations/001_init.sql")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration: %w", err)
|
||||||
|
}
|
||||||
|
_, err = d.pool.Exec(ctx, string(data))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apply migration: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) Pool() *pgxpool.Pool {
|
||||||
|
return d.pool
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS telegram_users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
telegram_id BIGINT NOT NULL UNIQUE,
|
||||||
|
username TEXT,
|
||||||
|
first_name TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vpn_users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
telegram_id BIGINT,
|
||||||
|
remnawave_uuid UUID NOT NULL UNIQUE,
|
||||||
|
remnawave_username VARCHAR(36) NOT NULL,
|
||||||
|
external_squad_uuid UUID,
|
||||||
|
internal_squad_uuids UUID[] NOT NULL DEFAULT '{}',
|
||||||
|
expire_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vpn_users_telegram ON vpn_users(telegram_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vpn_users_username ON vpn_users(remnawave_username);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_wizard (
|
||||||
|
admin_telegram_id BIGINT PRIMARY KEY,
|
||||||
|
step TEXT NOT NULL,
|
||||||
|
data JSONB NOT NULL DEFAULT '{}',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VPNUser struct {
|
||||||
|
ID int64
|
||||||
|
TelegramID *int64
|
||||||
|
RemnawaveUUID string
|
||||||
|
RemnawaveUsername string
|
||||||
|
ExternalSquadUUID *string
|
||||||
|
InternalSquadUUIDs []string
|
||||||
|
ExpireAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpsertTelegramUser(ctx context.Context, telegramID int64, username, firstName string) error {
|
||||||
|
_, err := d.pool.Exec(ctx, `
|
||||||
|
INSERT INTO telegram_users (telegram_id, username, first_name)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (telegram_id) DO UPDATE SET
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
first_name = EXCLUDED.first_name`,
|
||||||
|
telegramID, nullStr(username), nullStr(firstName))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) SaveVPNUser(ctx context.Context, u VPNUser) error {
|
||||||
|
_, err := d.pool.Exec(ctx, `
|
||||||
|
INSERT INTO vpn_users (
|
||||||
|
telegram_id, remnawave_uuid, remnawave_username,
|
||||||
|
external_squad_uuid, internal_squad_uuids, expire_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (remnawave_uuid) DO UPDATE SET
|
||||||
|
telegram_id = EXCLUDED.telegram_id,
|
||||||
|
remnawave_username = EXCLUDED.remnawave_username,
|
||||||
|
external_squad_uuid = EXCLUDED.external_squad_uuid,
|
||||||
|
internal_squad_uuids = EXCLUDED.internal_squad_uuids,
|
||||||
|
expire_at = EXCLUDED.expire_at,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
u.TelegramID, u.RemnawaveUUID, u.RemnawaveUsername,
|
||||||
|
u.ExternalSquadUUID, u.InternalSquadUUIDs, u.ExpireAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetVPNByTelegramID(ctx context.Context, telegramID int64) (*VPNUser, error) {
|
||||||
|
row := d.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, telegram_id, remnawave_uuid::text, remnawave_username,
|
||||||
|
external_squad_uuid::text, internal_squad_uuids::text[], expire_at
|
||||||
|
FROM vpn_users
|
||||||
|
WHERE telegram_id = $1
|
||||||
|
ORDER BY expire_at DESC NULLS LAST
|
||||||
|
LIMIT 1`, telegramID)
|
||||||
|
|
||||||
|
var u VPNUser
|
||||||
|
var ext *string
|
||||||
|
var internal []string
|
||||||
|
err := row.Scan(&u.ID, &u.TelegramID, &u.RemnawaveUUID, &u.RemnawaveUsername, &ext, &internal, &u.ExpireAt)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u.ExternalSquadUUID = ext
|
||||||
|
u.InternalSquadUUIDs = internal
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetVPNByUsername(ctx context.Context, username string) (*VPNUser, error) {
|
||||||
|
row := d.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, telegram_id, remnawave_uuid::text, remnawave_username,
|
||||||
|
external_squad_uuid::text, internal_squad_uuids::text[], expire_at
|
||||||
|
FROM vpn_users WHERE remnawave_username = $1`, username)
|
||||||
|
|
||||||
|
var u VPNUser
|
||||||
|
var ext *string
|
||||||
|
var internal []string
|
||||||
|
err := row.Scan(&u.ID, &u.TelegramID, &u.RemnawaveUUID, &u.RemnawaveUsername, &ext, &internal, &u.ExpireAt)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u.ExternalSquadUUID = ext
|
||||||
|
u.InternalSquadUUIDs = internal
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullStr(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WizardData map[string]any
|
||||||
|
|
||||||
|
type AdminWizard struct {
|
||||||
|
AdminID int64
|
||||||
|
Step string
|
||||||
|
Data WizardData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetWizard(ctx context.Context, adminID int64) (*AdminWizard, error) {
|
||||||
|
row := d.pool.QueryRow(ctx, `
|
||||||
|
SELECT admin_telegram_id, step, data
|
||||||
|
FROM admin_wizard WHERE admin_telegram_id = $1`, adminID)
|
||||||
|
|
||||||
|
var w AdminWizard
|
||||||
|
var raw []byte
|
||||||
|
if err := row.Scan(&w.AdminID, &w.Step, &raw); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(raw) > 0 {
|
||||||
|
_ = json.Unmarshal(raw, &w.Data)
|
||||||
|
}
|
||||||
|
if w.Data == nil {
|
||||||
|
w.Data = WizardData{}
|
||||||
|
}
|
||||||
|
return &w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) SetWizard(ctx context.Context, adminID int64, step string, data WizardData) error {
|
||||||
|
raw, _ := json.Marshal(data)
|
||||||
|
_, err := d.pool.Exec(ctx, `
|
||||||
|
INSERT INTO admin_wizard (admin_telegram_id, step, data, updated_at)
|
||||||
|
VALUES ($1, $2, $3, NOW())
|
||||||
|
ON CONFLICT (admin_telegram_id) DO UPDATE SET
|
||||||
|
step = EXCLUDED.step,
|
||||||
|
data = EXCLUDED.data,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
adminID, step, raw)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ClearWizard(ctx context.Context, adminID int64) error {
|
||||||
|
_, err := d.pool.Exec(ctx, `DELETE FROM admin_wizard WHERE admin_telegram_id = $1`, adminID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w WizardData) String(key string) string {
|
||||||
|
v, _ := w[key].(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w WizardData) Int(key string) int {
|
||||||
|
switch v := w[key].(type) {
|
||||||
|
case float64:
|
||||||
|
return int(v)
|
||||||
|
case int:
|
||||||
|
return v
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w WizardData) StringSlice(key string) []string {
|
||||||
|
raw, ok := w[key].([]any)
|
||||||
|
if !ok {
|
||||||
|
if ss, ok := w[key].([]string); ok {
|
||||||
|
return ss
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(raw))
|
||||||
|
for _, x := range raw {
|
||||||
|
if s, ok := x.(string); ok {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w WizardData) Set(key string, val any) {
|
||||||
|
w[key] = val
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w WizardData) ToggleUUID(key, uuid string) {
|
||||||
|
cur := w.StringSlice(key)
|
||||||
|
for i, id := range cur {
|
||||||
|
if id == uuid {
|
||||||
|
cur = append(cur[:i], cur[i+1:]...)
|
||||||
|
w[key] = cur
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w[key] = append(cur, uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
StepIdle = ""
|
||||||
|
StepAwaitUsername = "await_username"
|
||||||
|
StepAwaitDays = "await_days"
|
||||||
|
StepPickExternalSquad = "pick_external"
|
||||||
|
StepPickInternalSquads = "pick_internal"
|
||||||
|
StepConfirm = "confirm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DefaultExpireAt(days int) time.Time {
|
||||||
|
if days <= 0 {
|
||||||
|
days = 30
|
||||||
|
}
|
||||||
|
return time.Now().UTC().AddDate(0, 0, days)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package remnawave
|
package remnawave
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,24 +11,32 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Client обращается к Remnawave Panel API:
|
||||||
|
// - Base: REMNAWAVE_PANEL_URL (например https://panel.example.com)
|
||||||
|
// - Auth: Authorization: Bearer REMNAWAVE_API_TOKEN
|
||||||
|
// - Опционально: X-Api-Key: CADDY_AUTH_API_TOKEN
|
||||||
|
// Документация: https://docs.rw/docs/install/subscription-page/bundled
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
panelURL string
|
||||||
token string
|
token string
|
||||||
caddyToken string
|
caddyToken string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(baseURL, apiToken, caddyToken string) *Client {
|
func NewClient(panelURL, apiToken, caddyAuthToken string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
panelURL: strings.TrimRight(panelURL, "/"),
|
||||||
token: apiToken,
|
token: apiToken,
|
||||||
caddyToken: caddyToken,
|
caddyToken: caddyAuthToken,
|
||||||
http: &http.Client{
|
http: &http.Client{
|
||||||
Timeout: 15 * time.Second,
|
Timeout: 15 * time.Second,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) PanelURL() string { return c.panelURL }
|
||||||
|
|
||||||
func (c *Client) countFromEndpoint(ctx context.Context, path, arrayKey string) (int, error) {
|
func (c *Client) countFromEndpoint(ctx context.Context, path, arrayKey string) (int, error) {
|
||||||
resp, body, err := c.get(ctx, path)
|
resp, body, err := c.get(ctx, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -40,13 +49,41 @@ func (c *Client) countFromEndpoint(ctx context.Context, path, arrayKey string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) get(ctx context.Context, path string) (*http.Response, []byte, error) {
|
func (c *Client) get(ctx context.Context, path string) (*http.Response, []byte, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
return c.doRequest(ctx, http.MethodGet, c.panelURL+path, nil, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getPublic(ctx context.Context, path string) (*http.Response, []byte, error) {
|
||||||
|
return c.doRequest(ctx, http.MethodGet, c.panelURL+path, nil, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) post(ctx context.Context, path string, body any) (*http.Response, []byte, error) {
|
||||||
|
return c.doRequest(ctx, http.MethodPost, c.panelURL+path, body, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) patch(ctx context.Context, path string, body any) (*http.Response, []byte, error) {
|
||||||
|
return c.doRequest(ctx, http.MethodPatch, c.panelURL+path, body, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doRequest(ctx context.Context, method, url string, body any, withBearer bool) (*http.Response, []byte, error) {
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if withBearer {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
if c.caddyToken != "" {
|
if c.caddyToken != "" {
|
||||||
req.Header.Set("X-Api-Key", c.caddyToken)
|
req.Header.Set("X-Api-Key", c.caddyToken)
|
||||||
}
|
}
|
||||||
@@ -57,11 +94,19 @@ func (c *Client) get(ctx context.Context, path string) (*http.Response, []byte,
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, nil, err
|
return resp, nil, err
|
||||||
}
|
}
|
||||||
return resp, body, nil
|
return resp, respBody, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiError(status int, body []byte) error {
|
||||||
|
msg := trimBody(body, 300)
|
||||||
|
if msg == "" {
|
||||||
|
return fmt.Errorf("HTTP %d", status)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("HTTP %d: %s", status, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseCount(body []byte, arrayKey string) int {
|
func parseCount(body []byte, arrayKey string) int {
|
||||||
|
|||||||
@@ -10,39 +10,54 @@ import (
|
|||||||
type CheckItem struct {
|
type CheckItem struct {
|
||||||
Name string
|
Name string
|
||||||
OK bool
|
OK bool
|
||||||
|
Skipped bool
|
||||||
Status int
|
Status int
|
||||||
Detail string
|
Detail string
|
||||||
}
|
}
|
||||||
|
|
||||||
type HealthReport struct {
|
type HealthReport struct {
|
||||||
PanelName string
|
|
||||||
PanelURL string
|
PanelURL string
|
||||||
Checks []CheckItem
|
Checks []CheckItem
|
||||||
Users int
|
Users int
|
||||||
Nodes int
|
Nodes int
|
||||||
AllOK bool
|
AllOK bool
|
||||||
|
Hint string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthReport {
|
func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthReport {
|
||||||
report := HealthReport{
|
report := HealthReport{
|
||||||
PanelName: "",
|
PanelURL: c.panelURL,
|
||||||
PanelURL: c.baseURL,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
probes := []struct {
|
probes := []struct {
|
||||||
name string
|
name string
|
||||||
path string
|
path string
|
||||||
|
isWeb bool
|
||||||
}{
|
}{
|
||||||
{"Панель (веб)", "/"},
|
{"Панель (веб)", "/", true},
|
||||||
{"API (статистика)", "/api/system/stats/recap"},
|
{"API (статистика)", "/api/system/stats/recap", false},
|
||||||
{"API (пользователи)", "/api/users"},
|
{"API (пользователи)", "/api/users", false},
|
||||||
{"API (ноды)", "/api/nodes"},
|
{"API (ноды)", "/api/nodes", false},
|
||||||
{"Подписка (настройки)", "/api/subscription-settings"},
|
{"Подписка (настройки)", "/api/subscription-settings", false},
|
||||||
{"Подписка (API список)", "/api/subscriptions"},
|
{"Подписка (API список)", "/api/subscriptions", false},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apiFailures := 0
|
||||||
|
webOK := false
|
||||||
|
|
||||||
for _, p := range probes {
|
for _, p := range probes {
|
||||||
item := c.probe(ctx, p.name, p.path)
|
var item CheckItem
|
||||||
|
if p.isWeb {
|
||||||
|
item = c.probeWeb(ctx, p.name, p.path)
|
||||||
|
if item.OK {
|
||||||
|
webOK = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item = c.probeAPI(ctx, p.name, p.path)
|
||||||
|
if !item.OK {
|
||||||
|
apiFailures++
|
||||||
|
}
|
||||||
|
}
|
||||||
report.Checks = append(report.Checks, item)
|
report.Checks = append(report.Checks, item)
|
||||||
|
|
||||||
if p.path == "/api/users" && item.OK {
|
if p.path == "/api/users" && item.OK {
|
||||||
@@ -63,15 +78,22 @@ func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthRe
|
|||||||
} else {
|
} else {
|
||||||
report.Checks = append(report.Checks, CheckItem{
|
report.Checks = append(report.Checks, CheckItem{
|
||||||
Name: "Страница подписки",
|
Name: "Страница подписки",
|
||||||
OK: false,
|
OK: true,
|
||||||
Status: 0,
|
Skipped: true,
|
||||||
Detail: "не задана (REMNAWAVE_SUBSCRIPTION_URL)",
|
Detail: "опционально, не задана",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if webOK && apiFailures >= 4 {
|
||||||
|
report.Hint = "Веб открывается, но /api/* недоступен (502). " +
|
||||||
|
"По документации Remnawave API вызывается на REMNAWAVE_PANEL_URL (https://panel.example.com/api/...), " +
|
||||||
|
"а не на домене sub.*. Укажите URL админ-панели и токен REMNAWAVE_API_TOKEN. " +
|
||||||
|
"Док: https://docs.rw/docs/install/subscription-page/bundled"
|
||||||
|
}
|
||||||
|
|
||||||
report.AllOK = true
|
report.AllOK = true
|
||||||
for _, ch := range report.Checks {
|
for _, ch := range report.Checks {
|
||||||
if !ch.OK {
|
if !ch.OK && !ch.Skipped {
|
||||||
report.AllOK = false
|
report.AllOK = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -79,14 +101,27 @@ func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthRe
|
|||||||
return report
|
return report
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) probe(ctx context.Context, name, path string) CheckItem {
|
func (c *Client) probeWeb(ctx context.Context, name, path string) CheckItem {
|
||||||
item := CheckItem{Name: name}
|
item := CheckItem{Name: name}
|
||||||
|
resp, body, err := c.getPublic(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
item.Detail = err.Error()
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
return finishProbe(&item, resp, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) probeAPI(ctx context.Context, name, path string) CheckItem {
|
||||||
|
item := CheckItem{Name: name}
|
||||||
resp, body, err := c.get(ctx, path)
|
resp, body, err := c.get(ctx, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
item.Detail = err.Error()
|
item.Detail = err.Error()
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
return finishProbe(&item, resp, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishProbe(item *CheckItem, resp *http.Response, body []byte) CheckItem {
|
||||||
item.Status = resp.StatusCode
|
item.Status = resp.StatusCode
|
||||||
|
|
||||||
switch resp.StatusCode {
|
switch resp.StatusCode {
|
||||||
@@ -94,11 +129,31 @@ func (c *Client) probe(ctx context.Context, name, path string) CheckItem {
|
|||||||
item.OK = true
|
item.OK = true
|
||||||
item.Detail = "OK"
|
item.Detail = "OK"
|
||||||
case http.StatusUnauthorized, http.StatusForbidden:
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
item.Detail = fmt.Sprintf("HTTP %d — неверный токен или нет прав", resp.StatusCode)
|
item.Detail = "неверный REMNAWAVE_API_TOKEN или CADDY_AUTH_API_TOKEN"
|
||||||
default:
|
default:
|
||||||
item.Detail = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, trimBody(body, 120))
|
item.Detail = statusHint(resp.StatusCode)
|
||||||
|
if item.Detail == "" {
|
||||||
|
if s := trimBody(body, 80); s != "" {
|
||||||
|
item.Detail = s
|
||||||
|
} else {
|
||||||
|
item.Detail = http.StatusText(resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return *item
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusHint(code int) string {
|
||||||
|
switch code {
|
||||||
|
case http.StatusBadGateway:
|
||||||
|
return "Bad Gateway — прокси не достучался до API панели"
|
||||||
|
case http.StatusServiceUnavailable:
|
||||||
|
return "Service Unavailable — бэкенд панели не запущен"
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return "Not Found — путь /api не проксируется на панель"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
return item
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) probePublic(ctx context.Context, name, url string) CheckItem {
|
func (c *Client) probePublic(ctx context.Context, name, url string) CheckItem {
|
||||||
@@ -124,28 +179,35 @@ func (c *Client) probePublic(ctx context.Context, name, url string) CheckItem {
|
|||||||
item.Status = resp.StatusCode
|
item.Status = resp.StatusCode
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
||||||
item.OK = true
|
item.OK = true
|
||||||
item.Detail = fmt.Sprintf("OK (HTTP %d)", resp.StatusCode)
|
item.Detail = "OK"
|
||||||
} else {
|
} else {
|
||||||
item.Detail = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
item.Detail = statusHint(resp.StatusCode)
|
||||||
|
if item.Detail == "" {
|
||||||
|
item.Detail = http.StatusText(resp.StatusCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
func FormatReport(r HealthReport, panelName, panelURL string) string {
|
// FormatReport — обычный текст (без Markdown), чтобы URL и имена env отображались корректно.
|
||||||
|
func FormatReport(r HealthReport, panelName string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if panelName != "" {
|
|
||||||
b.WriteString(fmt.Sprintf("Панель: *%s*\nURL: `%s`\n\n", panelName, panelURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
icon := func(ok bool) string {
|
if panelName != "" {
|
||||||
if ok {
|
b.WriteString(fmt.Sprintf("Панель: %s\n", panelName))
|
||||||
return "✅"
|
|
||||||
}
|
|
||||||
return "❌"
|
|
||||||
}
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("REMNAWAVE_PANEL_URL: %s\n", r.PanelURL))
|
||||||
|
b.WriteString(fmt.Sprintf("API: %s/api/...\n\n", r.PanelURL))
|
||||||
|
|
||||||
for _, ch := range r.Checks {
|
for _, ch := range r.Checks {
|
||||||
line := fmt.Sprintf("%s *%s*", icon(ch.OK), ch.Name)
|
mark := "❌"
|
||||||
|
switch {
|
||||||
|
case ch.Skipped:
|
||||||
|
mark = "○"
|
||||||
|
case ch.OK:
|
||||||
|
mark = "✅"
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf("%s %s", mark, ch.Name)
|
||||||
if ch.Status > 0 {
|
if ch.Status > 0 {
|
||||||
line += fmt.Sprintf(" — HTTP %d", ch.Status)
|
line += fmt.Sprintf(" — HTTP %d", ch.Status)
|
||||||
}
|
}
|
||||||
@@ -156,13 +218,17 @@ func FormatReport(r HealthReport, panelName, panelURL string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if r.Users > 0 || r.Nodes > 0 {
|
if r.Users > 0 || r.Nodes > 0 {
|
||||||
b.WriteString(fmt.Sprintf("\n👥 Пользователей: %d\n📡 Нод: %d", r.Users, r.Nodes))
|
b.WriteString(fmt.Sprintf("\nПользователей: %d\nНод: %d", r.Users, r.Nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Hint != "" {
|
||||||
|
b.WriteString("\n\n💡 " + r.Hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.AllOK {
|
if r.AllOK {
|
||||||
b.WriteString("\n\n✅ *Все проверки пройдены*")
|
b.WriteString("\n\nВсе обязательные проверки пройдены.")
|
||||||
} else {
|
} else {
|
||||||
b.WriteString("\n\n⚠️ *Есть ошибки* — проверьте токен, URL и страницу подписки")
|
b.WriteString("\n\nЕсть ошибки — см. подсказку выше.")
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package remnawave
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Squad struct {
|
||||||
|
UUID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListInternalSquads(ctx context.Context) ([]Squad, error) {
|
||||||
|
return c.listSquads(ctx, "/api/internal-squads")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListExternalSquads(ctx context.Context) ([]Squad, error) {
|
||||||
|
return c.listSquads(ctx, "/api/external-squads")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) listSquads(ctx context.Context, path string) ([]Squad, error) {
|
||||||
|
resp, body, err := c.get(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, apiError(resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
return parseSquads(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSquads(body []byte) []Squad {
|
||||||
|
var wrap map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(body, &wrap) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if raw, ok := wrap["response"]; ok {
|
||||||
|
if list := squadsFromRaw(raw); len(list) > 0 {
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return squadsFromRaw(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func squadsFromRaw(data []byte) []Squad {
|
||||||
|
var arr []map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(data, &arr) == nil {
|
||||||
|
return squadsFromMaps(arr)
|
||||||
|
}
|
||||||
|
var obj map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(data, &obj) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, key := range []string{"internalSquads", "externalSquads", "squads"} {
|
||||||
|
if raw, ok := obj[key]; ok {
|
||||||
|
if list := squadsFromRaw(raw); len(list) > 0 {
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range obj {
|
||||||
|
if list := squadsFromRaw(v); len(list) > 0 {
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func squadsFromMaps(items []map[string]json.RawMessage) []Squad {
|
||||||
|
out := make([]Squad, 0, len(items))
|
||||||
|
for _, m := range items {
|
||||||
|
s := Squad{}
|
||||||
|
if raw, ok := m["uuid"]; ok {
|
||||||
|
_ = json.Unmarshal(raw, &s.UUID)
|
||||||
|
}
|
||||||
|
if raw, ok := m["name"]; ok {
|
||||||
|
_ = json.Unmarshal(raw, &s.Name)
|
||||||
|
}
|
||||||
|
if s.UUID != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package remnawave
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateUserInput struct {
|
||||||
|
Username string
|
||||||
|
ExpireAt time.Time
|
||||||
|
TelegramID *int64
|
||||||
|
ExternalSquadUUID *string
|
||||||
|
ActiveInternalSquads []string
|
||||||
|
TrafficLimitBytes *int64
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PanelUser struct {
|
||||||
|
UUID string
|
||||||
|
Username string
|
||||||
|
ShortUUID string
|
||||||
|
Status string
|
||||||
|
ExpireAt time.Time
|
||||||
|
SubscriptionURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetUserByUsername(ctx context.Context, username string) (*PanelUser, error) {
|
||||||
|
path := fmt.Sprintf("/api/users/by-username/%s", username)
|
||||||
|
resp, body, err := c.get(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, apiError(resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
return parsePanelUser(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetUserByTelegramID(ctx context.Context, telegramID int64) (*PanelUser, error) {
|
||||||
|
path := fmt.Sprintf("/api/users/by-telegram-id/%d", telegramID)
|
||||||
|
resp, body, err := c.get(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, apiError(resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
u := parsePanelUser(body)
|
||||||
|
if u == nil || u.UUID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateUser(ctx context.Context, in CreateUserInput) (*PanelUser, error) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"username": in.Username,
|
||||||
|
"expireAt": in.ExpireAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"status": "ACTIVE",
|
||||||
|
}
|
||||||
|
if in.TelegramID != nil {
|
||||||
|
payload["telegramId"] = *in.TelegramID
|
||||||
|
}
|
||||||
|
if in.ExternalSquadUUID != nil && *in.ExternalSquadUUID != "" {
|
||||||
|
payload["externalSquadUuid"] = *in.ExternalSquadUUID
|
||||||
|
}
|
||||||
|
if len(in.ActiveInternalSquads) > 0 {
|
||||||
|
payload["activeInternalSquads"] = in.ActiveInternalSquads
|
||||||
|
}
|
||||||
|
if in.TrafficLimitBytes != nil {
|
||||||
|
payload["trafficLimitBytes"] = *in.TrafficLimitBytes
|
||||||
|
}
|
||||||
|
if in.Description != "" {
|
||||||
|
payload["description"] = in.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body, err := c.post(ctx, "/api/users", payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return nil, apiError(resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
return parsePanelUser(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssignSquadsInput struct {
|
||||||
|
UUID string
|
||||||
|
Username string
|
||||||
|
ExternalSquadUUID *string
|
||||||
|
ActiveInternalSquads []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AssignSquads(ctx context.Context, in AssignSquadsInput) (*PanelUser, error) {
|
||||||
|
if in.UUID == "" && in.Username == "" {
|
||||||
|
return nil, fmt.Errorf("нужен uuid или username")
|
||||||
|
}
|
||||||
|
payload := map[string]any{}
|
||||||
|
if in.UUID != "" {
|
||||||
|
payload["uuid"] = in.UUID
|
||||||
|
} else {
|
||||||
|
payload["username"] = in.Username
|
||||||
|
}
|
||||||
|
if in.ExternalSquadUUID != nil {
|
||||||
|
payload["externalSquadUuid"] = in.ExternalSquadUUID
|
||||||
|
}
|
||||||
|
if in.ActiveInternalSquads != nil {
|
||||||
|
payload["activeInternalSquads"] = in.ActiveInternalSquads
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body, err := c.patch(ctx, "/api/users", payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, apiError(resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
return parsePanelUser(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePanelUser(body []byte) *PanelUser {
|
||||||
|
var wrap struct {
|
||||||
|
Response map[string]json.RawMessage `json:"response"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(body, &wrap) != nil || wrap.Response == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u := &PanelUser{}
|
||||||
|
if raw, ok := wrap.Response["uuid"]; ok {
|
||||||
|
_ = json.Unmarshal(raw, &u.UUID)
|
||||||
|
}
|
||||||
|
if raw, ok := wrap.Response["username"]; ok {
|
||||||
|
_ = json.Unmarshal(raw, &u.Username)
|
||||||
|
}
|
||||||
|
if raw, ok := wrap.Response["shortUuid"]; ok {
|
||||||
|
_ = json.Unmarshal(raw, &u.ShortUUID)
|
||||||
|
}
|
||||||
|
if raw, ok := wrap.Response["status"]; ok {
|
||||||
|
_ = json.Unmarshal(raw, &u.Status)
|
||||||
|
}
|
||||||
|
if raw, ok := wrap.Response["expireAt"]; ok {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||||
|
u.ExpireAt = t
|
||||||
|
} else if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||||
|
u.ExpireAt = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, ok := wrap.Response["subscriptionUrl"]; ok {
|
||||||
|
_ = json.Unmarshal(raw, &u.SubscriptionURL)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"telegramvpn/internal/bot"
|
"telegramvpn/internal/bot"
|
||||||
"telegramvpn/internal/config"
|
"telegramvpn/internal/config"
|
||||||
|
"telegramvpn/internal/db"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -18,22 +23,40 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
database, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
api, err := tgbotapi.NewBotAPI(cfg.BotToken)
|
api, err := tgbotapi.NewBotAPI(cfg.BotToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("не удалось подключиться к Telegram: %v", err)
|
log.Fatalf("не удалось подключиться к Telegram: %v", err)
|
||||||
}
|
}
|
||||||
api.Debug = cfg.BotDebug
|
api.Debug = cfg.BotDebug
|
||||||
|
|
||||||
log.Printf("бот @%s запущен, админ ID %d, панель %q (%s)",
|
log.Printf("бот @%s запущен, админ ID %d, панель %q, postgres ok",
|
||||||
api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawaveName, cfg.RemnawaveURL)
|
api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawavePanelURL)
|
||||||
|
|
||||||
handler := bot.NewHandler(cfg, api)
|
handler := bot.NewHandler(cfg, api, database)
|
||||||
handler.RegisterCommands()
|
handler.RegisterCommands()
|
||||||
|
|
||||||
u := tgbotapi.NewUpdate(0)
|
u := tgbotapi.NewUpdate(0)
|
||||||
u.Timeout = 60
|
u.Timeout = 60
|
||||||
|
|
||||||
for update := range api.GetUpdatesChan(u) {
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Println("остановка бота…")
|
||||||
|
return
|
||||||
|
case update, ok := <-api.GetUpdatesChan(u):
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
handler.HandleUpdate(update)
|
handler.HandleUpdate(update)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user