Compare commits
2 Commits
v0.10.0-beta
...
v0.20.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 65495ee2bf | |||
| 7d63603150 |
+9
-8
@@ -7,14 +7,15 @@ 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 (sub.*), только для проверки доступности
|
||||||
REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com
|
REMNAWAVE_SUBSCRIPTION_URL=
|
||||||
|
|
||||||
# Docker Compose читает этот файл как .env (скопируйте: cp .env.example .env)
|
# Docker Compose: cp .env.example .env
|
||||||
|
|||||||
@@ -2,6 +2,29 @@
|
|||||||
|
|
||||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
|
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
|
||||||
|
|
||||||
|
## [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]: https://git.evilfox.cc/test/tgvpn/releases/tag/v0.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/).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# tgvpn
|
# tgvpn
|
||||||
|
|
||||||
**Версия:** [0.10.0-beta](CHANGELOG.md) · [Релизы](https://git.evilfox.cc/test/tgvpn/releases)
|
**Версия:** [0.20.0](CHANGELOG.md) · [Релизы](https://git.evilfox.cc/test/tgvpn/releases)
|
||||||
|
|
||||||
Telegram-бот на Go (базовое приветствие; далее — VPN-функции).
|
Telegram-бот на Go (базовое приветствие; далее — VPN-функции).
|
||||||
|
|
||||||
@@ -306,10 +306,10 @@ 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.*`), отдельная проверка |
|
||||||
| `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) |
|
| `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
|
## Полезные команды Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -381,6 +406,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.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
|
```bash
|
||||||
|
|||||||
+25
-26
@@ -26,7 +26,7 @@ func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI) *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),
|
||||||
admin: cfg.TelegramAdminID,
|
admin: cfg.TelegramAdminID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,25 +172,32 @@ func (h *Handler) sendAdminHelp(chatID int64) {
|
|||||||
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 = adminInlineKeyboard()
|
msg.ReplyMarkup = adminInlineKeyboard()
|
||||||
h.send(msg)
|
h.send(msg)
|
||||||
}
|
}
|
||||||
@@ -202,19 +209,11 @@ 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 = adminInlineKeyboard()
|
msg.ReplyMarkup = adminInlineKeyboard()
|
||||||
if err := h.sendReturnErr(msg); err != nil {
|
h.send(msg)
|
||||||
msg.ParseMode = ""
|
|
||||||
h.send(msg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup {
|
func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup {
|
||||||
|
|||||||
+22
-14
@@ -7,14 +7,17 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,15 +34,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,6 +50,11 @@ 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://")
|
||||||
@@ -57,9 +65,9 @@ func Load() (*Config, error) {
|
|||||||
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,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,24 +10,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 +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) {
|
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 {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
req.Header.Set("Accept", "application/json, text/html, */*")
|
||||||
req.Header.Set("Accept", "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)
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-45
@@ -8,41 +8,56 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type CheckItem struct {
|
type CheckItem struct {
|
||||||
Name string
|
Name string
|
||||||
OK bool
|
OK bool
|
||||||
Status int
|
Skipped bool
|
||||||
Detail string
|
Status int
|
||||||
|
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 {
|
||||||
@@ -62,16 +77,23 @@ func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthRe
|
|||||||
report.Checks = append(report.Checks, c.probePublic(ctx, "Страница подписки", subURL))
|
report.Checks = append(report.Checks, c.probePublic(ctx, "Страница подписки", subURL))
|
||||||
} 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func main() {
|
|||||||
api.Debug = cfg.BotDebug
|
api.Debug = cfg.BotDebug
|
||||||
|
|
||||||
log.Printf("бот @%s запущен, админ ID %d, панель %q (%s)",
|
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 := bot.NewHandler(cfg, api)
|
||||||
handler.RegisterCommands()
|
handler.RegisterCommands()
|
||||||
|
|||||||
Reference in New Issue
Block a user