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
+151 -1
View File
@@ -42,6 +42,37 @@ import (
// «соединение установлено / не установлено» математически точной системы
// резервирования не построить. Он закрывает конкретный и реальный TOCTOU —
// параллельные HTTP-auth одного процесса, — и делает это fail-closed.
//
// Второй TOCTOU: ПЕРЕУПОРЯДОЧИВАНИЕ снимков `/online`.
//
// Учёта выданных разрешений самого по себе оказалось недостаточно, и это
// отдельный дефект, а не оттенок первого. Сетевой запрос выполнялся вне
// мьютекса, поэтому снимки приходили в критическую секцию в произвольном
// порядке — более старый мог обогнать более новый:
//
// 1. A получает разрешение при `/online = 0`; pending = [A], lastOnline = 0
// 2. B читает `/online = 0` и задерживается на обратном пути
// 3. A действительно подключается, Hysteria показывает `/online = 1`
// 4. C читает `/online = 1` и входит в резервацию ПЕРВЫМ:
// разрешение A признано проявившимся, lastOnline = 1, C получает отказ
// 5. B входит со своим устаревшим `online = 0`
// 6. `online > lastOnline` ложно, после чего lastOnline откатывается в 0
// 7. `0 + pending(0) < 1` -> B получает ALLOW
//
// При `maxDevices = 1` подключений становится два. Это НЕ data race: вся
// работа с памятью защищена мьютексом, поэтому детектор гонок здесь молчит
// принципиально, и поймать дефект может только семантическая проверка.
//
// Лечится это не глобальным мьютексом вокруг сети — он сериализовал бы
// подключения всех пиров через один HTTP-обмен, — а замком на ОДИН authID:
// см. lockPeerAdmission. Конкурируют между собой только авторизации одного и
// того же пира, а их упорядоченность и есть требуемое свойство: снимок,
// прочитанный под замком, не может оказаться старше уже обработанного.
//
// Оба механизма нужны одновременно и закрывают разные половины:
//
// замок по authID — снимки не переупорядочиваются;
// учёт разрешений — снимок не успевает измениться к следующему запросу.
// pendingAdmissionTTL — срок жизни выданного разрешения, которое ещё не
// проявилось в `/online`.
@@ -74,6 +105,67 @@ var deviceAdmissions = struct {
byAuthID map[string]*admissionState
}{byAuthID: map[string]*admissionState{}}
// admissionGate — замок одного authID вместе со счётчиком тех, кому он сейчас
// нужен.
//
// Счётчик существует ради удаления записи. Без него карта замков росла бы по
// одной записи на каждый когда-либо авторизовавшийся authId и не уменьшалась
// бы никогда — то есть та же утечка, от которой в учёте разрешений защищает
// forgetIfIdle, только этажом выше.
type admissionGate struct {
mu sync.Mutex
// waiting — сколько вызывающих держат замок или ждут его. Пока значение
// больше нуля, запись обязана оставаться в карте: удалив её, второй
// вызывающий создал бы НОВЫЙ замок и разошёлся бы с первым.
waiting int
}
var admissionGates = struct {
sync.Mutex
byAuthID map[string]*admissionGate
}{byAuthID: map[string]*admissionGate{}}
// lockPeerAdmission сериализует последовательность «прочитать `/online` ->
// занять место» для ОДНОГО пира и возвращает функцию освобождения.
//
// Замок именно по authID, а не один на процесс, и это существенно. Внутри него
// выполняется сетевой запрос к Traffic Stats API, поэтому общий замок означал
// бы, что все подключения всех пиров выстраиваются в очередь за одним
// HTTP-обменом. Здесь же конкурируют только авторизации одного пира — то есть
// ровно те, для которых порядок и решается.
//
// Время удержания ограничено сверху таймаутом самого обращения к Traffic Stats
// API (proxy.Hysteria2Api ставит контекст на 3 секунды), поэтому «застрявшая»
// Hysteria не превращает замок в бессрочный.
//
// Карта замков и карта учёта разрешений намеренно раздельны: первая описывает,
// кто сейчас проходит авторизацию, вторая — что уже выдано. Совмещение их в
// одной структуре означало бы удерживать мьютекс учёта на время сетевого
// запроса.
func lockPeerAdmission(authID string) func() {
admissionGates.Lock()
gate := admissionGates.byAuthID[authID]
if gate == nil {
gate = &admissionGate{}
admissionGates.byAuthID[authID] = gate
}
gate.waiting++
admissionGates.Unlock()
gate.mu.Lock()
return func() {
gate.mu.Unlock()
admissionGates.Lock()
defer admissionGates.Unlock()
gate.waiting--
if gate.waiting == 0 {
delete(admissionGates.byAuthID, authID)
}
}
}
// reserveDeviceSlot решает, есть ли для нового подключения свободное место, и
// занимает его.
//
@@ -83,6 +175,12 @@ var deviceAdmissions = struct {
// передаёт сюда уже полученное число. Под блокировкой остаются только
// несколько операций с map: держать её на время HTTP-обмена значило бы
// сериализовать все подключения всех пиров через один сетевой запрос.
//
// Упорядоченность снимков обеспечивает не этот мьютекс, а замок по authID:
// вызывающий обязан удерживать lockPeerAdmission от чтения `/online` и до
// возврата отсюда. Без него сюда попадал бы снимок старше уже обработанного, и
// строка `state.lastOnline = online` откатывала бы учёт назад — см. описание
// второго TOCTOU в начале файла.
func reserveDeviceSlot(authID string, online int64, maxDevices int64, now time.Time) bool {
deviceAdmissions.Lock()
defer deviceAdmissions.Unlock()
@@ -159,10 +257,62 @@ func (s *admissionState) forgetIfIdle(authID string) {
}
}
// sweepDeviceAdmissions убирает записи о пирах, за которыми ничего не числится.
//
// Зачем это нужно отдельно от forgetIfIdle. Тот срабатывает ТОЛЬКО на ветке
// отказа: после успешной выдачи разрешения запись остаётся с непустым pending,
// а когда разрешение протухает, снять запись уже некому — следующего обращения
// к этому authId может не быть никогда. Так в карте оставались пиры, удалённые
// из панели, и старые authId, переписанные импортом: за время жизни процесса
// она только росла.
//
// Решение принимается по ФАКТИЧЕСКОЙ картине подключений, а не по хранимому
// lastOnline: последний обновляется только на пути авторизации, поэтому у
// отключившегося пира он остаётся прежним сколь угодно долго.
//
// Удаление записи, у которой нет ни одного действующего разрешения и нет
// подключений, не меняет ни одного будущего решения. Следующая резервация
// начнёт с чистой записи и придёт к тому же ответу: dropMaterialized на пустом
// списке — no-op, а `state.lastOnline` в любом случае перезаписывается
// пришедшим значением до сравнения с лимитом.
//
// Замок authID здесь не берётся намеренно: удерживать его на всём обходе карты
// значило бы останавливать авторизацию каждые 30 секунд. Гонка с параллельной
// авторизацией безопасна — она либо уже добавила разрешение (тогда pending не
// пуст и запись остаётся), либо ещё не дошла до учёта (тогда она создаст
// запись заново, и это ровно та же чистая запись).
func sweepDeviceAdmissions(online map[string]int64, now time.Time) {
deviceAdmissions.Lock()
defer deviceAdmissions.Unlock()
for authID, state := range deviceAdmissions.byAuthID {
state.dropExpired(now)
if len(state.pending) > 0 {
continue
}
if online[authID] > 0 {
continue
}
delete(deviceAdmissions.byAuthID, authID)
}
}
// resetDeviceAdmissions очищает учёт. Существует ради тестов: состояние здесь
// принадлежит процессу, и без сброса тесты видели бы резервации друг друга.
func resetDeviceAdmissions() {
deviceAdmissions.Lock()
defer deviceAdmissions.Unlock()
deviceAdmissions.byAuthID = map[string]*admissionState{}
deviceAdmissions.Unlock()
// Замки сбрасывать НЕЛЬЗЯ: удерживаемый кем-то замок, потерянный из карты,
// перестал бы исключать второго вызывающего. Вместо сброса тесты проверяют,
// что после завершения работы карта пуста сама.
}
// heldPeerAdmissionGates — число живых замков. Существует ради тестов: утечка
// здесь выглядит именно как незакрытая запись в карте.
func heldPeerAdmissionGates() int {
admissionGates.Lock()
defer admissionGates.Unlock()
return len(admissionGates.byAuthID)
}