package remnawave import ( "context" "fmt" "net/http" "strings" ) type CheckItem struct { Name string OK bool Skipped bool Status int Detail string } type HealthReport struct { PanelURL string Checks []CheckItem Users int Nodes int AllOK bool Hint string } func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthReport { report := HealthReport{ PanelURL: c.panelURL, } probes := []struct { name string path string isWeb bool }{ {"Панель (веб)", "/", 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 { 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 { 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: 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 && !ch.Skipped { report.AllOK = false break } } return report } 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 { case http.StatusOK: item.OK = true item.Detail = "OK" case http.StatusUnauthorized, http.StatusForbidden: item.Detail = "неверный REMNAWAVE_API_TOKEN или CADDY_AUTH_API_TOKEN" default: 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 "" } } 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 = "OK" } else { item.Detail = statusHint(resp.StatusCode) if item.Detail == "" { item.Detail = http.StatusText(resp.StatusCode) } } return item } // FormatReport — обычный текст (без Markdown), чтобы URL и имена env отображались корректно. func FormatReport(r HealthReport, panelName string) string { var b strings.Builder 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 { 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) } 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.Hint != "" { b.WriteString("\n\n💡 " + r.Hint) } if r.AllOK { b.WriteString("\n\nВсе обязательные проверки пройдены.") } else { b.WriteString("\n\nЕсть ошибки — см. подсказку выше.") } return b.String() }