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:
2026-09-02 07:15:43 +05:00
parent 6d1686b2be
commit 8dcb50a07c
16 changed files with 1511 additions and 104 deletions
+72
View File
@@ -73,6 +73,18 @@ Hysteria-интеграции с официальной документацие
`сохранённое изменение → живая сессия`, `планировщик → полностью
принадлежащая ему работа`.
Двенадцатый проход — разбор состояния после одиннадцатого, снова со сверкой с
официальной документацией Hysteria 2. Тема: **вторая попытка**. Одиннадцатый
проход сделал правильным порядок «сначала запись, потом разрыв» и правильно
запретил откат при неудаче разрыва — но не дал системе способа прийти к
согласованному состоянию потом. Два состояния оставались навсегда: сессия
пира, которого импорт переподписал или удаление убрало, и превышение лимита
устройств после неудавшегося снижения. Вместе с ними закрыт второй TOCTOU в
лимите устройств — переупорядочивание снимков `/online`, которое учёт
разрешений сам по себе не ловил, а детектор гонок не мог показать в принципе.
Разбор задокументирован в
[docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md).
### Исправлено — правило доступа
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
@@ -167,6 +179,66 @@ Hysteria-интеграции с официальной документацие
резерваций, протухшие снимаются по внутреннему TTL. Ни Redis, ни таблиц в
базе, ни распределённых блокировок: HY2XS — один процесс на одном сервере.
### Исправлено — сходимость отзыва доступа (двенадцатый проход)
- **Устаревший снимок `/online` возвращал уже занятое место.** Учёт выданных
разрешений закрыл сравнение двух одинаковых снимков, но сетевой запрос
по-прежнему выполнялся вне блокировки, поэтому снимки приходили в резервацию
в произвольном порядке. Более старый обгонял более новый и откатывал
`lastOnline` назад: `A` получил разрешение при `online = 0`; `C` обработал
`online = 1` первым и признал разрешение `A` проявившимся; пришедший следом
`B` со своим устаревшим `0` увидел место снова свободным. При
`maxDevices = 1` подключений становилось два. Детектор гонок здесь молчит
принципиально — вся работа с памятью защищена мьютексом, гонка логическая.
Последовательность «прочитать `/online` → занять место» выполняется под
замком **по `authId`**, а не одним на процесс: внутри неё идёт сетевой
запрос, и общий замок выстроил бы подключения всех пиров в очередь за одним
HTTP-обменом. Карта замков не растёт — запись живёт ровно столько, сколько
есть желающие её взять.
- **Живая сессия без строки в базе не завершалась никогда.** Цикл учёта читал
`dao.ListPeer("auth_id in ?")` и обходил найденные строки, поэтому `authId`,
которому в базе ничего не соответствует, молча выпадал. А именно он и
остаётся единственным следом сессии после неудавшегося второго шага: импорт
заменил `auth_id`, удаление убрало строку. Повторить операцию в этом
состоянии невозможно — повтор того же импорта читает из базы уже новое
значение и рвёт его, — а восстановить состояние переподключением нельзя:
авторизация нового значения не знает. Сессия жила неограниченно долго.
- **Снижение `maxDevices` после неудавшегося разрыва не имело второй попытки.**
Условие сравнивало `*peerDto.MaxDevices < *before.MaxDevices`, а форма при
правке отправляет все поля, поэтому повторное сохранение давало `1 < 1` и
разрыва не делало. Лимит устройств в политику доступа не входит и входить не
должен — это свойство сессий, а не пира, — поэтому механизма схождения у него
не было вовсе, в отличие от `disabled`, квоты, срока и блокировки.
- **Цикл учёта стал сверкой живых сессий.** Обход идёт по каждому `authId` из
`/online`: нет строки в базе → разрыв; `peerAccessDenied` → разрыв;
непригодный `maxDevices` → разрыв; устройств больше разрешённого → разрыв.
Отказ базы при этом не рвёт ничего: «пира нет» и «прочитать не удалось» —
разные ответы, и трактовка второго как первого отключила бы всех
подключённых пиров сразу при недоступной SQLite. Таблицы отложенных
операций, очереди retry и хранимого «списка того, что не удалось разорвать»
не появилось: список живых сессий уже есть, и это `/online`. Число устройств
сверено с официальным контрактом Traffic Stats API — `/online` возвращает
количество экземпляров клиента Hysteria, а не число proxy-потоков.
- **Go 1.26.7 → 1.26.8.** Patch-релиз от 2026-09-01 (fixes в cgo, компиляторе,
runtime, `debug/elf` и `os`). Stdlib целиком попадает в production-бинарь,
поэтому «на один патч позади» — свойство выпускаемого артефакта, а не среды
сборки. Обновлены `GO_VERSION` с контрольной суммой и `toolchain` в
`apps/go.mod`: расхождение между ними роняет сборку на
`verify_go_toolchain_contract`. Major не менялся — линия 1.26 поддерживается.
- **Учёт разрешений больше не растёт бесконечно.** Запись снималась только на
ветке отказа: после успешной выдачи она оставалась с непустым списком, а
когда разрешение протухало, снять её было уже некому — следующего обращения
к этому `authId` могло не быть никогда. В карте копились удалённые пиры и
старые идентификаторы, переписанные импортом. Уборка идёт по фактической
картине подключений в том же цикле учёта — единственном месте продукта, где
она известна целиком.
### Исправлено — гейты сборки
- **Гейт fail-open срабатывал на корректном коде.** Проверка «авторизация не