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
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
# Разбор кода перед сборкой `1.0.0-rc3`
|
||||
|
||||
Источник — не прогон на хосте, а разбор дерева на коммите `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-компилятора на машине разработчика нет.
|
||||
@@ -41,3 +41,4 @@
|
||||
| Дата | Основание | Находки |
|
||||
| --- | --- | --- |
|
||||
| 2026-09-01 | коммит `c0a43ae9`, сверка с Hysteria 2 и Element Plus | [UX-06…UX-10, LOG-01…LOG-05, AUTH-01/02, CORE-01/02, TYPE-01](2026-09-01-v1.0.0-rc2-preflight-findings.md) |
|
||||
| 2026-09-02 | коммит `6d1686b8`, повторная сверка с Hysteria 2 | [ADMIT-04/05, RECON-01/02, GATE-02, DOC-02, VER-01](2026-09-02-v1.0.0-rc3-preflight-findings.md) |
|
||||
|
||||
Reference in New Issue
Block a user