Разбор кода на 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.
29 KiB
Разбор кода перед сборкой 1.0.0-rc2
Источник — не прогон на хосте, а разбор дерева на коммите c0a43ae9 и сверка
Hysteria-интеграции с официальной документацией Hysteria 2 и с API Element Plus.
Поэтому файл отдельный: дефекты приёмки rc1 перечислены в
2026-09-01-v1.0.0-rc1-ux-findings.md, и
смешивать их с найденным при чтении кода значило бы приписать хостовому прогону
то, чего он не показывал.
Проверить их на живом сервере ещё предстоит: на тестовом хосте по-прежнему
стоит rc1. Список ручных проверок — в конце документа.
Сводка
| ID | Дефект | Приоритет | Статус |
|---|---|---|---|
| UX-06 | Нет быстрого включения/отключения пира; отключение не рвёт сессию | P1 | закрыт |
| UX-07 | Подсказка «Экспорт настроек» без ограничения ширины | P2 | закрыт |
| UX-08 | Срок временной блокировки зашит в код и не сообщается | P2 | закрыт |
| UX-09 | Отмена подтверждения считалась ошибкой | P2 | закрыт |
| UX-10 | Подсказка имени пира описывала не действующее правило | P3 | закрыт |
| UX-11 | Отказ показывался дважды, а транспортный — ни разу | P1 | закрыт |
| LOG-01 | Журнал запросов пишет пустой msg |
P1 | закрыт |
| LOG-02 | Ширины колонок журнала не заданы | P1 UX | закрыт |
| LOG-03 | Отказ выгрузки журнала не ловился и глушился | P1 | закрыт |
| LOG-04 | Страница системных логов ломалась, пока нет файла журнала | P1 | закрыт |
| LOG-05 | Одна битая строка журнала ломала весь ответ | P1 | закрыт |
| AUTH-01 | maxDevices fail-open при отказе /online |
P1 | закрыт |
| AUTH-02 | maxDevices не проверялся, когда systemd отвечал «неактивна» |
P1 | закрыт |
| CORE-01 | Ошибка называет TCP port для UDP-эндпоинта |
P3 | закрыт |
| CORE-02 | banned_until писался дважды, отказ отчитывался как полный |
P1 | закрыт |
| TYPE-01 | Типы полей журнала в панели расходились с сервером | P3 | закрыт |
LOG-04, LOG-05, AUTH-02, CORE-02, UX-08…UX-11 и TYPE-01 в исходный разбор не входили и найдены при проверке его выводов по коду. UX-11 нашёлся позже остальных — при проверке уже внесённых исправлений.
UX-06 — отключение пира не отзывало доступ
Наблюдалось: пункта включения/отключения нет в меню строки вовсе. Сменить состояние можно было только через форму изменения.
Корневая причина глубже отсутствующего пункта. Вернуть пункт и вызвать
PATCH /peers/:id с disabled: 1 было бы недостаточно: UpdatePeer записывал
поле в SQLite и на этом заканчивался.
Запись disabled=1 видит выборка в Hysteria2Auth, то есть она закрывает
только БУДУЩИЕ обращения к HTTP-auth. Установленная QUIC-сессия живёт своей
жизнью и сама по себе не разрывается: после «Отключить» пир продолжал
пользоваться доступом сколько угодно долго, пока не переподключался по своей
воле. Панель при этом показывала его отключённым.
Официальная документация Hysteria описывает эти половины как пару: Traffic
Stats /kick завершает сессию, но клиент немедленно переподключается, поэтому
одновременно требуется блокировка в auth backend. По отдельности не работает ни
одна.
У продукта были обе половины, но разведённые по разным операциям:
disabled=1 -> блокирует последующий HTTP-auth
POST /kick -> разрывает текущую сессию
Как закрыто.
service.DisconnectPeers(ids)— только официальный/kick, без единой записи в базу. ПрежнийHysteria2Kickвместе с разрывом проставлялbanned_until, поэтому воспользоваться им для отключения было нельзя: операция записала бы заодно временную блокировку — другой механизм с другим сроком жизни и другим способом снятия.UpdatePeerприdisabled=1выполняет обе половины: сначала долговременную запись, затем разрыв.- Порядок обратному не подлежит. При обратном клиент успевает
переподключиться в окне между
/kickи записью и остаётся на связи с формально отключённым пиром. Порядок доказывается тестом, который снимает состояние базы В МОМЕНТ прихода/kick: после операции оба шага уже выполнены и проверять там нечего. - Неудача разрыва НЕ откатывает
disabled. Безопасная половина достигнута, и возвращать пиру полный доступ из-за отказа второго шага нельзя. - Состояние службы по systemd на этом пути не спрашивается.
util.Execсхлопывает «systemctl вернул 3» и «запустить systemctl не удалось» в одну ошибку, поэтому прежняя проверка!Hysteria2IsRunning() -> отказотказывала бы операции при живой Hysteria. Обращение к/kickотвечает на нужный вопрос напрямую. - Условие смотрит на ЗАПРОШЕННОЕ состояние, а не на переход из включённого: иначе после неудачного разрыва оператору пришлось бы включить пира, чтобы получить право отключить его снова.
Частичный результат сообщается кодом, а не прозой. Отдельный
peer_disconnect_failed: без него оператор прочитал бы обычную ошибку как «не
сработало, состояние прежнее» — вывод, прямо противоположный истине. Панель
показывает его предупреждением и обновляет строку.
Меню переведено на command. @click на каждом el-dropdown-item
заменён штатным контрактом el-dropdown: команда приходит в одно место, и
добавить пункт, забыв его подключить, становится невозможно.
AUTH-01 и AUTH-02 — ограничение устройств отключалось само
Hysteria2Auth после проверки пира спрашивал /online. При отказе:
onlineUsers, err := Hysteria2Online()
if err != nil {
logrus.WithError(err).Warn(...)
return *peer.Id, *peer.AuthId, nil
}
Недоступность внутреннего 127.0.0.1 превращала объявленный в панели «Лимит
устройств: 3» в безлимит. Узнать об этом оператор мог только по строке WARN в
журнале, которую никто не читает.
Вторая половина дыры оказалась тише первой и в исходный разбор не входила.
Hysteria2Online начинался с ярлыка:
if !Hysteria2IsRunning() {
return map[string]int64{}, nil
}
Пустая карта БЕЗ ОШИБКИ — это «онлайн никого», то есть лимит не проверяется, и
в журнале не появляется ни строки. А Hysteria2IsRunning отвечает через
util.Exec, который не отличает «служба неактивна» от «спросить не удалось».
Как закрыто. Пути разделены по назначению:
Hysteria2Online— для ОТОБРАЖЕНИЯ (дашборд, признак online в списке). Терпимость сохранена: пустая картина — честный ответ на вопрос «кто сейчас на связи», когда служба остановлена.hysteria2Online— фактический ответ Traffic Stats API, без ярлыков. Недоступность остаётся ошибкой. Этим путём идёт авторизация.
Направление отказа выбрано fail-closed осознанно: запрос авторизации приходит ОТ Hysteria, то есть в момент проверки Hysteria заведомо жива, а её Traffic Stats API слушает loopback внутри того же процесса. Его недоступность здесь — аномалия, а не штатное состояние.
Следствие, о котором нужно знать оператору. maxDevices имеет min=1,
безлимита у него не бывает, поэтому недоступность Traffic Stats API отказывает
в подключении всем пирам сразу — и это записывается в журнал уровнем error, а
не warn. Обратный выбор означал бы молчаливое снятие лимита со всех пиров
одновременно.
Заодно закрыты два соседних места на том же пути: секрет Traffic Stats API
читался как *config.Value без проверки на nil (паника в обработчике
machine-auth, то есть на пути каждого подключения пира), а повреждённый
maxDevices трактовался бы как отсутствие границы.
CORE-02 — временная блокировка отчитывалась отказом, будучи применённой
KickPeer записывал banned_until, затем звал Hysteria2Kick, который
записывал banned_until ВТОРОЙ РАЗ тем же значением. Хуже дублирования был
порядок отказа: Hysteria2Kick начинался с проверки состояния службы и
возвращал ошибку, не сделав ничего, — но первая запись к этому моменту уже
применилась. Операция отвечала чистым отказом, находясь в применённом
состоянии.
Закрыто тем же примитивом, что и UX-06: долговременная запись, затем
DisconnectPeers, затем — при неудаче разрыва — частичный результат отдельным
кодом.
Механизмы остались независимыми: banned_until истекает сам, disabled
снимается только руками; включение пира не сбрасывает временную блокировку, а
её снятие не включает отключённого пира.
LOG-01 — причина пустых системных логов
Это не рассогласование модели отображения с форматом файла. LogSystemVo
полностью соответствует структуре записи.
Middleware собирал поля правильно, но затем вызывал:
entry.Error()
entry.Warn()
entry.Info()
без аргумента сообщения, и logrus честно записывал "msg":"" для каждого HTTP
запроса. Пустой столбец на экране был точным отражением того, что записал
backend.
Как закрыто. middleware.RequestLogMessage собирает строку ИЗ ТЕХ ЖЕ
величин, что уже лежат в структурных полях:
GET /api/peers → 200 (7 ms)
POST /internal/hysteria/auth → 200 (2 ms)
Запись остаётся машиночитаемой; msg существует, чтобы человек мог прочитать
её, не собирая строку из шести колонок.
Query-строка сюда не попадает. Это действующий контракт безопасности, а не
небрежность: Hysteria обращается к машинному endpoint'у как
/internal/hysteria/auth?access_token=<machine token> при каждом подключении
пира, а журнал отдаётся оператору через ExportLog и уезжает в
diagnostics-бандл. Тест проверяет обе половины сразу: msg непустой И не несёт
ни токена, ни знака ?.
LOG-04 и LOG-05 — страница логов ломалась двумя разными способами
Ни один из них в исходный разбор не входил.
LOG-04. Ветка «файла журнала ещё нет» отвечала голым массивом:
vo.Success(logSystemVos, c)
Панель читает data.records, поэтому получала undefined и передавала его в
:data таблицы. То есть на свежепоставленном хосте — до первой записи в
журнал — страница системных логов не работала вовсе. Это ровно тот сценарий,
который проверяется на приёмке каждой чистой установки.
LOG-05. При неразбираемой строке выполнялось:
if err != nil {
vo.Fail("Unable to unmarshal log data", c)
continue
}
Ответ записывался в поток, цикл шёл дальше, а в конце безусловно выполнялся
vo.Success. Клиент получал два JSON-документа подряд, то есть невалидный
ответ. Достаточно было ОДНОЙ битой строки, чтобы страница перестала
открываться целиком — а строка бьётся штатно: lumberjack ротирует файл, и
обрыв последней записи на границе ротации — обычное событие.
Как закрыто. Форма ответа {records, total} на всех ветках; неразбираемая
строка пропускается без записи ответа — остальные записи прочитаны и полезны.
LOG-02 и LOG-03 — обе страницы журнала чинились дважды
Страницы системного журнала и журнала Hysteria были побайтово одинаковы, кроме вызываемого API, и несли одни и те же дефекты в двух экземплярах.
LOG-02. Ни width, ни min-width не заданы, поэтому Element Plus делил
ширину практически поровну: уровень и время получали по трети строки, а
сообщение — единственное содержимое журнала — тоже треть. Официальный API
разделяет width (фиксирует) и min-width (участвует в распределении
остатка).
LOG-03. Сетевой запрос стоял ПЕРЕД try:
let response = await exportLogApi(...);
try { ... } catch (e) { /* empty */ }
Отказ самого запроса этим catch не ловился вовсе, а всё внутри глушилось
молча. Оператор нажимал «Экспорт» и не получал ни файла, ни причины.
Как закрыто. Общий components/LogViewer — ширины, перенос сообщения,
выгрузка и обработка её отказа объявлены один раз. Служебные колонки
зафиксированы, колонка сообщения растягивается за счёт остатка и ПЕРЕНОСИТСЯ, а
не обрезается многоточием: у Hysteria в msg приезжает диагностический JSON, и
обрезанная строка не отвечает ни на один вопрос, ради которого страницу
открыли.
Сверх разбора: копий скачивания было четыре, а не три. Четвёртую —
выгрузку серверного конфига Hysteria — нашёл контрактный тест, потребовавший
единственности createObjectURL. Она несла тот же дефект порядка и вдобавок
падала на dis.split(...) при отсутствующем Content-Disposition, и это
исключение тоже глушилось. Сборка ссылки на скачивание живёт теперь в
utils/download.ts одна.
UX-07 — ширина всплывающей подсказки
Подсказка объявлялась без ограничения, поэтому длинный перевод получал
естественную ширину popper и растягивался почти на весь экран одной строкой.
Element Plus предоставляет для этого штатный popper-style; ограничение
поставлено им, а не глобальным CSS.
UX-08, UX-09, UX-10 — найдено при разборе панели пиров
UX-08. handleKick зашивал Date.now() + 60 * 60 * 1000 прямо в
обработчик: пункт «Отключить» молча блокировал пира на час без подтверждения, а
сколько продлится блокировка, не сообщалось ни до, ни после. Ключи локализации
peer.kickUtilTime и peer.releaseSuccess при этом существовали и были
мёртвыми. Теперь срок называется в подтверждении, а результат — сообщением;
пункты переименованы так, чтобы «временно заблокировать» не путалось с
«отключить пир».
UX-09. ElMessageBox отклоняет промис при нажатии «Отмена».
await ElMessageBox.confirm(...) без разбора отказа оставлял необработанное
отклонение промиса на каждую отмену — в четырёх местах страницы пиров и ещё
одном в верхней панели (выход из системы). Отмена — это ОТВЕТ оператора;
переведена в обычное false через confirmAction.
UX-10. Подсказка имени пира обещала «латиница, цифры и дефис», тогда как
действующее правило (service.IsValidPeerName) принимает
a-z A-Z 0-9 !@#$%^&*()_+-=. Подсказка осталась от правила, действовавшего до
EX-03: она обещала более узкий набор, чем сервер принимает, и оператор не имел
причин пробовать разрешённые символы. Тест теперь читает набор из серверной
константы PeerNameCharset, поэтому разойтись снова они не могут.
UX-11 — сообщение об отказе показывалось дважды или ни разу
Найдено при проверке собственных исправлений: два соседних дефекта в одном механизме, и оба вскрылись только когда действия строки пира начали сообщать свой исход сами.
Дважды. deletePeerApi, resetPeerTrafficApi, kickPeerApi и
releaseKickPeerApi не объявляли skipErrorToast, поэтому после UX-06 отказ
показывался и общим перехватчиком, и страницей. Для частичного результата
отзыва доступа это давало два противоречащих сообщения об одном событии:
предупреждение «состояние применено наполовину» и рядом ошибку.
Ни разу. Флаг skipErrorToast гасил не только отказ API, но и
ТРАНСПОРТНЫЙ отказ — обрыв соединения, таймаут, HTTP-статус вне 2xx. При этом
все страницы, объявлявшие флаг, в своих обработчиках писали
// транспортный отказ уже показан общим перехватчиком и молчали. Утверждение
было ложным: обрыв соединения при сохранении пира, его удалении или отзыве
доступа не показывал оператору ничего — операция просто не происходила молча.
Дефект существовал и до этого прохода, у savePeerApi и updatePeerApi.
Как закрыто. Флаг отнесён только к отказу API — тому, у которого есть
конверт с code и errors и, значит, есть что разбирать. Транспортный отказ
показывается всегда: у него конверта нет, страница о нём сказать ничего не
может, и молчание о нём означает операцию без объяснений. Обе половины
закреплены тестами.
CORE-01 и TYPE-01 — мелкие расхождения
CORE-01. resolvePublicEndpoint при невалидном HY2XS_PUBLIC_PORT писал
must be a valid TCP port, хотя публичный endpoint Hysteria — UDP/QUIC.
Транспорт из формулировки убран, чтобы не закладывать в сообщение об ошибке
заведомо ложную семантику.
TYPE-01. В панели latencyTime и statusCode объявлены строками, а сервер
шлёт их как int64. Пока обе колонки не отображались, расхождение было
безвредным; после LOG-01 и LOG-02 оно стало бы обычной ошибкой сравнения или
форматирования.
Чем закреплено
Тесты Go (apps/service/peer_access_test.go,
apps/controller/log_test.go, apps/middleware/log_test.go):
- контракт
/kickпроверяется против НАСТОЯЩЕГО HTTP — метод, путь, заголовокAuthorization, JSON-массив идентификаторов. Подменённый на уровне Go клиент доказал бы только то, что вызвана нужная функция, и молча пережил бы потерю заголовка; - порядок «запись → разрыв» — снимком состояния базы в момент прихода
/kick; - неоткат
disabledиbanned_untilпри неудаче разрыва; повторяемость операции; - независимость
disabledиbanned_untilдруг от друга; - fail-closed при отказе
/online, при недоступном порте и при systemd, отвечающем «служба неактивна»; границаdevice == maxDevices; повреждённыйmaxDevices; - сохранение терпимости пути отображения;
- непустой
msgвместе с отсутствием в нём токена и query-строки; - форма ответа страницы логов на всех ветках и пропуск битой строки.
Контрактные тесты панели (tools/test/frontend-contract.test.ts): общий
LogViewer на обеих страницах, явные ширины колонок, запрос внутри try,
единственность сборки скачивания, меню на command с пунктом
toggle-disabled, ограничение ширины подсказки, единственный
ElMessageBox.confirm, разбор частичного результата по коду, совпадение
подсказки имени с серверной константой.
Гейты приёмки (tools/build/lib/acceptance.sh):
run_access_revocation_acceptance и run_observability_acceptance.
Отдельно: проверка «единственный ElMessageBox.confirm» сначала поймала
собственный комментарий, объясняющий, почему прямого вызова здесь больше нет, —
ровно та ловушка, о которой предупреждает code_without_comments в
acceptance.sh. Проверки панели теперь тоже отбрасывают комментарии.
Что проверяется руками на rc2
Машина этого не докажет:
- отключить пир с активным подключением и убедиться, что соединение действительно обрывается, а не только меняется плашка в списке;
- повторно подключиться отключённым пиром и получить отказ;
- включить пир обратно и убедиться, что подключение восстанавливается;
- остановить
hysteria-server, отключить пир и прочитать предупреждение о частичном результате; убедиться, что строка показывает применённое состояние; - проверить fail-closed
maxDevices: сломать Traffic Stats API и убедиться, что подключение отклоняется, а в журнале появляется запись уровняerror; - открыть страницу системных логов на свежей установке ДО появления файла журнала;
- прочитать столбец
msgна обеих страницах логов, проверить ширины колонок и перенос длинного JSON Hysteria; - выгрузить оба журнала и конфиг Hysteria; отдельно проверить поведение при остановленной админке — отказ обязан быть виден;
- проверить ширину подсказки «Экспорт настроек» на узком экране;
- проверить, что отмена любого подтверждения не оставляет ошибок в консоли браузера.