From f360d536146f1fce321f04013bd0a14468772e8b Mon Sep 17 00:00:00 2001 From: tgvpn Date: Thu, 21 May 2026 00:47:13 +0300 Subject: [PATCH] Enhance /admin with full panel and subscription health checks --- .env.example | 2 + README.md | 9 +- internal/bot/handler.go | 126 ++++++++++++++++++-------- internal/config/config.go | 29 +++--- internal/remnawave/client.go | 40 --------- internal/remnawave/health.go | 168 +++++++++++++++++++++++++++++++++++ main.go | 1 + 7 files changed, 284 insertions(+), 91 deletions(-) create mode 100644 internal/remnawave/health.go diff --git a/.env.example b/.env.example index 7b0d796..ad1fe8f 100644 --- a/.env.example +++ b/.env.example @@ -14,5 +14,7 @@ REMNAWAVE_PANEL_URL=https://panel.example.com REMNAWAVE_API_TOKEN=your_api_token_here # Опционально, если перед панелью стоит Caddy с X-Api-Key REMNAWAVE_CADDY_TOKEN= +# Публичная страница подписки (Subscription Page), для проверки доступности +REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com # Docker Compose читает этот файл как .env (скопируйте: cp .env.example .env) diff --git a/README.md b/README.md index 816e735..234e188 100644 --- a/README.md +++ b/README.md @@ -213,15 +213,18 @@ go build -o bot . | `REMNAWAVE_PANEL_NAME` | нет | Название панели в админ-меню (по умолчанию «Панель 1») | | `REMNAWAVE_PANEL_URL` | да | URL панели Remnawave, например `https://vpn.example.com` | | `REMNAWAVE_API_TOKEN` | да | API-токен: панель → **Settings → API Tokens** ([документация](https://docs.rw/)) | -| `REMNAWAVE_CADDY_TOKEN` | нет | Доп. заголовок `X-Api-Key`, если панель за Caddy | +| `REMNAWAVE_CADDY_TOKEN` | нет | Доп. заголовок `X-Api-Key`, если панель за Caddy | +| `REMNAWAVE_SUBSCRIPTION_URL` | нет* | URL страницы подписки для проверки в `/admin check` (*рекомендуется) | | `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) | ### Админ-меню в боте Только пользователь с `TELEGRAM_ADMIN_ID`: -- `/admin` — inline-меню (конфиг панели, проверка API, ссылка на [docs.rw](https://docs.rw/)) -- Кнопки снизу (после `/start`): «Конфиг панели», «Проверить панель» +- `/admin` — админ-меню (панель 1, Remnawave) +- `/admin check` — полная проверка: веб панели, API (статистика, users, nodes), подписка (settings + API), страница подписки +- `/admin config` — конфиг панели в боте +- Кнопки снизу (после `/start`): «Проверить панель», «Конфиг панели» --- diff --git a/internal/bot/handler.go b/internal/bot/handler.go index ffdeb65..5d6b1c7 100644 --- a/internal/bot/handler.go +++ b/internal/bot/handler.go @@ -16,10 +16,10 @@ 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 + admin int64 } func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI) *Handler { @@ -31,6 +31,21 @@ func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI) *Handler { } } +func (h *Handler) RegisterCommands() { + commands := []tgbotapi.BotCommand{ + {Command: "start", Description: "Начать"}, + {Command: "admin", Description: "Админ-меню Remnawave (панель 1)"}, + } + scope := tgbotapi.BotCommandScope{Type: "chat", ChatID: h.admin} + cfg := tgbotapi.SetMyCommandsConfig{ + Commands: commands, + Scope: &scope, + } + if _, err := h.api.Request(cfg); err != nil { + log.Printf("не удалось зарегистрировать команды для админа: %v", err) + } +} + func (h *Handler) HandleUpdate(update tgbotapi.Update) { if update.CallbackQuery != nil { h.handleCallback(update.CallbackQuery) @@ -47,12 +62,8 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) { switch { case text == "/start": h.sendStart(chatID, userID, update.Message.From.FirstName) - case text == "/admin": - if !h.isAdmin(userID) { - h.sendText(chatID, "У вас нет доступа к админ-меню.") - return - } - h.sendAdminMenu(chatID, "Админ-меню VPN-панели Remnawave:") + case strings.HasPrefix(text, "/admin"): + h.handleAdminCommand(chatID, userID, text) case strings.HasPrefix(text, "/"): h.sendText(chatID, "Неизвестная команда. Для начала — /start") default: @@ -73,6 +84,28 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) { } } +func (h *Handler) handleAdminCommand(chatID, userID int64, text string) { + if !h.isAdmin(userID) { + h.sendText(chatID, "У вас нет доступа к админ-меню.") + return + } + + args := strings.Fields(text) + if len(args) == 1 { + h.sendAdminMenu(chatID) + return + } + + switch args[1] { + case "check", "проверка": + h.sendPanelCheck(chatID) + case "config", "конфиг": + h.sendPanelConfig(chatID) + default: + h.sendAdminHelp(chatID) + } +} + func (h *Handler) handleCallback(cq *tgbotapi.CallbackQuery) { answer := tgbotapi.NewCallback(cq.ID, "") if _, err := h.api.Request(answer); err != nil { @@ -90,7 +123,7 @@ func (h *Handler) handleCallback(cq *tgbotapi.CallbackQuery) { case "admin:check": h.sendPanelCheck(cq.Message.Chat.ID) case "admin:menu": - h.sendAdminMenu(cq.Message.Chat.ID, "Админ-меню VPN-панели Remnawave:") + h.sendAdminMenu(cq.Message.Chat.ID) default: h.editOrSend(cq.Message.Chat.ID, cq.Message.MessageID, "Неизвестное действие.") } @@ -107,7 +140,7 @@ func (h *Handler) sendStart(chatID, userID int64, firstName string) { } text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот на базе панели Remnawave.", name) if h.isAdmin(userID) { - text += "\n\nКоманда /admin — настройки и проверка панели." + text += "\n\n/admin — админ-меню\n/admin check — проверка API и подписки" } msg := tgbotapi.NewMessage(chatID, text) if h.isAdmin(userID) { @@ -116,22 +149,42 @@ func (h *Handler) sendStart(chatID, userID int64, firstName string) { h.send(msg) } -func (h *Handler) sendAdminMenu(chatID int64, title string) { - msg := tgbotapi.NewMessage(chatID, title) +func (h *Handler) sendAdminMenu(chatID int64) { + text := fmt.Sprintf( + "🛠 *Админ-меню* — %s\n\n"+ + "Команды:\n"+ + "• /admin — это меню\n"+ + "• /admin check — проверка панели, API и подписки\n"+ + "• /admin config — конфиг панели\n\n"+ + "Или кнопки ниже.", + escapeMarkdown(h.cfg.RemnawaveName), + ) + msg := tgbotapi.NewMessage(chatID, text) + msg.ParseMode = "Markdown" msg.ReplyMarkup = adminInlineKeyboard() h.send(msg) } +func (h *Handler) sendAdminHelp(chatID int64) { + h.sendText(chatID, "Неизвестный аргумент.\n\n/admin — меню\n/admin check — проверка\n/admin config — конфиг") +} + func (h *Handler) sendPanelConfig(chatID int64) { + subURL := h.cfg.RemnawaveSubscription + if subURL == "" { + subURL = "не задан" + } text := fmt.Sprintf( "⚙️ *%s* (Remnawave)\n\n"+ - "• URL: `%s`\n"+ + "• URL панели: `%s`\n"+ + "• URL подписки: `%s`\n"+ "• API token: `%s`\n"+ "• Caddy token: %s\n\n"+ - "Токен API создаётся в панели: *Settings → API Tokens*.\n"+ + "Токен API: панель → *Settings → API Tokens*.\n"+ "Документация: %s", escapeMarkdown(h.cfg.RemnawaveName), escapeMarkdown(h.cfg.RemnawaveURL), + escapeMarkdown(subURL), escapeMarkdown(maskSecret(h.cfg.RemnawaveToken)), caddyStatus(h.cfg.RemnawaveCaddy), docsURL, @@ -143,41 +196,35 @@ func (h *Handler) sendPanelConfig(chatID int64) { } func (h *Handler) sendPanelCheck(chatID int64) { - h.sendText(chatID, "Проверяю подключение к панели…") + h.sendText(chatID, fmt.Sprintf("Проверяю «%s»: панель, API, подписка…", h.cfg.RemnawaveName)) - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() - st, err := h.panel.Check(ctx) - if err != nil { - h.sendText(chatID, fmt.Sprintf("❌ %s\n\nПанель: %s", err.Error(), h.cfg.RemnawaveURL)) - return - } - - text := fmt.Sprintf( - "✅ %s\n\nПанель: *%s*\nURL: `%s`\nHTTP: %d", - st.Detail, + report := h.panel.FullCheck(ctx, h.cfg.RemnawaveSubscription) + text := remnawave.FormatReport( + report, escapeMarkdown(h.cfg.RemnawaveName), escapeMarkdown(h.cfg.RemnawaveURL), - st.StatusCode, ) - if st.Users > 0 || st.Nodes > 0 { - text += fmt.Sprintf("\n\n👥 Пользователей: %d\n📡 Нод: %d", st.Users, st.Nodes) - } + msg := tgbotapi.NewMessage(chatID, text) msg.ParseMode = "Markdown" msg.ReplyMarkup = adminInlineKeyboard() - h.send(msg) + if err := h.sendReturnErr(msg); err != nil { + msg.ParseMode = "" + h.send(msg) + } } func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup { return tgbotapi.NewInlineKeyboardMarkup( tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("📋 Конфиг панели", "admin:config"), - tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить панель", "admin:check"), + tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить (API+подписка)", "admin:check"), + tgbotapi.NewInlineKeyboardButtonData("📋 Конфиг", "admin:config"), ), tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonURL("📖 Документация Remnawave", docsURL), + tgbotapi.NewInlineKeyboardButtonURL("📖 Документация", docsURL), ), ) } @@ -186,8 +233,8 @@ func adminReplyKeyboard() tgbotapi.ReplyKeyboardMarkup { return tgbotapi.ReplyKeyboardMarkup{ Keyboard: [][]tgbotapi.KeyboardButton{ { - tgbotapi.NewKeyboardButton("📋 Конфиг панели"), tgbotapi.NewKeyboardButton("🔌 Проверить панель"), + tgbotapi.NewKeyboardButton("📋 Конфиг панели"), }, { tgbotapi.NewKeyboardButton("◀️ Выйти из админки"), @@ -203,11 +250,16 @@ func (h *Handler) sendText(chatID int64, text string) { } func (h *Handler) send(msg tgbotapi.MessageConfig) { - if _, err := h.api.Send(msg); err != nil { + if err := h.sendReturnErr(msg); err != nil { log.Printf("ошибка отправки: %v", err) } } +func (h *Handler) sendReturnErr(msg tgbotapi.MessageConfig) error { + _, err := h.api.Send(msg) + return err +} + func (h *Handler) editOrSend(chatID int64, messageID int, text string) { edit := tgbotapi.NewEditMessageText(chatID, messageID, text) if _, err := h.api.Send(edit); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 1d98d9c..8bc799e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,10 +11,11 @@ type Config struct { BotToken string BotDebug bool TelegramAdminID int64 - RemnawaveName string - RemnawaveURL string - RemnawaveToken string - RemnawaveCaddy string + RemnawaveName string + RemnawaveURL string + RemnawaveToken string + RemnawaveCaddy string + RemnawaveSubscription string } func Load() (*Config, error) { @@ -46,13 +47,19 @@ func Load() (*Config, error) { name = "Панель 1" } + subURL := strings.TrimRight(strings.TrimSpace(os.Getenv("REMNAWAVE_SUBSCRIPTION_URL")), "/") + if subURL != "" && !strings.HasPrefix(subURL, "http://") && !strings.HasPrefix(subURL, "https://") { + return nil, fmt.Errorf("REMNAWAVE_SUBSCRIPTION_URL должен начинаться с http:// или https://") + } + return &Config{ - BotToken: token, - BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"), - TelegramAdminID: adminID, - RemnawaveName: name, - RemnawaveURL: panelURL, - RemnawaveToken: panelToken, - RemnawaveCaddy: strings.TrimSpace(os.Getenv("REMNAWAVE_CADDY_TOKEN")), + BotToken: token, + BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"), + TelegramAdminID: adminID, + RemnawaveName: name, + RemnawaveURL: panelURL, + RemnawaveToken: panelToken, + RemnawaveCaddy: strings.TrimSpace(os.Getenv("REMNAWAVE_CADDY_TOKEN")), + RemnawaveSubscription: subURL, }, nil } diff --git a/internal/remnawave/client.go b/internal/remnawave/client.go index 4327e12..d4f5cf4 100644 --- a/internal/remnawave/client.go +++ b/internal/remnawave/client.go @@ -17,14 +17,6 @@ type Client struct { http *http.Client } -type PanelStatus struct { - OK bool - StatusCode int - Users int - Nodes int - Detail string -} - func NewClient(baseURL, apiToken, caddyToken string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), @@ -36,38 +28,6 @@ func NewClient(baseURL, apiToken, caddyToken string) *Client { } } -func (c *Client) Check(ctx context.Context) (PanelStatus, error) { - st := PanelStatus{} - - resp, body, err := c.get(ctx, "/api/system/stats/recap") - if err != nil { - return st, err - } - st.StatusCode = resp.StatusCode - - switch resp.StatusCode { - case http.StatusOK: - st.OK = true - st.Detail = "API панели отвечает" - case http.StatusUnauthorized, http.StatusForbidden: - return st, fmt.Errorf("доступ запрещён (HTTP %d): проверьте REMNAWAVE_API_TOKEN", resp.StatusCode) - default: - return st, fmt.Errorf("панель вернула HTTP %d: %s", resp.StatusCode, trimBody(body, 200)) - } - - users, err := c.countFromEndpoint(ctx, "/api/users", "users") - if err == nil { - st.Users = users - } - - nodes, err := c.countFromEndpoint(ctx, "/api/nodes", "nodes") - if err == nil { - st.Nodes = nodes - } - - return st, nil -} - func (c *Client) countFromEndpoint(ctx context.Context, path, arrayKey string) (int, error) { resp, body, err := c.get(ctx, path) if err != nil { diff --git a/internal/remnawave/health.go b/internal/remnawave/health.go new file mode 100644 index 0000000..6dac187 --- /dev/null +++ b/internal/remnawave/health.go @@ -0,0 +1,168 @@ +package remnawave + +import ( + "context" + "fmt" + "net/http" + "strings" +) + +type CheckItem struct { + Name string + OK bool + Status int + Detail string +} + +type HealthReport struct { + PanelName string + PanelURL string + Checks []CheckItem + Users int + Nodes int + AllOK bool +} + +func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthReport { + report := HealthReport{ + PanelName: "", + PanelURL: c.baseURL, + } + + probes := []struct { + name string + path string + }{ + {"Панель (веб)", "/"}, + {"API (статистика)", "/api/system/stats/recap"}, + {"API (пользователи)", "/api/users"}, + {"API (ноды)", "/api/nodes"}, + {"Подписка (настройки)", "/api/subscription-settings"}, + {"Подписка (API список)", "/api/subscriptions"}, + } + + for _, p := range probes { + item := c.probe(ctx, p.name, p.path) + report.Checks = append(report.Checks, item) + + if p.path == "/api/users" && item.OK { + if n, err := c.countFromEndpoint(ctx, "/api/users", "users"); err == nil { + report.Users = n + } + } + if p.path == "/api/nodes" && item.OK { + if n, err := c.countFromEndpoint(ctx, "/api/nodes", "nodes"); err == nil { + report.Nodes = n + } + } + } + + subURL := strings.TrimRight(strings.TrimSpace(subscriptionURL), "/") + if subURL != "" { + report.Checks = append(report.Checks, c.probePublic(ctx, "Страница подписки", subURL)) + } else { + report.Checks = append(report.Checks, CheckItem{ + Name: "Страница подписки", + OK: false, + Status: 0, + Detail: "не задана (REMNAWAVE_SUBSCRIPTION_URL)", + }) + } + + report.AllOK = true + for _, ch := range report.Checks { + if !ch.OK { + report.AllOK = false + break + } + } + return report +} + +func (c *Client) probe(ctx context.Context, name, path string) CheckItem { + item := CheckItem{Name: name} + + resp, body, err := c.get(ctx, path) + if err != nil { + item.Detail = err.Error() + return item + } + item.Status = resp.StatusCode + + switch resp.StatusCode { + case http.StatusOK: + item.OK = true + item.Detail = "OK" + case http.StatusUnauthorized, http.StatusForbidden: + item.Detail = fmt.Sprintf("HTTP %d — неверный токен или нет прав", resp.StatusCode) + default: + item.Detail = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, trimBody(body, 120)) + } + return item +} + +func (c *Client) probePublic(ctx context.Context, name, url string) CheckItem { + item := CheckItem{Name: name} + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + item.Detail = err.Error() + return item + } + req.Header.Set("Accept", "text/html,application/json,*/*") + if c.caddyToken != "" { + req.Header.Set("X-Api-Key", c.caddyToken) + } + + resp, err := c.http.Do(req) + if err != nil { + item.Detail = fmt.Sprintf("нет связи: %v", err) + return item + } + defer resp.Body.Close() + + item.Status = resp.StatusCode + if resp.StatusCode >= 200 && resp.StatusCode < 400 { + item.OK = true + item.Detail = fmt.Sprintf("OK (HTTP %d)", resp.StatusCode) + } else { + item.Detail = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + return item +} + +func FormatReport(r HealthReport, panelName, panelURL string) string { + var b strings.Builder + if panelName != "" { + b.WriteString(fmt.Sprintf("Панель: *%s*\nURL: `%s`\n\n", panelName, panelURL)) + } + + icon := func(ok bool) string { + if ok { + return "✅" + } + return "❌" + } + + for _, ch := range r.Checks { + line := fmt.Sprintf("%s *%s*", icon(ch.OK), ch.Name) + if ch.Status > 0 { + line += fmt.Sprintf(" — HTTP %d", ch.Status) + } + if ch.Detail != "" { + line += ": " + ch.Detail + } + b.WriteString(line + "\n") + } + + if r.Users > 0 || r.Nodes > 0 { + b.WriteString(fmt.Sprintf("\n👥 Пользователей: %d\n📡 Нод: %d", r.Users, r.Nodes)) + } + + if r.AllOK { + b.WriteString("\n\n✅ *Все проверки пройдены*") + } else { + b.WriteString("\n\n⚠️ *Есть ошибки* — проверьте токен, URL и страницу подписки") + } + return b.String() +} diff --git a/main.go b/main.go index 928664c..8aa71d5 100644 --- a/main.go +++ b/main.go @@ -28,6 +28,7 @@ func main() { api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawaveName, cfg.RemnawaveURL) handler := bot.NewHandler(cfg, api) + handler.RegisterCommands() u := tgbotapi.NewUpdate(0) u.Timeout = 60