Files
HY2XS_flamy/docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
T
founder b315001288 fix(admin): различать достижимость Traffic Stats API и соответствие профилю
Признак на странице конфигурации отвечал только на вопрос «достучится ли
админка», поэтому 0.0.0.0 показывался как норма — хотя внутренний control plane
при нём опубликован на всех интерфейсах, а оркестратор такой конфигурации не
создаёт. Состояний теперь четыре: канон профиля, wildcard, не-канонический
loopback и недостижимый адрес.

Backend не тронут: он по-прежнему отвечает только на вопрос достижимости —
превращать лишнюю публикацию в отказ обслуживания значило бы отключить всех
пиров. Исправлено ложное утверждение в его комментарии: пустой хост `:36712` в
Go означает все интерфейсы, а не loopback.

Удалены мёртвые фразы common.wait/enableSuccess/disableSuccess — остатки
операций запуска, остановки и смены версии Hysteria, которых у панели нет.
2026-09-03 03:39:42 +05:00

415 lines
28 KiB
Markdown
Raw 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-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 | закрыт |
| DIAG-01 | Панель считала wildcard нормальным состоянием control plane | 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`.
---
## DIAG-01 — панель считала wildcard нормальным состоянием control plane
Найдено при перепроверке фикса ADM-HY2-01 и относится только к диагностике: на
data plane не влияет.
Первая версия признака на странице конфигурации отвечала на вопрос
«достучится ли админка» и молчала по второму, не менее важному, — «тот ли это
адрес, который создаёт оркестратор»:
```ts
host === "" || host === "127.0.0.1" || host === "0.0.0.0"
```
`0.0.0.0` — не loopback, а wildcard: внутренний control plane при нём
опубликован на всех интерфейсах, и при `HY2XS_FIREWALL_MODE=external|off` его не
прикрывает ничто. Оркестратор такой конфигурации не создаёт, значит она
появилась правкой руками — и диагностический экран обязан это назвать, а не
показывать как норму. Официальная документация Traffic Stats API отдельно
предупреждает об ограничении доступа к этому listener'у.
Там же обнаружилась вторая неточность, уже в моей формулировке: **пустой хост
(`:36712`) — тоже wildcard, а не loopback.** В Go `:port` означает все
интерфейсы; комментарий в `parseTrafficStatsPort` утверждал обратное. На
поведение это не влияло (wildcard включает loopback, поэтому обмен состоится),
но описание контракта было ложным и исправлено.
**Как закрыто.** Backend не тронут: он по-прежнему отвечает ровно на вопрос
достижимости, и превращать лишнюю публикацию в отказ обслуживания нельзя — это
отключило бы всех пиров. Разделены понятия на стороне панели:
| адрес | состояние | что показано |
| --- | --- | --- |
| `127.0.0.1` | `canonical` | без пометки |
| `0.0.0.0`, пустой хост | `wildcard` | предупреждение: API доступен, но слушает все интерфейсы |
| `127.0.0.x` | `nonCanonicalLoopback` | предупреждение: доступен, но оркестратор такого не создаёт |
| прочее | `unreachable` | ошибка: доступ пиров уже не работает |
## 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`) и открыть страницу
конфигурации: секция обязана попасть в «расхождение конфигурации»;
8. заменить `trafficStats.listen` на `0.0.0.0:36712`: продукт продолжает
работать, а страница конфигурации показывает предупреждение о публикации на
всех интерфейсах.