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
+529
View File
@@ -0,0 +1,529 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
)
// Отзыв секрета сходится ДАЖЕ ТОГДА, когда `/kick` был бесполезен.
//
// Проверяемое свойство одно: после смены секрета сессия, установленная по
// старому, не может остаться жить неограниченно долго. Способов оказаться в
// таком состоянии два, и они разной природы:
//
// `/kick` вернул 500 — второй шаг операции не удался;
// `/kick` вернул 200, но сессии ещё не — второй шаг удался и всё равно
// было: backend-auth по старому секрету не подействовал.
// ещё выполнялся
//
// Второй случай — не отказ и не гонка по памяти. Hysteria дожидается ответа
// backend-auth и только ПОСЛЕ `ok=true` выставляет `authenticated = true` и
// сообщает Traffic Stats API о новом онлайне, поэтому `/kick`, прошедший в это
// окно, соединения просто не видит. Атомарной пары «решение авторизации +
// регистрация онлайна» upstream API не даёт.
//
// Сходимость обеспечивается тем, что новое поколение учётных данных получает
// новый `auth_id`: пережившая сессия называется старым значением, которого в
// базе больше нет, и очередной цикл учёта видит её как orphan.
// rotationStats — Traffic Stats API, у которого ОДНО названное обращение к
// `/online` удерживается до команды теста, а `/kick` при этом продолжает
// обслуживаться.
//
// Обслуживание `/kick` во время удержания обязательно: воспроизводимое
// состояние — это именно «отзыв прошёл целиком, пока авторизация висела», и
// стенд, сериализующий оба запроса, его бы не собрал.
type rotationStats struct {
mu sync.Mutex
online map[string]int64
kickStatus int
kickedKeys [][]string
holdOnlineCall int
onlineCalls int
held chan struct{}
released chan struct{}
}
func startRotationStats(t *testing.T, online map[string]int64, holdOnlineCall int) *rotationStats {
t.Helper()
if online == nil {
online = map[string]int64{}
}
stats := &rotationStats{
online: online,
holdOnlineCall: holdOnlineCall,
held: make(chan struct{}),
released: make(chan struct{}),
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/online":
stats.mu.Lock()
stats.onlineCalls++
call := stats.onlineCalls
// Снимок делается на ПРИХОДЕ запроса: к моменту доставки ответа
// картина уже другая, и это ровно то, что воспроизводится.
snapshot := make(map[string]int64, len(stats.online))
for key, value := range stats.online {
snapshot[key] = value
}
stats.mu.Unlock()
if call == stats.holdOnlineCall {
close(stats.held)
select {
case <-stats.released:
case <-time.After(5 * time.Second):
// Команды не последовало — отпускаем, чтобы падение было по
// существу, а не по таймауту всего прогона.
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(snapshot)
case "/traffic":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]bo.Hysteria2UserTraffic{})
case "/kick":
stats.mu.Lock()
defer stats.mu.Unlock()
var keys []string
if err := json.NewDecoder(r.Body).Decode(&keys); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
stats.kickedKeys = append(stats.kickedKeys, keys)
if stats.kickStatus != 0 {
w.WriteHeader(stats.kickStatus)
return
}
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
pointHysteriaConfigAt(t, server.URL)
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
}
return stats
}
func (s *rotationStats) setOnline(online map[string]int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.online = online
}
func (s *rotationStats) setKickStatus(status int) {
s.mu.Lock()
defer s.mu.Unlock()
s.kickStatus = status
}
func (s *rotationStats) forgetKicks() {
s.mu.Lock()
defer s.mu.Unlock()
s.kickedKeys = nil
}
func (s *rotationStats) kicked() []string {
s.mu.Lock()
defer s.mu.Unlock()
out := []string{}
for _, keys := range s.kickedKeys {
out = append(out, keys...)
}
return out
}
func (s *rotationStats) awaitHeld(t *testing.T) {
t.Helper()
select {
case <-s.held:
case <-time.After(5 * time.Second):
t.Fatal("удерживаемое обращение к /online так и не пришло")
}
}
func (s *rotationStats) release() { close(s.released) }
// authIDOfPeer возвращает текущую идентичность сессий пира.
func authIDOfPeer(t *testing.T, name string) string {
t.Helper()
peer, ok := snapshotPeers(t)[name]
if !ok {
t.Fatalf("пир %q не найден", name)
}
return strVal(peer.AuthId)
}
func secretDigestOfPeer(t *testing.T, name string) string {
t.Helper()
peer, ok := snapshotPeers(t)[name]
if !ok {
t.Fatalf("пир %q не найден", name)
}
return strVal(peer.SecretDigest)
}
// --- Ротация через форму панели ----------------------------------------------
// Смена секрета меняет идентичность живых сессий, а рвётся при этом СТАРАЯ.
func TestUpdatePeerSecretRotationRotatesAuthID(t *testing.T) {
newTestDB(t)
stub := startRotationStats(t, nil, 0)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("brand-new-secret")}); err != nil {
t.Fatalf("смена секрета отказала: %v", err)
}
after := authIDOfPeer(t, "alpha1")
if after == "alpha-auth-id" {
t.Fatal("новое поколение учётных данных сохранило прежнюю идентичность сессий")
}
if after == "" {
t.Fatal("идентичность сессий стёрта вместо ротации")
}
// Разрыв идёт по СТАРОМУ значению: именно им Hysteria знает отзываемую
// сессию. Разрыв по новому не завершил бы ничего.
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
t.Fatalf("в /kick ушло %v, ожидался старый идентификатор", got)
}
}
// Сессия, установленная по уже отозванному секрету ПОСЛЕ успешного `/kick`,
// завершается очередным циклом учёта.
//
// Это главный сценарий: ни одна операция здесь не отказала. Отзыв прошёл
// целиком, а соединение всё равно оказалось живым — потому что Hysteria
// регистрирует его только после возврата backend-auth.
func TestSecretRotationReconcilesInFlightOldCredential(t *testing.T) {
newTestDB(t)
// Удерживается ПЕРВОЕ обращение к `/online` — то самое, внутри которого
// зависает авторизация по старому секрету.
stub := startRotationStats(t, map[string]int64{}, 1)
id := seedPeer(t, "alpha1", "alpha-auth-id")
// 1. Клиент со старым секретом начинает авторизацию и застревает в /online.
type authOutcome struct {
authID string
err error
}
inflight := make(chan authOutcome, 1)
go func() {
_, authID, err := Hysteria2Auth("alpha1-secret")
inflight <- authOutcome{authID: authID, err: err}
}()
stub.awaitHeld(t)
// 2. Оператор меняет секрет. Запись проходит, `/kick` отвечает 200 —
// операция успешна целиком.
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("brand-new-secret")}); err != nil {
t.Fatalf("смена секрета отказала: %v", err)
}
rotated := authIDOfPeer(t, "alpha1")
if rotated == "alpha-auth-id" {
t.Fatal("идентичность сессий не сменилась вместе с секретом")
}
// 3. Задержанная авторизация возвращается уже ПОСЛЕ разрыва, и Hysteria
// регистрирует сессию под старым идентификатором.
stub.release()
outcome := <-inflight
if outcome.err != nil {
t.Fatalf("подготовка сценария: авторизация по старому секрету не дошла до ответа: %v", outcome.err)
}
if outcome.authID != "alpha-auth-id" {
t.Fatalf("подготовка сценария: авторизация вернула %q, ожидался старый идентификатор", outcome.authID)
}
stub.setOnline(map[string]int64{"alpha-auth-id": 1})
stub.forgetKicks()
// 4. Сходимость: сессия названа значением, которого в базе больше нет.
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
t.Fatalf("сессия по отозванному секрету пережила цикл учёта: в /kick ушло %v", got)
}
}
// Тот же исход, когда `/kick` при ротации не удался вовсе.
func TestSecretRotationRecoversAfterKickFailure(t *testing.T) {
newTestDB(t)
stub := startRotationStats(t, map[string]int64{"alpha-auth-id": 1}, 0)
stub.setKickStatus(http.StatusInternalServerError)
id := seedPeer(t, "alpha1", "alpha-auth-id")
requireDisconnectError(t, UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("brand-new-secret")}))
// Неудача разрыва не откатывает ни новый секрет, ни новую идентичность:
// безопасная половина операции достигнута.
if authIDOfPeer(t, "alpha1") == "alpha-auth-id" {
t.Fatal("идентичность сессий откачена после неудачного разрыва")
}
stub.setKickStatus(0)
stub.forgetKicks()
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
t.Fatalf("старая сессия не завершена следующим циклом учёта: %v", got)
}
}
// Повтор ТОГО ЖЕ секрета — это повторная попытка отзыва, а не новое поколение
// учётных данных.
//
// Разрыв обязан выполниться снова (иначе после неудавшегося `/kick` оператору
// пришлось бы придумывать пиру ещё один секрет), а идентичность сессий обязана
// остаться прежней: менять её без смены credentials незачем — это обесценило бы
// привязку трафика на ровном месте.
func TestUpdatePeerSameSecretDoesNotRotateAuthID(t *testing.T) {
newTestDB(t)
stub := startRotationStats(t, nil, 0)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("alpha1-secret")}); err != nil {
t.Fatalf("повторная отправка секрета отказала: %v", err)
}
if got := authIDOfPeer(t, "alpha1"); got != "alpha-auth-id" {
t.Fatalf("идентичность сессий сменилась без смены учётных данных: %q", got)
}
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
t.Fatalf("повторный отзыв не дошёл до /kick: %v", got)
}
}
// Секрет из одних пробелов означает «не менять» на ОБОИХ путях сразу.
//
// Раньше правило было записано дважды: запись проверяла `!= ""`, а решение о
// разрыве — `TrimSpace(...) != ""`. Такой секрет записывался бы в базу как
// новые учётные данные, не разрывая сессий, — то есть отзыв, о котором механизм
// сходимости не знает.
func TestUpdatePeerBlankSecretChangesNothing(t *testing.T) {
newTestDB(t)
stub := startRotationStats(t, nil, 0)
id := seedPeer(t, "alpha1", "alpha-auth-id")
before := secretDigestOfPeer(t, "alpha1")
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr(" ")}); err != nil {
t.Fatalf("правка отказала: %v", err)
}
if got := secretDigestOfPeer(t, "alpha1"); got != before {
t.Fatal("секрет из одних пробелов записан как новые учётные данные")
}
if got := authIDOfPeer(t, "alpha1"); got != "alpha-auth-id" {
t.Fatalf("идентичность сессий сменилась без смены секрета: %q", got)
}
if got := stub.kicked(); len(got) != 0 {
t.Fatalf("сессия разорвана без единого изменения: %v", got)
}
}
// --- Ротация импортом ---------------------------------------------------------
// Импорт — вторая дверь к смене учётных данных, и правило поколения у неё то же.
//
// Случай, проходивший мимо: файл несёт ПРЕЖНИЙ `auth_id` и НОВЫЙ секрет.
// Идентичность сессий оставалась той же, и сессия по отозванному секрету
// выглядела законной.
func TestImportSecretRotationRotatesAuthID(t *testing.T) {
newTestDB(t)
stub := startRotationStats(t, map[string]int64{"keeper-auth": 1}, 0)
seedPeer(t, "keeper", "keeper-auth")
item := importItem("keeper", "keeper-auth")
item.Secret = "brand-new-secret"
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
t.Fatalf("импорт отказал: %v", err)
}
rotated := authIDOfPeer(t, "keeper")
if rotated == "keeper-auth" {
t.Fatalf("импорт нового секрета сохранил прежнюю идентичность сессий")
}
if got := stub.kicked(); len(got) != 1 || got[0] != "keeper-auth" {
t.Fatalf("в /kick ушло %v, ожидался старый идентификатор", got)
}
// И сходимость: сессия, пережившая разрыв, становится orphan.
stub.forgetKicks()
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 || got[0] != "keeper-auth" {
t.Fatalf("сессия по отозванному импортом секрету пережила цикл учёта: %v", got)
}
}
// Совпадение по ИМЕНИ, файл без `auth_id`: идентичность всё равно обязана
// смениться вместе с секретом.
func TestImportSecretRotationRotatesAuthIDWhenMatchedByName(t *testing.T) {
newTestDB(t)
stub := startRotationStats(t, nil, 0)
seedPeer(t, "keeper", "keeper-auth")
item := importItem("keeper", "")
item.Secret = "brand-new-secret"
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
t.Fatalf("импорт отказал: %v", err)
}
if got := authIDOfPeer(t, "keeper"); got == "keeper-auth" {
t.Fatal("импорт нового секрета по совпадению имени сохранил прежнюю идентичность сессий")
}
if got := stub.kicked(); len(got) != 1 || got[0] != "keeper-auth" {
t.Fatalf("в /kick ушло %v, ожидался старый идентификатор", got)
}
}
// Повторный импорт ТОГО ЖЕ файла идентичность не трогает: поколение учётных
// данных не изменилось.
//
// Без этого перенос пиров превращал бы каждую повторную попытку в смену
// идентификаторов всех пиров партии.
func TestImportSameSecretKeepsAuthID(t *testing.T) {
newTestDB(t)
startRotationStats(t, nil, 0)
seedPeer(t, "keeper", "keeper-auth")
item := importItem("keeper", "keeper-auth")
item.Secret = "keeper-secret" // ровно тот, что уже записан seedPeer
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
t.Fatalf("импорт отказал: %v", err)
}
if got := authIDOfPeer(t, "keeper"); got != "keeper-auth" {
t.Fatalf("повторный импорт того же секрета сменил идентичность сессий: %q", got)
}
}
// Файл, несущий СВОЙ новый `auth_id`, ротации не требует: идентичность уже
// сменилась им самим, и старая сессия становится orphan без нашего участия.
func TestImportKeepsExplicitNewAuthID(t *testing.T) {
newTestDB(t)
startRotationStats(t, nil, 0)
seedPeer(t, "keeper", "keeper-auth")
item := importItem("keeper", "new-auth-id")
item.Secret = "brand-new-secret"
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
t.Fatalf("импорт отказал: %v", err)
}
if got := authIDOfPeer(t, "keeper"); got != "new-auth-id" {
t.Fatalf("значение auth_id из файла подменено ротацией: %q", got)
}
}
// Импорт без секрета учётных данных не меняет, поэтому и идентичность остаётся.
func TestImportWithoutSecretKeepsAuthID(t *testing.T) {
newTestDB(t)
startRotationStats(t, nil, 0)
seedPeer(t, "keeper", "keeper-auth")
if err := UpsertPeerExport([]bo.PeerExport{importItem("keeper", "keeper-auth")}); err != nil {
t.Fatalf("импорт отказал: %v", err)
}
if got := authIDOfPeer(t, "keeper"); got != "keeper-auth" {
t.Fatalf("импорт без секрета сменил идентичность сессий: %q", got)
}
}
// --- Правило поколения --------------------------------------------------------
func TestCredentialGenerationChanged(t *testing.T) {
stored := "digest-1"
cases := []struct {
name string
stored *string
next string
want bool
}{
{"другой digest", &stored, "digest-2", true},
{"тот же digest", &stored, "digest-1", false},
{"строка без digest", nil, "digest-1", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := credentialGenerationChanged(tc.stored, tc.next); got != tc.want {
t.Fatalf("credentialGenerationChanged = %v, ожидалось %v", got, tc.want)
}
})
}
}
func TestRequestedSecretTreatsBlankAsAbsent(t *testing.T) {
cases := []struct {
name string
provided *string
want string
requested bool
}{
{"поле отсутствует", nil, "", false},
{"пустая строка", strPtr(""), "", false},
{"одни пробелы", strPtr(" "), "", false},
{"значение с краевыми пробелами", strPtr(" secret "), "secret", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
secret, requested := requestedSecret(tc.provided)
if requested != tc.requested || secret != tc.want {
t.Fatalf("requestedSecret = (%q, %v), ожидалось (%q, %v)",
secret, requested, tc.want, tc.requested)
}
})
}
}
// Идентификатор, выданный ротацией, неотличим от выданного при создании: одна
// длина, один алфавит, один генератор.
func TestNewPeerAuthIDShape(t *testing.T) {
seen := map[string]struct{}{}
for i := 0; i < 64; i++ {
authID, err := newPeerAuthID()
if err != nil {
t.Fatalf("генерация идентичности отказала: %v", err)
}
if len(authID) != peerAuthIDLength {
t.Fatalf("длина %d, ожидалось %d", len(authID), peerAuthIDLength)
}
if !peerAuthIDPattern.MatchString(authID) {
t.Fatalf("идентификатор %q не проходит собственную проверку продукта", authID)
}
if _, duplicate := seen[authID]; duplicate {
t.Fatalf("генератор повторил значение %q", authID)
}
seen[authID] = struct{}{}
}
}