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

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

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

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

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

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

Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
This commit is contained in:
2026-09-01 17:17:17 +05:00
parent c0a43ae915
commit 162759c599
37 changed files with 3072 additions and 343 deletions
+132 -26
View File
@@ -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) {