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
+75 -12
View File
@@ -61,7 +61,7 @@ func SanitizeHysteria2ConfigYaml(raw string) ([]byte, error) {
return nil, err
}
redactNode(&document, nil)
redactNode(&document, nil, newAliasGuard())
out, err := yaml.Marshal(&document)
if err != nil {
@@ -120,7 +120,53 @@ func isSecretMapPath(path []string) bool {
return false
}
func redactNode(node *yaml.Node, path []string) {
// Секрет, спрятанный за YAML-якорем, тоже покидал сервер.
//
// Что было. И redactNode, и redactSubtree разбирали четыре вида узлов:
// документ, последовательность, отображение и скаляр. Пятый вид —
// yaml.AliasNode — не обрабатывался ни одной из них, а yaml.v3 якоря и ссылки
// поддерживает:
//
// shared: &credential VERY_SECRET_VALUE
//
// obfs:
// type: salamander
// salamander:
// password: *credential
//
// Утечек здесь ДВЕ, и обе тихие. Значение под ключом `password` — это
// AliasNode, и redactSubtree на нём был no-op. А сам якорь объявлен под
// ключом `shared`, который секретоподобным не выглядит, поэтому и он
// оставался нетронутым. В выгрузке секрет оказывался дважды.
//
// Канонический конфиг HY2XS якорей не содержит, но экспорт существует ещё и
// для диагностики чужих и правленых руками файлов, а обещание «секреты не
// покидают сервер» либо абсолютно, либо его нет.
//
// Лечится переходом ПО ССЫЛКЕ: alias ведёт к тому же узлу, что и якорь,
// поэтому редакция цели закрывает оба вхождения сразу. Защита от циклов
// обязательна — YAML допускает ссылку на предка, и обход без неё не завершится.
// aliasGuard помнит уже посещённые узлы обхода.
type aliasGuard map[*yaml.Node]struct{}
func newAliasGuard() aliasGuard {
return aliasGuard{}
}
// enter возвращает false, если узел уже посещался в этом обходе.
func (g aliasGuard) enter(node *yaml.Node) bool {
if node == nil {
return false
}
if _, seen := g[node]; seen {
return false
}
g[node] = struct{}{}
return true
}
func redactNode(node *yaml.Node, path []string, guard aliasGuard) {
if node == nil {
return
}
@@ -128,18 +174,25 @@ func redactNode(node *yaml.Node, path []string) {
switch node.Kind {
case yaml.DocumentNode:
for _, child := range node.Content {
redactNode(child, path)
redactNode(child, path, guard)
}
case yaml.AliasNode:
// Обход продолжается по цели ссылки: путь остаётся прежним, потому что
// alias стоит ровно там, где стояло бы само значение.
if guard.enter(node) {
redactNode(node.Alias, path, guard)
}
case yaml.SequenceNode:
for _, child := range node.Content {
// Индекс не попадает в путь: правила формулируются по именам полей.
redactNode(child, path)
redactNode(child, path, guard)
}
case yaml.MappingNode:
if isSecretMapPath(path) {
redactAllScalarValues(node)
redactAllScalarValues(node, guard)
return
}
for i := 0; i+1 < len(node.Content); i += 2 {
@@ -148,10 +201,10 @@ func redactNode(node *yaml.Node, path []string) {
childPath := append(append([]string{}, path...), key)
if isSecretKey(key) {
redactSubtree(value)
redactSubtree(value, guard)
continue
}
redactNode(value, childPath)
redactNode(value, childPath, guard)
}
case yaml.ScalarNode:
@@ -182,30 +235,40 @@ func sanitizeURLScalar(node *yaml.Node) {
node.Style = 0
}
func redactSubtree(node *yaml.Node) {
func redactSubtree(node *yaml.Node, guard aliasGuard) {
if node == nil {
return
}
switch node.Kind {
case yaml.ScalarNode:
setRedacted(node)
case yaml.AliasNode:
// Редактируется ЦЕЛЬ ссылки: она же является значением якоря, поэтому
// одно действие закрывает и `*alias`, и место объявления `&anchor`.
if guard.enter(node) {
redactSubtree(node.Alias, guard)
}
case yaml.MappingNode, yaml.SequenceNode, yaml.DocumentNode:
redactAllScalarValues(node)
redactAllScalarValues(node, guard)
}
}
func redactAllScalarValues(node *yaml.Node) {
func redactAllScalarValues(node *yaml.Node, guard aliasGuard) {
switch node.Kind {
case yaml.MappingNode:
for i := 0; i+1 < len(node.Content); i += 2 {
redactSubtree(node.Content[i+1])
redactSubtree(node.Content[i+1], guard)
}
case yaml.SequenceNode, yaml.DocumentNode:
for _, child := range node.Content {
redactSubtree(child)
redactSubtree(child, guard)
}
case yaml.ScalarNode:
setRedacted(node)
case yaml.AliasNode:
if guard.enter(node) {
redactSubtree(node.Alias, guard)
}
}
}