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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user