Второй разбор того же слоя, уже по состоянию после 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: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
55 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 | закрыт |
| CORE-03 | Удаление пира не отзывало доступ и теряло authId |
P0 | закрыт |
| CORE-04 | Разрыв сессии выполнялся только при disabled=1 |
P1 | закрыт |
| CORE-05 | Импорт не завершал сессии переписанных пиров | P1 | закрыт |
| CORE-06 | Джоба учёта убегала из жизненного цикла планировщика | P1 | закрыт |
| CORE-07 | Три nil-разыменования в cron роняли процесс целиком | P0 | закрыт |
| QUOTA-01 | Исчерпанная квота не отключала пира никогда | P0 | закрыт |
| AUTH-03 | Параллельные подключения превышали maxDevices |
P1 | закрыт |
| GATE-01 | Гейт fail-open срабатывал на корректном коде | P1 | закрыт |
| UX-12 | Импорт не разбирал свой исход и не обновлял список | P2 | закрыт |
LOG-04, LOG-05, AUTH-02, CORE-02, UX-08…UX-11 и TYPE-01 в исходный разбор не входили и найдены при проверке его выводов по коду. UX-11 нашёлся позже остальных — при проверке уже внесённых исправлений.
CORE-03…06, AUTH-03 и QUOTA-01 — второй проход разбора, уже по состоянию после принятых исправлений. CORE-07, GATE-01 и UX-12 найдены при их закрытии.
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 -> разрывает текущую сессию
Как закрыто.
- Отдельный примитив разрыва — только официальный
/kick, без единой записи в базу. ПрежнийHysteria2Kickвместе с разрывом проставлялbanned_until, поэтому воспользоваться им для отключения было нельзя: операция записала бы заодно временную блокировку — другой механизм с другим сроком жизни и другим способом снятия. (Тогда он называлсяDisconnectPeersи принимал идентификаторы пиров; во втором проходе сталdisconnectAuthIDs— см. CORE-03 и CORE-05, где старыйauthIdнужен уже после его исчезновения из базы.) UpdatePeerприdisabled=1выполняет обе половины: сначала долговременную запись, затем разрыв. (Во втором проходе перечень операций расширен — см. CORE-04.)- Порядок обратному не подлежит. При обратном клиент успевает
переподключиться в окне между
/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: долговременная запись, затем разрыв,
затем — при неудаче разрыва — частичный результат отдельным кодом. Во втором
проходе этот путь стал общим для всех операций отзыва — reconcileLiveSessions
(см. CORE-04).
Механизмы остались независимыми: 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 оно стало бы обычной ошибкой сравнения или
форматирования.
QUOTA-01 — исчерпанная квота не отключала пира никогда
Правило доступа существовало в двух экземплярах, написанных разными условиями в разных местах.
Авторизация прятала его в выборке:
disabled = 0
and (quota_bytes < 0 or quota_bytes > download_bytes + upload_bytes)
and (expires_at = 0 or ? < expires_at)
and ? > banned_until
Принудительное отключение — в своей:
disabled = 1
or (quota_bytes > 0 and quota_bytes < download_bytes + upload_bytes)
or (expires_at > 0 and ? > expires_at)
or ? < banned_until
Второе условие не является отрицанием первого, и расхождение приходилось ровно на границы:
| состояние | авторизация | принудительное отключение |
|---|---|---|
quota = 0 |
отказ | сессию не рвёт |
usage = quota |
отказ | сессию не рвёт |
now = expiresAt |
отказ | сессию не рвёт |
now = bannedUntil |
отказ | сессию не рвёт |
Хуже всего вела себя исчерпанная квота. quota_bytes < download + upload
требует СТРОГОГО превышения, а счётчики растут порциями по ответу Traffic Stats
API — попадание в точное равенство является обычным исходом очередного сбора, а
не экзотикой. Пир с исчерпанной квотой не пускался заново, но его живая сессия
не разрывалась никогда: он продолжал пользоваться доступом, пока не
переподключался по своей воле.
Как закрыто. Политика вынесена из SQL в одну функцию peerAccessDenied
(apps/service/peer_access.go); авторизация ищет пира только по
secret_digest, а cron применяет ту же функцию к пирам, которых Hysteria
назвала онлайн. Расходиться им теперь физически негде. Границы зафиксированы
таблицей в docs/admin/04-admin-panel.md и точечными тестами: набор проверок
состоит в основном из равенств, потому что расходились именно они.
Отдельно: quota = -1 объявлен единственным каноничным способом снять
ограничение, quota = 0 означает ноль байтов. Отрицательное значение любой
величины трактуется как безлимит — так же, как это делала выборка авторизации;
через двери продукта значение меньше -1 недостижимо.
CORE-03 — удаление пира не отзывало доступ
DeletePeer состоял из одной строки:
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
Строка исчезала, живая QUIC-сессия оставалась. Хуже того, вместе со строкой
исчезал auth_id — единственное, чем эту сессию можно было бы завершить.
Состояние становилось невосстановимым: удалённый пир пользовался доступом,
пока не переподключался по своей воле, и сделать с этим было уже нечего.
Как закрыто. Порядок: прочитать пира и запомнить authId → записать
disabled=1 → /kick по запомненному значению → удалить строку. Неудача
разрыва оставляет строку на месте отключённой, поэтому новые подключения
запрещены, а оператор повторяет удаление. Отката после /kick нет.
Контроллер переведён на failService: удаление умеет завершиться частично, и
через vo.Fail этот исход уезжал бы панели неотличимо от полного отказа.
CORE-04 — разрыв выполнялся только при отключении
Условие было одно:
if peerDto.Disabled != nil && *peerDto.Disabled == 1 {
Мимо него проходили четыре операции, каждая из которых закрывает доступ:
смена секрета старые учётные данные недействительны, сессия жива
урезание квоты «100 ГБ -> 5 ГБ» при израсходованных 10 ГБ
перенос срока «истекает завтра» -> «истёк вчера»
снижение лимита «5 устройств -> 1» при пяти подключённых
Панель показывала новое состояние, а пир продолжал пользоваться доступом по старому — тот же дефект, что и UX-06, только под другими именами полей.
Как закрыто. updateRequiresReconcile принимает решение по снимку «до» и
запрошенным изменениям. Квота и срок проверяются через ту же
peerAccessDenied, поэтому «закрывает доступ» здесь и «не пустит при следующем
подключении» — буквально одно условие.
Правило асимметрично намеренно: ограничение применяется немедленно,
послабление — нет. При любом сочетании изменений уходит ровно один /kick.
CORE-05 — импорт не завершал сессии переписанных пиров
Импорт переписывает auth_id, secret_digest, quota_bytes, expires_at и
disabled существующего пира целиком, но сессий не трогал вовсе.
Как закрыто. Старые authId собираются ВНУТРИ транзакции, разрыв идёт
ПОСЛЕ commit. Оба слова существенны: внутри — потому что после commit старого
значения в базе уже нет; после — потому что /kick до commit оставляет клиенту
окно, в котором он переподключается к ещё не изменённому пиру.
Рвутся сессии всех существующих записей партии, а не тех, у кого изменилось
конкретное поле. Это сознательно более простой контракт, чем diff по семи
полям: не появляется второй таблицы правил «какие поля импорта считаются
access-changing» — то есть второго места, где политика может разойтись с
peerAccessDenied. Вновь созданные пиры не рвутся: до импорта их сессий
существовать не могло.
CORE-06 и CORE-07 — джоба учёта
CORE-06. Устройство было таким:
CronHandleAccount()
-> go func()
-> go saveAccountTraffic()
-> go kickAccount()
Для планировщика джоба заканчивалась почти мгновенно — сразу после запуска
внешней горутины. StopCron(), который честно ждёт scheduler.Stop().Done(),
не ждал НИЧЕГО из настоящей работы: планировщик отчитывался «джоб не осталось»,
releaseResource() закрывал SQLite, а внутренние горутины продолжали писать в
закрытое соединение.
Второе следствие того же устройства было тише. Обе половины запускались параллельно, поэтому принудительное отключение читало счётчики ДО того, как в них попадала только что снятая дельта: превышение квоты замечалось в лучшем случае со следующего тика, а на границе — не замечалось вовсе.
CORE-07 — три nil-разыменования на том же пути, и все внутри горутин, где их некому перехватить, то есть каждое роняет процесс целиком вместе с обработчиком machine-auth:
*trafficSecretConfig.Value // строка config без значения
*item.AuthId // строка пира с NULL auth_id
*item.Id // строка пира без идентификатора (CronResetTraffic)
Заодно: при пустом наборе целей в Hysteria уезжал POST /kick с пустым
массивом в теле — каждые 30 секунд.
Как закрыто. Джоба синхронна, под одним accountJobMutex на весь цикл
(trafficMutex и kickMutex удалены — они защищали каждую половину от самой
себя, но не защищали пару от расщепления). Порядок строгий: сбор трафика, затем
enforcement. Секрет берётся общей hysteria2TrafficSecret(), которая отличает
«ключа нет» от пустого значения. Повреждённые строки пропускаются с записью в
журнал. Гейт Hysteria2IsRunning удалён: util.Exec не отличает «служба
неактивна» от «спросить не удалось», поэтому сломанный systemctl при живой
Hysteria молча отключал и учёт, и enforcement — без единой строки в журнале.
Что осталось известным ограничением. GET /traffic?clear=1 деструктивен:
счётчики Hysteria обнуляются сразу после отправки ответа, поэтому дельта,
которую не удалось записать в SQLite, потеряна безвозвратно. Раньше такой отказ
делал continue и не оставлял следа в исходе джобы; теперь каждая потеря
считается и попадает в ошибку цикла. Полное решение требует смены модели учёта
(недеструктивное чтение плюс долговременные checkpoint'ы) и в 1.0.0 намеренно
не вводится: квота — операционный предел доступа, а не учёт с финансово
значимым каждым байтом.
AUTH-03 — параллельные подключения превышали лимит устройств
Между чтением /online и ответом «allow» место ничем не удерживалось:
A: GET /online -> 2 B: GET /online -> 2
max = 3
A: 2 < 3 -> allow B: 2 < 3 -> allow
стало 4
Мьютекс вокруг /online это не чинит, и это главное в дефекте. Ответив
«allow», админка не создаёт подключение — его только начинает устанавливать
Hysteria, и клиент попадает в статистику позже. Следующий /online, даже
строго после первого, продолжает показывать прежнее число; сериализация лишь
сузила бы окно.
Как закрыто. Process-local учёт выданных, но ещё не проявившихся разрешений
(apps/service/peer_admission.go). Решение принимается по сумме «подключено
плюс зарезервировано»; рост online снимает соответствующее число резерваций,
протухшие снимаются по TTL. Сетевой запрос выполняется вне блокировки: под ней
остаются только операции с map.
TTL — 30 секунд, величина внутренняя и пользовательской настройкой не является: это компенсация задержки между ответом авторизации и появлением клиента в статистике, а не политика доступа. Выбор fail-closed: в аномальном случае возможен короткий ложный отказ, но параллельные auth больше не перепрыгивают лимит.
Ни Redis, ни таблицы в базе, ни распределённых блокировок: HY2XS — один процесс на одном сервере с Hysteria.
Чего механизм не обещает: без обратного вызова от Hysteria «соединение установлено / не установлено» математически точной системы резервирования не построить.
GATE-01 — гейт fail-open срабатывал на корректном коде
Найдено при переписывании приёмки. Проверка «авторизация не возвращает успех из ветки ошибки» была записана так:
const failOpen = /err != nil \{[\s\S]*?return \*peer\.Id/;
Ленивый [\s\S]*? свободно пересекает границы блоков, поэтому регулярка
срабатывала на ЛЮБОЙ функции, где после какой-нибудь проверки ошибки где-то
ниже стоит успешный возврат. Проверено прямо на коде из HEAD: на корректной
реализации она даёт совпадение, то есть гейт нельзя удовлетворить, не сломав
продукт.
Как закрыто. Тело ветки выделяется по балансу фигурных скобок — тогда «внутри ветки» действительно означает внутри ветки. Логика гейта проверена в обе стороны: на настоящей дыре срабатывает, на корректном коде — нет.
UX-12 — импорт не разбирал свой исход
importPeerApi не объявлял skipErrorToast, а handleImport не имел ни
try, ни catch. Пока импорт не умел завершаться частично, это было незаметно.
После CORE-05 отказ уходил бы необработанным отклонением промиса, handleQuery()
до выполнения не доходил — список оставался с прежними данными при уже
изменённой базе, — а общий перехватчик показывал бы частичный результат красной
ошибкой, то есть сообщал бы оператору обратное тому, что произошло.
Как закрыто. Импорт разбирает исход тем же reportPeerActionError, что и
действия строки, а файл убирается из очереди и список обновляется при любом
исходе.
Заодно формулировка peer_disconnect_failed во всех трёх местах (сервер и обе
локали) сделана operation-neutral. Прежняя — «новые подключения пира
запрещены» — была верна ровно для отключения пира; теперь через этот код
отчитываются восемь операций, а для удалённого пира она просто бессмысленна.
Чем закреплено
Тесты 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-строки; - форма ответа страницы логов на всех ветках и пропуск битой строки.
Тесты второго прохода (peer_access_policy_test.go,
peer_reconcile_test.go, cron_test.go, peer_admission_test.go):
- границы политики доступа точечно — набор состоит в основном из равенств, потому что расходились именно они; fail-closed на повреждённой строке;
- удаление:
disabledзаписан ДО/kick(снимком базы в момент прихода запроса), строка остаётся при неудаче разрыва, повторяемость; - правка: ротация секрета, урезание квоты ниже расхода и ровно по расходу,
перенос срока в прошлое, снижение лимита устройств — каждое рвёт сессию;
послабления и косметика — нет; сочетание изменений даёт ОДИН
/kick; - импорт: старый
authIdпосле commit, откат партии не рвёт ничего, batch с дедупликацией, частичный результат при неудаче разрыва; - cron: границы
usage == quota,quota == 0, истёкший срок и истёкшая блокировка; сбор трафика ДО enforcement (снимком расхода в момент/online); синхронность джобы; пропуск наложенного тика; отсутствие паники на пустом секрете и на строке без идентификатора; работа при systemd, отвечающем «служба неактивна»; - лимит устройств под нагрузкой: два одновременных запроса на последнее свободное место — барьер на стороне Traffic Stats API держит оба до тех пор, пока оба не прочитают одно и то же состояние. Проверено, что тест ловит прежнюю реализацию: с ней проходят оба запроса.
go test -race ./service/... — отдельный обязательный шаг сборки: состояние
трекера разрешений и мьютекс цикла учёта принадлежат процессу, и их
корректность не наблюдаема в обычном прогоне.
Контрактные тесты панели (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.
Гейты закрывают архитектурные инварианты, а не поведение:
правило доступа объявлено один раз, и колонок политики нет в SQL;
production POST /kick достижим только через disconnectAuthIDs;
disconnectAuthIDs не читает и не пишет состояние пира;
в CronHandleAccount нет отсоединённых горутин;
Hysteria2IsRunning не участвует в cron и в авторизации;
DeletePeer завершает сессию до удаления строки;
импорт разрывает сессии после COMMIT;
Delete и Import отвечают структурированной ошибкой;
детектор гонок обязателен и не имеет обходов.
Что гейтами НЕ доказывается и намеренно оставлено тестам: что удаление
действительно сохраняет disabled до разрыва, что импорт рвёт именно старый
authId, что лимит устройств выдерживает параллельные запросы. Это поведение, и
grep о нём сказать ничего не может.
Отдельно: проверка «единственный ElMessageBox.confirm» сначала поймала
собственный комментарий, объясняющий, почему прямого вызова здесь больше нет, —
ровно та ловушка, о которой предупреждает code_without_comments в
acceptance.sh. Проверки панели теперь тоже отбрасывают комментарии. Тот же
класс дал GATE-01: проверка, написанная регуляркой по тексту, срабатывала на
корректном коде.
Что проверяется руками на rc2
Машина этого не докажет:
- отключить пир с активным подключением и убедиться, что соединение действительно обрывается, а не только меняется плашка в списке;
- повторно подключиться отключённым пиром и получить отказ;
- включить пир обратно и убедиться, что подключение восстанавливается;
- остановить
hysteria-server, отключить пир и прочитать предупреждение о частичном результате; убедиться, что строка показывает применённое состояние; - проверить fail-closed
maxDevices: сломать Traffic Stats API и убедиться, что подключение отклоняется, а в журнале появляется запись уровняerror; - открыть страницу системных логов на свежей установке ДО появления файла журнала;
- прочитать столбец
msgна обеих страницах логов, проверить ширины колонок и перенос длинного JSON Hysteria; - выгрузить оба журнала и конфиг Hysteria; отдельно проверить поведение при остановленной админке — отказ обязан быть виден;
- проверить ширину подсказки «Экспорт настроек» на узком экране;
- проверить, что отмена любого подтверждения не оставляет ошибок в консоли браузера.
Добавлено вторым проходом:
- удалить пира с активным подключением и убедиться, что соединение обрывается, а не только исчезает строка;
- остановить
hysteria-server, удалить пира — строка обязана остаться в списке отключённой, с предупреждением о частичном результате; поднять службу и повторить удаление; - сменить секрет пира с активным подключением: соединение обрывается, старая клиентская ссылка перестаёт работать, новая работает;
- урезать квоту ниже израсходованного у подключённого пира — соединение обрывается немедленно, а не со следующим тиком cron;
- перенести срок действия в прошлое — то же;
- снизить лимит устройств у пира с несколькими подключениями: все обрываются, после переподключения проходит новое разрешённое число;
- импортировать файл с уже существующими пирами — их соединения обрываются один раз; вновь созданные пиры не затрагиваются; при остановленной Hysteria импорт применяется целиком и сообщает о частичном результате, а список обновляется;
- израсходовать квоту до нуля на живой сессии и дождаться тика cron: соединение обрывается (раньше — не обрывалось никогда);
- дождаться истечения срока действия на живой сессии — то же;
- остановить
systemctl(не Hysteria) и убедиться, что учёт трафика и принудительное отключение продолжают работать; - подключить одновременно больше устройств, чем разрешено, и убедиться,
что принято ровно
maxDevices; - перезапустить админку под нагрузкой и убедиться, что в журнале нет записей о работе с закрытой базой после остановки.