fix(admin): достроить вторые половины отзыва доступа, лимита и журнала

Разбор кода на c0a43ae со сверкой с официальной документацией Hysteria 2.
Общая тема: операции, у которых была только одна из двух необходимых половин.

Отзыв доступа. Запись disabled=1 видит лишь выборка в Hysteria2Auth, то есть
закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей
жизнью и сама не разрывается. После «Отключить» пир пользовался доступом сколько
угодно долго, а панель показывала его отключённым. Появился DisconnectPeers —
только официальный Traffic Stats /kick, без записи в базу; прежний Hysteria2Kick
вместе с разрывом проставлял banned_until и потому для отключения не годился.
Порядок «запись, затем разрыв» обратному не подлежит и доказан снимком базы в
момент прихода /kick. Неудача разрыва не откатывает disabled и сообщается кодом
peer_disconnect_failed: обычная ошибка означала бы для оператора вывод, прямо
противоположный истине. KickPeer переведён на тот же примитив — он писал
banned_until дважды и мог ответить чистым отказом уже в применённом состоянии.

Ограничение устройств. Отказ /online обрабатывался возвратом успеха
авторизации, то есть недоступность 127.0.0.1 превращала объявленный лимит в
безлимит. Вторая половина дыры была тише: Hysteria2Online отдавал пустую карту
БЕЗ ошибки, когда systemd отвечал «служба неактивна», — а этот ответ не
отличается от «спросить systemctl не удалось». Пути разделены: терпимый для
отображения, строгий для решения о доступе. Hysteria2IsRunning убран с путей
принятия решений совсем.

Журнал. entry.Info() вызывался без аргумента, и logrus писал "msg":"" для
каждого запроса — пустой столбец на экране был точным отражением файла. Ветка
«файла ещё нет» отвечала голым массивом вместо {records,total}, поэтому на
свежей установке страница системных логов не работала вовсе. Битая строка
вызывала vo.Fail И continue: клиент получал два JSON-документа подряд.

Панель. Общий LogViewer и utils/download.ts (копий скачивания было четыре, две
ставили запрос вне try и глушили причину); меню на command с быстрым
включением/отключением; popper-style у подсказки; kick с подтверждением и
названным сроком; отмена подтверждений перестала быть ошибкой. Отдельно:
skipErrorToast гасил и транспортный отказ, при том что страницы писали
«перехватчик уже показал» и молчали, — обрыв связи не показывал ничего.

Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
This commit is contained in:
2026-09-01 17:17:17 +05:00
parent c0a43ae915
commit 162759c599
37 changed files with 3072 additions and 343 deletions
+620
View File
@@ -0,0 +1,620 @@
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"
)
// Отзыв доступа проверяется ПРОТИВ НАСТОЯЩЕГО 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)
}
}
// withHysteriaRunning подменяет ответ systemd о состоянии службы.
func withHysteriaRunning(t *testing.T, running bool) {
t.Helper()
previous := hysteria2IsRunning
hysteria2IsRunning = func() bool { return running }
t.Cleanup(func() { hysteria2IsRunning = 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)
}
}
// --- DisconnectPeers ---------------------------------------------------------
func TestDisconnectPeersSendsOfficialKickContract(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := DisconnectPeers([]int64{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 TestDisconnectPeersDoesNotTouchPeerState(t *testing.T) {
newTestDB(t)
startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := DisconnectPeers([]int64{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)
}
}
func TestDisconnectPeersIsNoopWithoutTargets(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
if err := DisconnectPeers(nil); err != nil {
t.Fatalf("пустой список признан отказом: %v", err)
}
// Пир без authId Hysteria не знает: рвать нечего.
id := seedPeer(t, "alpha1", "")
if err := DisconnectPeers([]int64{id}); err != nil {
t.Fatalf("пир без authId признан отказом: %v", err)
}
if stub.kickCalls != 0 {
t.Fatalf("сделан вызов /kick без единой цели: %d", stub.kickCalls)
}
}
func TestDisconnectPeersReportsApiFailure(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := DisconnectPeers([]int64{id}); err == nil {
t.Fatal("отказ Traffic Stats API не сообщён")
}
}
// Состояние службы по systemd на этом пути не спрашивается вовсе: ответ
// systemctl не отличает «служба неактивна» от «спросить не удалось», и на
// прежнем пути его сбой отказывал операции при живой Hysteria.
func TestDisconnectPeersIgnoresSystemdOpinion(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
withHysteriaRunning(t, false)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := DisconnectPeers([]int64{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}})
withHysteriaRunning(t, false)
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("пир без идентичности принят")
}
}
// Отображение остаётся терпимым: пустая картина подключений — честный ответ на
// вопрос «кто сейчас на связи», когда служба остановлена. Строгость нужна
// только там, где принимается решение о доступе.
func TestHysteria2OnlineStaysTolerantForDisplay(t *testing.T) {
newTestDB(t)
pointHysteriaConfigAtDeadPort(t)
withHysteriaRunning(t, false)
online, err := Hysteria2Online()
if err != nil {
t.Fatalf("отображение подключений стало отказывать: %v", err)
}
if len(online) != 0 {
t.Fatalf("ожидалась пустая картина подключений, получено %v", online)
}
}