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