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:
2026-09-02 23:24:01 +05:00
parent 8dcb50a07c
commit cb20d8d28f
66 changed files with 4981 additions and 2684 deletions
+76 -15
View File
@@ -15,6 +15,7 @@ import (
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
)
// Отзыв доступа проверяется ПРОТИВ НАСТОЯЩЕГО HTTP, а не против подменённого
@@ -183,13 +184,13 @@ func pointHysteriaConfigAtDeadPort(t *testing.T) {
}
}
// withHysteriaRunning подменяет ответ systemd о состоянии службы.
func withHysteriaRunning(t *testing.T, running bool) {
// withHysteriaServiceState подменяет ответ systemd о состоянии службы.
func withHysteriaServiceState(t *testing.T, state HysteriaServiceState) {
t.Helper()
previous := hysteria2IsRunning
hysteria2IsRunning = func() bool { return running }
t.Cleanup(func() { hysteria2IsRunning = previous })
previous := hysteriaServiceState
hysteriaServiceState = func() HysteriaServiceState { return state }
t.Cleanup(func() { hysteriaServiceState = previous })
}
func peerByName(t *testing.T, name string) (disabled int64, bannedUntil int64) {
@@ -351,7 +352,7 @@ func TestDisconnectAuthIDsReportsApiFailure(t *testing.T) {
func TestDisconnectAuthIDsIgnoresSystemdOpinion(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
withHysteriaRunning(t, false)
withHysteriaServiceState(t, HysteriaServiceInactive)
if err := disconnectAuthIDs([]string{"alpha-auth-id"}); err != nil {
t.Fatalf("разрыв сессии отказал из-за мнения systemd: %v", err)
@@ -576,7 +577,7 @@ func TestHysteria2AuthRejectsWhenTrafficStatsUnreachable(t *testing.T) {
func TestHysteria2AuthEnforcesLimitWhenSystemdSaysStopped(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}})
withHysteriaRunning(t, false)
withHysteriaServiceState(t, HysteriaServiceInactive)
seedPeer(t, "alpha1", "alpha-auth-id")
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
@@ -648,19 +649,79 @@ func TestHysteria2AuthRejectsPeerWithoutIdentity(t *testing.T) {
}
}
// Отображение остаётся терпимым: пустая картина подключений — честный ответ на
// вопрос «кто сейчас на связи», когда служба остановлена. Строгость нужна
// только там, где принимается решение о доступе.
func TestHysteria2OnlineStaysTolerantForDisplay(t *testing.T) {
// Путь отображения БОЛЬШЕ НЕ выдаёт «никто не подключён» за ответ.
//
// Прежний контракт был терпимым: при неактивной по мнению systemd службе
// возвращалась пустая карта без ошибки. Выглядело это как честный ответ «сейчас
// никого нет», а означало «спросить не удалось либо не спрашивали вовсе» —
// ответ systemctl не отличает «служба неактивна» от «systemctl не запустился».
//
// Цена была не в формулировке. Сборщик метрик получал `err == nil` и выставлял
// `apiReachable = true`, ни разу не обратившись к Traffic Stats API, поэтому
// дашборд утверждал одновременно «Hysteria остановлена» и «API доступен, онлайн
// 0». Список пиров по тому же ответу показывал всех офлайн.
//
// Теперь недоступность остаётся ошибкой, а как показать её оператору, решает
// вызывающий — см. TestPagePeerReportsUnknownOnlineState.
func TestHysteria2OnlineReportsFailureInsteadOfEmptyPicture(t *testing.T) {
newTestDB(t)
pointHysteriaConfigAtDeadPort(t)
withHysteriaRunning(t, false)
withHysteriaServiceState(t, HysteriaServiceInactive)
if _, err := Hysteria2Online(); err == nil {
t.Fatal("недоступность Traffic Stats API выдана за пустую картину подключений")
}
}
// Мнение systemd на картину подключений не влияет вовсе: спрашивают того, кто
// знает ответ.
func TestHysteria2OnlineIgnoresSystemdOpinion(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
withHysteriaServiceState(t, HysteriaServiceInactive)
online, err := Hysteria2Online()
if err != nil {
t.Fatalf("отображение подключений стало отказывать: %v", err)
t.Fatalf("картина подключений отказала из-за мнения systemd: %v", err)
}
if len(online) != 0 {
t.Fatalf("ожидалась пустая картина подключений, получено %v", online)
if online["alpha-auth-id"] != 2 {
t.Fatalf("мнение systemd подменило фактический ответ API: %v", online)
}
}
// Список пиров при недоступном API сообщает «состояние неизвестно», а не «все
// офлайн»: это разные ответы, и первый в аварии ведёт оператора искать
// проблему у пользователей.
func TestPagePeerReportsUnknownOnlineState(t *testing.T) {
newTestDB(t)
pointHysteriaConfigAtDeadPort(t)
seedPeer(t, "alpha1", "alpha-auth-id")
records, total, onlineState, err := PagePeer(dto.PeerPageDto{})
if err != nil {
t.Fatalf("список пиров отказал из-за недоступного Traffic Stats API: %v", err)
}
if total != 1 || len(records) != 1 {
t.Fatalf("список пиров потерян: записей %d, всего %d", len(records), total)
}
if onlineState != vo.PeerOnlineStateUnavailable {
t.Fatalf("состояние подключений объявлено как %q, ожидалось %q", onlineState, vo.PeerOnlineStateUnavailable)
}
}
func TestPagePeerReportsKnownOnlineState(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
seedPeer(t, "alpha1", "alpha-auth-id")
records, _, onlineState, err := PagePeer(dto.PeerPageDto{})
if err != nil {
t.Fatalf("список пиров отказал: %v", err)
}
if onlineState != vo.PeerOnlineStateOk {
t.Fatalf("состояние подключений объявлено как %q, ожидалось %q", onlineState, vo.PeerOnlineStateOk)
}
if len(records) != 1 || !records[0].Online || records[0].OnlineDevices != 2 {
t.Fatalf("картина подключений не доехала до строки: %+v", records)
}
}