Files
HY2XS_flamy/docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md
T
founder cb20d8d28f 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
2026-09-02 23:24:01 +05:00

19 KiB
Raw Blame History

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

Findings base:      6d1686b8   — дерево, на котором найдены дефекты
Fixes verified in:  8dcb50a    — дерево, на котором проверены исправления

Две базы названы отдельно намеренно: сам разбор шёл по первой, а выводы исправлений проверялись по второй, и без этой пары читателю приходилось гадать, к какому состоянию относится каждое утверждение отчёта.

Источник — не прогон на хосте, а разбор дерева на коммите 6d1686b8 и повторная сверка Hysteria-интеграции с официальной документацией Hysteria 2. Разбор проводился по состоянию после предыдущего прохода (2026-09-01-v1.0.0-rc2-preflight-findings.md), то есть проверялись выводы уже принятых исправлений, а не исходное состояние.

Тема прохода — вторая попытка. Предыдущий проход сделал правильным порядок «сначала долговременная запись, потом разрыв сессии» и правильно запретил откат при неудаче разрыва. Но способа прийти к согласованному состоянию потом он не дал: у двух операций повтор не работал вовсе, и их частичный результат оставался навсегда.

Проверить на живом сервере ещё предстоит: список ручных проверок — в конце документа.

Сводка

ID Дефект Приоритет Статус
ADMIT-04 Устаревший снимок /online возвращал уже занятое место P0 закрыт
RECON-01 Живая сессия без строки в базе не завершалась никогда P0 закрыт
RECON-02 Снижение maxDevices после неудачного /kick не имело второй попытки P1 закрыт
ADMIT-05 Учёт выданных разрешений рос неограниченно P2 закрыт
GATE-02 Гейты приёмки не покрывали ни одного из новых инвариантов P1 закрыт
DOC-02 Два места документации описывали снятую архитектуру P3 закрыт
VER-01 GO_VERSION отставала на patch-релиз P3 закрыт

ADMIT-04 и RECON-01 найдены внешним разбором; RECON-02, ADMIT-05, GATE-02 и вторая половина DOC-02 — при проверке его выводов по коду.


ADMIT-04 — устаревший снимок /online возвращал уже занятое место

Наблюдалось только рассуждением: тестами это состояние не воспроизводилось, а go test -race был и остаётся зелёным.

Предыдущий проход закрыл сравнение двух ОДИНАКОВЫХ снимков: появился process-local учёт выданных, но ещё не проявившихся разрешений (apps/service/peer_admission.go). Сетевой запрос при этом по-прежнему выполнялся вне блокировки — сознательно, чтобы не сериализовать подключения всех пиров через один HTTP-обмен, — и это оставило вторую половину гонки открытой: снимки приходили в резервацию в произвольном порядке.

1. A получает разрешение при /online = 0;  pending = [A], lastOnline = 0
2. B читает /online = 0 и задерживается на обратном пути
3. A подключается — Hysteria показывает /online = 1
4. C читает /online = 1 и первым входит в резервацию:
      online > lastOnline -> разрешение A признано проявившимся
      lastOnline = 1, C получает отказ (верно)
5. B входит со своим устаревшим online = 0
6. `online > lastOnline` ложно; следом безусловное lastOnline = online
7. lastOnline = 0, pending пуст -> 0 < 1 -> B ДОПУЩЕН

При maxDevices = 1 подключений становится два — ровно тем способом, от которого лимит и должен защищать.

Это не data race. Все обращения к памяти корректно защищены мьютексом, поэтому детектор гонок здесь молчит принципиально, и «-race зелёный» не является свидетельством. Доказать свойство может только семантический тест.

Исправление. Последовательность «прочитать /online → занять место» выполняется под замком по authId (lockPeerAdmission). Не глобальный мьютекс вокруг сети: внутри замка идёт HTTP-обмен, и общий замок выстроил бы подключения всех пиров в одну очередь. Конкурируют только авторизации одного пира, а их упорядоченность и есть требуемое свойство — снимок, прочитанный под замком, не может оказаться старше уже обработанного. Время удержания ограничено сверху таймаутом обращения к Traffic Stats API (3 с).

Учёт разрешений при этом остаётся нужен: сериализация не устраняет задержку между ответом allow и появлением клиента в /online. Механизмы закрывают разные половины и работают вместе.

Карта замков не растёт: запись живёт ровно столько, сколько есть желающие её взять (счётчик ссылок).

Закреплено: TestHysteria2AuthRejectsStaleOnlineSnapshot — воспроизводит последовательность выше через удержание конкретного ответа /online; проверено, что тест падает на коде без замка. Плюс TestPeerAdmissionGate* — исключение одного authId, отсутствие сериализации разных, отсутствие утечки записей.


RECON-01 — живая сессия без строки в базе не завершалась никогда

Корневая причина — форма обхода в cron:

peers, err := dao.ListPeer("auth_id in ?", chunk)
for _, peer := range peers { ... }

Обход шёл по НАЙДЕННЫМ строкам, поэтому authId, которому в базе ничего не соответствует, молча выпадал. А именно он и остаётся единственным следом сессии после неудавшегося второго шага:

DB: old-auth
импорт old-auth -> new-auth, COMMIT прошёл
/kick old-auth -> 500

Повторить операцию в этом состоянии невозможно: applyPeerImportEntry читает replaced := authIDOf(existing), то есть повтор того же файла найдёт в базе уже new-auth и разорвёт ЕГО. Старое значение после первой неудачи не хранится нигде. Восстановить состояние переподключением тоже нельзя — авторизация нового значения не знает, — а старая QUIC-сессия живёт своей жизнью сколь угодно долго. То же самое даёт удаление пира, у которого не удался /kick и следом всё-таки прошло удаление строки.

Это ровно тот класс stale session, от которого весь предыдущий проход и должен был защитить.

Исправление. enforcePeerAccess обходит каждый authId из /online, а не строки выборки. Идентификатор без строки в базе — orphan-сессия, и правильный исход у неё тот же, что и у первой попытки: /kick.

Отдельно проверено, что отказ базы не рвёт сессии. «Пира нет» и «прочитать не удалось» — разные ответы; трактовка второго как первого отключила бы всех подключённых пиров сразу при недоступной SQLite. Ошибка выборки прекращает цикл до единого обращения к /kick.

Закреплено: TestCronKicksSessionWithoutPeerRow, TestCronReconcilesSessionAfterFailedImportKick (end-to-end: импорт old→new, /kick 500, затем обычный цикл учёта рвёт old), TestCronSendsNoKickWhenPeerLookupFails.


RECON-02 — снижение maxDevices после неудачного /kick не имело второй попытки

maxDevices: 5 -> 1
DB update: OK
/kick: FAIL

Оператор повторяет сохранение формы. Панель при правке отправляет все поля, включая maxDevices, но условие разрыва сравнивает

*peerDto.MaxDevices < *before.MaxDevices

то есть 1 < 1false. Второй PATCH возвращает успех, /kick больше не вызывается, и пять подключённых клиентов продолжают работать при лимите 1.

Для disabled повторяемость сделана специально (условие смотрит на ЗАПРОШЕННОЕ состояние, а не на переход), для квоты и срока её обеспечивает cron через peerAccessDenied. Лимит устройств в политику доступа не входит и входить не должен — это свойство сессий, а не хранимого состояния пира, — поэтому механизма схождения у него не было вовсе.

Исправление — не в условии updateRequiresReconcile. Делать разрыв при каждом сохранении формы нельзя: тогда любая правка пометки рвала бы сессии. Схождение обеспечивает та же сверка живых сессий: онлайн-устройств > maxDevices/kick. Побочно это лечит и любое другое случайное превышение лимита.

Число устройств взято из upstream-контракта, а не из предположения. Официальная документация Traffic Stats API: /online возвращает «the number of Hysteria client instances ("devices"), NOT the number of active proxy connections». Сравнение с maxDevices корректно.

Предикат peerSessionNeedsReconcile не является вторым экземпляром политики доступа: disabled, квота, срок и блокировка остаются целиком за peerAccessDenied, и предикат его вызывает, а не повторяет.

Закреплено: TestCronKicksWhenOnlineExceedsMaxDevices, TestCronDoesNotKickAtExactDeviceLimit (граница), TestCronKicksPeerWithUnusableMaxDevices, TestCronReconcilesSessionAfterFailedMaxDevicesReduction (end-to-end).


ADMIT-05 — учёт выданных разрешений рос неограниченно

forgetIfIdle вызывался только на ветке отказа. После успешной выдачи запись оставалась с непустым списком разрешений, а когда разрешение протухало, снять её было уже некому: следующего обращения к этому authId могло не быть никогда. В карте копились удалённые пиры и старые идентификаторы, переписанные импортом, — за время жизни процесса она только росла.

Исправление. Уборка идёт по ФАКТИЧЕСКОЙ картине подключений в цикле учёта — единственном месте продукта, где она известна целиком. Решение принимается не по хранимому lastOnline: тот обновляется только на пути авторизации и у отключившегося пира остаётся прежним сколь угодно долго.

Удаление записи без действующих разрешений и без подключений не меняет ни одного будущего решения: следующая резервация начнёт с чистой записи и придёт к тому же ответу.

Закреплено: TestSweepDeviceAdmissionsForgetsIdlePeers, TestSweepDeviceAdmissionsKeepsOnlinePeers, TestCronSweepsAdmissionsOfOfflinePeers.


GATE-02 — гейты приёмки не покрывали новых инвариантов

run_access_revocation_acceptance проверял наличие reserveDeviceSlot и reconcileLiveSessions, но ни порядок «замок → чтение /online», ни форму обхода в cron. То есть исправления ADMIT-04 и RECON-01 можно было бы снять следующим проходом, не уронив сборку.

Добавлены три гейта:

  1. авторизация берёт замок до чтения /online и ключует его *peer.AuthId (литеральный ключ означал бы один замок на процесс);
  2. enforcePeerAccess обходит authIDs из /online, ветка !found завершает сессию, а не пропускает её, отказ выборки прекращает цикл до /kick, и уборка учёта вызывается;
  3. peerSessionNeedsReconcile вызывает peerAccessDenied и не упоминает ни одного поля политики доступа самостоятельно.

Каждый гейт проверен в обе стороны: он проходит на исправленном коде и падает на восстановленном состоянии «до».


DOC-02 — документация описывала снятую архитектуру

Два места, а не одно:

  1. docs/testing/11-3-target-and-runtime.md:40 — «traffic accounting/kick ориентируются на systemd status». Прямо противоположно реализации после предыдущего прохода и остальной документации;
  2. docs/admin/04-admin-panel.md — «GET /online (вне блокировки: сеть не должна сериализовать все подключения)». Верно описывало прежнее устройство и стало ложным вместе с исправлением ADMIT-04.

Второе найдено при закрытии первого: документация здесь входит в acceptance-контракт, поэтому расхождение — не косметика.


VER-01 — GO_VERSION отставала на patch-релиз

GO_VERSION=1.26.7; 1.26.8 вышел 2026-09-01 (fixes в cgo, компиляторе, runtime, debug/elf и os). Stdlib целиком попадает в production-бинарь, поэтому «на один патч позади» — свойство выпускаемого артефакта, а не среды сборки.

Не архитектурный blocker и не security emergency; закрыто вместе с остальным, раз проход всё равно затрагивает контракт версий. Major не менялся: линия 1.26 поддерживается, переход на 1.27 ради номера не нужен.

Обновлены GO_VERSION, GO_LINUX_AMD64_SHA256 и toolchain в apps/go.mod — расхождение между ними роняет сборку на verify_go_toolchain_contract. Контрольная сумма взята из https://go.dev/dl/?mode=json&include=all и сверена повторным независимым запросом.


Что осталось проверить на живом хосте

Автоматика доказывает логику; следующие свойства наблюдаемы только на реальном сервере с Hysteria и настоящими клиентами:

  1. подключить maxDevices + 1 устройств одновременно и убедиться, что принято ровно maxDevices;
  2. снизить maxDevices при нескольких подключённых устройствах, оборвав Traffic Stats API на время операции, и убедиться, что следующий цикл учёта (не позднее 30 с) разрывает сессии;
  3. импортировать пира с новым auth_id, оборвав /kick, и убедиться, что старая сессия завершается следующим циклом;
  4. удалить пира с оборванным /kick, затем восстановить API и убедиться, что повтор удаления проходит целиком;
  5. убедиться, что при недоступной SQLite (симуляция) ни одна сессия не рвётся;
  6. go test -race ./service/... на релизном билдере: локально проверка невыполнима, C-компилятора на машине разработчика нет.