Files
HY2XS_flamy/docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md
T
founder 8dcb50a07c fix(admin): дать отзыву доступа вторую попытку, а лимиту устройств — порядок снимков
Предыдущий проход сделал правильным порядок «сначала долговременная запись,
потом разрыв сессии» и правильно запретил откат при неудаче разрыва. Способа
прийти к согласованному состоянию ПОТОМ он не дал: у двух операций повтор не
работал вовсе.

Импорт, заменивший auth_id: после неудавшегося /kick старое значение не
хранится нигде, повтор того же файла читает из базы уже новое и рвёт его, а
cron пропускал незнакомый authID молча — dao.ListPeer просто не возвращала
строку. Живая сессия оставалась навсегда.

Снижение maxDevices: повтор формы даёт 1 < 1 -> false, разрыва больше нет.
Лимит устройств в политику доступа не входит и входить не должен — это
свойство сессий, — поэтому механизма схождения у него не было.

enforcePeerAccess стал сверкой живых сессий: обход идёт по каждому authID из
/online. Нет строки в базе -> kick; peerAccessDenied -> kick; непригодный
maxDevices -> kick; устройств больше разрешённого -> kick. Отказ базы при этом
не рвёт ничего. Ни таблицы отложенных операций, ни очереди retry: список живых
сессий уже есть, и это /online.

Отдельно закрыт второй TOCTOU лимита устройств. Учёт выданных разрешений
закрыл сравнение двух одинаковых снимков, но сетевой запрос выполнялся вне
блокировки, поэтому снимки приходили в резервацию в произвольном порядке и
устаревший откатывал lastOnline назад, возвращая уже занятое место. Это не
data race — память защищена мьютексом, и -race здесь молчит принципиально.
Последовательность «прочитать /online -> занять место» выполняется под замком
по authId; глобальный замок не годится, внутри идёт сетевой запрос.

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

Гейты приёмки доращены под все три инварианта и проверены в обе стороны.
Go 1.26.7 -> 1.26.8. Документация приведена в соответствие в двух местах,
где описывала снятую архитектуру.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md
2026-09-02 07:15:43 +05:00

19 KiB
Raw Blame History

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

Источник — не прогон на хосте, а разбор дерева на коммите 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-компилятора на машине разработчика нет.