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:
@@ -694,6 +694,25 @@ access-changing», то есть второго места, где полити
|
||||
отвечает кодом `peer_disconnect_failed`, панель показывает его предупреждением
|
||||
и обновляет список.
|
||||
|
||||
**И не оставляет систему в этом состоянии навсегда.** Сообщить оператору о
|
||||
частичном результате недостаточно: повторить второй шаг он может не всегда.
|
||||
|
||||
```text
|
||||
операция повтор той же операции после неудачного /kick
|
||||
───────────────────────────────────────────────────────────────
|
||||
disabled = 1 работает: условие смотрит на ЗАПРОШЕННОЕ состояние
|
||||
удаление работает: строка осталась с disabled = 1
|
||||
блокировка работает: cron видит banned_until через peerAccessDenied
|
||||
квота / срок работает: cron видит их через peerAccessDenied
|
||||
maxDevices ↓ НЕ работает: 1 < 1 -> false, разрыва больше не будет
|
||||
импорт old→new НЕ работает: в базе уже new, повтор разорвёт ЕГО
|
||||
```
|
||||
|
||||
Две нижние строки не имели механизма схождения вовсе, и обе закрывает cron —
|
||||
см. «Сверка живых сессий» ниже. Отдельной таблицы retry, очереди отложенных
|
||||
операций и хранимого «списка того, что не удалось разорвать» для этого не
|
||||
нужно: `/online` и есть список живых сессий.
|
||||
|
||||
Формулировка сообщения **не называет конкретную операцию**: через этот код
|
||||
отчитываются все восемь строк таблицы выше, а для удалённого пира фраза «новые
|
||||
подключения пира запрещены» была бы просто бессмысленной.
|
||||
@@ -728,7 +747,7 @@ TryLock (пропустить тик, если предыдущий ещё ид
|
||||
↓
|
||||
GET /traffic?clear=1 → записать дельты в счётчики пиров
|
||||
↓
|
||||
GET /online → применить peerAccessDenied → POST /kick
|
||||
GET /online → сверить живые сессии → POST /kick
|
||||
```
|
||||
|
||||
Порядок обязателен: enforcement принимает решение по счётчикам, значит счётчики
|
||||
@@ -750,6 +769,47 @@ Hysteria обнуляются сразу после отправки ответ
|
||||
не вводится. Квота здесь — операционный предел доступа, а не учёт с финансово
|
||||
значимым каждым байтом.
|
||||
|
||||
#### Сверка живых сессий
|
||||
|
||||
Cron обходит **каждый `authId`, который Hysteria считает живым**, а не тех
|
||||
пиров, которых удалось найти в базе. Разница между этими двумя формулировками и
|
||||
есть то, что делает частичный результат обратимым.
|
||||
|
||||
```text
|
||||
для каждого authId из GET /online:
|
||||
|
||||
выборка пиров не удалась → не рвать НИЧЕГО (цикл прекращается)
|
||||
строки в базе нет → /kick (пир удалён либо переподписан)
|
||||
peerAccessDenied → /kick (disabled / квота / срок / блокировка)
|
||||
maxDevices непригоден → /kick (повреждённая граница — не «безлимит»)
|
||||
устройств > maxDevices → /kick (лимит снижен, сессии остались)
|
||||
```
|
||||
|
||||
Прежний обход выглядел как `ListPeer("auth_id in ?") → range peers`, поэтому
|
||||
идентификатор, которому в базе ничего не соответствует, **молча выпадал**. А
|
||||
именно он и остаётся единственным следом сессии после неудачного второго шага
|
||||
удаления или импорта: `auth_id` в строке уже заменён либо строки нет вовсе, и
|
||||
восстановить состояние переподключением невозможно — авторизация нового
|
||||
значения не знает, а старая сессия живёт своей жизнью.
|
||||
|
||||
**Отказ базы не является основанием рвать сессии.** «Пира нет» и «прочитать не
|
||||
удалось» — разные ответы, и трактовать второй как первый значит отключить всех
|
||||
подключённых пиров сразу при недоступной SQLite. Ошибка выборки прекращает
|
||||
цикл до единого обращения к `/kick`.
|
||||
|
||||
**Число устройств берётся из upstream-контракта, а не из предположения.**
|
||||
`GET /online` по официальной документации Traffic Stats API возвращает
|
||||
количество экземпляров клиента Hysteria («устройства»), а не число proxy-потоков.
|
||||
|
||||
Предикат живых сессий (`peerSessionNeedsReconcile`) **не является вторым
|
||||
экземпляром политики доступа**: `disabled`, квота, срок и блокировка остаются
|
||||
целиком за `peerAccessDenied`, и предикат его вызывает, а не повторяет. Своего
|
||||
у него ровно одно — инвариант, которого в хранимом состоянии пира нет: сколько
|
||||
устройств сейчас на связи.
|
||||
|
||||
Побочное следствие того же обхода — уборка учёта выданных разрешений: цикл
|
||||
учёта единственный в продукте знает фактическую картину подключений целиком.
|
||||
|
||||
### Ограничение устройств проверяется fail-closed
|
||||
|
||||
`maxDevices` проверяется по `/online` Traffic Stats API, который возвращает
|
||||
@@ -793,11 +853,13 @@ A: 2 < 3 -> allow B: 2 < 3 -> allow
|
||||
|
||||
```text
|
||||
1. обычная проверка политики доступа
|
||||
2. GET /online (вне блокировки: сеть не должна сериализовать все подключения)
|
||||
3. снять протухшие разрешения
|
||||
4. рост online означает, что столько же разрешений превратились в подключения
|
||||
5. решение по сумме: online + выданные разрешения
|
||||
6. свободно -> занять место и allow; иначе deny
|
||||
2. взять замок ЭТОГО пира
|
||||
3. GET /online
|
||||
4. снять протухшие разрешения
|
||||
5. рост online означает, что столько же разрешений превратились в подключения
|
||||
6. решение по сумме: online + выданные разрешения
|
||||
7. свободно -> занять место и allow; иначе deny
|
||||
8. отпустить замок
|
||||
```
|
||||
|
||||
Учёт **process-local**: HY2XS — один процесс на одном сервере с Hysteria, и ни
|
||||
@@ -807,10 +869,46 @@ Redis, ни таблицы в базе, ни распределённых бло
|
||||
клиента в статистике, а не политика доступа. Если клиент авторизовался и не
|
||||
подключился, резервация исчезает сама.
|
||||
|
||||
#### Снимки `/online` не переупорядочиваются
|
||||
|
||||
Учёта разрешений самого по себе оказалось недостаточно, и это отдельный дефект,
|
||||
а не оттенок предыдущего. Пока сетевой запрос выполнялся **вне** блокировки,
|
||||
снимки приходили в резервацию в произвольном порядке, и более старый откатывал
|
||||
учёт назад:
|
||||
|
||||
```text
|
||||
A получил разрешение при online = 0; pending = [A], lastOnline = 0
|
||||
B прочитал online = 0 и задержался на обратном пути
|
||||
A подключился — Hysteria показывает online = 1
|
||||
C прочитал online = 1 и вошёл ПЕРВЫМ:
|
||||
разрешение A признано проявившимся, lastOnline = 1, C отклонён
|
||||
B входит со своим устаревшим 0 -> lastOnline снова 0 -> B ДОПУЩЕН
|
||||
```
|
||||
|
||||
При `maxDevices = 1` подключений становилось два. Детектор гонок здесь
|
||||
бесполезен **принципиально**: вся работа с памятью защищена мьютексом, и гонка
|
||||
логическая, а не по памяти. Доказать такое свойство может только семантический
|
||||
тест.
|
||||
|
||||
Поэтому последовательность «прочитать `/online` → занять место» выполняется под
|
||||
замком, и замок этот — **по `authId`, а не один на процесс**. Внутри него идёт
|
||||
сетевой запрос: общий замок выстроил бы подключения всех пиров в очередь за
|
||||
одним HTTP-обменом. Конкурируют только авторизации одного и того же пира, а их
|
||||
упорядоченность и есть требуемое свойство. Время удержания ограничено сверху
|
||||
таймаутом обращения к Traffic Stats API.
|
||||
|
||||
Оба механизма нужны одновременно и закрывают разные половины:
|
||||
|
||||
```text
|
||||
замок по authId — снимки не переупорядочиваются
|
||||
учёт разрешений — снимок не успевает измениться к следующему запросу
|
||||
```
|
||||
|
||||
Чего механизм не обещает: без обратного вызова от Hysteria «соединение
|
||||
установлено / не установлено» математически точной системы резервирования не
|
||||
построить. Он закрывает конкретный и реальный случай — параллельные HTTP-auth
|
||||
одного процесса — и делает это fail-closed.
|
||||
построить. Он закрывает конкретные и реальные случаи — параллельные HTTP-auth
|
||||
одного процесса — и делает это fail-closed. Случайное превышение лимита по
|
||||
любой другой причине устраняет сверка живых сессий в цикле учёта.
|
||||
|
||||
### Что нельзя делать
|
||||
|
||||
@@ -829,6 +927,15 @@ Redis, ни таблицы в базе, ни распределённых бло
|
||||
- обращаться к `/kick` мимо `disconnectAuthIDs`;
|
||||
- удалять пира, не запомнив его `authId` и не завершив сессию до удаления;
|
||||
- разрывать сессии импорта до `COMMIT` либо по новым `authId`;
|
||||
- читать `/online` вне замка пира на пути авторизации: устаревший снимок
|
||||
возвращает уже занятое место, и детектор гонок этого не показывает;
|
||||
- заводить один замок авторизации на процесс: внутри него идёт сетевой запрос;
|
||||
- пропускать в цикле учёта `authId`, которому в базе ничего не соответствует, —
|
||||
это единственный след сессии после неудавшегося разрыва при удалении и
|
||||
импорте;
|
||||
- трактовать отказ базы как «пира нет» и рвать по нему сессии;
|
||||
- заводить таблицу отложенных операций или очередь retry ради схождения:
|
||||
список живых сессий уже есть, и это `/online`;
|
||||
- запускать работу джобы учёта в отсоединённых горутинах: планировщик обязан
|
||||
её видеть, иначе `StopCron()` вернётся раньше, чем она закончит;
|
||||
- считать квоту биллинговым учётом: чтение `/traffic?clear=1` деструктивно;
|
||||
@@ -1012,4 +1119,10 @@ Compatibility-ветка пережила слой совместимости,
|
||||
21. удаление пира завершает его сессию до того, как исчезнет `authId`
|
||||
22. импорт разрывает старые сессии после `COMMIT` и по старым `authId`
|
||||
23. джоба учёта выполняется синхронно, и `StopCron()` её дожидается
|
||||
24. лимит устройств не превышается параллельными запросами авторизации
|
||||
24. лимит устройств не превышается параллельными запросами авторизации — в том
|
||||
числе когда снимки `/online` приходят в обратном порядке
|
||||
25. цикл учёта сверяет КАЖДУЮ живую сессию из `/online`, а не только тех пиров,
|
||||
которых удалось найти в базе; отказ базы при этом не рвёт ничего
|
||||
26. неудавшийся разрыв не оставляет систему в несогласованном состоянии
|
||||
навсегда: сессия удалённого либо переподписанного пира и превышение
|
||||
`maxDevices` устраняются очередным циклом учёта
|
||||
|
||||
Reference in New Issue
Block a user