cb20d8d28f
Отзыв секрета не сходился: `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
374 lines
25 KiB
Markdown
374 lines
25 KiB
Markdown
# Разбор кода перед сборкой `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`) и открыть страницу
|
||
конфигурации: секция обязана попасть в «расхождение конфигурации».
|