Align Remnawave config with official docs and fix admin health check

This commit is contained in:
tgvpn
2026-05-21 00:59:55 +03:00
parent 7bde0d8a26
commit 7d63603150
7 changed files with 231 additions and 105 deletions
+25 -7
View File
@@ -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)
}
+111 -45
View File
@@ -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()
}