Files
HY2XS_flamy/docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md
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

284 lines
19 KiB
Markdown
Raw Permalink 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-rc3`
```text
Findings base: 6d1686b8 — дерево, на котором найдены дефекты
Fixes verified in: 8dcb50a — дерево, на котором проверены исправления
```
Две базы названы отдельно намеренно: сам разбор шёл по первой, а выводы
исправлений проверялись по второй, и без этой пары читателю приходилось
гадать, к какому состоянию относится каждое утверждение отчёта.
Источник — не прогон на хосте, а разбор дерева на коммите `6d1686b8` и повторная
сверка Hysteria-интеграции с официальной документацией Hysteria 2. Разбор
проводился по состоянию **после** предыдущего прохода
([2026-09-01-v1.0.0-rc2-preflight-findings.md](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-обмен, — и это оставило вторую половину гонки
открытой: снимки приходили в резервацию в произвольном порядке.
```text
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:**
```go
peers, err := dao.ListPeer("auth_id in ?", chunk)
for _, peer := range peers { ... }
```
Обход шёл по НАЙДЕННЫМ строкам, поэтому `authId`, которому в базе ничего не
соответствует, молча выпадал. А именно он и остаётся единственным следом сессии
после неудавшегося второго шага:
```text
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` не имело второй попытки
```text
maxDevices: 5 -> 1
DB update: OK
/kick: FAIL
```
Оператор повторяет сохранение формы. Панель при правке отправляет все поля,
включая `maxDevices`, но условие разрыва сравнивает
```go
*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 можно было бы снять
следующим проходом, не уронив сборку.
Добавлены три гейта:
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-компилятора на машине разработчика нет.