fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним, поэтому сессия, установленная по отозванным учётным данным, была неотличима от законной, и цикл учёта не имел признака, по которому её следовало завершить. У состояния есть путь без единой неудачи — Hysteria регистрирует соединение в Traffic Stats API только после возврата backend-auth, поэтому успешный /kick может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт по старому, пережившая сессия становится orphan. Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4, админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала лимит устройств, учёт трафика и принудительное отключение разом. Адрес зафиксирован, а расхождение файла с ним админка называет. Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним значением, а доступность Traffic Stats API выводилась из него же. Журнал Hysteria разбирается в фактическом формате upstream (time — дробное число), страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в браузер, санитайзер выгрузки следует по YAML-якорям. Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
This commit is contained in:
@@ -24,6 +24,104 @@ import (
|
||||
// из-за которого каждая первая загрузка печатала в журнал
|
||||
// `duplicated key not allowed` уровня error на здоровом старте.
|
||||
|
||||
// Идентичность живых сессий привязана к ПОКОЛЕНИЮ учётных данных.
|
||||
//
|
||||
// Что было. `auth_id` создавался один раз и не менялся никогда, а секрет
|
||||
// менялся отдельно от него. Между этими двумя фактами и жил дефект отзыва:
|
||||
//
|
||||
// до: secret S1 -> authId A
|
||||
// после: secret S2 -> authId A
|
||||
//
|
||||
// Отзыв секрета состоит из двух шагов — записать новый digest и завершить
|
||||
// сессии, установленные по старому. Второй шаг может не удаться, и это
|
||||
// нормально: сходимость обязан обеспечить cron. Но сверять ему было нечем.
|
||||
// Сессия называется в `/online` просто `A`, в базе `A` существует, доступ пиру
|
||||
// открыт, устройств не больше разрешённого — то есть по всем признакам это
|
||||
// действующая сессия нового состояния. Признака «установлена по уже отозванному
|
||||
// секрету» в системе не существовало вовсе.
|
||||
//
|
||||
// Хуже того, у этого состояния есть путь БЕЗ единой неудачи. Ответ авторизации
|
||||
// и регистрация соединения в Traffic Stats API — не одна транзакция: Hysteria
|
||||
// сначала дожидается `Authenticate`, и только ПОСЛЕ возврата `ok=true`
|
||||
// выставляет `authenticated = true` и вызывает `LogOnlineState(id, true)`
|
||||
// (исходники app/v2.12.2). Значит `/kick`, прошедший, пока backend-auth ещё
|
||||
// выполнялся, этого соединения увидеть не обязан:
|
||||
//
|
||||
// 1. клиент с S1 начинает авторизацию, Hysteria2Auth читает peer и застревает
|
||||
// внутри GET /online;
|
||||
// 2. оператор меняет секрет, UpdatePeer сохраняет S2 и УСПЕШНО зовёт /kick A;
|
||||
// 3. задержанная авторизация возвращает ALLOW и A;
|
||||
// 4. Hysteria регистрирует сессию A — уже после kick'а.
|
||||
//
|
||||
// Атомарной пары «решение авторизации + регистрация онлайна» upstream API не
|
||||
// даёт, поэтому повторным чтением базы перед `return ALLOW` окно не закрыть: оно
|
||||
// сдвинется, но останется. Закрывается это тем, что отозванная генерация
|
||||
// перестаёт быть валидной ИДЕНТИЧНОСТЬЮ:
|
||||
//
|
||||
// до: S1 -> authId A
|
||||
// ротация: S2 -> authId B, /kick A
|
||||
// A в /online -> в базе только B -> orphan -> kick
|
||||
//
|
||||
// То есть используется уже существующий механизм сходимости
|
||||
// (enforcePeerAccess обходит каждый authID из `/online`), а не заводится
|
||||
// отдельный реестр отозванных поколений, очередь повторов и таблица retry.
|
||||
//
|
||||
// Цена решения названа прямо: сессия, пережившая kick, до следующего цикла
|
||||
// учёта считается сессией НЕИЗВЕСТНОГО пира, поэтому её дельта трафика
|
||||
// приписывается некому и попадает в потери цикла (см. saveAccountTraffic).
|
||||
// Это не более 30 секунд трафика одного пира на одну ротацию, и это осознанный
|
||||
// размен: квота здесь — операционная граница доступа, а не биллинг. Колонка
|
||||
// «прежний auth_id» ради этих 30 секунд ввела бы второй идентификатор сессии,
|
||||
// то есть ровно то состояние, из-за которого отзыв и не сходился.
|
||||
|
||||
// peerAuthIDLength — длина генерируемого `auth_id`.
|
||||
//
|
||||
// Значение объявлено здесь, а не тремя литералами `18` по местам создания:
|
||||
// создание через панель, создание импортом и ротация обязаны давать
|
||||
// идентификатор одного вида.
|
||||
const peerAuthIDLength = 18
|
||||
|
||||
// newPeerAuthID создаёт идентичность живых сессий пира.
|
||||
//
|
||||
// Единственный генератор `auth_id` в продукте. Коллизия при 62^18 вариантах
|
||||
// недостижима практически, а если бы случилась — UNIQUE(auth_id) отклонит
|
||||
// запись ДО обращения к `/kick`, и операция вернёт отказ, не изменив состояния.
|
||||
// Повторная попытка внутри генератора для этого не нужна.
|
||||
func newPeerAuthID() (string, error) {
|
||||
return util.RandomString(peerAuthIDLength)
|
||||
}
|
||||
|
||||
// credentialGenerationChanged отвечает, действительно ли меняется поколение
|
||||
// учётных данных.
|
||||
//
|
||||
// Отдельная функция, потому что вопрос не тот же самый, что «оператор прислал
|
||||
// секрет». Повторная отправка ТОГО ЖЕ секрета — это запрос на повторный отзыв
|
||||
// (и он по-прежнему рвёт сессию), но нового поколения credentials при этом не
|
||||
// возникает, и менять идентичность сессий незачем: смена `auth_id` без смены
|
||||
// секрета обесценила бы накопленную привязку трафика без единой причины.
|
||||
//
|
||||
// Строка без сохранённого digest считается сменой: чем бы ни было её
|
||||
// содержимое, оно не то, что записывается сейчас.
|
||||
func credentialGenerationChanged(storedDigest *string, newDigest string) bool {
|
||||
return storedDigest == nil || *storedDigest != newDigest
|
||||
}
|
||||
|
||||
// requestedSecret приводит присланный секрет к решению «менять или не менять».
|
||||
//
|
||||
// Правило одно на обе задачи — на запись и на разрыв сессии. Раньше их было
|
||||
// два: UpdatePeer проверял `*peerDto.Secret != ""`, а updateRequiresReconcile —
|
||||
// `strings.TrimSpace(...) != ""`. Секрет из одних пробелов, пришедший мимо
|
||||
// нормализации DTO (прямой вызов сервиса, тесты), записывался бы в базу как
|
||||
// новые учётные данные, но сессию бы не рвал — то есть отзыв, о котором
|
||||
// механизм сходимости не знает.
|
||||
func requestedSecret(provided *string) (string, bool) {
|
||||
if provided == nil {
|
||||
return "", false
|
||||
}
|
||||
secret := strings.TrimSpace(*provided)
|
||||
return secret, secret != ""
|
||||
}
|
||||
|
||||
// generatedSecretRandomLength — длина случайной части автогенерируемого
|
||||
// секрета.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user