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{}{} } }