Files
HY2XS_flamy/docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md
founder 6d1686b2be fix(admin): свести access-control к одному правилу и одному пути отзыва
Второй разбор того же слоя, уже по состоянию после 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: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
2026-09-01 20:46:21 +05:00

774 lines
55 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Разбор кода перед сборкой `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=<machine 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. перезапустить админку под нагрузкой и убедиться, что в журнале нет записей
о работе с закрытой базой после остановки.