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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user