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
+70
View File
@@ -100,3 +100,73 @@ func peerAccessDenied(peer entity.Peer, now int64) bool {
return false
}
// Живая сессия сверяется с сохранённым состоянием ПОВТОРЯЕМО.
//
// Что было. Приведение сессий к состоянию базы выполнялось ровно один раз — в
// той же операции, которая это состояние записала. Порядок «сначала запись,
// потом `/kick`» правильный, и откат при неудаче разрыва делать нельзя: часть
// операции, закрывающая доступ, уже достигнута. Но второй попытки после
// неудачи не существовало вовсе, и два состояния оставались навсегда.
//
// Первое — замена `auth_id` импортом:
//
// импорт old-auth -> new-auth, COMMIT прошёл
// /kick old-auth -> 500
// оператор повторяет тот же импорт
// applyPeerImportEntry читает из базы уже new-auth и рвёт ЕГО
//
// Старый идентификатор после первой же неудачи не хранился нигде, а cron его
// пропускал: `dao.ListPeer("auth_id in ?")` просто не возвращала строку, и
// authID, которого нет в базе, молча выпадал из обхода. Живая QUIC-сессия
// удалённого или переподписанного пира продолжалась сколько угодно долго.
//
// Второе — снижение `maxDevices`:
//
// 5 -> 1, запись прошла, /kick -> 500
// оператор повторяет сохранение формы
// updateRequiresReconcile сравнивает 1 < 1 -> false, разрыва нет
//
// Для `disabled` повторяемость сделана специально (условие смотрит на
// ЗАПРОШЕННОЕ состояние, а не на переход), для квоты и срока её обеспечивает
// cron через peerAccessDenied. Лимит устройств в политику доступа не входит и
// входить не должен — это свойство не пира, а его сессий, — поэтому здесь у
// него не было ни одного механизма схождения.
//
// Оба состояния закрывает один и тот же приём: cron сверяет не «кого из
// известных пиров пора отключить», а КАЖДЫЙ authID, который Hysteria считает
// живым. Отдельная таблица retry, очередь отложенных операций и хранимый
// «список того, что не удалось разорвать» для этого не нужны: `/online` и есть
// список живых сессий, и сверять его достаточно.
// peerSessionNeedsReconcile отвечает, устарела ли живая сессия пира.
//
// Вторым экземпляром политики доступа не является: disabled, quota, expiry и
// ban остаются целиком за peerAccessDenied, и эта функция их не повторяет, а
// вызывает. Своего здесь ровно одно — инвариант живых сессий, которого в
// хранимом состоянии пира нет: число подключённых устройств.
//
// доступ закрыт -> сессия устарела
// maxDevices непригоден -> сессия устарела
// устройств больше, чем разрешено -> сессии устарели
//
// Непригодный `maxDevices` ведёт к разрыву по той же причине, по которой он
// ведёт к отказу в авторизации: повреждённая граница — это не «безлимит».
//
// Число устройств берётся из `/online`, который по официальному контракту
// Traffic Stats API возвращает количество экземпляров клиента Hysteria
// («устройства»), а не число proxy-потоков. То есть сравнение с `maxDevices`
// здесь опирается на upstream-контракт, а не на предположение.
//
// Выбирать «лишнее устройство» не нужно и невозможно: `/kick` оперирует
// идентификатором клиента. После разрыва клиенты переподключаются, и
// admission пропустит ровно столько, сколько разрешено теперь.
func peerSessionNeedsReconcile(peer entity.Peer, onlineDevices int64, now int64) bool {
if peerAccessDenied(peer, now) {
return true
}
if peer.MaxDevices == nil || *peer.MaxDevices < 1 {
return true
}
return onlineDevices > *peer.MaxDevices
}