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
+58 -9
View File
@@ -2,6 +2,7 @@ package service
import (
"fmt"
"sort"
"sync"
"time"
@@ -242,7 +243,8 @@ func (e *trafficLossError) Error() string {
)
}
// enforcePeerAccess завершает сессии пиров, которым доступ уже закрыт.
// enforcePeerAccess приводит ЖИВЫЕ СЕССИИ в соответствие с сохранённым
// состоянием.
//
// Политика берётся из peerAccessDenied — той же функции, по которой пира
// пускает или не пускает авторизация. Собственного SQL-условия здесь больше
@@ -250,6 +252,25 @@ func (e *trafficLossError) Error() string {
// расходилось на границах quota, expiry и ban, и исчерпавший квоту пир не
// пускался заново, но и не отключался никогда.
//
// Обход идёт по КАЖДОМУ authID, который Hysteria считает живым, а не по
// найденным в базе пирам. Прежняя реализация читала
//
// peers, err := dao.ListPeer("auth_id in ?", chunk)
// for _, peer := range peers { ... }
//
// и потому не видела сессий, которым в базе больше ничего не соответствует.
// Это не теоретический случай: `auth_id` перезаписывает импорт, а строку
// целиком убирает удаление. Обе операции рвут старую сессию сами, но их второй
// шаг может не удаться — и тогда единственным местом, где о ней ещё известно,
// остаётся сам `/online`. Пропуская незнакомый идентификатор молча, cron
// оставлял такую сессию жить неограниченно долго. Подробности — в
// peer_session-разделе peer_access.go.
//
// Отказ базы НЕ приводит к разрыву. «Пира нет» и «прочитать не удалось» —
// разные ответы, и второй не даёт права рвать ничьи сессии: недоступная SQLite
// иначе означала бы отключение всех подключённых пиров сразу. Ошибка чтения
// прекращает цикл до единого обращения к `/kick`.
//
// Обход последовательный. Прежняя реализация раскладывала online-пиров на
// чанки по 10 и запускала по горутине на чанк с sync.WaitGroup внутри уже
// отсоединённой горутины. Параллельность здесь не нужна: обращений к базе
@@ -259,12 +280,20 @@ func enforcePeerAccess(apiPort int64, trafficStatsSecret string) error {
if err != nil {
return err
}
// Учёт выданных разрешений чистится по фактической картине подключений, и
// это единственное место продукта, где она известна целиком. Делается это
// до любых решений: уборка ни на что не влияет и ничего не рвёт.
sweepDeviceAdmissions(online, time.Now())
if len(online) == 0 {
return nil
}
authIDs := make([]string, 0, len(online))
for authID := range online {
// Пустой ключ ничему не соответствует: рвать по нему нечего, и в
// dedup disconnectAuthIDs он всё равно не попал бы.
if authID == "" {
continue
}
@@ -273,25 +302,45 @@ func enforcePeerAccess(apiPort int64, trafficStatsSecret string) error {
if len(authIDs) == 0 {
return nil
}
// Порядок ключей карты в Go случаен; сортировка делает и обращение к
// `/kick`, и журнал воспроизводимыми.
sort.Strings(authIDs)
now := time.Now().UnixMilli()
kick := make([]string, 0, len(authIDs))
known := make(map[string]entity.Peer, len(authIDs))
for _, chunk := range util.SplitArr(authIDs, 100) {
peers, err := dao.ListPeer("auth_id in ?", chunk)
if err != nil {
return err
}
for _, peer := range peers {
// Строка без authId Hysteria не знает: рвать нечего. Раньше здесь
// стояло `*item.AuthId` без проверки — паника на повреждённой
// строке внутри отсоединённой горутины.
// Строка без authId Hysteria не знает. Раньше здесь стояло
// `*item.AuthId` без проверки — паника на повреждённой строке
// внутри отсоединённой горутины.
authID := authIDOf(peer)
if authID == "" {
continue
}
if peerAccessDenied(peer, now) {
kick = append(kick, authID)
}
known[authID] = peer
}
}
now := time.Now().UnixMilli()
kick := make([]string, 0, len(authIDs))
for _, authID := range authIDs {
peer, found := known[authID]
if !found {
// Сессия, которой в базе больше ничего не соответствует: пир удалён
// либо его идентификатор заменён импортом, а разрыв в тот момент не
// удался. Восстановить такое состояние переподключением нельзя —
// авторизация нового значения не знает, — поэтому единственный
// правильный исход тот же, что и у первой попытки.
logrus.WithField("authId", authID).
Warn("cron: живая сессия без пира в базе; сессия завершается")
kick = append(kick, authID)
continue
}
if peerSessionNeedsReconcile(peer, online[authID], now) {
kick = append(kick, authID)
}
}