cb20d8d28f
Отзыв секрета не сходился: `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
236 lines
15 KiB
Go
236 lines
15 KiB
Go
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:"
|