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