Files
founder cb20d8d28f 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
2026-09-02 23:24:01 +05:00

236 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"fmt"
"strings"
"hy2xs-admin/dao"
"hy2xs-admin/util"
)
// Секреты пиров: генерация, отпечаток, шифрование.
// Криптоматериал пиров берётся из dao, а не создаётся здесь заново.
//
// Что было. В этом файле лежали СОБСТВЕННЫЕ getOrCreateConfigKey и
// getPeerSecretEncryptionKey — построчные копии одноимённых функций из
// dao/sqlite.go. Две реализации, порождающие один и тот же материал шифрования,
// в двух пакетах: bootstrap-пир получал ключ через dao, а всё остальное — через
// service. Пока обе читали один ключ из таблицы `config`, поведение совпадало,
// но любое расхождение означало бы, что секреты пиров шифруются одним ключом, а
// расшифровываются другим, и проявилось бы это на живых пирах.
//
// Копия в service несла ещё и порядок «сначала INSERT, при ошибке UPDATE»,
// из-за которого каждая первая загрузка печатала в журнал
// `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 — длина случайной части автогенерируемого
// секрета.
//
// 24 символа из алфавита util.RandomString (62 символа) дают примерно 143 бита
// энтропии. Источник — crypto/rand с отбрасыванием смещённых байтов, то есть
// тот же генератор, которым создаются JWT_SECRET и ключи шифрования секретов.
const generatedSecretRandomLength = 24
// GeneratePeerSecret создаёт секрет подключения пира.
//
// Владелец автогенерации — сервисный слой, и это существенно. Панель обещает
// оператору «оставьте пустым — сгенерируем автоматически», и то же обещание
// обязано действовать для прямого вызова API, для импорта и для будущих
// клиентов. Генерация во frontend означала бы, что обещание выполняется ровно
// для одной двери из четырёх, а остальные тихо получают пустое значение.
//
// Имя пира входит в секрет префиксом: в клиенте секрет виден оператору, и
// узнать по нему, какому пиру он принадлежит, полезнее, чем скрыть эту связь.
// Стойкость от этого не страдает — она обеспечивается случайной частью, а имя
// пира и так публично известно из ссылки.
func GeneratePeerSecret(peerName string) (string, error) {
generated, err := util.RandomString(generatedSecretRandomLength)
if err != nil {
return "", err
}
if peerName == "" {
return generated, nil
}
return fmt.Sprintf("%s.%s", peerName, generated), nil
}
// resolvePeerSecret возвращает секрет, который следует сохранить: заданный
// оператором либо сгенерированный.
//
// К этому моменту нормализация DTO уже привела «поле отсутствует», «пустая
// строка» и «одни пробелы» к одному состоянию — nil. Повторный TrimSpace здесь
// нужен для вызовов мимо слоя DTO (тесты, внутренние пути): «сгенерировать»
// обязано означать одно и то же на всех входах.
func resolvePeerSecret(peerName string, provided *string) (string, error) {
if provided != nil {
if manual := strings.TrimSpace(*provided); manual != "" {
return manual, nil
}
}
return GeneratePeerSecret(peerName)
}
// GetPeerSecretKey — HMAC-ключ, которым считается secret_digest пира.
func GetPeerSecretKey() (string, error) {
return dao.GetOrCreatePeerSecretDigestKey()
}
func PeerSecretDigest(rawSecret string) (string, error) {
secretKey, err := GetPeerSecretKey()
if err != nil {
return "", err
}
return util.HmacSHA256Hex(rawSecret, secretKey), nil
}
func EncryptPeerSecret(rawSecret string) (string, error) {
key, err := dao.GetOrCreatePeerSecretEncryptionKey()
if err != nil {
return "", err
}
return util.EncryptAESGCM(rawSecret, key)
}
// DecryptPeerSecret расшифровывает сохранённый секрет пира.
//
// Формат хранения ровно один: `v1:` + AES-GCM. Значение без этого префикса —
// не «секрет в старом формате», а повреждённые данные, и ответом на них
// является ошибка.
//
// Что было:
//
// if !strings.HasPrefix(stored, "v1:") {
// return stored, nil
// }
//
// то есть содержимое колонки возвращалось как якобы успешно расшифрованный
// секрет. Ветка досталась от поколения, в котором секреты пиров лежали в базе
// открытым текстом; при clean-install-only политике такой строки не может
// существовать — EncryptPeerSecret всегда пишет префикс, — а вред остаётся:
//
// повреждённая колонка → мусор уходит в клиентскую ссылку как секрет;
// резервная копия с секретами → мусор попадает в файл вместо credentials;
// значение, записанное в обход → принимается без единой проверки.
//
// Это тот же класс, что и удалённый SHA-224 fallback при входе: молчаливое
// «понимаем формат предыдущего поколения» превращается в молчаливое «понимаем
// что угодно».
func DecryptPeerSecret(stored string) (string, error) {
if !strings.HasPrefix(stored, peerSecretCipherPrefix) {
return "", fmt.Errorf(
"секрет пира хранится в неизвестном формате: ожидался префикс %q. "+
"HY2XS хранит секреты пиров только зашифрованными",
peerSecretCipherPrefix,
)
}
key, err := dao.GetOrCreatePeerSecretEncryptionKey()
if err != nil {
return "", err
}
return util.DecryptAESGCM(stored, key)
}
// peerSecretCipherPrefix — единственный поддерживаемый формат хранения.
// Значение задаёт util.EncryptAESGCM; здесь оно объявлено, чтобы проверка и
// сообщение об ошибке не расходились с ним по разным файлам молча.
const peerSecretCipherPrefix = "v1:"