cb20d8d28f
Отзыв секрета не сходился: `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
728 lines
30 KiB
Go
728 lines
30 KiB
Go
package service
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"sync"
|
||
"testing"
|
||
|
||
"hy2xs-admin/dao"
|
||
"hy2xs-admin/model/constant"
|
||
"hy2xs-admin/model/dto"
|
||
"hy2xs-admin/model/vo"
|
||
)
|
||
|
||
// Отзыв доступа проверяется ПРОТИВ НАСТОЯЩЕГО HTTP, а не против подменённого
|
||
// клиента.
|
||
//
|
||
// Контракт, который здесь доказывается, — официальный Traffic Stats API
|
||
// Hysteria: метод, путь, заголовок Authorization и JSON-массив идентификаторов
|
||
// в теле. Подменённый на уровне Go клиент доказал бы только то, что вызвана
|
||
// нужная функция, и молча пережил бы, например, потерю заголовка. Слой proxy
|
||
// ходит на 127.0.0.1:<порт из конфига>, а httptest слушает ровно там, поэтому
|
||
// настоящий запрос ставится без единой правки продуктового кода.
|
||
|
||
const testTrafficStatsSecret = "test-traffic-stats-secret"
|
||
|
||
// trafficStatsStub — Traffic Stats API Hysteria в объёме, который использует
|
||
// админка.
|
||
type trafficStatsStub struct {
|
||
mu sync.Mutex
|
||
|
||
// online отдаётся на GET /online.
|
||
online map[string]int64
|
||
// onlineStatus != 0 подменяет ответ /online кодом отказа.
|
||
onlineStatus int
|
||
// kickStatus != 0 подменяет ответ /kick кодом отказа.
|
||
kickStatus int
|
||
|
||
// Наблюдения.
|
||
onlineCalls int
|
||
kickCalls int
|
||
kickedKeys [][]string
|
||
kickAuth []string
|
||
// disabledAtKick — состояние колонки disabled на момент прихода /kick.
|
||
// Именно этим доказывается порядок «сначала запись, потом разрыв»:
|
||
// проверить его после операции нельзя, там оба шага уже завершены.
|
||
disabledAtKick []map[string]int64
|
||
bannedAtKick []map[string]int64
|
||
}
|
||
|
||
func (s *trafficStatsStub) snapshot(t *testing.T) (map[string]int64, map[string]int64) {
|
||
t.Helper()
|
||
|
||
disabled := map[string]int64{}
|
||
banned := map[string]int64{}
|
||
peers, err := dao.ListPeer("1=1")
|
||
if err != nil {
|
||
return disabled, banned
|
||
}
|
||
for _, peer := range peers {
|
||
if peer.Name == nil {
|
||
continue
|
||
}
|
||
if peer.Disabled != nil {
|
||
disabled[*peer.Name] = *peer.Disabled
|
||
}
|
||
if peer.BannedUntil != nil {
|
||
banned[*peer.Name] = *peer.BannedUntil
|
||
}
|
||
}
|
||
return disabled, banned
|
||
}
|
||
|
||
// startTrafficStats поднимает Traffic Stats API на 127.0.0.1 и указывает на
|
||
// него конфиг Hysteria, который читает админка.
|
||
func startTrafficStats(t *testing.T, stub *trafficStatsStub) *trafficStatsStub {
|
||
t.Helper()
|
||
|
||
if stub == nil {
|
||
stub = &trafficStatsStub{}
|
||
}
|
||
if stub.online == nil {
|
||
stub.online = map[string]int64{}
|
||
}
|
||
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
stub.mu.Lock()
|
||
defer stub.mu.Unlock()
|
||
|
||
switch r.URL.Path {
|
||
case "/online":
|
||
stub.onlineCalls++
|
||
if stub.onlineStatus != 0 {
|
||
w.WriteHeader(stub.onlineStatus)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(stub.online)
|
||
case "/kick":
|
||
stub.kickCalls++
|
||
stub.kickAuth = append(stub.kickAuth, r.Header.Get("Authorization"))
|
||
|
||
disabled, banned := stub.snapshot(t)
|
||
stub.disabledAtKick = append(stub.disabledAtKick, disabled)
|
||
stub.bannedAtKick = append(stub.bannedAtKick, banned)
|
||
|
||
var keys []string
|
||
if err := json.NewDecoder(r.Body).Decode(&keys); err != nil {
|
||
w.WriteHeader(http.StatusBadRequest)
|
||
return
|
||
}
|
||
stub.kickedKeys = append(stub.kickedKeys, keys)
|
||
|
||
if stub.kickStatus != 0 {
|
||
w.WriteHeader(stub.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 stub
|
||
}
|
||
|
||
// pointHysteriaConfigAt пишет конфиг Hysteria, в котором Traffic Stats API
|
||
// слушает порт переданного адреса.
|
||
func pointHysteriaConfigAt(t *testing.T, serverURL string) {
|
||
t.Helper()
|
||
|
||
host := serverURL
|
||
for _, prefix := range []string{"http://", "https://"} {
|
||
if len(host) > len(prefix) && host[:len(prefix)] == prefix {
|
||
host = host[len(prefix):]
|
||
}
|
||
}
|
||
_, port, err := net.SplitHostPort(host)
|
||
if err != nil {
|
||
t.Fatalf("не удалось разобрать адрес тестового сервера %q: %v", serverURL, err)
|
||
}
|
||
|
||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||
content := fmt.Sprintf("listen: \":443\"\ntrafficStats:\n listen: \"127.0.0.1:%s\"\n secret: %q\n",
|
||
port, testTrafficStatsSecret)
|
||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||
t.Fatalf("не удалось записать тестовый конфиг Hysteria: %v", err)
|
||
}
|
||
|
||
previous := constant.Hysteria2ConfigPath
|
||
constant.Hysteria2ConfigPath = path
|
||
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
|
||
}
|
||
|
||
// pointHysteriaConfigAtDeadPort указывает конфиг на порт, который никто не
|
||
// слушает: так воспроизводится недоступность Traffic Stats API.
|
||
func pointHysteriaConfigAtDeadPort(t *testing.T) {
|
||
t.Helper()
|
||
|
||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||
if err != nil {
|
||
t.Fatalf("не удалось занять порт: %v", err)
|
||
}
|
||
addr := listener.Addr().String()
|
||
// Порт освобождается сразу: адрес известен, слушателя на нём нет.
|
||
if err := listener.Close(); err != nil {
|
||
t.Fatalf("не удалось освободить порт: %v", err)
|
||
}
|
||
|
||
pointHysteriaConfigAt(t, "http://"+addr)
|
||
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
|
||
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
|
||
}
|
||
}
|
||
|
||
// withHysteriaServiceState подменяет ответ systemd о состоянии службы.
|
||
func withHysteriaServiceState(t *testing.T, state HysteriaServiceState) {
|
||
t.Helper()
|
||
|
||
previous := hysteriaServiceState
|
||
hysteriaServiceState = func() HysteriaServiceState { return state }
|
||
t.Cleanup(func() { hysteriaServiceState = previous })
|
||
}
|
||
|
||
func peerByName(t *testing.T, name string) (disabled int64, bannedUntil int64) {
|
||
t.Helper()
|
||
|
||
peer, ok := snapshotPeers(t)[name]
|
||
if !ok {
|
||
t.Fatalf("пир %q не найден", name)
|
||
}
|
||
if peer.Disabled != nil {
|
||
disabled = *peer.Disabled
|
||
}
|
||
if peer.BannedUntil != nil {
|
||
bannedUntil = *peer.BannedUntil
|
||
}
|
||
return disabled, bannedUntil
|
||
}
|
||
|
||
func requireDisconnectError(t *testing.T, err error) {
|
||
t.Helper()
|
||
|
||
if err == nil {
|
||
t.Fatal("частичный результат не сообщён: ошибки нет вовсе")
|
||
}
|
||
var peerErr *PeerError
|
||
if !errors.As(err, &peerErr) {
|
||
t.Fatalf("отказ не несёт машиночитаемого кода: %v", err)
|
||
}
|
||
if peerErr.Code != constant.ErrCodePeerDisconnectFailed {
|
||
t.Fatalf("ожидался код %q, получен %q", constant.ErrCodePeerDisconnectFailed, peerErr.Code)
|
||
}
|
||
if peerErr.Field != "" {
|
||
t.Errorf("частичный результат отнесён к полю формы %q, хотя относится к операции целиком", peerErr.Field)
|
||
}
|
||
}
|
||
|
||
// --- disconnectAuthIDs -------------------------------------------------------
|
||
|
||
func TestDisconnectAuthIDsSendsOfficialKickContract(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startTrafficStats(t, nil)
|
||
|
||
if err := disconnectAuthIDs([]string{"alpha-auth-id"}); err != nil {
|
||
t.Fatalf("разрыв сессии отказал: %v", err)
|
||
}
|
||
|
||
if stub.kickCalls != 1 {
|
||
t.Fatalf("ожидался ровно один вызов /kick, было %d", stub.kickCalls)
|
||
}
|
||
if got := stub.kickedKeys[0]; len(got) != 1 || got[0] != "alpha-auth-id" {
|
||
t.Fatalf("в /kick уехали не те идентификаторы: %v", got)
|
||
}
|
||
if stub.kickAuth[0] != testTrafficStatsSecret {
|
||
t.Fatalf("секрет Traffic Stats API не передан в Authorization: %q", stub.kickAuth[0])
|
||
}
|
||
}
|
||
|
||
// Разрыв сессии НИЧЕГО не пишет в базу. Прежний Hysteria2Kick заодно
|
||
// проставлял banned_until, из-за чего им нельзя было воспользоваться для
|
||
// операции «Отключить»: она записала бы временную блокировку — другой механизм
|
||
// с другим сроком жизни.
|
||
func TestDisconnectAuthIDsDoesNotTouchPeerState(t *testing.T) {
|
||
newTestDB(t)
|
||
startTrafficStats(t, nil)
|
||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
if err := disconnectAuthIDs([]string{"alpha-auth-id"}); err != nil {
|
||
t.Fatalf("разрыв сессии отказал: %v", err)
|
||
}
|
||
|
||
disabled, bannedUntil := peerByName(t, "alpha1")
|
||
if disabled != 0 {
|
||
t.Errorf("разрыв сессии изменил disabled: %d", disabled)
|
||
}
|
||
if bannedUntil != 0 {
|
||
t.Errorf("разрыв сессии изменил banned_until: %d", bannedUntil)
|
||
}
|
||
}
|
||
|
||
// Запрос без единой цели не отправляется вовсе. Раньше по аналогичному пути в
|
||
// cron уезжал POST с пустым массивом в теле каждые 30 секунд.
|
||
func TestDisconnectAuthIDsIsNoopWithoutTargets(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startTrafficStats(t, nil)
|
||
|
||
if err := disconnectAuthIDs(nil); err != nil {
|
||
t.Fatalf("пустой список признан отказом: %v", err)
|
||
}
|
||
// Пир без authId Hysteria не знает: рвать нечего.
|
||
if err := disconnectAuthIDs([]string{"", ""}); err != nil {
|
||
t.Fatalf("набор из пустых идентификаторов признан отказом: %v", err)
|
||
}
|
||
|
||
if stub.kickCalls != 0 {
|
||
t.Fatalf("сделан вызов /kick без единой цели: %d", stub.kickCalls)
|
||
}
|
||
}
|
||
|
||
// Один и тот же идентификатор — один разрыв. Импорт и правка пира легко дают
|
||
// повторы, и слать их в Hysteria по разу на каждое вхождение незачем.
|
||
func TestDisconnectAuthIDsDeduplicates(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startTrafficStats(t, nil)
|
||
|
||
if err := disconnectAuthIDs([]string{"a", "b", "a", "", "b", "a"}); err != nil {
|
||
t.Fatalf("разрыв сессии отказал: %v", err)
|
||
}
|
||
|
||
if stub.kickCalls != 1 {
|
||
t.Fatalf("ожидался один вызов /kick, было %d", stub.kickCalls)
|
||
}
|
||
if got := stub.kickedKeys[0]; len(got) != 2 {
|
||
t.Fatalf("дубликаты уехали в Hysteria: %v", got)
|
||
}
|
||
}
|
||
|
||
// Размер запроса задаёт продукт, а не содержимое пользовательского файла:
|
||
// импорт применяет до MaxPeerImportItems записей за операцию.
|
||
func TestDisconnectAuthIDsSplitsLargeBatches(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startTrafficStats(t, nil)
|
||
|
||
authIDs := make([]string, 0, kickChunkSize*2+5)
|
||
for i := 0; i < cap(authIDs); i++ {
|
||
authIDs = append(authIDs, fmt.Sprintf("auth-%d", i))
|
||
}
|
||
|
||
if err := disconnectAuthIDs(authIDs); err != nil {
|
||
t.Fatalf("разрыв сессии отказал: %v", err)
|
||
}
|
||
|
||
if stub.kickCalls != 3 {
|
||
t.Fatalf("ожидалось 3 обращения к /kick, было %d", stub.kickCalls)
|
||
}
|
||
total := 0
|
||
for _, keys := range stub.kickedKeys {
|
||
if len(keys) > kickChunkSize {
|
||
t.Fatalf("чанк больше предела: %d", len(keys))
|
||
}
|
||
total += len(keys)
|
||
}
|
||
if total != len(authIDs) {
|
||
t.Fatalf("потеряны идентификаторы: отправлено %d из %d", total, len(authIDs))
|
||
}
|
||
}
|
||
|
||
func TestDisconnectAuthIDsReportsApiFailure(t *testing.T) {
|
||
newTestDB(t)
|
||
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
|
||
|
||
if err := disconnectAuthIDs([]string{"alpha-auth-id"}); err == nil {
|
||
t.Fatal("отказ Traffic Stats API не сообщён")
|
||
}
|
||
}
|
||
|
||
// Состояние службы по systemd на этом пути не спрашивается вовсе: ответ
|
||
// systemctl не отличает «служба неактивна» от «спросить не удалось», и на
|
||
// прежнем пути его сбой отказывал операции при живой Hysteria.
|
||
func TestDisconnectAuthIDsIgnoresSystemdOpinion(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startTrafficStats(t, nil)
|
||
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||
|
||
if err := disconnectAuthIDs([]string{"alpha-auth-id"}); err != nil {
|
||
t.Fatalf("разрыв сессии отказал из-за мнения systemd: %v", err)
|
||
}
|
||
if stub.kickCalls != 1 {
|
||
t.Fatalf("разрыв сессии пропущен из-за мнения systemd: вызовов /kick %d", stub.kickCalls)
|
||
}
|
||
}
|
||
|
||
// --- Отключение пира ---------------------------------------------------------
|
||
|
||
func TestUpdatePeerDisableWritesThenDisconnects(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startTrafficStats(t, nil)
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
one := int64(1)
|
||
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
|
||
t.Fatalf("отключение пира отказало: %v", err)
|
||
}
|
||
|
||
disabled, bannedUntil := peerByName(t, "alpha1")
|
||
if disabled != 1 {
|
||
t.Fatalf("пир не отключён: disabled=%d", disabled)
|
||
}
|
||
// Временная блокировка — независимый механизм, отключение её не ставит.
|
||
if bannedUntil != 0 {
|
||
t.Errorf("отключение проставило banned_until=%d", bannedUntil)
|
||
}
|
||
|
||
if stub.kickCalls != 1 {
|
||
t.Fatalf("активная сессия не разорвана: вызовов /kick %d", stub.kickCalls)
|
||
}
|
||
|
||
// Порядок: к моменту прихода /kick запрет новых подключений уже в базе.
|
||
// При обратном порядке клиент успевает переподключиться в окне между
|
||
// разрывом и записью и остаётся на связи с формально отключённым пиром.
|
||
if got := stub.disabledAtKick[0]["alpha1"]; got != 1 {
|
||
t.Fatalf("/kick пришёл раньше записи disabled: на момент разрыва disabled=%d", got)
|
||
}
|
||
}
|
||
|
||
// Неудача разрыва НЕ откатывает запрет новых подключений: безопасная половина
|
||
// операции достигнута, и возвращать пиру полный доступ из-за второго шага
|
||
// нельзя.
|
||
func TestUpdatePeerDisableKeepsStateWhenDisconnectFails(t *testing.T) {
|
||
newTestDB(t)
|
||
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
one := int64(1)
|
||
err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one})
|
||
requireDisconnectError(t, err)
|
||
|
||
if disabled, _ := peerByName(t, "alpha1"); disabled != 1 {
|
||
t.Fatalf("запрет новых подключений откачен после неудачного разрыва: disabled=%d", disabled)
|
||
}
|
||
}
|
||
|
||
// Включение пира не рвёт сессий и не трогает временную блокировку: это
|
||
// независимые механизмы.
|
||
func TestUpdatePeerEnableDoesNotDisconnect(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startTrafficStats(t, nil)
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
if err := KickPeer(id, 1_700_000_000_000); err != nil {
|
||
t.Fatalf("временная блокировка отказала: %v", err)
|
||
}
|
||
kicksAfterBan := stub.kickCalls
|
||
|
||
zero := int64(0)
|
||
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &zero}); err != nil {
|
||
t.Fatalf("включение пира отказало: %v", err)
|
||
}
|
||
|
||
if stub.kickCalls != kicksAfterBan {
|
||
t.Fatalf("включение пира вызвало /kick: %d -> %d", kicksAfterBan, stub.kickCalls)
|
||
}
|
||
disabled, bannedUntil := peerByName(t, "alpha1")
|
||
if disabled != 0 {
|
||
t.Fatalf("пир не включён: disabled=%d", disabled)
|
||
}
|
||
if bannedUntil != 1_700_000_000_000 {
|
||
t.Errorf("включение сбросило независимую временную блокировку: banned_until=%d", bannedUntil)
|
||
}
|
||
}
|
||
|
||
// Правка постороннего поля не является отзывом доступа и сессий не рвёт.
|
||
func TestUpdatePeerUnrelatedFieldDoesNotDisconnect(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startTrafficStats(t, nil)
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
if err := UpdatePeer(id, dto.PeerUpdateDto{Remark: strPtr("ноутбук")}); err != nil {
|
||
t.Fatalf("правка комментария отказала: %v", err)
|
||
}
|
||
if stub.kickCalls != 0 {
|
||
t.Fatalf("правка комментария разорвала сессии: вызовов /kick %d", stub.kickCalls)
|
||
}
|
||
}
|
||
|
||
// Операция повторяема: условие смотрит на ЗАПРОШЕННОЕ состояние, а не на
|
||
// переход из включённого. Иначе после неудачного разрыва оператору пришлось бы
|
||
// включить пира, чтобы получить право отключить его снова.
|
||
func TestUpdatePeerDisableIsRetryable(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
one := int64(1)
|
||
requireDisconnectError(t, UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}))
|
||
|
||
stub.mu.Lock()
|
||
stub.kickStatus = 0
|
||
stub.mu.Unlock()
|
||
|
||
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
|
||
t.Fatalf("повторное отключение отказало: %v", err)
|
||
}
|
||
if stub.kickCalls != 2 {
|
||
t.Fatalf("повторная попытка не дошла до /kick: вызовов %d", stub.kickCalls)
|
||
}
|
||
}
|
||
|
||
// --- Временная блокировка ----------------------------------------------------
|
||
|
||
func TestKickPeerWritesBanOnceAndDisconnects(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startTrafficStats(t, nil)
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
const bannedUntil = int64(1_700_000_000_000)
|
||
if err := KickPeer(id, bannedUntil); err != nil {
|
||
t.Fatalf("временная блокировка отказала: %v", err)
|
||
}
|
||
|
||
disabled, banned := peerByName(t, "alpha1")
|
||
if banned != bannedUntil {
|
||
t.Fatalf("banned_until не записан: %d", banned)
|
||
}
|
||
// Временная блокировка и отключение независимы.
|
||
if disabled != 0 {
|
||
t.Errorf("временная блокировка изменила disabled: %d", disabled)
|
||
}
|
||
if stub.kickCalls != 1 {
|
||
t.Fatalf("ожидался ровно один вызов /kick, было %d", stub.kickCalls)
|
||
}
|
||
if got := stub.bannedAtKick[0]["alpha1"]; got != bannedUntil {
|
||
t.Fatalf("/kick пришёл раньше записи banned_until: на момент разрыва %d", got)
|
||
}
|
||
}
|
||
|
||
// Регрессия: прежний путь начинался с проверки состояния службы и возвращал
|
||
// чистый отказ, НЕ СДЕЛАВ ничего, — при том что запись banned_until к этому
|
||
// моменту уже применилась. Оператор видел «не сработало» у сработавшей
|
||
// блокировки.
|
||
func TestKickPeerReportsPartialResultWhenDisconnectFails(t *testing.T) {
|
||
newTestDB(t)
|
||
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
const bannedUntil = int64(1_700_000_000_000)
|
||
requireDisconnectError(t, KickPeer(id, bannedUntil))
|
||
|
||
if _, banned := peerByName(t, "alpha1"); banned != bannedUntil {
|
||
t.Fatalf("блокировка не применена, хотя сообщён частичный результат: banned_until=%d", banned)
|
||
}
|
||
}
|
||
|
||
func TestReleaseKickPeerLeavesDisabledAlone(t *testing.T) {
|
||
newTestDB(t)
|
||
startTrafficStats(t, nil)
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
one := int64(1)
|
||
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
|
||
t.Fatalf("отключение пира отказало: %v", err)
|
||
}
|
||
if err := KickPeer(id, 1_700_000_000_000); err != nil {
|
||
t.Fatalf("временная блокировка отказала: %v", err)
|
||
}
|
||
if err := ReleaseKickPeer(id); err != nil {
|
||
t.Fatalf("снятие блокировки отказало: %v", err)
|
||
}
|
||
|
||
disabled, banned := peerByName(t, "alpha1")
|
||
if banned != 0 {
|
||
t.Fatalf("banned_until не сброшен: %d", banned)
|
||
}
|
||
if disabled != 1 {
|
||
t.Fatalf("снятие временной блокировки включило отключённого пира: disabled=%d", disabled)
|
||
}
|
||
}
|
||
|
||
// --- Ограничение устройств (fail-closed) -------------------------------------
|
||
|
||
func TestHysteria2AuthRejectsWhenTrafficStatsRefuses(t *testing.T) {
|
||
newTestDB(t)
|
||
startTrafficStats(t, &trafficStatsStub{onlineStatus: http.StatusInternalServerError})
|
||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||
t.Fatal("отказ Traffic Stats API впустил подключение без проверки лимита устройств")
|
||
}
|
||
}
|
||
|
||
func TestHysteria2AuthRejectsWhenTrafficStatsUnreachable(t *testing.T) {
|
||
newTestDB(t)
|
||
pointHysteriaConfigAtDeadPort(t)
|
||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||
t.Fatal("недоступность Traffic Stats API впустила подключение без проверки лимита устройств")
|
||
}
|
||
}
|
||
|
||
// Регрессия AUTH-02, вторая половина дыры и самая тихая: общий Hysteria2Online
|
||
// отдавал ПУСТУЮ КАРТУ БЕЗ ОШИБКИ, когда systemd отвечал «служба неактивна», —
|
||
// а этот ответ не отличается от «спросить systemctl не удалось». Лимит
|
||
// устройств переставал проверяться, и в журнале не появлялось ни строки.
|
||
func TestHysteria2AuthEnforcesLimitWhenSystemdSaysStopped(t *testing.T) {
|
||
newTestDB(t)
|
||
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}})
|
||
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||
t.Fatal("мнение systemd отключило проверку лимита устройств")
|
||
}
|
||
}
|
||
|
||
func TestHysteria2AuthAcceptsBelowDeviceLimit(t *testing.T) {
|
||
newTestDB(t)
|
||
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
gotID, gotAuth, err := Hysteria2Auth("alpha1-secret")
|
||
if err != nil {
|
||
t.Fatalf("подключение в пределах лимита отклонено: %v", err)
|
||
}
|
||
if gotID != id || gotAuth != "alpha-auth-id" {
|
||
t.Fatalf("авторизация вернула не того пира: id=%d authId=%q", gotID, gotAuth)
|
||
}
|
||
}
|
||
|
||
func TestHysteria2AuthRejectsAtDeviceLimit(t *testing.T) {
|
||
newTestDB(t)
|
||
// seedPeer создаёт пира с maxDevices = 3.
|
||
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}})
|
||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||
t.Fatal("подключение сверх лимита устройств принято")
|
||
}
|
||
}
|
||
|
||
// Повреждённая граница — это не «безлимит». Строка без пригодного maxDevices
|
||
// на пути принятия решения о доступе обязана вести к отказу.
|
||
func TestHysteria2AuthRejectsUnusableDeviceLimit(t *testing.T) {
|
||
newTestDB(t)
|
||
startTrafficStats(t, nil)
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"max_devices": 0}); err != nil {
|
||
t.Fatalf("не удалось подготовить повреждённую строку: %v", err)
|
||
}
|
||
|
||
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||
t.Fatal("пир без пригодного лимита устройств принят")
|
||
}
|
||
}
|
||
|
||
// Путь машинной авторизации проходят ВСЕ подключения пиров, поэтому
|
||
// разыменование nil на нём означает панику в обработчике, а не отказ одному
|
||
// клиенту.
|
||
func TestHysteria2AuthRejectsPeerWithoutIdentity(t *testing.T) {
|
||
newTestDB(t)
|
||
startTrafficStats(t, nil)
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"auth_id": ""}); err != nil {
|
||
t.Fatalf("не удалось подготовить повреждённую строку: %v", err)
|
||
}
|
||
|
||
defer func() {
|
||
if recovered := recover(); recovered != nil {
|
||
t.Fatalf("повреждённая строка уронила обработчик авторизации: %v", recovered)
|
||
}
|
||
}()
|
||
|
||
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||
t.Fatal("пир без идентичности принят")
|
||
}
|
||
}
|
||
|
||
// Путь отображения БОЛЬШЕ НЕ выдаёт «никто не подключён» за ответ.
|
||
//
|
||
// Прежний контракт был терпимым: при неактивной по мнению systemd службе
|
||
// возвращалась пустая карта без ошибки. Выглядело это как честный ответ «сейчас
|
||
// никого нет», а означало «спросить не удалось либо не спрашивали вовсе» —
|
||
// ответ systemctl не отличает «служба неактивна» от «systemctl не запустился».
|
||
//
|
||
// Цена была не в формулировке. Сборщик метрик получал `err == nil` и выставлял
|
||
// `apiReachable = true`, ни разу не обратившись к Traffic Stats API, поэтому
|
||
// дашборд утверждал одновременно «Hysteria остановлена» и «API доступен, онлайн
|
||
// 0». Список пиров по тому же ответу показывал всех офлайн.
|
||
//
|
||
// Теперь недоступность остаётся ошибкой, а как показать её оператору, решает
|
||
// вызывающий — см. TestPagePeerReportsUnknownOnlineState.
|
||
func TestHysteria2OnlineReportsFailureInsteadOfEmptyPicture(t *testing.T) {
|
||
newTestDB(t)
|
||
pointHysteriaConfigAtDeadPort(t)
|
||
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("картина подключений отказала из-за мнения systemd: %v", err)
|
||
}
|
||
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)
|
||
}
|
||
}
|