cb20d8d28f
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним, поэтому сессия, установленная по отозванным учётным данным, была неотличима от законной, и цикл учёта не имел признака, по которому её следовало завершить. У состояния есть путь без единой неудачи — Hysteria регистрирует соединение в Traffic Stats API только после возврата backend-auth, поэтому успешный /kick может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт по старому, пережившая сессия становится orphan. Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4, админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала лимит устройств, учёт трафика и принудительное отключение разом. Адрес зафиксирован, а расхождение файла с ним админка называет. Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним значением, а доступность Traffic Stats API выводилась из него же. Журнал Hysteria разбирается в фактическом формате upstream (time — дробное число), страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в браузер, санитайзер выгрузки следует по YAML-якорям. Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
148 lines
5.4 KiB
Go
148 lines
5.4 KiB
Go
package service
|
|
|
|
import (
|
|
"hy2xs-admin/dao"
|
|
"hy2xs-admin/model/vo"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func DashboardSummary() (vo.DashboardSummaryVo, error) {
|
|
nowMs := time.Now().UnixMilli()
|
|
summary := vo.DashboardSummaryVo{CollectedAt: nowMs}
|
|
summary.Health.Collector = vo.DataHealthVo{Status: "stale", MessageKey: "dashboard.error.collectorStale"}
|
|
summary.Health.Hysteria = vo.DataHealthVo{Status: "ok"}
|
|
// До первого снимка сборщика о службе не известно ничего. Нулевое значение
|
|
// `Running = false` прочиталось бы как «остановлена» — то самое смешение
|
|
// «неизвестно» и «выключено», от которого избавляет HysteriaServiceState.
|
|
summary.Hysteria.ServiceState = string(HysteriaServiceUnknown)
|
|
|
|
snapshot := DashboardSnapshot()
|
|
if snapshot.CollectedAt > 0 {
|
|
summary.CollectedAt = snapshot.CollectedAt
|
|
summary.System = snapshot.System
|
|
summary.Hysteria = snapshot.Hysteria
|
|
summary.Peers.OnlinePeers = snapshot.OnlinePeers
|
|
summary.Peers.OnlineDevices = snapshot.OnlineDevices
|
|
summary.Health.Collector = snapshot.CollectorState
|
|
summary.Health.Hysteria = snapshot.HysteriaState
|
|
if nowMs-snapshot.CollectedAt > int64(90*time.Second/time.Millisecond) {
|
|
summary.Health.Collector = vo.DataHealthVo{Status: "stale", MessageKey: "dashboard.error.collectorStale", LastSuccessAt: snapshot.CollectedAt}
|
|
}
|
|
}
|
|
|
|
if peerSummary, err := dao.DashboardPeerSummary(nowMs); err == nil {
|
|
summary.Peers.Total = peerSummary.Total
|
|
summary.Peers.Enabled = peerSummary.Enabled
|
|
summary.Peers.Disabled = peerSummary.Disabled
|
|
summary.Peers.Expired = peerSummary.Expired
|
|
}
|
|
if trafficSummary, err := dao.DashboardTrafficSummary(); err == nil {
|
|
summary.Traffic = trafficSummary
|
|
}
|
|
|
|
summary.SecurityRisks = DashboardSecurityRisks(summary)
|
|
return summary, nil
|
|
}
|
|
|
|
func DashboardTimeseries(rangeKey string) (vo.DashboardTimeseriesVo, error) {
|
|
nowMs := time.Now().UnixMilli()
|
|
fromMs := nowMs - int64(24*time.Hour/time.Millisecond)
|
|
bucketMs := int64(5 * time.Minute / time.Millisecond)
|
|
source := "sample"
|
|
r := "24h"
|
|
|
|
switch strings.TrimSpace(rangeKey) {
|
|
case "1h":
|
|
fromMs = nowMs - int64(time.Hour/time.Millisecond)
|
|
bucketMs = int64(time.Minute / time.Millisecond)
|
|
source = "sample"
|
|
r = "1h"
|
|
case "7d":
|
|
fromMs = nowMs - int64(7*24*time.Hour/time.Millisecond)
|
|
bucketMs = int64(time.Hour / time.Millisecond)
|
|
source = "hourly"
|
|
r = "7d"
|
|
case "24h", "":
|
|
fromMs = nowMs - int64(24*time.Hour/time.Millisecond)
|
|
bucketMs = int64(5 * time.Minute / time.Millisecond)
|
|
source = "sample"
|
|
r = "24h"
|
|
default:
|
|
fromMs = nowMs - int64(24*time.Hour/time.Millisecond)
|
|
bucketMs = int64(5 * time.Minute / time.Millisecond)
|
|
source = "sample"
|
|
r = "24h"
|
|
}
|
|
|
|
traffic, err := dao.DashboardTrafficTimeseries(fromMs, nowMs, bucketMs, source)
|
|
if err != nil {
|
|
return vo.DashboardTimeseriesVo{}, err
|
|
}
|
|
systemRows, err := dao.DashboardSystemTimeseries(fromMs, nowMs, bucketMs)
|
|
if err != nil {
|
|
return vo.DashboardTimeseriesVo{}, err
|
|
}
|
|
|
|
return vo.DashboardTimeseriesVo{
|
|
Range: r,
|
|
Traffic: traffic,
|
|
System: systemRows,
|
|
CollectedAt: nowMs,
|
|
}, nil
|
|
}
|
|
|
|
func DashboardTopPeers(rangeKey string, limit int) ([]vo.DashboardTopPeerVo, error) {
|
|
nowMs := time.Now().UnixMilli()
|
|
normalizedRange := strings.TrimSpace(strings.ToLower(rangeKey))
|
|
fromMs := nowMs - int64(24*time.Hour/time.Millisecond)
|
|
source := "sample"
|
|
|
|
switch normalizedRange {
|
|
case "1h":
|
|
fromMs = nowMs - int64(time.Hour/time.Millisecond)
|
|
source = "sample"
|
|
case "7d":
|
|
fromMs = nowMs - int64(7*24*time.Hour/time.Millisecond)
|
|
source = "hourly"
|
|
case "24h", "":
|
|
fromMs = nowMs - int64(24*time.Hour/time.Millisecond)
|
|
source = "sample"
|
|
default:
|
|
fromMs = nowMs - int64(24*time.Hour/time.Millisecond)
|
|
source = "sample"
|
|
}
|
|
return dao.DashboardTopPeers(fromMs, nowMs, limit, source)
|
|
}
|
|
|
|
func DashboardSecurity() ([]vo.SecurityRiskVo, error) {
|
|
summary, err := DashboardSummary()
|
|
if err != nil {
|
|
return []vo.SecurityRiskVo{}, err
|
|
}
|
|
return summary.SecurityRisks, nil
|
|
}
|
|
|
|
// DashboardSecurityRisks называет ровно то, что известно.
|
|
//
|
|
// «Служба остановлена» и «состояние службы неизвестно» — разные сообщения с
|
|
// разными действиями оператора. Пока состояние было булевым, второе выдавалось
|
|
// за первое: сломанный systemctl при живой Hysteria показывал критическую
|
|
// плашку «служба остановлена», и оператор шёл перезапускать работающий
|
|
// туннель.
|
|
func DashboardSecurityRisks(summary vo.DashboardSummaryVo) []vo.SecurityRiskVo {
|
|
risks := make([]vo.SecurityRiskVo, 0)
|
|
switch summary.Hysteria.ServiceState {
|
|
case string(HysteriaServiceActive):
|
|
// Обслуживание идёт — сообщать нечего.
|
|
case string(HysteriaServiceUnknown):
|
|
risks = append(risks, vo.SecurityRiskVo{Key: "dashboard.security.hysteriaStateUnknown", Severity: "warning", Dismissible: false})
|
|
default:
|
|
risks = append(risks, vo.SecurityRiskVo{Key: "dashboard.security.hysteriaStopped", Severity: "critical", Dismissible: false})
|
|
}
|
|
if !summary.Hysteria.ApiReachable {
|
|
risks = append(risks, vo.SecurityRiskVo{Key: "dashboard.security.trafficApiUnavailable", Severity: "warning", Dismissible: false})
|
|
}
|
|
return risks
|
|
}
|