package service import ( "encoding/json" "net/http" "net/http/httptest" "sync" "testing" "time" "hy2xs-admin/dao" "hy2xs-admin/model/constant" ) // Лимит устройств проверяется на ПАРАЛЛЕЛЬНЫХ запросах авторизации. // // Прежняя проверка сравнивала ответ `/online` с maxDevices и сразу отвечала // «allow»: между чтением и ответом место ничем не удерживалось, поэтому два // одновременных подключения при `online = max-1` получали разрешение оба и // объявленный лимит превышался. // // Последовательный тест этого не поймает никогда — нужен барьер, на котором // оба запроса гарантированно видят ОДНО И ТО ЖЕ состояние Hysteria. // --- Барьерный тест против настоящего HTTP ----------------------------------- // startBarrierTrafficStats поднимает Traffic Stats API, который задерживает // первые `hold` обращений к `/online` до тех пор, пока не придут все. // // Так воспроизводится ровно то состояние гонки, которое случается на живом // сервере: оба запроса авторизации прочитали статистику до того, как хоть один // из них успел превратиться в подключение. func startBarrierTrafficStats(t *testing.T, online map[string]int64, hold int) { t.Helper() var mu sync.Mutex arrived := 0 release := make(chan struct{}) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/online": mu.Lock() arrived++ last := arrived == hold mu.Unlock() if last { close(release) } else { select { case <-release: case <-time.After(5 * time.Second): // Барьер не собрался — отпускаем, чтобы тест упал по // существу, а не по таймауту всего прогона. } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(online) case "/kick": 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) } } // Главная регрессия AUTH-03: при `online = max-1` разрешение обязан получить // ровно ОДИН из двух одновременных запросов. func TestHysteria2AuthHoldsDeviceLimitUnderConcurrency(t *testing.T) { newTestDB(t) // seedPeer создаёт пира с maxDevices = 3, поэтому online = 2 — это // последнее свободное место. startBarrierTrafficStats(t, map[string]int64{"alpha-auth-id": 2}, 2) seedPeer(t, "alpha1", "alpha-auth-id") var wg sync.WaitGroup results := make([]error, 2) for i := range results { wg.Add(1) go func(idx int) { defer wg.Done() _, _, err := Hysteria2Auth("alpha1-secret") results[idx] = err }(i) } wg.Wait() allowed := 0 for _, err := range results { if err == nil { allowed++ } } if allowed != 1 { t.Fatalf("на последнее свободное место допущено %d подключений из 2", allowed) } } // Свободных мест два — проходят оба: механизм не должен превращаться в // сериализацию подключений. func TestHysteria2AuthAdmitsBothWhenTwoSlotsFree(t *testing.T) { newTestDB(t) startBarrierTrafficStats(t, map[string]int64{"alpha-auth-id": 1}, 2) seedPeer(t, "alpha1", "alpha-auth-id") var wg sync.WaitGroup results := make([]error, 2) for i := range results { wg.Add(1) go func(idx int) { defer wg.Done() _, _, err := Hysteria2Auth("alpha1-secret") results[idx] = err }(i) } wg.Wait() for idx, err := range results { if err != nil { t.Fatalf("подключение %d отклонено при двух свободных местах: %v", idx, err) } } } // Резервации принадлежат КОНКРЕТНОМУ пиру: занятое место одного не должно // закрывать доступ другому. func TestDeviceAdmissionsAreIsolatedPerPeer(t *testing.T) { newTestDB(t) startBarrierTrafficStats(t, map[string]int64{"alpha-auth-id": 2, "bravo-auth-id": 0}, 2) seedPeer(t, "alpha1", "alpha-auth-id") seedPeer(t, "bravo2", "bravo-auth-id") var wg sync.WaitGroup var alphaErr, bravoErr error wg.Add(2) go func() { defer wg.Done() _, _, alphaErr = Hysteria2Auth("alpha1-secret") }() go func() { defer wg.Done() _, _, bravoErr = Hysteria2Auth("bravo2-secret") }() wg.Wait() if alphaErr != nil { t.Fatalf("первое подключение пира на последнее место отклонено: %v", alphaErr) } if bravoErr != nil { t.Fatalf("резервация чужого пира закрыла доступ: %v", bravoErr) } } // Последовательно тот же лимит тоже держится: второй запрос видит место, // занятое первым, хотя `/online` ещё показывает прежнее число. func TestHysteria2AuthCountsPendingAdmissionSequentially(t *testing.T) { newTestDB(t) // Число НЕ меняется между запросами — именно так и ведёт себя Hysteria, // пока клиент ещё устанавливает соединение. startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 1}}) seedPeer(t, "alpha1", "alpha-auth-id") // max = 3, online = 1 -> свободно два места. if _, _, err := Hysteria2Auth("alpha1-secret"); err != nil { t.Fatalf("первое подключение отклонено: %v", err) } if _, _, err := Hysteria2Auth("alpha1-secret"); err != nil { t.Fatalf("второе подключение отклонено: %v", err) } // Третье превысило бы лимит: 1 онлайн + 2 выданных разрешения. if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil { t.Fatal("подключение сверх лимита принято: выданные разрешения не учтены") } } // --- Единица учёта ------------------------------------------------------------ const admissionAuthID = "auth-under-test" func TestReserveDeviceSlotAllowsUpToLimit(t *testing.T) { resetDeviceAdmissions() t.Cleanup(resetDeviceAdmissions) now := time.Now() for i := 1; i <= 3; i++ { if !reserveDeviceSlot(admissionAuthID, 0, 3, now) { t.Fatalf("разрешение %d из 3 отклонено", i) } } if reserveDeviceSlot(admissionAuthID, 0, 3, now) { t.Fatal("выдано четвёртое разрешение при лимите 3") } } // Рост числа онлайн-устройств означает, что выданные разрешения превратились в // подключения. Не сняв их, админка посчитала бы одно устройство дважды, и // лимит стал бы вдвое строже объявленного. func TestReserveDeviceSlotAbsorbsMaterializedAdmissions(t *testing.T) { resetDeviceAdmissions() t.Cleanup(resetDeviceAdmissions) now := time.Now() if !reserveDeviceSlot(admissionAuthID, 0, 2, now) { t.Fatal("первое разрешение отклонено") } // Клиент подключился: Hysteria теперь видит одно устройство. if !reserveDeviceSlot(admissionAuthID, 1, 2, now) { t.Fatal("проявившееся разрешение посчитано дважды") } // Теперь занято: одно подключение плюс одно выданное разрешение. if reserveDeviceSlot(admissionAuthID, 1, 2, now) { t.Fatal("выдано разрешение сверх лимита") } } // Разрешение, за которым не последовало подключения, освобождает место само. func TestReserveDeviceSlotExpiresPendingAdmission(t *testing.T) { resetDeviceAdmissions() t.Cleanup(resetDeviceAdmissions) now := time.Now() if !reserveDeviceSlot(admissionAuthID, 0, 1, now) { t.Fatal("первое разрешение отклонено") } if reserveDeviceSlot(admissionAuthID, 0, 1, now) { t.Fatal("выдано разрешение сверх лимита 1") } // Клиент так и не подключился. later := now.Add(pendingAdmissionTTL + time.Second) if !reserveDeviceSlot(admissionAuthID, 0, 1, later) { t.Fatal("протухшее разрешение не освободило место") } } // Место не занимается отказом: иначе серия отклонённых попыток удерживала бы // слоты на всё время TTL. func TestReserveDeviceSlotDoesNotConsumeSlotOnRefusal(t *testing.T) { resetDeviceAdmissions() t.Cleanup(resetDeviceAdmissions) now := time.Now() for i := 0; i < 5; i++ { if reserveDeviceSlot(admissionAuthID, 3, 3, now) { t.Fatal("выдано разрешение при исчерпанном лимите") } } // Одно устройство отключилось — место обязано быть свободно немедленно. if !reserveDeviceSlot(admissionAuthID, 2, 3, now) { t.Fatal("отклонённые попытки заняли места") } } // admissionEntries — размер учёта. func admissionEntries() int { deviceAdmissions.Lock() defer deviceAdmissions.Unlock() return len(deviceAdmissions.byAuthID) } // Учёт не растёт от повторных обращений: одна запись на пира, сколько бы // попыток он ни сделал. // // Границы роста здесь две, и обе существенны. Верхняя — число пиров: сюда // попадают только authId, прошедшие поиск по secret_digest и всю политику // доступа, поэтому произвольный ключ извне добавить нельзя. Нижняя — запись // исчезает, как только помнить о пире нечего (см. следующий тест). func TestReserveDeviceSlotKeepsOneEntryPerPeer(t *testing.T) { resetDeviceAdmissions() t.Cleanup(resetDeviceAdmissions) now := time.Now() for i := 0; i < 100; i++ { reserveDeviceSlot("transient-auth", 1, 1, now) } if got := admissionEntries(); got != 1 { t.Fatalf("повторные попытки одного пира дали %d записей", got) } } // Запись исчезает, когда о пире нечего помнить: он не онлайн и выданных // разрешений за ним нет. Без этого карта накапливала бы по строке на каждый // когда-либо авторизовавшийся authId, включая давно удалённых пиров. func TestReserveDeviceSlotForgetsIdlePeer(t *testing.T) { resetDeviceAdmissions() t.Cleanup(resetDeviceAdmissions) now := time.Now() if !reserveDeviceSlot(admissionAuthID, 1, 3, now) { t.Fatal("разрешение отклонено при свободном месте") } if admissionEntries() != 1 { t.Fatal("учёт не запомнил выданное разрешение") } // Пир отключился целиком, а выданное разрешение протухло: помнить нечего. later := now.Add(pendingAdmissionTTL + time.Second) // Лимит 0 — разрешение не выдаётся, поэтому запись остаться не должна. reserveDeviceSlot(admissionAuthID, 0, 0, later) if got := admissionEntries(); got != 0 { t.Fatalf("запись о неактивном пире осталась: записей %d", got) } } // Учёт выдерживает параллельный доступ и не выдаёт больше мест, чем есть. // Проверка ловит дефект и без детектора гонок: он виден по числу разрешений. func TestReserveDeviceSlotIsConcurrencySafe(t *testing.T) { resetDeviceAdmissions() t.Cleanup(resetDeviceAdmissions) const workers = 50 const limit = int64(7) now := time.Now() var wg sync.WaitGroup var mu sync.Mutex allowed := 0 for i := 0; i < workers; i++ { wg.Add(1) go func() { defer wg.Done() if reserveDeviceSlot(admissionAuthID, 0, limit, now) { mu.Lock() allowed++ mu.Unlock() } }() } wg.Wait() if int64(allowed) != limit { t.Fatalf("выдано %d разрешений при лимите %d", allowed, limit) } }