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:
+104
@@ -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 срабатывал на корректном коде.** Проверка «авторизация не
|
||||
|
||||
Reference in New Issue
Block a user