# Разбор кода перед сборкой `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:`. ```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`: продукт продолжает работать, а страница конфигурации показывает предупреждение о публикации на всех интерфейсах.