Files
HY2XS_flamy/docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
T
founder b9d3c03f8d fix(admin): считать достижимым только тот адрес Traffic Stats API, который админка действительно опрашивает
Проверка принимала любой ip.IsLoopback(), то есть считала рабочим и 127.0.0.5.
Это неверно: слушатель на конкретном адресе принимает соединения только на него,
а слой proxy обращается строго к http://127.0.0.1:<port>.

  bind 127.0.0.5:38712  ->  dial 127.0.0.1:38712  ->  connection refused
  bind 0.0.0.0:38713    ->  dial 127.0.0.1:38713  ->  connected

Такой адрес выглядел локальным, ломал контур доступа целиком (лимит устройств
fail-closed => не подключается никто) и не вызывал у админки ни одного
возражения. Принимаются ровно 127.0.0.1, 0.0.0.0 и пустой хост.

IPv6-wildcard не принимается сознательно: соединение он принял бы, но HY2XS
объявлен IPv4-only, а зависеть в ответе «достучусь» от net.ipv6.bindv6only
нельзя.

На странице конфигурации мягкое состояние nonCanonicalLoopback убрано: прочий
loopback — это ошибка, а не предупреждение. Осталось три состояния: канон
профиля, wildcard, недостижим.

Свойство закреплено тестом с настоящими сокетами, а гейт приёмки запрещает
возврат IsLoopback() и требует негативного случая 127.0.0.5 в тестах.
2026-09-03 03:53:46 +05:00

31 KiB
Raw Blame History

Разбор кода перед сборкой 1.0.0-rc4

Findings base:      8dcb50a   — дерево, на котором найдены дефекты
Fixes verified in:  рабочее дерево этого прохода

Источник — разбор дерева после прохода rc3 и сверка 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 и завершить сессии, установленные по старому. Второй шаг умеет не удаться, и это заложено в архитектуру: сходимость обязан обеспечить цикл учёта. Но сверять ему было нечем.

до:    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):

1. клиент с S1 начинает авторизацию, Hysteria2Auth ждёт ответа GET /online
2. оператор меняет секрет: запись прошла, /kick вернул 200
3. задержанная авторизация возвращает ALLOW со СТАРЫМ authId
4. Hysteria регистрирует сессию — уже после kick'а

Атомарной пары «решение авторизации + регистрация онлайна» upstream API не даёт, поэтому повторным чтением базы перед ответом окно не закрыть: оно сдвинется, но останется.

Как закрыто. Поколение учётных данных и идентичность сессий связаны:

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>.

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 не влияет.

Первая версия признака на странице конфигурации отвечала на вопрос «достучится ли админка» и молчала по второму, не менее важному, — «тот ли это адрес, который создаёт оркестратор»:

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.5 unreachable ошибка: доступ пиров уже не работает

DIAG-01a — «прочий loopback» был не предупреждением, а отказом

Первая редакция этого же фикса завела мягкое состояние nonCanonicalLoopback для адресов вида 127.0.0.5, а backend принимал их через ip.IsLoopback(). Это неверно, и проверено экспериментом:

bind 127.0.0.5:38712  →  dial 127.0.0.1:38712  →  connection refused
bind 0.0.0.0:38713    →  dial 127.0.0.1:38713  →  connected
bind [::]:38714       →  dial 127.0.0.1:38714  →  connected

Слушатель на конкретном адресе принимает соединения только на него, а слой proxy обращается строго к http://127.0.0.1:<port>. То есть 127.0.0.5 выглядит «локальным», но control plane при нём уже не работает — и админка молчала бы об этом, отказывая при этом всем пирам.

Проверка стала точной: принимаются ровно 127.0.0.1, 0.0.0.0 и пустой хост. IPv6-wildcard ([::]) не принимается, хотя эксперимент показал, что соединение он принял бы: HY2XS объявлен IPv4-only, а достижимость такого слушателя зависит от net.ipv6.bindv6only, которым продукт не управляет — отвечать «достучусь» на основании чужого sysctl нельзя, а указанное в отказе reconfigure для этой конфигурации всё равно верное действие.

Гейт приёмки закрепляет именно семантику: ip.IsLoopback() в файле запрещён, обе принимаемые формы названы литералами, а негативный случай 127.0.0.5 обязан присутствовать в тестах. Само свойство «bind на конкретный loopback не принимает соединение на 127.0.0.1» зафиксировано отдельным тестом с настоящими сокетами — иначе правило выглядит произвольным ужесточением, и следующий читатель вернёт IsLoopback() обратно.

ADM-HY2-02 — JSON-журнал не разбирался ни одной строкой

Юнит запускает Hysteria с HYSTERIA_LOG_FORMAT=json. Разбор складывал запись прямым json.Unmarshal в vo.LogHysteria2Vo, у которого Time string.

Уточнение к внешнему разбору. Предложенная замена поля на int64 не работает. JSON-логгер app/v2.12.2 объявлен так:

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 были одним значением

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.

Уточнение к внешнему разбору. Утечек две, а не одна:

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.ReleaseHysteria2return nil, вызывавшийся при завершении сервиса; остаток модели, в которой панель считала Hysteria своим подпроцессом;
  • PeerClientConfigVo.QrCode — второй канал доставки QR, который панель рисует сама из ссылки;
  • компонент UnitSelect и три функции utils/byte.ts — без потребителей.

GATE-03 — гейт проверял форму, а не замок

Освобождение admission-замка проверялось регулярным выражением /defer\s+\w+\(\)/, то есть «в функции есть какой-нибудь отложенный вызов». Такой гейт пережил бы

unlockAdmission := lockPeerAdmission(...)
defer someOtherCleanup()

— замок, который не отпускается никогда. Теперь имя переменной берётся из самого присваивания, поэтому проверяется освобождение именно этого замка, а переименование переменной гейт не ломает.


Что проверено независимо

Исходники app/v2.12.2 — по трём вопросам, от которых зависели решения: порядок Authenticateauthenticated = trueLogOnlineState; точный EncoderConfig JSON-логгера (включая тип, который даёт EpochMillisTimeEncoder); переменная HYSTERIA_DISABLE_UPDATE_CHECK.

Поведение yaml.v3 на рекурсивных якорях проверено экспериментом, а не предположением: библиотека строит циклический граф узлов и умеет его же сериализовать обратно.

Что остаётся релизным гейтом

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