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:
@@ -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 срабатывал на корректном коде.** Проверка «авторизация не
|
||||
|
||||
Reference in New Issue
Block a user