Enhance /admin with full panel and subscription health checks
This commit is contained in:
@@ -14,5 +14,7 @@ REMNAWAVE_PANEL_URL=https://panel.example.com
|
|||||||
REMNAWAVE_API_TOKEN=your_api_token_here
|
REMNAWAVE_API_TOKEN=your_api_token_here
|
||||||
# Опционально, если перед панелью стоит Caddy с X-Api-Key
|
# Опционально, если перед панелью стоит Caddy с X-Api-Key
|
||||||
REMNAWAVE_CADDY_TOKEN=
|
REMNAWAVE_CADDY_TOKEN=
|
||||||
|
# Публичная страница подписки (Subscription Page), для проверки доступности
|
||||||
|
REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com
|
||||||
|
|
||||||
# Docker Compose читает этот файл как .env (скопируйте: cp .env.example .env)
|
# Docker Compose читает этот файл как .env (скопируйте: cp .env.example .env)
|
||||||
|
|||||||
@@ -213,15 +213,18 @@ go build -o bot .
|
|||||||
| `REMNAWAVE_PANEL_NAME` | нет | Название панели в админ-меню (по умолчанию «Панель 1») |
|
| `REMNAWAVE_PANEL_NAME` | нет | Название панели в админ-меню (по умолчанию «Панель 1») |
|
||||||
| `REMNAWAVE_PANEL_URL` | да | URL панели Remnawave, например `https://vpn.example.com` |
|
| `REMNAWAVE_PANEL_URL` | да | URL панели Remnawave, например `https://vpn.example.com` |
|
||||||
| `REMNAWAVE_API_TOKEN` | да | API-токен: панель → **Settings → API Tokens** ([документация](https://docs.rw/)) |
|
| `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 (только для отладки) |
|
| `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) |
|
||||||
|
|
||||||
### Админ-меню в боте
|
### Админ-меню в боте
|
||||||
|
|
||||||
Только пользователь с `TELEGRAM_ADMIN_ID`:
|
Только пользователь с `TELEGRAM_ADMIN_ID`:
|
||||||
|
|
||||||
- `/admin` — inline-меню (конфиг панели, проверка API, ссылка на [docs.rw](https://docs.rw/))
|
- `/admin` — админ-меню (панель 1, Remnawave)
|
||||||
- Кнопки снизу (после `/start`): «Конфиг панели», «Проверить панель»
|
- `/admin check` — полная проверка: веб панели, API (статистика, users, nodes), подписка (settings + API), страница подписки
|
||||||
|
- `/admin config` — конфиг панели в боте
|
||||||
|
- Кнопки снизу (после `/start`): «Проверить панель», «Конфиг панели»
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+89
-37
@@ -16,10 +16,10 @@ import (
|
|||||||
const docsURL = "https://docs.rw/"
|
const docsURL = "https://docs.rw/"
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
api *tgbotapi.BotAPI
|
api *tgbotapi.BotAPI
|
||||||
panel *remnawave.Client
|
panel *remnawave.Client
|
||||||
admin int64
|
admin int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI) *Handler {
|
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) {
|
func (h *Handler) HandleUpdate(update tgbotapi.Update) {
|
||||||
if update.CallbackQuery != nil {
|
if update.CallbackQuery != nil {
|
||||||
h.handleCallback(update.CallbackQuery)
|
h.handleCallback(update.CallbackQuery)
|
||||||
@@ -47,12 +62,8 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) {
|
|||||||
switch {
|
switch {
|
||||||
case text == "/start":
|
case text == "/start":
|
||||||
h.sendStart(chatID, userID, update.Message.From.FirstName)
|
h.sendStart(chatID, userID, update.Message.From.FirstName)
|
||||||
case text == "/admin":
|
case strings.HasPrefix(text, "/admin"):
|
||||||
if !h.isAdmin(userID) {
|
h.handleAdminCommand(chatID, userID, text)
|
||||||
h.sendText(chatID, "У вас нет доступа к админ-меню.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.sendAdminMenu(chatID, "Админ-меню VPN-панели Remnawave:")
|
|
||||||
case strings.HasPrefix(text, "/"):
|
case strings.HasPrefix(text, "/"):
|
||||||
h.sendText(chatID, "Неизвестная команда. Для начала — /start")
|
h.sendText(chatID, "Неизвестная команда. Для начала — /start")
|
||||||
default:
|
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) {
|
func (h *Handler) handleCallback(cq *tgbotapi.CallbackQuery) {
|
||||||
answer := tgbotapi.NewCallback(cq.ID, "")
|
answer := tgbotapi.NewCallback(cq.ID, "")
|
||||||
if _, err := h.api.Request(answer); err != nil {
|
if _, err := h.api.Request(answer); err != nil {
|
||||||
@@ -90,7 +123,7 @@ func (h *Handler) handleCallback(cq *tgbotapi.CallbackQuery) {
|
|||||||
case "admin:check":
|
case "admin:check":
|
||||||
h.sendPanelCheck(cq.Message.Chat.ID)
|
h.sendPanelCheck(cq.Message.Chat.ID)
|
||||||
case "admin:menu":
|
case "admin:menu":
|
||||||
h.sendAdminMenu(cq.Message.Chat.ID, "Админ-меню VPN-панели Remnawave:")
|
h.sendAdminMenu(cq.Message.Chat.ID)
|
||||||
default:
|
default:
|
||||||
h.editOrSend(cq.Message.Chat.ID, cq.Message.MessageID, "Неизвестное действие.")
|
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)
|
text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот на базе панели Remnawave.", name)
|
||||||
if h.isAdmin(userID) {
|
if h.isAdmin(userID) {
|
||||||
text += "\n\nКоманда /admin — настройки и проверка панели."
|
text += "\n\n/admin — админ-меню\n/admin check — проверка API и подписки"
|
||||||
}
|
}
|
||||||
msg := tgbotapi.NewMessage(chatID, text)
|
msg := tgbotapi.NewMessage(chatID, text)
|
||||||
if h.isAdmin(userID) {
|
if h.isAdmin(userID) {
|
||||||
@@ -116,22 +149,42 @@ func (h *Handler) sendStart(chatID, userID int64, firstName string) {
|
|||||||
h.send(msg)
|
h.send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) sendAdminMenu(chatID int64, title string) {
|
func (h *Handler) sendAdminMenu(chatID int64) {
|
||||||
msg := tgbotapi.NewMessage(chatID, title)
|
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()
|
msg.ReplyMarkup = adminInlineKeyboard()
|
||||||
h.send(msg)
|
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) {
|
func (h *Handler) sendPanelConfig(chatID int64) {
|
||||||
|
subURL := h.cfg.RemnawaveSubscription
|
||||||
|
if subURL == "" {
|
||||||
|
subURL = "не задан"
|
||||||
|
}
|
||||||
text := fmt.Sprintf(
|
text := fmt.Sprintf(
|
||||||
"⚙️ *%s* (Remnawave)\n\n"+
|
"⚙️ *%s* (Remnawave)\n\n"+
|
||||||
"• URL: `%s`\n"+
|
"• URL панели: `%s`\n"+
|
||||||
|
"• URL подписки: `%s`\n"+
|
||||||
"• API token: `%s`\n"+
|
"• API token: `%s`\n"+
|
||||||
"• Caddy token: %s\n\n"+
|
"• Caddy token: %s\n\n"+
|
||||||
"Токен API создаётся в панели: *Settings → API Tokens*.\n"+
|
"Токен API: панель → *Settings → API Tokens*.\n"+
|
||||||
"Документация: %s",
|
"Документация: %s",
|
||||||
escapeMarkdown(h.cfg.RemnawaveName),
|
escapeMarkdown(h.cfg.RemnawaveName),
|
||||||
escapeMarkdown(h.cfg.RemnawaveURL),
|
escapeMarkdown(h.cfg.RemnawaveURL),
|
||||||
|
escapeMarkdown(subURL),
|
||||||
escapeMarkdown(maskSecret(h.cfg.RemnawaveToken)),
|
escapeMarkdown(maskSecret(h.cfg.RemnawaveToken)),
|
||||||
caddyStatus(h.cfg.RemnawaveCaddy),
|
caddyStatus(h.cfg.RemnawaveCaddy),
|
||||||
docsURL,
|
docsURL,
|
||||||
@@ -143,41 +196,35 @@ func (h *Handler) sendPanelConfig(chatID int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) sendPanelCheck(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()
|
defer cancel()
|
||||||
|
|
||||||
st, err := h.panel.Check(ctx)
|
report := h.panel.FullCheck(ctx, h.cfg.RemnawaveSubscription)
|
||||||
if err != nil {
|
text := remnawave.FormatReport(
|
||||||
h.sendText(chatID, fmt.Sprintf("❌ %s\n\nПанель: %s", err.Error(), h.cfg.RemnawaveURL))
|
report,
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
text := fmt.Sprintf(
|
|
||||||
"✅ %s\n\nПанель: *%s*\nURL: `%s`\nHTTP: %d",
|
|
||||||
st.Detail,
|
|
||||||
escapeMarkdown(h.cfg.RemnawaveName),
|
escapeMarkdown(h.cfg.RemnawaveName),
|
||||||
escapeMarkdown(h.cfg.RemnawaveURL),
|
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 := tgbotapi.NewMessage(chatID, text)
|
||||||
msg.ParseMode = "Markdown"
|
msg.ParseMode = "Markdown"
|
||||||
msg.ReplyMarkup = adminInlineKeyboard()
|
msg.ReplyMarkup = adminInlineKeyboard()
|
||||||
h.send(msg)
|
if err := h.sendReturnErr(msg); err != nil {
|
||||||
|
msg.ParseMode = ""
|
||||||
|
h.send(msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup {
|
func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup {
|
||||||
return tgbotapi.NewInlineKeyboardMarkup(
|
return tgbotapi.NewInlineKeyboardMarkup(
|
||||||
tgbotapi.NewInlineKeyboardRow(
|
tgbotapi.NewInlineKeyboardRow(
|
||||||
tgbotapi.NewInlineKeyboardButtonData("📋 Конфиг панели", "admin:config"),
|
tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить (API+подписка)", "admin:check"),
|
||||||
tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить панель", "admin:check"),
|
tgbotapi.NewInlineKeyboardButtonData("📋 Конфиг", "admin:config"),
|
||||||
),
|
),
|
||||||
tgbotapi.NewInlineKeyboardRow(
|
tgbotapi.NewInlineKeyboardRow(
|
||||||
tgbotapi.NewInlineKeyboardButtonURL("📖 Документация Remnawave", docsURL),
|
tgbotapi.NewInlineKeyboardButtonURL("📖 Документация", docsURL),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -186,8 +233,8 @@ func adminReplyKeyboard() tgbotapi.ReplyKeyboardMarkup {
|
|||||||
return tgbotapi.ReplyKeyboardMarkup{
|
return tgbotapi.ReplyKeyboardMarkup{
|
||||||
Keyboard: [][]tgbotapi.KeyboardButton{
|
Keyboard: [][]tgbotapi.KeyboardButton{
|
||||||
{
|
{
|
||||||
tgbotapi.NewKeyboardButton("📋 Конфиг панели"),
|
|
||||||
tgbotapi.NewKeyboardButton("🔌 Проверить панель"),
|
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) {
|
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)
|
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) {
|
func (h *Handler) editOrSend(chatID int64, messageID int, text string) {
|
||||||
edit := tgbotapi.NewEditMessageText(chatID, messageID, text)
|
edit := tgbotapi.NewEditMessageText(chatID, messageID, text)
|
||||||
if _, err := h.api.Send(edit); err != nil {
|
if _, err := h.api.Send(edit); err != nil {
|
||||||
|
|||||||
+18
-11
@@ -11,10 +11,11 @@ type Config struct {
|
|||||||
BotToken string
|
BotToken string
|
||||||
BotDebug bool
|
BotDebug bool
|
||||||
TelegramAdminID int64
|
TelegramAdminID int64
|
||||||
RemnawaveName string
|
RemnawaveName string
|
||||||
RemnawaveURL string
|
RemnawaveURL string
|
||||||
RemnawaveToken string
|
RemnawaveToken string
|
||||||
RemnawaveCaddy string
|
RemnawaveCaddy string
|
||||||
|
RemnawaveSubscription string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -46,13 +47,19 @@ func Load() (*Config, error) {
|
|||||||
name = "Панель 1"
|
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{
|
return &Config{
|
||||||
BotToken: token,
|
BotToken: token,
|
||||||
BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"),
|
BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"),
|
||||||
TelegramAdminID: adminID,
|
TelegramAdminID: adminID,
|
||||||
RemnawaveName: name,
|
RemnawaveName: name,
|
||||||
RemnawaveURL: panelURL,
|
RemnawaveURL: panelURL,
|
||||||
RemnawaveToken: panelToken,
|
RemnawaveToken: panelToken,
|
||||||
RemnawaveCaddy: strings.TrimSpace(os.Getenv("REMNAWAVE_CADDY_TOKEN")),
|
RemnawaveCaddy: strings.TrimSpace(os.Getenv("REMNAWAVE_CADDY_TOKEN")),
|
||||||
|
RemnawaveSubscription: subURL,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,14 +17,6 @@ type Client struct {
|
|||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
type PanelStatus struct {
|
|
||||||
OK bool
|
|
||||||
StatusCode int
|
|
||||||
Users int
|
|
||||||
Nodes int
|
|
||||||
Detail string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClient(baseURL, apiToken, caddyToken string) *Client {
|
func NewClient(baseURL, apiToken, caddyToken string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
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) {
|
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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ func main() {
|
|||||||
api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawaveName, cfg.RemnawaveURL)
|
api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawaveName, cfg.RemnawaveURL)
|
||||||
|
|
||||||
handler := bot.NewHandler(cfg, api)
|
handler := bot.NewHandler(cfg, api)
|
||||||
|
handler.RegisterCommands()
|
||||||
|
|
||||||
u := tgbotapi.NewUpdate(0)
|
u := tgbotapi.NewUpdate(0)
|
||||||
u.Timeout = 60
|
u.Timeout = 60
|
||||||
|
|||||||
Reference in New Issue
Block a user