fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному

Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним,
поэтому сессия, установленная по отозванным учётным данным, была неотличима от
законной, и цикл учёта не имел признака, по которому её следовало завершить. У
состояния есть путь без единой неудачи — Hysteria регистрирует соединение в
Traffic Stats API только после возврата backend-auth, поэтому успешный /kick
может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт
по старому, пережившая сессия становится orphan.

Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4,
админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала
лимит устройств, учёт трафика и принудительное отключение разом. Адрес
зафиксирован, а расхождение файла с ним админка называет.

Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при
ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним
значением, а доступность Traffic Stats API выводилась из него же. Журнал
Hysteria разбирается в фактическом формате upstream (time — дробное число),
страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в
браузер, санитайзер выгрузки следует по YAML-якорям.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
This commit is contained in:
2026-09-02 23:24:01 +05:00
parent 8dcb50a07c
commit cb20d8d28f
66 changed files with 4981 additions and 2684 deletions
+104
View File
@@ -85,6 +85,17 @@ Hysteria-интеграции с официальной документацие
Разбор задокументирован в
[docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md).
Тринадцатый проход — граница между HY2XS и Hysteria, со сверкой по исходникам
тега `app/v2.12.2`, а не только по документации. Предыдущие два прохода привели
в порядок внутреннюю логику отзыва доступа; здесь закрываются места, где эта
логика соприкасается с чужим компонентом и с оператором: идентичность сессий,
которая не менялась вместе с учётными данными и потому лишала цикл учёта
единственного признака отзыва; адрес Traffic Stats API, у которого было два
несовместимых контракта в одном продукте; формат журнала Hysteria, который
разбирался неверно на каждой строке; и панель, показывавшая как факт то, чего
никто не проверял. Разбор задокументирован в
[docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md).
### Исправлено — правило доступа
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
@@ -239,6 +250,99 @@ Hysteria-интеграции с официальной документацие
картине подключений в том же цикле учёта — единственном месте продукта, где
она известна целиком.
### Исправлено — отзыв учётных данных и граница с Hysteria (тринадцатый проход)
- **Смена секрета не отзывала доступ гарантированно.** Отзыв состоит из двух
шагов, и второй умеет не удаться — сходимость обязан обеспечить цикл учёта. Но
сверять ему было нечем: `auth_id` при смене секрета оставался прежним, поэтому
сессия, установленная по отозванным учётным данным, называлась тем же
значением, что и законная, пир в базе существовал, доступ был открыт,
устройств не больше разрешённого. Признака «установлена по недействительному
секрету» в системе не существовало.
Хуже того, у этого состояния есть путь **без единой неудачи**. Hysteria
дожидается ответа backend-auth и только после `ok = true` помечает соединение
аутентифицированным и сообщает о нём Traffic Stats API (проверено по
исходникам `app/v2.12.2`), поэтому `/kick`, прошедший успешно, пока
авторизация по старому секрету ещё выполнялась, этого соединения не видит.
Атомарной пары «решение авторизации + регистрация онлайна» upstream API не
даёт, и повторным чтением базы перед ответом окно не закрыть.
Теперь новое поколение учётных данных получает новый `auth_id`, а `/kick` идёт
по старому: пережившая сессия становится orphan и завершается очередным циклом
учёта — механизмом, который уже существует. Правило действует на обеих дверях
к смене секрета, включая импорт, где случай «прежний `auth_id` + новый секрет»
проходил мимо. Ротация происходит тогда и только тогда, когда меняется
`secret_digest`. Цена названа прямо: трафик доживающей сессии за эти секунды
не приписывается пиру и попадает в потери цикла.
- **Адрес Traffic Stats API имел два контракта.** Оркестратор принимал любой
IPv4 и честно подставлял его в `trafficStats.listen`, а проверка профиля
сверяла конфиг с тем же значением — все гейты проходили. Админка при этом
берёт из `listen` только порт и всегда идёт на `127.0.0.1`. Валидная по всем
проверкам конфигурация разводила компоненты по разным адресам и выключала
лимит устройств, учёт трафика и принудительное отключение разом: `/online`
недоступен → авторизация fail-closed → не подключается никто. Адрес
зафиксирован на `127.0.0.1`, а админка называет расхождение вместо молчаливой
подстановки loopback.
- **JSON-журнал Hysteria не разбирался ни одной строкой.** Юнит запускает
Hysteria с `HYSTERIA_LOG_FORMAT=json`, но `time` в этом формате — число, и
притом дробное (`EpochMillisTimeEncoder` делит наносекунды на миллисекунду),
поэтому разбор в структуру со строковым полем падал всегда и уходил в
fallback: панель показывала сырой JSON. Замена типа поля на `int64` не
помогла бы. Разбор ведётся по фактическому формату, а структурный контекст
записи (`addr`, `id`, `error`, `listen`, …) больше не выбрасывается, а
дописывается к сообщению в устойчивом порядке и проходит санитайз. Заодно
перестали теряться записи, у которых journald отдаёт `MESSAGE` массивом байт.
- **«Служба остановлена» и «состояние неизвестно» были одним значением.**
`util.Exec` выбрасывает вывод команды при ненулевом коде возврата, а
`systemctl is-active` отвечает словом состояния в stdout вместе с кодом 3 —
прочитать его было нечем. Дашборд из-за этого умел утверждать «Hysteria
остановлена» и «Traffic Stats API доступен» одновременно: доступность API
выводилась из того же ответа systemd, а не из обращения к API. Появился
`util.ExecProbe`, состояние службы стало трёхзначным, а доступность API —
независимым фактом. Список пиров при недоступном API отвечает «онлайн
неизвестен», а не «все офлайн».
- **Страница конфигурации показывала дефолты UI вместо файла.** Ответ сервера
накладывался на полный объект значений по умолчанию, поэтому отсутствующая
секция `trafficStats` показывалась как `:9999`, явное `speedTest: false`
считалось ненастроенным, а `ignoreClientBandwidth` без блока `bandwidth` не
показывался вовсе. Экран, существующий ради диагностики расхождений, эти
расхождения скрывал. Теперь панель показывает записанные значения (отличая
«не задано» от значения) и отдельно перечисляет секции вне production-профиля.
Три редактора, которые ничего не сохраняли, удалены вместе с их компонентами.
- **Читающий экран отдавал больше секретов, чем выгрузка.** Пароль обфускации,
токены ACME DNS, учётные данные outbound-прокси и masquerade уезжали в
браузер в открытом виде, хотя санитизированный экспорт того же конфига их
вырезает. Теперь вместо значения показывается диагностический факт: «задан» /
«не задан», имена параметров без значений, auth-URL с вырезанным токеном.
- **Секрет за YAML-якорем покидал сервер.** Санитайзер выгрузки не обрабатывал
`yaml.AliasNode`: значение по ссылке оставалось нетронутым, а объявление
якоря стояло под несекретным именем ключа — секрет уезжал в файл дважды.
Обход идёт по цели ссылки, с защитой от циклов: `yaml.v3` на ссылке,
указывающей на предка, строит действительно циклический граф узлов.
- **Hysteria больше не проверяет обновления сама.** В сборочном и e2e окружении
`HYSTERIA_DISABLE_UPDATE_CHECK=1` стоял, а в production-юните — нет. Версией
владеет один источник (`versions.env` → сборка → пакет → оркестратор), и
production не имеет права отличаться от тестового окружения.
- **Удалены мёртвые остатки прежней архитектуры.** `util.CompareVersion`
(лексикографическое сравнение версий без потребителя: `2.10 < 2.9`),
`service.ReleaseHysteria2` (пустая заглушка, вызывавшаяся при завершении
сервиса), `PeerClientConfigVo.QrCode` (второй канал доставки QR, который
панель рисует сама), компонент `UnitSelect` и три функции `utils/byte.ts`.
- **Гейт освобождения admission-замка проверял форму, а не замок.**
`/defer\s+\w+\(\)/` означало «в функции есть какой-нибудь отложенный вызов» и
пережило бы `defer someOtherCleanup()` рядом со взятым замком. Теперь имя
переменной берётся из самого присваивания.
### Исправлено — гейты сборки
- **Гейт fail-open срабатывал на корректном коде.** Проверка «авторизация не