Отзыв секрета не сходился: `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
19 KiB
Разбор кода перед сборкой 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 < 1 → false. Второй 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 можно было бы снять
следующим проходом, не уронив сборку.
Добавлены три гейта:
- авторизация берёт замок до чтения
/onlineи ключует его*peer.AuthId(литеральный ключ означал бы один замок на процесс); enforcePeerAccessобходитauthIDsиз/online, ветка!foundзавершает сессию, а не пропускает её, отказ выборки прекращает цикл до/kick, и уборка учёта вызывается;peerSessionNeedsReconcileвызываетpeerAccessDeniedи не упоминает ни одного поля политики доступа самостоятельно.
Каждый гейт проверен в обе стороны: он проходит на исправленном коде и падает на восстановленном состоянии «до».
DOC-02 — документация описывала снятую архитектуру
Два места, а не одно:
docs/testing/11-3-target-and-runtime.md:40— «traffic accounting/kick ориентируются на systemd status». Прямо противоположно реализации после предыдущего прохода и остальной документации;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 и настоящими клиентами:
- подключить
maxDevices + 1устройств одновременно и убедиться, что принято ровноmaxDevices; - снизить
maxDevicesпри нескольких подключённых устройствах, оборвав Traffic Stats API на время операции, и убедиться, что следующий цикл учёта (не позднее 30 с) разрывает сессии; - импортировать пира с новым
auth_id, оборвав/kick, и убедиться, что старая сессия завершается следующим циклом; - удалить пира с оборванным
/kick, затем восстановить API и убедиться, что повтор удаления проходит целиком; - убедиться, что при недоступной SQLite (симуляция) ни одна сессия не рвётся;
go test -race ./service/...на релизном билдере: локально проверка невыполнима, C-компилятора на машине разработчика нет.