diff --git a/.env.example b/.env.example index 8f00a7c..e41f863 100644 --- a/.env.example +++ b/.env.example @@ -9,13 +9,22 @@ TELEGRAM_ADMIN_ID=123456789 # --- Remnawave (официальные имена: https://docs.rw/docs/install/subscription-page/bundled) --- REMNAWAVE_PANEL_NAME=Панель 1 -# URL панели: https://j5.evilfox.win (тест) или https://panel.example.com / http://remnawave:3000 -REMNAWAVE_PANEL_URL=https://j5.evilfox.win +# URL панели: https://panel.example.com или http://remnawave:3000 (внутри Docker-сети) +REMNAWAVE_PANEL_URL=https://panel.example.com # API-токен: Remnawave Settings → API Tokens (Authorization: Bearer) REMNAWAVE_API_TOKEN=API_TOKEN_FROM_REMNAWAVE # Если используется Caddy with security — X-Api-Key к панели CADDY_AUTH_API_TOKEN= -# Опционально: Subscription Page (например sub5.evilfox.win) -REMNAWAVE_SUBSCRIPTION_URL=https://sub5.evilfox.win +# Опционально: Subscription Page (например https://sub.example.com) +REMNAWAVE_SUBSCRIPTION_URL= + +# PostgreSQL (docker-compose подставляет URL к сервису db) +DATABASE_URL=postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable + +# Создание пользователей по умолчанию +DEFAULT_USER_DAYS=30 +# UUID сквадов из панели (/admin squads), через запятую для internal +DEFAULT_EXTERNAL_SQUAD_UUID= +DEFAULT_INTERNAL_SQUAD_UUIDS= # Docker Compose: cp .env.example .env diff --git a/CHANGELOG.md b/CHANGELOG.md index c0307b3..77f96ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/). +## [Unreleased] + +### Добавлено + +- 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 ### Изменено @@ -23,7 +33,7 @@ - Раздел в README: Remnawave API (по официальной документации) - Пример `curl` для проверки API с сервера -[0.20.0]: https://git.evilfox.cc/test/tgvpn/releases/tag/v0.20.0 +[0.20.0]: # ## [0.10.0-beta] — 2026-05-21 @@ -55,4 +65,4 @@ - `internal/config` — загрузка конфигурации - `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]: # diff --git a/README.md b/README.md index 1f63161..894a77b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # tgvpn -**Версия:** [0.20.0](CHANGELOG.md) · [Релизы](https://git.evilfox.cc/test/tgvpn/releases) +**Версия:** [0.20.0](CHANGELOG.md) Telegram-бот на Go (базовое приветствие; далее — VPN-функции). @@ -10,6 +10,7 @@ Telegram-бот на Go (базовое приветствие; далее — V |-----------|---------| | Docker | 24+ | | Docker Compose | v2 (`docker compose`) | +| PostgreSQL | 16+ (в compose включён) | | Токен бота | [@BotFather](https://t.me/BotFather) | | Сеть | Исходящий HTTPS к `api.telegram.org` (порт 443) | @@ -22,7 +23,7 @@ Telegram-бот на Go (базовое приветствие; далее — V ### 1. Клонирование ```bash -git clone https://git.evilfox.cc/test/tgvpn.git +git clone cd tgvpn ``` @@ -39,9 +40,9 @@ BOT_TOKEN=ваш_токен_от_BotFather BOT_DEBUG=false TELEGRAM_ADMIN_ID=123456789 REMNAWAVE_PANEL_NAME=Панель 1 -REMNAWAVE_PANEL_URL=https://j5.evilfox.win +REMNAWAVE_PANEL_URL=https://panel.example.com REMNAWAVE_API_TOKEN=токен_из_панели -REMNAWAVE_SUBSCRIPTION_URL=https://sub5.evilfox.win +REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com ``` > **Важно:** файл `.env` не попадает в git и не копируется в образ. Compose передаёт переменные в контейнер при старте. @@ -115,7 +116,7 @@ docker compose version sudo mkdir -p /opt/tgvpn sudo chown $USER:$USER /opt/tgvpn cd /opt/tgvpn -git clone https://git.evilfox.cc/test/tgvpn.git . +git clone . ``` ### Шаг 4. Настройка `.env` @@ -260,7 +261,7 @@ docker image prune -f | `git pull` конфликтует с локальными правками | `git stash` → `git pull` → `git stash pop` или сбросить локальные изменения: `git checkout -- .` | | Бот не стартует после pull | `docker compose logs bot` — часто не хватает новой переменной в `.env` | | Старый код в контейнере | Обязательно `--build`: `docker compose up -d --build` | -| Нет доступа к git | Проверьте SSH/HTTPS-доступ к `git.evilfox.cc` | +| Нет доступа к git | Проверьте SSH/HTTPS-доступ к вашему git-серверу | --- @@ -272,7 +273,7 @@ docker image prune -f 2. В PowerShell: ```powershell -git clone https://git.evilfox.cc/test/tgvpn.git +git clone cd tgvpn Copy-Item .env.example .env # отредактируйте .env — вставьте BOT_TOKEN @@ -310,6 +311,10 @@ go build -o bot . | `REMNAWAVE_API_TOKEN` | да | Токен из **Remnawave Settings → API Tokens**, заголовок `Authorization: Bearer` | | `CADDY_AUTH_API_TOKEN` | нет | `X-Api-Key`, если включён Caddy with security (как в оф. `.env` subscription-page) | | `REMNAWAVE_SUBSCRIPTION_URL` | нет | Опционально: домен Subscription Page (`sub.*`), отдельная проверка | +| `DATABASE_URL` | да | PostgreSQL, в compose: `postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable` | +| `DEFAULT_USER_DAYS` | нет | Срок подписки по умолчанию (30) | +| `DEFAULT_EXTERNAL_SQUAD_UUID` | нет | External squad по умолчанию при быстром создании | +| `DEFAULT_INTERNAL_SQUAD_UUIDS` | нет | Internal squads через запятую | | `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) | ### Админ-меню в боте @@ -319,7 +324,11 @@ go build -o bot . - `/admin` — админ-меню (панель 1, Remnawave) - `/admin check` — полная проверка: веб панели, API (статистика, users, nodes), подписка (settings + API), страница подписки - `/admin config` — конфиг панели в боте -- Кнопки снизу (после `/start`): «Проверить панель», «Конфиг панели» +- `/admin user` — мастер создания пользователя в Remnawave + назначение сквадов +- `/admin user <логин> [дней]` — быстрое создание (сквады из `DEFAULT_*` в `.env`) +- `/admin squads` — список internal/external squads +- `/admin assign <логин>` — назначить сквады существующему пользователю +- Кнопки: «Создать пользователя», «Сквады», «Проверить панель», «Конфиг» --- @@ -410,9 +419,9 @@ docker compose logs bot # ошибки сети, токена Частая причина: в `REMNAWAVE_PANEL_URL` указан домен **страницы подписки** (`sub.example.com`), а не **админ-панели** (`panel.example.com`). -1. Укажите URL **панели** (не sub): `REMNAWAVE_PANEL_URL=https://j5.evilfox.win` +1. Укажите URL **панели** (не sub): `REMNAWAVE_PANEL_URL=https://panel.example.com` 2. Токен API: `REMNAWAVE_API_TOKEN=...` (Settings → API Tokens) -3. Страницу подписки — опционально: `REMNAWAVE_SUBSCRIPTION_URL=https://sub5.evilfox.win` +3. Страницу подписки — опционально: `REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com` 4. Проверьте на сервере: `docker compose ps` (Remnawave Panel запущен), логи reverse proxy ### Контейнер постоянно перезапускается @@ -438,9 +447,10 @@ sudo usermod -aG docker $USER tgvpn/ ├── main.go ├── internal/ -│ ├── bot/ # обработчики Telegram, админ-меню +│ ├── bot/ # Telegram, админ-меню, создание пользователей │ ├── config/ # переменные окружения -│ └── remnawave/ # клиент API панели +│ ├── db/ # PostgreSQL, миграции, мастер админа +│ └── remnawave/ # API панели (users, squads) ├── Dockerfile # multi-stage сборка ├── docker-compose.yml # оркестрация ├── .env.example # шаблон переменных @@ -454,4 +464,4 @@ tgvpn/ ## Репозиторий -https://git.evilfox.cc/test/tgvpn.git +Укажите URL вашего приватного git-репозитория при клонировании. diff --git a/docker-compose.yml b/docker-compose.yml index 6a7a6c1..6770186 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,20 @@ services: + db: + image: postgres:16-alpine + container_name: tgvpn-db + restart: unless-stopped + environment: + POSTGRES_USER: tgvpn + POSTGRES_PASSWORD: tgvpn + POSTGRES_DB: tgvpn + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tgvpn -d tgvpn"] + interval: 5s + timeout: 5s + retries: 5 + bot: build: context: . @@ -6,9 +22,14 @@ services: image: tgvpn-bot:latest container_name: tgvpn-bot restart: unless-stopped + depends_on: + db: + condition: service_healthy env_file: - .env environment: BOT_DEBUG: ${BOT_DEBUG:-false} - # Long polling — исходящие HTTPS к api.telegram.org - # ports не нужны, пока нет webhook + DATABASE_URL: postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable + +volumes: + pgdata: diff --git a/go.mod b/go.mod index c75c8f6..e240564 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,17 @@ module telegramvpn -go 1.22 +go 1.25.0 require ( 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 ) + +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 +) diff --git a/go.sum b/go.sum index 0467a41..d25504b 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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= diff --git a/internal/bot/admin_users.go b/internal/bot/admin_users.go new file mode 100644 index 0000000..e744f1f --- /dev/null +++ b/internal/bot/admin_users.go @@ -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 +} diff --git a/internal/bot/handler.go b/internal/bot/handler.go index 4ff5bf1..d04c787 100644 --- a/internal/bot/handler.go +++ b/internal/bot/handler.go @@ -8,6 +8,7 @@ import ( "time" "telegramvpn/internal/config" + "telegramvpn/internal/db" "telegramvpn/internal/remnawave" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" @@ -16,18 +17,20 @@ import ( const docsURL = "https://docs.rw/" type Handler struct { - cfg *config.Config - api *tgbotapi.BotAPI - panel *remnawave.Client - admin int64 + cfg *config.Config + api *tgbotapi.BotAPI + panel *remnawave.Client + database *db.DB + 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{ - cfg: cfg, - api: api, - panel: remnawave.NewClient(cfg.RemnawavePanelURL, cfg.RemnawaveAPIToken, cfg.CaddyAuthAPIToken), - admin: cfg.TelegramAdminID, + cfg: cfg, + api: api, + panel: remnawave.NewClient(cfg.RemnawavePanelURL, cfg.RemnawaveAPIToken, cfg.CaddyAuthAPIToken), + database: database, + admin: cfg.TelegramAdminID, } } @@ -61,12 +64,15 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) { switch { case text == "/start": - h.sendStart(chatID, userID, update.Message.From.FirstName) + h.sendStart(chatID, userID, update.Message.From.FirstName, update.Message.From.UserName) case strings.HasPrefix(text, "/admin"): h.handleAdminCommand(chatID, userID, text) case strings.HasPrefix(text, "/"): h.sendText(chatID, "Неизвестная команда. Для начала — /start") default: + if h.isAdmin(userID) && h.handleWizardMessage(chatID, userID, text) { + return + } if h.isAdmin(userID) { switch text { case "📋 Конфиг панели": @@ -75,6 +81,12 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) { case "🔌 Проверить панель": h.sendPanelCheck(chatID) return + case "👤 Создать пользователя": + h.startUserWizard(chatID, userID) + return + case "📡 Сквады": + h.sendSquadsList(chatID) + return case "◀️ Выйти из админки": h.sendText(chatID, "Админ-меню закрыто. /admin — снова открыть.") return @@ -101,6 +113,8 @@ func (h *Handler) handleAdminCommand(chatID, userID int64, text string) { h.sendPanelCheck(chatID) case "config", "конфиг": h.sendPanelConfig(chatID) + case "user", "пользователь", "squads", "сквады", "assign", "сквад", "cancel", "отмена", "help": + h.handleAdminUsersSubcommand(chatID, userID, args[2:]) default: h.sendAdminHelp(chatID) } @@ -117,7 +131,15 @@ func (h *Handler) handleCallback(cq *tgbotapi.CallbackQuery) { return } + if h.handleWizardCallback(cq) { + return + } + switch cq.Data { + case "admin:user": + h.startUserWizard(cq.Message.Chat.ID, cq.From.ID) + case "admin:squads": + h.sendSquadsList(cq.Message.Chat.ID) case "admin:config": h.sendPanelConfig(cq.Message.Chat.ID) case "admin:check": @@ -133,14 +155,17 @@ func (h *Handler) isAdmin(userID int64) bool { return userID == h.admin } -func (h *Handler) sendStart(chatID, userID int64, firstName string) { +func (h *Handler) sendStart(chatID, userID int64, firstName, tgUsername string) { + ctx := context.Background() + _ = h.database.UpsertTelegramUser(ctx, userID, tgUsername, firstName) + name := firstName if name == "" { name = "друг" } text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот на базе панели Remnawave.", name) if h.isAdmin(userID) { - text += "\n\n/admin — админ-меню\n/admin check — проверка API и подписки" + text += "\n\n/admin — админ-меню\n/admin user — создать пользователя\n/admin squads — сквады" } msg := tgbotapi.NewMessage(chatID, text) if h.isAdmin(userID) { @@ -155,7 +180,10 @@ func (h *Handler) sendAdminMenu(chatID int64) { "Команды:\n"+ "• /admin — это меню\n"+ "• /admin check — проверка панели, API и подписки\n"+ - "• /admin config — конфиг панели\n\n"+ + "• /admin config — конфиг панели\n"+ + "• /admin user — создать пользователя\n"+ + "• /admin squads — список сквадов\n"+ + "• /admin assign <логин> — назначить сквады\n\n"+ "Или кнопки ниже.", escapeMarkdown(h.cfg.RemnawaveName), ) @@ -166,7 +194,7 @@ func (h *Handler) sendAdminMenu(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) { @@ -219,7 +247,11 @@ func (h *Handler) sendPanelCheck(chatID int64) { func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup { return tgbotapi.NewInlineKeyboardMarkup( tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить (API+подписка)", "admin:check"), + tgbotapi.NewInlineKeyboardButtonData("👤 Создать пользователя", "admin:user"), + tgbotapi.NewInlineKeyboardButtonData("📡 Сквады", "admin:squads"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить", "admin:check"), tgbotapi.NewInlineKeyboardButtonData("📋 Конфиг", "admin:config"), ), tgbotapi.NewInlineKeyboardRow( @@ -231,6 +263,10 @@ func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup { func adminReplyKeyboard() tgbotapi.ReplyKeyboardMarkup { return tgbotapi.ReplyKeyboardMarkup{ Keyboard: [][]tgbotapi.KeyboardButton{ + { + tgbotapi.NewKeyboardButton("👤 Создать пользователя"), + tgbotapi.NewKeyboardButton("📡 Сквады"), + }, { tgbotapi.NewKeyboardButton("🔌 Проверить панель"), tgbotapi.NewKeyboardButton("📋 Конфиг панели"), diff --git a/internal/config/config.go b/internal/config/config.go index 85434c5..3524a16 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,7 +18,11 @@ type Config struct { RemnawavePanelURL string RemnawaveAPIToken string CaddyAuthAPIToken string - RemnawaveSubscription string + RemnawaveSubscription string + DatabaseURL string + DefaultUserDays int + DefaultExternalSquadUUID string + DefaultInternalSquadUUIDs []string } func Load() (*Config, error) { @@ -60,14 +64,40 @@ func Load() (*Config, error) { 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 := 30 + if v := strings.TrimSpace(os.Getenv("DEFAULT_USER_DAYS")); v != "" { + if d, err := strconv.Atoi(v); err == nil && d > 0 { + days = 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{ - BotToken: token, - BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"), - TelegramAdminID: adminID, - RemnawaveName: name, - RemnawavePanelURL: panelURL, - RemnawaveAPIToken: apiToken, - CaddyAuthAPIToken: caddy, - RemnawaveSubscription: subURL, + BotToken: token, + BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"), + TelegramAdminID: adminID, + RemnawaveName: name, + RemnawavePanelURL: panelURL, + RemnawaveAPIToken: apiToken, + CaddyAuthAPIToken: caddy, + RemnawaveSubscription: subURL, + DatabaseURL: dbURL, + DefaultUserDays: days, + DefaultExternalSquadUUID: strings.TrimSpace(os.Getenv("DEFAULT_EXTERNAL_SQUAD_UUID")), + DefaultInternalSquadUUIDs: internalSquads, }, nil } diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..f49f2fd --- /dev/null +++ b/internal/db/db.go @@ -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 +} diff --git a/internal/db/migrations/001_init.sql b/internal/db/migrations/001_init.sql new file mode 100644 index 0000000..b36dacb --- /dev/null +++ b/internal/db/migrations/001_init.sql @@ -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() +); diff --git a/internal/db/users.go b/internal/db/users.go new file mode 100644 index 0000000..c34c833 --- /dev/null +++ b/internal/db/users.go @@ -0,0 +1,75 @@ +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) 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 +} diff --git a/internal/db/wizard.go b/internal/db/wizard.go new file mode 100644 index 0000000..70fafbd --- /dev/null +++ b/internal/db/wizard.go @@ -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) +} diff --git a/internal/remnawave/client.go b/internal/remnawave/client.go index 13b60f6..f758a5e 100644 --- a/internal/remnawave/client.go +++ b/internal/remnawave/client.go @@ -1,6 +1,7 @@ package remnawave import ( + "bytes" "context" "encoding/json" "fmt" @@ -48,20 +49,38 @@ func (c *Client) countFromEndpoint(ctx context.Context, path, arrayKey string) ( } func (c *Client) get(ctx context.Context, path string) (*http.Response, []byte, error) { - return c.doRequest(ctx, c.panelURL+path, true) + 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, c.panelURL+path, false) + return c.doRequest(ctx, http.MethodGet, c.panelURL+path, nil, false) } -func (c *Client) doRequest(ctx context.Context, url string, withBearer bool) (*http.Response, []byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) +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 { return nil, nil, err } - req.Header.Set("Accept", "application/json, text/html, */*") + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") if withBearer { req.Header.Set("Authorization", "Bearer "+c.token) } @@ -75,11 +94,19 @@ func (c *Client) doRequest(ctx context.Context, url string, withBearer bool) (*h } 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 { 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 { diff --git a/internal/remnawave/squads.go b/internal/remnawave/squads.go new file mode 100644 index 0000000..0fe9df1 --- /dev/null +++ b/internal/remnawave/squads.go @@ -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 +} diff --git a/internal/remnawave/users.go b/internal/remnawave/users.go new file mode 100644 index 0000000..0f72b87 --- /dev/null +++ b/internal/remnawave/users.go @@ -0,0 +1,128 @@ +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) 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 + } + } + } + if raw, ok := wrap.Response["subscriptionUrl"]; ok { + _ = json.Unmarshal(raw, &u.SubscriptionURL) + } + return u +} diff --git a/main.go b/main.go index 4a561f4..7f4c3d6 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,15 @@ package main import ( + "context" "log" + "os" + "os/signal" + "syscall" "telegramvpn/internal/bot" "telegramvpn/internal/config" + "telegramvpn/internal/db" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/joho/godotenv" @@ -18,22 +23,40 @@ func main() { 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) if err != nil { log.Fatalf("не удалось подключиться к Telegram: %v", err) } api.Debug = cfg.BotDebug - log.Printf("бот @%s запущен, админ ID %d, панель %q (%s)", - api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawaveName, cfg.RemnawavePanelURL) + log.Printf("бот @%s запущен, админ ID %d, панель %q, postgres ok", + api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawavePanelURL) - handler := bot.NewHandler(cfg, api) + handler := bot.NewHandler(cfg, api, database) handler.RegisterCommands() u := tgbotapi.NewUpdate(0) u.Timeout = 60 - for update := range api.GetUpdatesChan(u) { - handler.HandleUpdate(update) + for { + select { + case <-ctx.Done(): + log.Println("остановка бота…") + return + case update, ok := <-api.GetUpdatesChan(u): + if !ok { + return + } + handler.HandleUpdate(update) + } } }