fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `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
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/model/vo"
|
||||
)
|
||||
|
||||
// Дашборд не имеет права утверждать физически несовместимые вещи.
|
||||
//
|
||||
// Что было:
|
||||
//
|
||||
// Hysteria stopped
|
||||
// Traffic Stats API reachable
|
||||
// 0 online users
|
||||
//
|
||||
// Все три строки выводились из ОДНОГО ответа systemctl: общий Hysteria2Online
|
||||
// при неактивной службе отдавал пустую карту без ошибки, и сборщик метрик
|
||||
// выставлял `apiReachable = true`, ни разу не сходив в API.
|
||||
//
|
||||
// Матрица ниже проверяет все четыре клетки: два источника, два исхода каждый.
|
||||
|
||||
func TestParseSystemdActiveState(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
output string
|
||||
want HysteriaServiceState
|
||||
}{
|
||||
{"active", "active\n", HysteriaServiceActive},
|
||||
{"inactive", "inactive\n", HysteriaServiceInactive},
|
||||
{"failed", "failed\n", HysteriaServiceInactive},
|
||||
{"activating", "activating\n", HysteriaServiceInactive},
|
||||
{"deactivating", "deactivating\n", HysteriaServiceInactive},
|
||||
{"пробелы по краям", " active ", HysteriaServiceActive},
|
||||
{"несколько строк", "active\nactive\n", HysteriaServiceActive},
|
||||
{"пустой ответ", "", HysteriaServiceUnknown},
|
||||
// Список состояний systemd пополнялся и раньше. Молчаливое приведение
|
||||
// незнакомого слова к «остановлена» вернуло бы ровно ту ложь, ради
|
||||
// которой заведено третье значение.
|
||||
{"неизвестное состояние", "quantum\n", HysteriaServiceUnknown},
|
||||
{"текст ошибки вместо состояния", "Failed to connect to bus\n", HysteriaServiceUnknown},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := parseSystemdActiveState(tc.output); got != tc.want {
|
||||
t.Fatalf("parseSystemdActiveState(%q) = %q, ожидалось %q", tc.output, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Клетка 1: служба активна, API отвечает.
|
||||
func TestDashboardStateActiveServiceReachableApi(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
|
||||
withHysteriaServiceState(t, HysteriaServiceActive)
|
||||
|
||||
CollectMetricsSnapshot()
|
||||
snapshot := DashboardSnapshot()
|
||||
|
||||
if snapshot.Hysteria.ServiceState != string(HysteriaServiceActive) || !snapshot.Hysteria.Running {
|
||||
t.Fatalf("состояние службы: %+v", snapshot.Hysteria)
|
||||
}
|
||||
if !snapshot.Hysteria.ApiReachable {
|
||||
t.Fatal("API отвечает, но объявлен недоступным")
|
||||
}
|
||||
if snapshot.OnlineDevices != 2 || snapshot.OnlinePeers != 1 {
|
||||
t.Fatalf("картина подключений: пиров %d, устройств %d", snapshot.OnlinePeers, snapshot.OnlineDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// Клетка 2: служба неактивна, API молчит. Оба факта согласованы, и оба верны.
|
||||
func TestDashboardStateInactiveServiceUnreachableApi(t *testing.T) {
|
||||
newTestDB(t)
|
||||
pointHysteriaConfigAtDeadPort(t)
|
||||
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||||
|
||||
CollectMetricsSnapshot()
|
||||
snapshot := DashboardSnapshot()
|
||||
|
||||
if snapshot.Hysteria.ServiceState != string(HysteriaServiceInactive) {
|
||||
t.Fatalf("состояние службы объявлено как %q", snapshot.Hysteria.ServiceState)
|
||||
}
|
||||
if snapshot.Hysteria.ApiReachable {
|
||||
t.Fatal("недоступный Traffic Stats API объявлен доступным")
|
||||
}
|
||||
}
|
||||
|
||||
// Клетка 3 — главная регрессия: спросить systemctl не удалось, но Hysteria
|
||||
// жива и API отвечает.
|
||||
//
|
||||
// Прежний путь показывал «служба остановлена» и «API доступен» одновременно,
|
||||
// причём второе — не сходив в API. Теперь неизвестность названа неизвестностью,
|
||||
// а доступность API получена фактическим обращением.
|
||||
func TestDashboardStateUnknownServiceReachableApi(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
||||
withHysteriaServiceState(t, HysteriaServiceUnknown)
|
||||
|
||||
CollectMetricsSnapshot()
|
||||
snapshot := DashboardSnapshot()
|
||||
|
||||
if snapshot.Hysteria.ServiceState != string(HysteriaServiceUnknown) {
|
||||
t.Fatalf("неизвестное состояние выдано за %q", snapshot.Hysteria.ServiceState)
|
||||
}
|
||||
if snapshot.Hysteria.Running {
|
||||
t.Fatal("неизвестное состояние выдано за работающую службу")
|
||||
}
|
||||
if !snapshot.Hysteria.ApiReachable {
|
||||
t.Fatal("мнение systemd подменило фактический ответ Traffic Stats API")
|
||||
}
|
||||
if snapshot.OnlineDevices != 1 {
|
||||
t.Fatalf("картина подключений потеряна: устройств %d", snapshot.OnlineDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// Клетка 4: служба активна, но API отказывает — например, Hysteria ещё
|
||||
// поднимается или порт занят чужим процессом.
|
||||
func TestDashboardStateActiveServiceFailingApi(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{onlineStatus: http.StatusInternalServerError})
|
||||
withHysteriaServiceState(t, HysteriaServiceActive)
|
||||
|
||||
CollectMetricsSnapshot()
|
||||
snapshot := DashboardSnapshot()
|
||||
|
||||
if !snapshot.Hysteria.Running {
|
||||
t.Fatal("активная служба объявлена остановленной")
|
||||
}
|
||||
if snapshot.Hysteria.ApiReachable {
|
||||
t.Fatal("отказ API объявлен доступностью")
|
||||
}
|
||||
if snapshot.HysteriaState.Status != "error" {
|
||||
t.Fatalf("отказ API не попал в состояние данных: %+v", snapshot.HysteriaState)
|
||||
}
|
||||
}
|
||||
|
||||
// «Состояние неизвестно» и «служба остановлена» — разные сообщения оператору с
|
||||
// разной срочностью и разными действиями.
|
||||
func TestDashboardSecurityRisksDistinguishUnknownFromStopped(t *testing.T) {
|
||||
riskKeys := func(state HysteriaServiceState, apiReachable bool) []string {
|
||||
summary := vo.DashboardSummaryVo{}
|
||||
summary.Hysteria.ServiceState = string(state)
|
||||
summary.Hysteria.Running = state == HysteriaServiceActive
|
||||
summary.Hysteria.ApiReachable = apiReachable
|
||||
|
||||
keys := make([]string, 0)
|
||||
for _, risk := range DashboardSecurityRisks(summary) {
|
||||
keys = append(keys, risk.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
if got := riskKeys(HysteriaServiceActive, true); len(got) != 0 {
|
||||
t.Fatalf("исправная система получила предупреждения: %v", got)
|
||||
}
|
||||
|
||||
stopped := riskKeys(HysteriaServiceInactive, true)
|
||||
if len(stopped) != 1 || stopped[0] != "dashboard.security.hysteriaStopped" {
|
||||
t.Fatalf("остановленная служба: %v", stopped)
|
||||
}
|
||||
|
||||
unknown := riskKeys(HysteriaServiceUnknown, true)
|
||||
if len(unknown) != 1 || unknown[0] != "dashboard.security.hysteriaStateUnknown" {
|
||||
t.Fatalf("неизвестное состояние выдано за остановленную службу: %v", unknown)
|
||||
}
|
||||
|
||||
both := riskKeys(HysteriaServiceInactive, false)
|
||||
if len(both) != 2 {
|
||||
t.Fatalf("два независимых отказа схлопнулись в один: %v", both)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user