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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user