# Разбор кода перед сборкой `1.0.0-rc2` Источник — не прогон на хосте, а разбор дерева на коммите `c0a43ae9` и сверка Hysteria-интеграции с официальной документацией Hysteria 2 и с API Element Plus. Поэтому файл отдельный: дефекты приёмки `rc1` перечислены в [2026-09-01-v1.0.0-rc1-ux-findings.md](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. По отдельности не работает ни одна. У продукта были обе половины, но разведённые по разным операциям: ```text disabled=1 -> блокирует последующий HTTP-auth POST /kick -> разрывает текущую сессию ``` **Как закрыто.** 1. Отдельный примитив разрыва — только официальный `/kick`, без единой записи в базу. Прежний `Hysteria2Kick` вместе с разрывом проставлял `banned_until`, поэтому воспользоваться им для отключения было нельзя: операция записала бы заодно временную блокировку — другой механизм с другим сроком жизни и другим способом снятия. (Тогда он назывался `DisconnectPeers` и принимал идентификаторы пиров; во втором проходе стал `disconnectAuthIDs` — см. CORE-03 и CORE-05, где старый `authId` нужен уже после его исчезновения из базы.) 2. `UpdatePeer` при `disabled=1` выполняет обе половины: сначала долговременную запись, затем разрыв. (Во втором проходе перечень операций расширен — см. CORE-04.) 3. Порядок обратному не подлежит. При обратном клиент успевает переподключиться в окне между `/kick` и записью и остаётся на связи с формально отключённым пиром. Порядок доказывается тестом, который снимает состояние базы В МОМЕНТ прихода `/kick`: после операции оба шага уже выполнены и проверять там нечего. 4. Неудача разрыва НЕ откатывает `disabled`. Безопасная половина достигнута, и возвращать пиру полный доступ из-за отказа второго шага нельзя. 5. Состояние службы по systemd на этом пути не спрашивается. `util.Exec` схлопывает «systemctl вернул 3» и «запустить systemctl не удалось» в одну ошибку, поэтому прежняя проверка `!Hysteria2IsRunning() -> отказ` отказывала бы операции при живой Hysteria. Обращение к `/kick` отвечает на нужный вопрос напрямую. 6. Условие смотрит на ЗАПРОШЕННОЕ состояние, а не на переход из включённого: иначе после неудачного разрыва оператору пришлось бы включить пира, чтобы получить право отключить его снова. **Частичный результат сообщается кодом, а не прозой.** Отдельный `peer_disconnect_failed`: без него оператор прочитал бы обычную ошибку как «не сработало, состояние прежнее» — вывод, прямо противоположный истине. Панель показывает его предупреждением и обновляет строку. **Меню переведено на `command`.** `@click` на каждом `el-dropdown-item` заменён штатным контрактом `el-dropdown`: команда приходит в одно место, и добавить пункт, забыв его подключить, становится невозможно. --- ## AUTH-01 и AUTH-02 — ограничение устройств отключалось само `Hysteria2Auth` после проверки пира спрашивал `/online`. При отказе: ```go onlineUsers, err := Hysteria2Online() if err != nil { logrus.WithError(err).Warn(...) return *peer.Id, *peer.AuthId, nil } ``` Недоступность внутреннего `127.0.0.1` превращала объявленный в панели «Лимит устройств: 3» в безлимит. Узнать об этом оператор мог только по строке `WARN` в журнале, которую никто не читает. **Вторая половина дыры оказалась тише первой и в исходный разбор не входила.** `Hysteria2Online` начинался с ярлыка: ```go 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 собирал поля правильно, но затем вызывал: ```go entry.Error() entry.Warn() entry.Info() ``` без аргумента сообщения, и logrus честно записывал `"msg":""` для каждого HTTP запроса. Пустой столбец на экране был точным отражением того, что записал backend. **Как закрыто.** `middleware.RequestLogMessage` собирает строку ИЗ ТЕХ ЖЕ величин, что уже лежат в структурных полях: ```text GET /api/peers → 200 (7 ms) POST /internal/hysteria/auth → 200 (2 ms) ``` Запись остаётся машиночитаемой; `msg` существует, чтобы человек мог прочитать её, не собирая строку из шести колонок. Query-строка сюда не попадает. Это действующий контракт безопасности, а не небрежность: Hysteria обращается к машинному endpoint'у как `/internal/hysteria/auth?access_token=` при каждом подключении пира, а журнал отдаётся оператору через `ExportLog` и уезжает в diagnostics-бандл. Тест проверяет обе половины сразу: `msg` непустой И не несёт ни токена, ни знака `?`. --- ## LOG-04 и LOG-05 — страница логов ломалась двумя разными способами Ни один из них в исходный разбор не входил. **LOG-04.** Ветка «файла журнала ещё нет» отвечала голым массивом: ```go vo.Success(logSystemVos, c) ``` Панель читает `data.records`, поэтому получала `undefined` и передавала его в `:data` таблицы. То есть на свежепоставленном хосте — до первой записи в журнал — страница системных логов не работала вовсе. Это ровно тот сценарий, который проверяется на приёмке каждой чистой установки. **LOG-05.** При неразбираемой строке выполнялось: ```go 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`: ```ts 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 — исчерпанная квота не отключала пира никогда Правило доступа существовало в двух экземплярах, написанных разными условиями в разных местах. Авторизация прятала его в выборке: ```sql disabled = 0 and (quota_bytes < 0 or quota_bytes > download_bytes + upload_bytes) and (expires_at = 0 or ? < expires_at) and ? > banned_until ``` Принудительное отключение — в своей: ```sql 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` состоял из одной строки: ```go func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) } ``` Строка исчезала, живая QUIC-сессия оставалась. Хуже того, вместе со строкой исчезал `auth_id` — единственное, чем эту сессию можно было бы завершить. Состояние становилось **невосстановимым**: удалённый пир пользовался доступом, пока не переподключался по своей воле, и сделать с этим было уже нечего. **Как закрыто.** Порядок: прочитать пира и запомнить `authId` → записать `disabled=1` → `/kick` по запомненному значению → удалить строку. Неудача разрыва оставляет строку на месте отключённой, поэтому новые подключения запрещены, а оператор повторяет удаление. Отката после `/kick` нет. Контроллер переведён на `failService`: удаление умеет завершиться частично, и через `vo.Fail` этот исход уезжал бы панели неотличимо от полного отказа. --- ## CORE-04 — разрыв выполнялся только при отключении Условие было одно: ```go if peerDto.Disabled != nil && *peerDto.Disabled == 1 { ``` Мимо него проходили четыре операции, каждая из которых закрывает доступ: ```text смена секрета старые учётные данные недействительны, сессия жива урезание квоты «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.** Устройство было таким: ```go CronHandleAccount() -> go func() -> go saveAccountTraffic() -> go kickAccount() ``` Для планировщика джоба заканчивалась почти мгновенно — сразу после запуска внешней горутины. `StopCron()`, который честно ждёт `scheduler.Stop().Done()`, не ждал НИЧЕГО из настоящей работы: планировщик отчитывался «джоб не осталось», `releaseResource()` закрывал SQLite, а внутренние горутины продолжали писать в закрытое соединение. Второе следствие того же устройства было тише. Обе половины запускались параллельно, поэтому принудительное отключение читало счётчики ДО того, как в них попадала только что снятая дельта: превышение квоты замечалось в лучшем случае со следующего тика, а на границе — не замечалось вовсе. **CORE-07** — три nil-разыменования на том же пути, и все внутри горутин, где их некому перехватить, то есть каждое роняет процесс целиком вместе с обработчиком machine-auth: ```go *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» место ничем не удерживалось: ```text 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 срабатывал на корректном коде Найдено при переписывании приёмки. Проверка «авторизация не возвращает успех из ветки ошибки» была записана так: ```js 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`. Гейты закрывают архитектурные инварианты, а не поведение: ```text правило доступа объявлено один раз, и колонок политики нет в 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` Машина этого не докажет: 1. отключить пир с активным подключением и убедиться, что соединение действительно обрывается, а не только меняется плашка в списке; 2. повторно подключиться отключённым пиром и получить отказ; 3. включить пир обратно и убедиться, что подключение восстанавливается; 4. остановить `hysteria-server`, отключить пир и прочитать предупреждение о частичном результате; убедиться, что строка показывает применённое состояние; 5. проверить fail-closed `maxDevices`: сломать Traffic Stats API и убедиться, что подключение отклоняется, а в журнале появляется запись уровня `error`; 6. открыть страницу системных логов на свежей установке ДО появления файла журнала; 7. прочитать столбец `msg` на обеих страницах логов, проверить ширины колонок и перенос длинного JSON Hysteria; 8. выгрузить оба журнала и конфиг Hysteria; отдельно проверить поведение при остановленной админке — отказ обязан быть виден; 9. проверить ширину подсказки «Экспорт настроек» на узком экране; 10. проверить, что отмена любого подтверждения не оставляет ошибок в консоли браузера. Добавлено вторым проходом: 11. **удалить пира с активным подключением** и убедиться, что соединение обрывается, а не только исчезает строка; 12. остановить `hysteria-server`, удалить пира — строка обязана остаться в списке отключённой, с предупреждением о частичном результате; поднять службу и повторить удаление; 13. **сменить секрет** пира с активным подключением: соединение обрывается, старая клиентская ссылка перестаёт работать, новая работает; 14. **урезать квоту** ниже израсходованного у подключённого пира — соединение обрывается немедленно, а не со следующим тиком cron; 15. **перенести срок** действия в прошлое — то же; 16. **снизить лимит устройств** у пира с несколькими подключениями: все обрываются, после переподключения проходит новое разрешённое число; 17. **импортировать файл** с уже существующими пирами — их соединения обрываются один раз; вновь созданные пиры не затрагиваются; при остановленной Hysteria импорт применяется целиком и сообщает о частичном результате, а список обновляется; 18. **израсходовать квоту до нуля** на живой сессии и дождаться тика cron: соединение обрывается (раньше — не обрывалось никогда); 19. дождаться истечения срока действия на живой сессии — то же; 20. остановить `systemctl` (не Hysteria) и убедиться, что учёт трафика и принудительное отключение продолжают работать; 21. подключить **одновременно** больше устройств, чем разрешено, и убедиться, что принято ровно `maxDevices`; 22. перезапустить админку под нагрузкой и убедиться, что в журнале нет записей о работе с закрытой базой после остановки.