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:
2026-09-02 23:24:01 +05:00
parent 8dcb50a07c
commit cb20d8d28f
66 changed files with 4981 additions and 2684 deletions
+98
View File
@@ -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 — длина случайной части автогенерируемого
// секрета.
//