From 7d63603150bc833a575f8f35aefb8029197f26e4 Mon Sep 17 00:00:00 2001 From: tgvpn Date: Thu, 21 May 2026 00:59:55 +0300 Subject: [PATCH] Align Remnawave config with official docs and fix admin health check --- .env.example | 17 ++-- README.md | 42 +++++++++- internal/bot/handler.go | 51 ++++++------ internal/config/config.go | 36 ++++---- internal/remnawave/client.go | 32 +++++-- internal/remnawave/health.go | 156 +++++++++++++++++++++++++---------- main.go | 2 +- 7 files changed, 231 insertions(+), 105 deletions(-) diff --git a/.env.example b/.env.example index ad1fe8f..3a6d83d 100644 --- a/.env.example +++ b/.env.example @@ -7,14 +7,15 @@ BOT_DEBUG=false # Telegram user ID администратора (узнать: @userinfobot или @getidsbot) TELEGRAM_ADMIN_ID=123456789 -# Remnawave — панель 1 (https://docs.rw/) +# --- Remnawave (официальные имена: https://docs.rw/docs/install/subscription-page/bundled) --- REMNAWAVE_PANEL_NAME=Панель 1 +# URL панели: https://panel.example.com или http://remnawave:3000 (внутри Docker-сети) REMNAWAVE_PANEL_URL=https://panel.example.com -# Settings → API Tokens в панели Remnawave -REMNAWAVE_API_TOKEN=your_api_token_here -# Опционально, если перед панелью стоит Caddy с X-Api-Key -REMNAWAVE_CADDY_TOKEN= -# Публичная страница подписки (Subscription Page), для проверки доступности -REMNAWAVE_SUBSCRIPTION_URL=https://sub.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 (sub.*), только для проверки доступности +REMNAWAVE_SUBSCRIPTION_URL= -# Docker Compose читает этот файл как .env (скопируйте: cp .env.example .env) +# Docker Compose: cp .env.example .env diff --git a/README.md b/README.md index 0a4a78c..08e46e5 100644 --- a/README.md +++ b/README.md @@ -306,10 +306,10 @@ go build -o bot . | `BOT_TOKEN` | да | Токен от @BotFather | | `TELEGRAM_ADMIN_ID` | да | Числовой Telegram user ID администратора (например, [@userinfobot](https://t.me/userinfobot)) | | `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_SUBSCRIPTION_URL` | нет* | URL страницы подписки для проверки в `/admin check` (*рекомендуется) | +| `REMNAWAVE_PANEL_URL` | да | URL панели — сюда же идут запросы API (`/api/...`). Пример: `https://panel.example.com` ([док](https://docs.rw/docs/install/subscription-page/bundled)) | +| `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.*`), отдельная проверка | | `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) | ### Админ-меню в боте @@ -323,6 +323,31 @@ go build -o bot . --- +## 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" +``` + +--- + ## Полезные команды Docker ```bash @@ -381,6 +406,15 @@ docker compose logs bot # ошибки сети, токена - Убедитесь, что на сервере нет блокировки Telegram (firewall, провайдер). - Проверьте: `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.evilfox.cc` +2. Токен API: `REMNAWAVE_API_TOKEN=...` (Settings → API Tokens) +3. Страницу подписки — опционально: `REMNAWAVE_SUBSCRIPTION_URL=https://sub.evilfox.cc` +4. Проверьте на сервере: `docker compose ps` (Remnawave Panel запущен), логи reverse proxy + ### Контейнер постоянно перезапускается ```bash diff --git a/internal/bot/handler.go b/internal/bot/handler.go index 5d6b1c7..4ff5bf1 100644 --- a/internal/bot/handler.go +++ b/internal/bot/handler.go @@ -26,7 +26,7 @@ func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI) *Handler { return &Handler{ cfg: cfg, api: api, - panel: remnawave.NewClient(cfg.RemnawaveURL, cfg.RemnawaveToken, cfg.RemnawaveCaddy), + panel: remnawave.NewClient(cfg.RemnawavePanelURL, cfg.RemnawaveAPIToken, cfg.CaddyAuthAPIToken), admin: cfg.TelegramAdminID, } } @@ -172,25 +172,32 @@ func (h *Handler) sendAdminHelp(chatID int64) { func (h *Handler) sendPanelConfig(chatID int64) { subURL := h.cfg.RemnawaveSubscription if subURL == "" { - subURL = "не задан" + subURL = "не задана (опционально)" + } + caddy := h.cfg.CaddyAuthAPIToken + if caddy == "" { + caddy = "не задан" + } else { + caddy = maskSecret(caddy) } text := fmt.Sprintf( - "⚙️ *%s* (Remnawave)\n\n"+ - "• URL панели: `%s`\n"+ - "• URL подписки: `%s`\n"+ - "• API token: `%s`\n"+ - "• Caddy token: %s\n\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, + "⚙️ %s (Remnawave)\n\n"+ + "REMNAWAVE_PANEL_URL:\n%s\n"+ + "(API: %s/api/... + Bearer REMNAWAVE_API_TOKEN)\n\n"+ + "REMNAWAVE_SUBSCRIPTION_URL (опц.):\n%s\n\n"+ + "REMNAWAVE_API_TOKEN: %s\n"+ + "CADDY_AUTH_API_TOKEN: %s\n\n"+ + "Токен: Remnawave Settings → API Tokens\n"+ + "Док: %s", + h.cfg.RemnawaveName, + h.cfg.RemnawavePanelURL, + h.cfg.RemnawavePanelURL, + subURL, + maskSecret(h.cfg.RemnawaveAPIToken), + caddy, + "https://docs.rw/docs/install/subscription-page/bundled", ) msg := tgbotapi.NewMessage(chatID, text) - msg.ParseMode = "Markdown" msg.ReplyMarkup = adminInlineKeyboard() h.send(msg) } @@ -202,19 +209,11 @@ func (h *Handler) sendPanelCheck(chatID int64) { defer cancel() report := h.panel.FullCheck(ctx, h.cfg.RemnawaveSubscription) - text := remnawave.FormatReport( - report, - escapeMarkdown(h.cfg.RemnawaveName), - escapeMarkdown(h.cfg.RemnawaveURL), - ) + text := remnawave.FormatReport(report, h.cfg.RemnawaveName) msg := tgbotapi.NewMessage(chatID, text) - msg.ParseMode = "Markdown" msg.ReplyMarkup = adminInlineKeyboard() - if err := h.sendReturnErr(msg); err != nil { - msg.ParseMode = "" - h.send(msg) - } + h.send(msg) } func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup { diff --git a/internal/config/config.go b/internal/config/config.go index 8bc799e..85434c5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,14 +7,17 @@ import ( "strings" ) +// См. официальную схему env: https://docs.rw/docs/install/subscription-page/bundled +// REMNAWAVE_PANEL_URL + REMNAWAVE_API_TOKEN (+ опционально CADDY_AUTH_API_TOKEN) + type Config struct { - BotToken string - BotDebug bool - TelegramAdminID int64 + BotToken string + BotDebug bool + TelegramAdminID int64 RemnawaveName string - RemnawaveURL string - RemnawaveToken string - RemnawaveCaddy string + RemnawavePanelURL string + RemnawaveAPIToken string + CaddyAuthAPIToken string RemnawaveSubscription string } @@ -31,15 +34,15 @@ func Load() (*Config, error) { panelURL := strings.TrimRight(strings.TrimSpace(os.Getenv("REMNAWAVE_PANEL_URL")), "/") 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://") { - 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")) - if panelToken == "" { - return nil, fmt.Errorf("REMNAWAVE_API_TOKEN не задан (создайте в панели: Settings → API Tokens)") + apiToken := strings.TrimSpace(os.Getenv("REMNAWAVE_API_TOKEN")) + if apiToken == "" { + return nil, fmt.Errorf("REMNAWAVE_API_TOKEN не задан (Remnawave Settings → API Tokens)") } name := strings.TrimSpace(os.Getenv("REMNAWAVE_PANEL_NAME")) @@ -47,6 +50,11 @@ func Load() (*Config, error) { 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")), "/") if subURL != "" && !strings.HasPrefix(subURL, "http://") && !strings.HasPrefix(subURL, "https://") { return nil, fmt.Errorf("REMNAWAVE_SUBSCRIPTION_URL должен начинаться с http:// или https://") @@ -57,9 +65,9 @@ func Load() (*Config, error) { 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")), + RemnawavePanelURL: panelURL, + RemnawaveAPIToken: apiToken, + CaddyAuthAPIToken: caddy, RemnawaveSubscription: subURL, }, nil } diff --git a/internal/remnawave/client.go b/internal/remnawave/client.go index d4f5cf4..13b60f6 100644 --- a/internal/remnawave/client.go +++ b/internal/remnawave/client.go @@ -10,24 +10,32 @@ import ( "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 { - baseURL string + panelURL string token string caddyToken string http *http.Client } -func NewClient(baseURL, apiToken, caddyToken string) *Client { +func NewClient(panelURL, apiToken, caddyAuthToken string) *Client { return &Client{ - baseURL: strings.TrimRight(baseURL, "/"), + panelURL: strings.TrimRight(panelURL, "/"), token: apiToken, - caddyToken: caddyToken, + caddyToken: caddyAuthToken, http: &http.Client{ Timeout: 15 * time.Second, }, } } +func (c *Client) PanelURL() string { return c.panelURL } + func (c *Client) countFromEndpoint(ctx context.Context, path, arrayKey string) (int, error) { resp, body, err := c.get(ctx, path) if err != nil { @@ -40,13 +48,23 @@ func (c *Client) countFromEndpoint(ctx context.Context, path, arrayKey string) ( } 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, c.panelURL+path, true) +} + +func (c *Client) getPublic(ctx context.Context, path string) (*http.Response, []byte, error) { + return c.doRequest(ctx, c.panelURL+path, 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) if err != nil { return nil, nil, err } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Accept", "application/json") + req.Header.Set("Accept", "application/json, text/html, */*") + if withBearer { + req.Header.Set("Authorization", "Bearer "+c.token) + } if c.caddyToken != "" { req.Header.Set("X-Api-Key", c.caddyToken) } diff --git a/internal/remnawave/health.go b/internal/remnawave/health.go index 6dac187..0f3abe8 100644 --- a/internal/remnawave/health.go +++ b/internal/remnawave/health.go @@ -8,41 +8,56 @@ import ( ) type CheckItem struct { - Name string - OK bool - Status int - Detail string + Name string + OK bool + Skipped bool + Status int + Detail string } type HealthReport struct { - PanelName string - PanelURL string - Checks []CheckItem - Users int - Nodes int - AllOK bool + PanelURL string + Checks []CheckItem + Users int + Nodes int + AllOK bool + Hint string } func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthReport { report := HealthReport{ - PanelName: "", - PanelURL: c.baseURL, + PanelURL: c.panelURL, } probes := []struct { - name string - path string + name string + path string + isWeb bool }{ - {"Панель (веб)", "/"}, - {"API (статистика)", "/api/system/stats/recap"}, - {"API (пользователи)", "/api/users"}, - {"API (ноды)", "/api/nodes"}, - {"Подписка (настройки)", "/api/subscription-settings"}, - {"Подписка (API список)", "/api/subscriptions"}, + {"Панель (веб)", "/", true}, + {"API (статистика)", "/api/system/stats/recap", false}, + {"API (пользователи)", "/api/users", false}, + {"API (ноды)", "/api/nodes", false}, + {"Подписка (настройки)", "/api/subscription-settings", false}, + {"Подписка (API список)", "/api/subscriptions", false}, } + apiFailures := 0 + webOK := false + 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) if p.path == "/api/users" && item.OK { @@ -62,16 +77,23 @@ func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthRe 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)", + Name: "Страница подписки", + OK: true, + Skipped: true, + 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 for _, ch := range report.Checks { - if !ch.OK { + if !ch.OK && !ch.Skipped { report.AllOK = false break } @@ -79,14 +101,27 @@ func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthRe 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} + 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) if err != nil { item.Detail = err.Error() return item } + return finishProbe(&item, resp, body) +} + +func finishProbe(item *CheckItem, resp *http.Response, body []byte) CheckItem { item.Status = resp.StatusCode switch resp.StatusCode { @@ -94,11 +129,31 @@ func (c *Client) probe(ctx context.Context, name, path string) CheckItem { item.OK = true item.Detail = "OK" case http.StatusUnauthorized, http.StatusForbidden: - item.Detail = fmt.Sprintf("HTTP %d — неверный токен или нет прав", resp.StatusCode) + item.Detail = "неверный REMNAWAVE_API_TOKEN или CADDY_AUTH_API_TOKEN" 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 { @@ -124,28 +179,35 @@ func (c *Client) probePublic(ctx context.Context, name, url string) CheckItem { item.Status = resp.StatusCode if resp.StatusCode >= 200 && resp.StatusCode < 400 { item.OK = true - item.Detail = fmt.Sprintf("OK (HTTP %d)", resp.StatusCode) + item.Detail = "OK" } 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 } -func FormatReport(r HealthReport, panelName, panelURL string) string { +// FormatReport — обычный текст (без Markdown), чтобы URL и имена env отображались корректно. +func FormatReport(r HealthReport, panelName 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 "❌" + if panelName != "" { + b.WriteString(fmt.Sprintf("Панель: %s\n", panelName)) } + 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 { - 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 { 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 { - 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 { - b.WriteString("\n\n✅ *Все проверки пройдены*") + b.WriteString("\n\nВсе обязательные проверки пройдены.") } else { - b.WriteString("\n\n⚠️ *Есть ошибки* — проверьте токен, URL и страницу подписки") + b.WriteString("\n\nЕсть ошибки — см. подсказку выше.") } return b.String() } diff --git a/main.go b/main.go index 8aa71d5..4a561f4 100644 --- a/main.go +++ b/main.go @@ -25,7 +25,7 @@ func main() { api.Debug = cfg.BotDebug log.Printf("бот @%s запущен, админ ID %d, панель %q (%s)", - api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawaveName, cfg.RemnawaveURL) + api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawaveName, cfg.RemnawavePanelURL) handler := bot.NewHandler(cfg, api) handler.RegisterCommands()