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
@@ -0,0 +1,373 @@
# Разбор кода перед сборкой `1.0.0-rc4`
```text
Findings base: 8dcb50a — дерево, на котором найдены дефекты
Fixes verified in: рабочее дерево этого прохода
```
Источник — разбор дерева после [прохода
rc3](2026-09-02-v1.0.0-rc3-preflight-findings.md) и сверка Hysteria-интеграции с
исходниками тега `app/v2.12.2`, а не только с документацией: часть выводов
зависит от того, что именно делает upstream-код, а не от того, как это описано.
Тема прохода — **граница между компонентами**. Предыдущие два прохода привели в
порядок внутреннюю логику отзыва доступа: единое правило доступа, правильный
порядок «запись → разрыв», повторяемость операций и сходимость через цикл учёта.
Этот проход занимается местами, где HY2XS соприкасается с Hysteria и с
оператором: идентичностью сессий, адресом control plane, форматом журнала и тем,
что панель показывает как факт.
## Сводка
| ID | Дефект | Приоритет | Статус |
| --- | --- | --- | --- |
| ROT-01 | Ротация секрета сохраняла идентичность сессий, и отзыв не сходился | P1 | закрыт |
| ADM-HY2-01 | Адрес Traffic Stats API имел два несовместимых контракта | P1 | закрыт |
| ADM-HY2-02 | JSON-журнал Hysteria 2.12.2 не разбирался ни одной строкой | P2 | закрыт |
| ADM-HY2-03 | «Служба остановлена», «неизвестно» и доступность API были одним значением | P2 | закрыт |
| ADM-HY2-04 | В production работала upstream-проверка обновлений | P2-low | закрыт |
| ADM-HY2-05 | Страница конфигурации показывала дефолты UI вместо файла | P2 | закрыт |
| ADM-HY2-06 | Санитайзер выгрузки не следовал по YAML-якорям | P2 | закрыт |
| ADM-HY2-07 | Читающий экран отдавал в браузер больше секретов, чем выгрузка | P3 | закрыт |
| ADM-HY2-08 | Мёртвые остатки прежней архитектуры | P4 | закрыт |
| GATE-03 | Гейт освобождения admission-замка проверял форму, а не замок | P3 | закрыт |
| DOC-03 | Отчёт rc3 не называл, к какому дереву относятся выводы | P3 | закрыт |
ROT-01 и ADM-HY2-01…08 пришли внешним разбором; уточнения ниже — при проверке
его выводов по коду и исходникам upstream. Три из них меняли предложенное
решение, а не только формулировку, и отмечены как **уточнение**.
---
## ROT-01 — ротация секрета сохраняла идентичность сессий
**Наблюдалось рассуждением и воспроизведено тестом.** `go test -race` здесь
зелёный и был зелёным: гонка логическая, работа с памятью в ней не участвует.
Отзыв секрета состоит из двух шагов — записать новый `secret_digest` и завершить
сессии, установленные по старому. Второй шаг умеет не удаться, и это заложено в
архитектуру: сходимость обязан обеспечить цикл учёта. Но сверять ему было нечем.
```text
до: secret S1 -> authId A
после: secret S2 -> authId A
```
Сессия в `/online` называется просто `A`. Пир `A` в базе существует, доступ ему
открыт, устройств не больше разрешённого — по всем признакам это действующая
сессия нового состояния. Признака «установлена по уже отозванному секрету» в
системе не существовало.
**Уточнение к внешнему разбору.** Дефект не ограничивался формой панели. Импорт
— вторая дверь к смене учётных данных, и через неё проходил тот же случай:
запись найдена по имени либо несёт прежний `auth_id`, а секрет в файле новый.
`applyPeerImportEntry` писал `auth_id` только тогда, когда он задан в файле,
поэтому идентичность оставалась прежней.
**Путь без единой неудачи.** Помимо неудавшегося `/kick` существует сценарий, в
котором все операции успешны. Hysteria дожидается ответа backend-auth и только
после `ok = true` помечает соединение аутентифицированным и сообщает о нём
Traffic Stats API (`app/v2.12.2`):
```text
1. клиент с S1 начинает авторизацию, Hysteria2Auth ждёт ответа GET /online
2. оператор меняет секрет: запись прошла, /kick вернул 200
3. задержанная авторизация возвращает ALLOW со СТАРЫМ authId
4. Hysteria регистрирует сессию — уже после kick'а
```
Атомарной пары «решение авторизации + регистрация онлайна» upstream API не даёт,
поэтому повторным чтением базы перед ответом окно не закрыть: оно сдвинется, но
останется.
**Как закрыто.** Поколение учётных данных и идентичность сессий связаны:
```text
peer.id — постоянная идентичность записи
secret — учётные данные
auth_id — идентичность поколения живых сессий
```
Новый секрет получает новый `auth_id`; `/kick` идёт по старому. Пережившая
сессия называется значением, которого в базе больше нет, и цикл учёта видит её
как orphan — механизмом, который уже существует. Ротация происходит **тогда и
только тогда**, когда меняется `secret_digest`: повторная отправка того же
секрета остаётся повторной попыткой отзыва, но нового поколения не создаёт.
Ни отдельной таблицы отозванных поколений, ни очереди повторов, ни
распределённых блокировок не заведено.
**Цена названа прямо.** До следующего цикла учёта такая сессия считается сессией
неизвестного пира, поэтому её дельта трафика приписывается некому и попадает в
потери цикла. Это не более 30 секунд трафика одного пира на одну ротацию.
Колонка «прежний `auth_id`» ради этих секунд ввела бы второй идентификатор
сессии — ровно то состояние, из-за которого отзыв и не сходился.
**Чем закреплено.** `apps/service/peer_secret_rotation_test.go`: удержание
in-flight авторизации внутри `/online` с успешным `/kick` посередине,
`/kick` → 500, повтор того же секрета, секрет из одних пробелов, четыре
сценария импорта. Плюс гейт приёмки на форму записи в обеих дверях.
---
## ADM-HY2-01 — два контракта одного адреса
`normalizeIpv4Host` принимал любой корректный IPv4, шаблон честно рендерил
`trafficStats.listen: <адрес>:36712`, а `assertHysteriaConfigMatchesProfile`
сверял установленный конфиг с тем же значением. Все гейты проходили.
Вторая половина продукта имеет другой контракт: `GetHysteria2ApiPort` берёт из
`trafficStats.listen` **только порт**, а `proxy.NewHysteria2Api` всегда строит
`http://127.0.0.1:<port>`.
```text
HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=192.168.1.10
Hysteria слушает 192.168.1.10:36712
админка идёт 127.0.0.1:36712
/online недоступен → лимит устройств fail-closed → отказ авторизации ВСЕМ пирам
учёт трафика и принудительное отключение не работают
```
То есть валидная с точки зрения всех проверок конфигурация выключала продукт.
**Как закрыто.** Адрес зафиксирован: `validateRuntimeConfig` принимает только
`127.0.0.1`. Traffic Stats API — внутренний control plane одного процесса на
одной машине, сценария с другим адресом у него нет.
**Уточнение к внешнему разбору.** Одной половины мало. Файл может разойтись с
оркестратором правкой руками, поэтому вторая половина фикса — в админке:
`parseTrafficStatsPort` больше не отбрасывает хост молча, а называет
расхождение. Wildcard и loopback принимаются (обмен через них состоится), любой
другой адрес — отказ с указанием на `reconfigure`. Молчаливая подстановка
loopback вместо прочитанного значения и есть тот самый второй контракт.
**Чем закреплено.** `orchestrator/test/env.test.ts` (`0.0.0.0`, `127.0.0.2`,
LAN, публичный адрес), `apps/service/config_traffic_stats_test.go`, гейты
приёмки на обе половины и на `package/config/hy2xs.env`.
---
## ADM-HY2-02 — JSON-журнал не разбирался ни одной строкой
Юнит запускает Hysteria с `HYSTERIA_LOG_FORMAT=json`. Разбор складывал запись
прямым `json.Unmarshal` в `vo.LogHysteria2Vo`, у которого `Time string`.
**Уточнение к внешнему разбору.** Предложенная замена поля на `int64` не
работает. JSON-логгер `app/v2.12.2` объявлен так:
```go
TimeKey: "time", LevelKey: "level", MessageKey: "msg",
EncodeTime: zapcore.EpochMillisTimeEncoder
```
а `EpochMillisTimeEncoder` печатает `float64` — наносекунды, делённые на
миллисекунду, то есть **дробное** число вида `1788321234567.1235`. На `int64`
разбор падал бы так же, как на `string`.
Каждая строка уходила в fallback, и панель показывала сырой JSON: структурный
журнал был включён, а структурой никто не пользовался.
**Как закрыто.** Разбор идёт через `map[string]any`: известные ключи заполняют
колонки, остальные (`addr`, `id`, `error`, `listen`, `tx`, …) дописываются к
сообщению как `msg [key=value …]` в алфавитном порядке и проходят тот же
санитайз. `time` принимается числом и строкой; при отсутствии берётся
`__REALTIME_TIMESTAMP` journald.
**Найдено сверх разбора.** `journalctl -o json` отдаёт `MESSAGE` **массивом
байт**, если сообщение не является корректным UTF-8. Прежний `Message string`
ронял разбор всей строки, и она молча выпадала из журнала — то есть именно те
записи, ради которых журнал чаще всего и открывают.
**Чем закреплено.** `apps/service/journal_test.go` — записи собираются тем же
способом, каким их пишет zap.
---
## ADM-HY2-03 — «остановлена», «неизвестно» и доступность API были одним значением
```text
Hysteria остановлена
Traffic Stats API доступен
онлайн: 0
```
Три утверждения об одной системе, первые два несовместимы, и все три получены из
одного ответа `systemctl`: общий `Hysteria2Online` при неактивной службе отдавал
пустую карту **без ошибки**, поэтому сборщик метрик выставлял
`apiReachable = true`, ни разу не обратившись к API, а список пиров показывал
всех офлайн.
**Уточнение к внешнему разбору.** Развязать флаги было недостаточно: состояние
службы физически нечем было прочитать. `util.Exec` выбрасывает вывод команды,
как только код возврата не нулевой, а `systemctl is-active` отвечает словом
состояния в stdout **вместе** с кодом 3.
**Как закрыто.** Добавлен `util.ExecProbe`, для которого ненулевой код — ответ,
а не отказ. Состояние службы стало трёхзначным (`active` / `inactive` /
`unknown`), `Hysteria2Online` спрашивает Traffic Stats API напрямую и возвращает
ошибку, а решает, как её показать, вызывающий: дашборд — отдельными фактами,
список пиров — признаком `onlineState: unavailable` и «онлайн неизвестен» вместо
«офлайн». Незнакомое слово в ответе systemd означает `unknown`, а не
«остановлена».
**Чем закреплено.** `apps/service/hysteria2_state_test.go` (матрица 2×2),
разбор ответа `is-active`, `PagePeer` в обоих состояниях,
`apps/util/exec_probe_test.go`.
---
## ADM-HY2-04 — upstream-проверка обновлений в production
`app/v2.12.2` по умолчанию проверяет обновления после старта. В сборочном и e2e
окружении HY2XS она отключена, а в `hysteria-server.service` — нет.
Security-дефекта здесь нет: Hysteria бинарник не заменяет. Дефект
архитектурный — у версии обязан быть один владелец
(`versions.env` → сборка → пакет → оркестратор), а production не имеет права
отличаться от тестового окружения.
**Как закрыто.** `Environment=HYSTERIA_DISABLE_UPDATE_CHECK=1` в юните + гейт
приёмки.
---
## ADM-HY2-05 — экран показывал дефолты UI вместо файла
Панель накладывала ответ сервера на полный объект значений по умолчанию, поэтому
отвечала не на тот вопрос:
| в файле | показывалось |
| --- | --- |
| секции `trafficStats` нет | `listen: :9999` |
| `speedTest: false` | вкладка спрятана как «не задано» |
| `ignoreClientBandwidth: true` без `bandwidth` | не показано вовсе |
| `masquerade.string.statusCode` (200..599) | переключатель |
| ни `tls`, ни `acme` | дефолты ACME |
Экран, существующий ради диагностики расхождений, эти расхождения скрывал.
**Как закрыто — сокращением, а не развитием.** Ответ описывает
production-профиль: значения так, как они записаны (`null` = «не задано»), и
отдельный список секций вне профиля, считаемый по сырому YAML — секция, которую
типизированная модель не понимает, обязана быть замечена, а не потеряна. Списки
секций в Go и в оркестраторе сверяются гейтом приёмки.
Удалены три редактора, которые ничего не сохраняли (outbounds, список значений,
словарь), вместе с их компонентами и view-моделью на `DeepRequired`: пока
«универсальный редактор Hysteria» существует в дереве, он отрастает заново.
Полный документ по-прежнему доступен санитизированной выгрузкой.
---
## ADM-HY2-06 — санитайзер не следовал по YAML-якорям
`redactNode` и `redactSubtree` разбирали документ, последовательность,
отображение и скаляр, но не `yaml.AliasNode`.
**Уточнение к внешнему разбору.** Утечек две, а не одна:
```yaml
shared: &credential VERY_SECRET_VALUE
obfs:
salamander:
password: *credential
```
Значение под `password` — ссылка, и `redactSubtree` на ней был no-op. Само
объявление якоря стоит под ключом `shared`, секретоподобным не выглядящим, — и
его не трогал никто. Секрет уезжал в выгрузку дважды.
**Как закрыто.** Обход идёт по цели ссылки: редакция цели закрывает оба
вхождения сразу. Защита от циклов обязательна — `yaml.v3` на ссылке, указывающей
на предка, строит действительно циклический граф узлов (проверено), и без неё
обход не завершился бы.
**Чем закреплено.** Четыре регрессии в `hysteria2_export_test.go`: скаляр за
якорем, URL с учётными данными, составной узел, рекурсивная ссылка.
---
## ADM-HY2-07 — читающий экран был щедрее выгрузки
`auth` и `trafficStats.secret` были закрыты `json:"-"`, а пароль обфускации,
токены ACME DNS, учётные данные outbound-прокси и `masquerade.proxy.url` — нет.
Скачиваемая выгрузка того же конфига их вырезает.
Привилегий это не повышало — маршрут под admin JWT, — но read-only экрану эти
значения не нужны. Закрыто вместе с ADM-HY2-05: вместо значения показывается
диагностический факт («задан» / «не задан», имена параметров без значений,
адрес auth-URL с вырезанным токеном). Проверяется сериализацией ответа целиком,
а не перечислением полей: новое поле без `json:"-"` иначе не заметил бы никто.
---
## ADM-HY2-08 — мёртвые остатки
- `util.CompareVersion` — лексикографическое сравнение версий без единого
потребителя (`2.10` < `2.9`); удалён вместе с файлом;
- `service.ReleaseHysteria2``return nil`, вызывавшийся при завершении
сервиса; остаток модели, в которой панель считала Hysteria своим подпроцессом;
- `PeerClientConfigVo.QrCode` — второй канал доставки QR, который панель рисует
сама из ссылки;
- компонент `UnitSelect` и три функции `utils/byte.ts` — без потребителей.
---
## GATE-03 — гейт проверял форму, а не замок
Освобождение admission-замка проверялось регулярным выражением
`/defer\s+\w+\(\)/`, то есть «в функции есть какой-нибудь отложенный вызов».
Такой гейт пережил бы
```go
unlockAdmission := lockPeerAdmission(...)
defer someOtherCleanup()
```
— замок, который не отпускается никогда. Теперь имя переменной берётся из самого
присваивания, поэтому проверяется освобождение **именно этого** замка, а
переименование переменной гейт не ломает.
---
## Что проверено независимо
Исходники `app/v2.12.2` — по трём вопросам, от которых зависели решения:
порядок `Authenticate``authenticated = true``LogOnlineState`; точный
`EncoderConfig` JSON-логгера (включая тип, который даёт `EpochMillisTimeEncoder`);
переменная `HYSTERIA_DISABLE_UPDATE_CHECK`.
Поведение `yaml.v3` на рекурсивных якорях проверено экспериментом, а не
предположением: библиотека строит циклический граф узлов и умеет его же
сериализовать обратно.
## Что остаётся релизным гейтом
```bash
go test -race ./service/... -count=1
```
на Debian-билдере. Локально `-race` недоступен (`CGO_ENABLED=0`, компилятора C
нет), и это ограничение среды, а не результат. Обе гонки, найденные в этом и
предыдущем проходах, — логические: детектор гонок на них молчит принципиально,
поэтому закрыты они детерминированными тестами с удержанием ответа `/online`.
## Ручные проверки на живом сервере
1. сменить секрет пира при активном подключении; убедиться, что клиент со старым
секретом теряет доступ не позднее 30 секунд, а `authId` в списке изменился;
2. то же при недоступном Traffic Stats API на момент сохранения (частичный
результат) — сходимость обязана произойти после его восстановления;
3. подменить `trafficStats.listen` на LAN-адрес: админка обязана назвать
расхождение, а страница конфигурации — пометить адрес;
4. остановить `hysteria-server`: дашборд показывает «остановлена» и «API
недоступен», список пиров — «онлайн неизвестен»;
5. сломать доступ к `systemctl` при живой Hysteria: дашборд показывает
«состояние неизвестно» и **доступный** API;
6. открыть страницу журнала Hysteria: записи разобраны, контекст виден,
секретов нет;
7. добавить в конфиг секцию вне профиля (например `resolver`) и открыть страницу
конфигурации: секция обязана попасть в «расхождение конфигурации».