Files
HY2XS_flamy/apps/service/peer_secret_rotation_test.go
founder cb20d8d28f 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
2026-09-02 23:24:01 +05:00

530 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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{}{}
}
}