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:
@@ -26,7 +26,29 @@ func InitHysteria2() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Hysteria2IsRunning — ответ systemd о состоянии службы, и НЕ БОЛЬШЕ ТОГО.
|
||||
//
|
||||
// Точность этого ответа ограничена сверху устройством util.Exec: он схлопывает
|
||||
// «systemctl вернул 3, служба неактивна» и «запустить systemctl не удалось» в
|
||||
// одну ошибку. Различить их здесь нельзя, поэтому false означает «служба
|
||||
// неактивна ИЛИ спросить не получилось».
|
||||
//
|
||||
// Отсюда правило, которое стоило продукту двух дыр: на этом значении нельзя
|
||||
// строить решения о доступе и о применении операции. Ему место в отображении
|
||||
// (дашборд, признак online в списке), где ошибочное «выключено» стоит одной
|
||||
// неверной плашки. Решения о доступе принимаются по фактическому ответу
|
||||
// Traffic Stats API — см. hysteria2Online и DisconnectPeers.
|
||||
func Hysteria2IsRunning() bool {
|
||||
return hysteria2IsRunning()
|
||||
}
|
||||
|
||||
// hysteria2IsRunning — та же проверка в виде значения.
|
||||
//
|
||||
// Переменная нужна тестам: без неё каждая проверка, зависящая от состояния
|
||||
// службы, доказуема только на живом systemd, то есть не доказуема в сборке
|
||||
// вовсе. Продуктовый путь от этого не меняется — значение по умолчанию и есть
|
||||
// прежняя реализация.
|
||||
var hysteria2IsRunning = func() bool {
|
||||
_, err := util.Exec("systemctl is-active --quiet hysteria-server")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
+132
-26
@@ -48,7 +48,10 @@ func resolvePublicEndpoint() (string, int, error) {
|
||||
}
|
||||
port, err := strconv.Atoi(portRaw)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return "", 0, errors.New("HY2XS_PUBLIC_PORT must be a valid TCP port")
|
||||
// Транспорт в формулировке не называется: публичный endpoint Hysteria —
|
||||
// UDP/QUIC, и «TCP port» здесь закладывал в сообщение об ошибке
|
||||
// заведомо ложную семантику.
|
||||
return "", 0, errors.New("HY2XS_PUBLIC_PORT must be a valid port")
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
@@ -68,67 +71,170 @@ func Hysteria2Auth(conPass string) (int64, string, error) {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
// Ограничение количества устройств
|
||||
onlineUsers, err := Hysteria2Online()
|
||||
if err != nil {
|
||||
logrus.WithError(err).Warn("hysteria2 online users unavailable; skip device-limit check")
|
||||
return *peer.Id, *peer.AuthId, nil
|
||||
// Строка без идентичности — повреждённые данные, а не пир.
|
||||
//
|
||||
// Проверка стоит здесь по той же причине, что и проверка maxDevices ниже:
|
||||
// это путь КАЖДОГО подключения пира, и разыменование nil на нём означает
|
||||
// панику в обработчике machine-auth, а не отказ одному клиенту.
|
||||
if peer.Id == nil || peer.AuthId == nil || *peer.AuthId == "" {
|
||||
logrus.Error("peer row has no usable identity; rejecting auth")
|
||||
return 0, "", errors.New("peer identity unavailable")
|
||||
}
|
||||
device, exist := onlineUsers[*peer.AuthId]
|
||||
if exist && *peer.MaxDevices <= device {
|
||||
|
||||
// Ограничение количества устройств — fail-closed.
|
||||
//
|
||||
// Раньше отказ Traffic Stats API обрабатывался так:
|
||||
//
|
||||
// onlineUsers, err := Hysteria2Online()
|
||||
// if err != nil {
|
||||
// logrus.WithError(err).Warn(...)
|
||||
// return *peer.Id, *peer.AuthId, nil
|
||||
// }
|
||||
//
|
||||
// То есть недоступность внутреннего 127.0.0.1 превращала объявленный в
|
||||
// панели «Лимит устройств: 3» в безлимит, и узнать об этом оператор мог
|
||||
// только по строке WARN в журнале, которую никто не читает. Ограничение,
|
||||
// которое отключается само при первой же внутренней неполадке, не является
|
||||
// ограничением.
|
||||
//
|
||||
// Вторая половина той же дыры была тише: общий Hysteria2Online отдавал
|
||||
// пустую карту БЕЗ ошибки, когда systemd отвечал «служба неактивна», —
|
||||
// а этот ответ не отличается от «спросить systemctl не удалось». Поэтому
|
||||
// здесь берётся строгий путь: только фактический ответ Traffic Stats API.
|
||||
//
|
||||
// Направление отказа выбрано осознанно. Запрос авторизации приходит ОТ
|
||||
// Hysteria, то есть в момент этой проверки Hysteria заведомо жива, а её
|
||||
// Traffic Stats API слушает loopback внутри того же процесса. Его
|
||||
// недоступность здесь — не штатное состояние, а аномалия, и пускать
|
||||
// подключения без единственной проверки, которая ещё не выполнена, значит
|
||||
// молча снять лимит со всех пиров сразу.
|
||||
onlineUsers, err := hysteria2Online()
|
||||
if err != nil {
|
||||
logrus.WithError(err).
|
||||
WithField("peerId", *peer.Id).
|
||||
Error("hysteria2 traffic stats api unavailable; device limit cannot be enforced, rejecting auth")
|
||||
return 0, "", errors.New("device limit unavailable")
|
||||
}
|
||||
|
||||
// maxDevices без значения — это не «безлимит», а неизвестная граница.
|
||||
// Схема даёт колонке DEFAULT, форма требует min=1, импорт приводит <=0 к 3,
|
||||
// поэтому nil здесь означать может только повреждённую строку — и на пути
|
||||
// принятия решения о доступе она обязана вести к отказу, а не к пропуску.
|
||||
if peer.MaxDevices == nil || *peer.MaxDevices < 1 {
|
||||
logrus.WithField("peerId", *peer.Id).
|
||||
Error("peer has no usable maxDevices; rejecting auth")
|
||||
return 0, "", errors.New("device limit unavailable")
|
||||
}
|
||||
|
||||
if device, exist := onlineUsers[*peer.AuthId]; exist && *peer.MaxDevices <= device {
|
||||
return 0, "", errors.New("device limited")
|
||||
}
|
||||
|
||||
return *peer.Id, *peer.AuthId, nil
|
||||
}
|
||||
|
||||
// Hysteria2Online — картина подключений ДЛЯ ОТОБРАЖЕНИЯ.
|
||||
//
|
||||
// Терпима к недоступности Hysteria намеренно: дашборду и списку пиров нечего
|
||||
// показать, если Traffic Stats API молчит, и пустая картина — честный ответ на
|
||||
// вопрос «кто сейчас на связи», когда служба остановлена.
|
||||
//
|
||||
// Этой терпимости НЕТ в решениях о доступе. Ярлык `!hysteria2IsRunning() ->
|
||||
// пусто, ошибки нет` раньше стоял на общем пути, и через него ограничение
|
||||
// устройств обходилось молча: systemctl отвечает через util.Exec, который не
|
||||
// отличает «служба неактивна» от «спросить не удалось», а «никого нет онлайн»
|
||||
// для проверки лимита означает «пускать всех». Отказа при этом не возникало
|
||||
// вовсе — не было даже записи в журнале.
|
||||
func Hysteria2Online() (map[string]int64, error) {
|
||||
if !Hysteria2IsRunning() {
|
||||
if !hysteria2IsRunning() {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
return hysteria2Online()
|
||||
}
|
||||
|
||||
// hysteria2Online — фактический ответ Traffic Stats API, без ярлыков.
|
||||
//
|
||||
// Недоступность здесь остаётся ошибкой: вызывающий обязан решить, что она для
|
||||
// него значит, и не может получить пустую карту вместо отказа.
|
||||
func hysteria2Online() (map[string]int64, error) {
|
||||
apiPort, err := GetHysteria2ApiPort()
|
||||
if err != nil {
|
||||
return nil, errors.New("get hysteria2 apiPort err")
|
||||
}
|
||||
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||
secret, err := hysteria2TrafficSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
onlineUsers, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(*trafficSecretConfig.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return onlineUsers, nil
|
||||
return proxy.NewHysteria2Api(apiPort).OnlineUsers(secret)
|
||||
}
|
||||
|
||||
func Hysteria2Kick(ids []int64, kickUtilTime int64) error {
|
||||
if !Hysteria2IsRunning() {
|
||||
return errors.New("hysteria2 is not running")
|
||||
// hysteria2TrafficSecret отдаёт секрет Traffic Stats API.
|
||||
//
|
||||
// Отсутствующее значение ключа — отказ, а не пустая строка. Раньше по этому
|
||||
// пути стояло `*config.Value` без проверки: строка в таблице `config` без
|
||||
// значения роняла бы админку паникой на разыменовании nil прямо в обработчике
|
||||
// machine-auth, то есть на пути каждого подключения пира.
|
||||
func hysteria2TrafficSecret() (string, error) {
|
||||
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := dao.UpdatePeer(ids, map[string]interface{}{"banned_until": kickUtilTime}); err != nil {
|
||||
return err
|
||||
if trafficSecretConfig.Value == nil || *trafficSecretConfig.Value == "" {
|
||||
return "", errors.New("hysteria2 traffic stats secret is not configured")
|
||||
}
|
||||
return *trafficSecretConfig.Value, nil
|
||||
}
|
||||
|
||||
// DisconnectPeers завершает активные Hysteria-сессии пиров и НИЧЕГО не пишет в
|
||||
// базу.
|
||||
//
|
||||
// Разрыв сессии и запись состояния разделены сознательно. Прежний
|
||||
// Hysteria2Kick делал и то и другое: вместе с обращением к `/kick` он
|
||||
// проставлял `banned_until`. Из-за этого им нельзя было воспользоваться для
|
||||
// отключения пира — операция `disabled=1` записала бы заодно временную
|
||||
// блокировку, а это другой механизм с другим сроком жизни и другим способом
|
||||
// снятия. Единственный вызывающий (KickPeer) при этом писал `banned_until` ещё
|
||||
// и сам, то есть одно и то же значение уезжало в базу дважды.
|
||||
//
|
||||
// Здесь остаётся ровно официальный Traffic Stats `/kick` и ничего больше.
|
||||
//
|
||||
// Состояние службы по systemd НЕ проверяется. Раньше путь начинался с
|
||||
// `!Hysteria2IsRunning() -> отказ`, и это давало худшее из двух: ответ systemd
|
||||
// не отличает «служба неактивна» от «спросить не удалось» (см.
|
||||
// Hysteria2IsRunning), поэтому сбой самого systemctl превращался в отказ
|
||||
// операции при живой Hysteria, а обратная ошибка молча пропускала бы разрыв.
|
||||
// Обращение к `/kick` отвечает на нужный вопрос напрямую и без посредника.
|
||||
func DisconnectPeers(ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
peers, err := dao.ListPeer("id in ?", ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var keys []string
|
||||
|
||||
keys := make([]string, 0, len(peers))
|
||||
for _, item := range peers {
|
||||
if item.AuthId == nil || *item.AuthId == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, *item.AuthId)
|
||||
}
|
||||
// Пир без authId Hysteria не знает: рвать нечего, и это не отказ.
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
apiPort, err := GetHysteria2ApiPort()
|
||||
if err != nil {
|
||||
return errors.New("get hysteria2 apiPort err")
|
||||
}
|
||||
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||
secret, err := hysteria2TrafficSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = proxy.NewHysteria2Api(apiPort).KickUsers(keys, *trafficSecretConfig.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return proxy.NewHysteria2Api(apiPort).KickUsers(keys, secret)
|
||||
}
|
||||
|
||||
func Hysteria2Url(accountId int64) (string, error) {
|
||||
|
||||
+64
-2
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
@@ -160,7 +162,52 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
|
||||
if peerDto.Remark != nil {
|
||||
updates["remark"] = *peerDto.Remark
|
||||
}
|
||||
return dao.UpdatePeer([]int64{id}, updates)
|
||||
if err := dao.UpdatePeer([]int64{id}, updates); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Отключение пира — это ОБЕ половины официального контракта Hysteria.
|
||||
//
|
||||
// Запись `disabled=1` закрывает только будущие обращения к HTTP-auth: её
|
||||
// видит условие выборки в Hysteria2Auth. Уже установленная QUIC-сессия
|
||||
// живёт своей жизнью и сама по себе не разрывается — то есть после
|
||||
// «Отключить» пир продолжал пользоваться доступом сколько угодно долго,
|
||||
// пока не переподключался по своей воле. Панель при этом показывала его
|
||||
// отключённым.
|
||||
//
|
||||
// Вторую половину даёт Traffic Stats `/kick`. Официальная документация
|
||||
// описывает их именно как пару: `/kick` завершает сессию, но клиент
|
||||
// немедленно переподключается, поэтому одновременно требуется блокировка в
|
||||
// auth backend. По отдельности не работает ни одна.
|
||||
//
|
||||
// Порядок обязателен и обратному не подлежит: сначала долговременная
|
||||
// запись, потом разрыв. При обратном порядке клиент успевает
|
||||
// переподключиться в окне между `/kick` и записью — и остаётся на связи с
|
||||
// формально отключённым пиром.
|
||||
//
|
||||
// Условие проверяет ЗАПРОШЕННОЕ состояние, а не переход из включённого.
|
||||
// Так операция остаётся повторяемой: если разрыв не удался, оператор
|
||||
// повторяет «Отключить» и получает вторую попытку, вместо того чтобы
|
||||
// включать пира ради возможности отключить его снова.
|
||||
if peerDto.Disabled != nil && *peerDto.Disabled == 1 {
|
||||
return disconnectAfterRevoke(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// disconnectAfterRevoke рвёт сессии пира после уже применённой записи.
|
||||
//
|
||||
// Отказ НЕ откатывает состояние: безопасная его половина достигнута, и
|
||||
// возвращать пиру полный доступ из-за неудачи второго шага нельзя. Вызывающему
|
||||
// возвращается частичный результат отдельным кодом — см. PeerDisconnectError.
|
||||
func disconnectAfterRevoke(id int64) error {
|
||||
if err := DisconnectPeers([]int64{id}); err != nil {
|
||||
logrus.WithError(err).
|
||||
WithField("peerId", id).
|
||||
Error("peer access revoked in database, but hysteria2 session disconnect failed")
|
||||
return PeerDisconnectError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// assertBootstrapPeerIdentityUnchanged запрещает менять то, что продублировано
|
||||
@@ -246,11 +293,26 @@ func ReleaseKickPeer(id int64) error {
|
||||
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": 0})
|
||||
}
|
||||
|
||||
// KickPeer — временная блокировка: пир не пускается до bannedUntil, а его
|
||||
// текущая сессия завершается.
|
||||
//
|
||||
// Механизм независим от `disabled`. Это два разных решения оператора с разным
|
||||
// сроком жизни: `banned_until` истекает сам, `disabled` снимается только
|
||||
// руками. Поэтому ReleaseKickPeer не трогает `disabled`, а включение пира не
|
||||
// сбрасывает `banned_until`.
|
||||
//
|
||||
// Что было. `banned_until` записывался здесь, а затем ВТОРОЙ РАЗ внутри
|
||||
// Hysteria2Kick — то же значение двумя операторами подряд. Хуже дублирования
|
||||
// был порядок отказа: Hysteria2Kick начинался с проверки состояния службы и
|
||||
// возвращал ошибку, не сделав ничего, — но первая запись к этому моменту уже
|
||||
// применилась. Операция отвечала чистым отказом, находясь при этом в
|
||||
// применённом состоянии, и оператор видел «не сработало» у сработавшей
|
||||
// блокировки.
|
||||
func KickPeer(id int64, bannedUntil int64) error {
|
||||
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": bannedUntil}); err != nil {
|
||||
return err
|
||||
}
|
||||
return Hysteria2Kick([]int64{id}, bannedUntil)
|
||||
return disconnectAfterRevoke(id)
|
||||
}
|
||||
|
||||
func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,11 @@ func TestCreatePeerRefusesReservedName(t *testing.T) {
|
||||
// оператор не смог бы ни отключить bootstrap-пира, ни ограничить ему квоту.
|
||||
func TestUpdatePeerAllowsNonIdentityChangesOnBootstrapPeer(t *testing.T) {
|
||||
newTestDB(t)
|
||||
// Отключение пира — это ещё и разрыв его активных сессий, поэтому Traffic
|
||||
// Stats API обязан отвечать. Без него операция завершилась бы частичным
|
||||
// результатом (запись применена, сессия не разорвана), и тест доказывал бы
|
||||
// не то, ради чего написан.
|
||||
startTrafficStats(t, nil)
|
||||
id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName)
|
||||
|
||||
disabled := int64(1)
|
||||
|
||||
@@ -54,6 +54,27 @@ var ErrPeerNameReserved = &PeerError{
|
||||
),
|
||||
}
|
||||
|
||||
// PeerDisconnectError — долговременное состояние применено, активную сессию
|
||||
// завершить не удалось.
|
||||
//
|
||||
// Причина отказа `/kick` в сообщение НЕ попадает. Она приходит из слоя proxy в
|
||||
// виде транспортного текста («http connection error») и оператору не говорит
|
||||
// ничего, а диагностическая ценность у неё есть только в журнале, куда она и
|
||||
// пишется вызывающим. Оператору нужно другое: что именно уже сделано и что
|
||||
// осталось сделать.
|
||||
//
|
||||
// Отказ относится к операции целиком, а не к полю формы: поля, которое можно
|
||||
// было бы исправить, здесь нет.
|
||||
func PeerDisconnectError() *PeerError {
|
||||
return &PeerError{
|
||||
Code: constant.ErrCodePeerDisconnectFailed,
|
||||
Message: "новые подключения пира запрещены, но завершить его активные " +
|
||||
"сессии не удалось: Traffic Stats API Hysteria недоступен. " +
|
||||
"Уже установленное соединение может продолжать работать до " +
|
||||
"переподключения клиента",
|
||||
}
|
||||
}
|
||||
|
||||
// PeerNameTakenError — имя уже занято другим пиром.
|
||||
func PeerNameTakenError(name string) *PeerError {
|
||||
return &PeerError{
|
||||
|
||||
Reference in New Issue
Block a user