6d1686b2be
Второй разбор того же слоя, уже по состоянию после 162759c. Тема: границы между
частями access-control. Прошлый проход починил одну операцию отзыва доступа и
оставил остальные; правило доступа при этом продолжало существовать в двух
экземплярах. Проведены три границы: состояние пира -> решение о доступе,
сохранённое изменение -> живая сессия, планировщик -> принадлежащая ему работа.
Правило доступа. Оно было записано двумя разными SQL-условиями: одним в выборке
Hysteria2Auth, другим в выборке cron. Второе не является отрицанием первого, и
расхождение приходилось ровно на границы — quota=0, usage=quota, now=expiresAt,
now=bannedUntil: авторизация отказывала, cron сессию не рвал. Условие cron
требовало СТРОГОГО превышения квоты, а счётчики растут порциями по ответу
Traffic Stats API, поэтому точное равенство — обычный исход очередного сбора.
Пир с исчерпанной квотой не пускался заново, но его живая сессия не разрывалась
никогда. Политика вынесена в peerAccessDenied; авторизация ищет пира только по
secret_digest, cron применяет ту же функцию. quota=-1 — единственный безлимит,
quota=0 — ноль байтов, bannedUntil=now — блокировка уже закончилась. Строка без
решающего поля трактуется как повреждённая и ведёт к отказу.
Операции, оставлявшие живую сессию. DeletePeer состоял из одного dao.DeletePeer:
строка исчезала вместе с auth_id, то есть вместе с единственным, чем эту сессию
можно было завершить, — состояние становилось невосстановимым. Разрыв при
изменении выполнялся только при disabled=1, поэтому мимо проходили смена
секрета, урезание квоты ниже израсходованного, перенос срока в прошлое и
снижение maxDevices. Импорт переписывает auth_id, секрет, квоту, срок и disabled
целиком и не трогал сессий вовсе. Все операции идут теперь через один
reconcileLiveSessions, а он — через disconnectAuthIDs, единственный вход к /kick:
он принимает готовые идентификаторы, дедуплицирует их, разбивает на части и не
обращается к базе. Импорт собирает старые auth_id ВНУТРИ транзакции (после
commit их в базе уже нет) и рвёт ПОСЛЕ commit (до него клиент успел бы
переподключиться к ещё не изменённому пиру). Правило асимметрично намеренно:
ограничение применяется немедленно, послабление — нет.
Цикл учёта. CronHandleAccount запускала горутину, которая запускала ещё две, —
для планировщика джоба заканчивалась почти мгновенно, поэтому StopCron не ждал
настоящей работы: releaseResource закрывал SQLite, а горутины продолжали в неё
писать. Параллельность обеих половин означала ещё и то, что enforcement читал
счётчики до записи снятой дельты. Джоба стала синхронной, под одним мьютексом на
весь цикл, порядок строгий. Закрыты три nil-разыменования — trafficSecretConfig,
item.AuthId и item.Id, — каждое из которых роняло процесс целиком вместе с
обработчиком machine-auth. Гейт Hysteria2IsRunning убран: util.Exec не отличает
«служба неактивна» от «спросить не удалось», и сломанный systemctl при живой
Hysteria молча отключал и учёт, и enforcement. Потеря дельты при отказе SQLite
больше не молчит: чтение /traffic?clear=1 деструктивно, и каждая потеря
считается. Checkpoint accounting в 1.0.0 намеренно не вводится — квота здесь
операционный предел доступа, а не учёт с финансово значимым каждым байтом.
Лимит устройств. Между чтением /online и ответом allow место ничем не
удерживалось: при online=max-1 два одновременных запроса получали разрешение
оба. Мьютекс вокруг /online этого не чинит — ответив allow, админка не создаёт
подключение, и следующий запрос продолжает видеть прежнее число. Появился
process-local учёт выданных, но ещё не проявившихся разрешений: решение по сумме
«подключено плюс зарезервировано», рост online снимает соответствующее их число,
протухшие снимаются по внутреннему TTL. Сеть опрашивается вне блокировки.
Гейты. Проверка «авторизация не возвращает успех из ветки ошибки» была записана
регуляркой err != nil \{[\s\S]*?return \*peer\.Id, а ленивый [\s\S]*? свободно
пересекает границы блоков: она даёт совпадение на коде из HEAD, то есть гейт
нельзя было удовлетворить, не сломав продукт. Тело ветки теперь выделяется по
балансу фигурных скобок, и логика проверена в обе стороны. go test -race стал
обязательным шагом сборки: состояние трекера разрешений и мьютекс цикла учёта
принадлежат процессу, и их корректность не наблюдаема ни в go test, ни в go vet;
пропуск при недоступном компиляторе не предусмотрен.
Панель. importPeerApi не объявлял skipErrorToast, а handleImport не имел ни try,
ни catch: после появления частичного результата отказ уходил бы необработанным
отклонением промиса, список не обновлялся бы при уже изменённой базе, а общий
перехватчик показал бы предупреждение красной ошибкой. Формулировка
peer_disconnect_failed во всех трёх местах сделана operation-neutral: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
667 lines
26 KiB
Go
667 lines
26 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"
|
||
)
|
||
|
||
// Отзыв доступа проверяется ПРОТИВ НАСТОЯЩЕГО 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)
|
||
}
|
||
}
|
||
|
||
// --- 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)
|
||
withHysteriaRunning(t, false)
|
||
|
||
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}})
|
||
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)
|
||
}
|
||
}
|