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

312 lines
11 KiB
Go

package service
import (
"errors"
"net/url"
"os"
"strings"
"gopkg.in/yaml.v3"
"hy2xs-admin/model/constant"
)
// RedactedPlaceholder — маркер вырезанного секрета в экспортируемом конфиге.
const RedactedPlaceholder = "<redacted>"
// GetRawHysteria2Config возвращает исходный YAML серверного конфига без
// прохода через типизированную модель.
//
// Это отдельный слой от GetHysteria2Config намеренно: типизированная модель
// отражает известные HY2XS поля и используется для отображения, а сырой YAML
// нужен там, где нельзя потерять поля, о которых HY2XS пока не знает.
//
// Источник ровно один — файл. Ветка «сначала SQLite, потом файл» удалена
// вместе с ключом HYSTERIA2_CONFIG: см. GetHysteria2Config в service/config.go.
// Здесь у неё было дополнительное следствие — выгрузка «фактического
// серверного конфига» могла отдать оператору документ, по которому Hysteria
// никогда не работала.
func GetRawHysteria2Config() (string, error) {
content, err := os.ReadFile(constant.Hysteria2ConfigPath)
if err != nil {
return "", err
}
return string(content), nil
}
// ExportHysteria2ConfigYaml готовит серверный конфиг к выгрузке оператору.
//
// Гарантии:
// 1. неизвестные upstream-поля сохраняются — будущие версии Hysteria не
// обрезаются только потому, что HY2XS ещё не научился их показывать;
// 2. известные секреты и неизвестные поля с секретоподобным именем не
// покидают сервер в открытом виде.
func ExportHysteria2ConfigYaml() ([]byte, error) {
raw, err := GetRawHysteria2Config()
if err != nil {
return nil, err
}
return SanitizeHysteria2ConfigYaml(raw)
}
// SanitizeHysteria2ConfigYaml вырезает секреты из YAML, сохраняя структуру и
// все прочие поля документа.
func SanitizeHysteria2ConfigYaml(raw string) ([]byte, error) {
if strings.TrimSpace(raw) == "" {
return nil, errors.New("hysteria2 config is empty")
}
var document yaml.Node
if err := yaml.Unmarshal([]byte(raw), &document); err != nil {
return nil, err
}
redactNode(&document, nil, newAliasGuard())
out, err := yaml.Marshal(&document)
if err != nil {
return nil, err
}
return out, nil
}
// secretKeyMarkers покрывает известные секреты HY2XS/Hysteria и типовые имена,
// под которыми секрет может появиться в будущем upstream-поле.
//
// Гарантия формулируется именно так: вырезаются известные секреты и
// неизвестные поля с секретоподобным именем. Обещать, что под правило попадёт
// ЛЮБОЙ будущий секрет, нельзя — обобщённый sanitizer этого не умеет.
//
// Список синхронизирован с SECRET_KEY_MARKERS в
// orchestrator/src/lib/redaction.ts.
var secretKeyMarkers = []string{
"password",
"passwd",
"passphrase",
"secret",
"token",
"credential",
"apikey",
"api_key",
"privatekey",
"private_key",
"accesskey",
"access_key",
"secretkey",
"secret_key",
"authorization",
"cookie",
"bearer",
"signature",
}
func isSecretKey(key string) bool {
lowered := strings.ToLower(key)
for _, marker := range secretKeyMarkers {
if strings.Contains(lowered, marker) {
return true
}
}
return false
}
// isSecretMapPath — узлы, где секретом являются все значения карты, а не ключ.
func isSecretMapPath(path []string) bool {
joined := strings.Join(path, ".")
switch joined {
case "auth.userpass", "acme.dns.config":
return true
}
return false
}
// Секрет, спрятанный за 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
}
switch node.Kind {
case yaml.DocumentNode:
for _, child := range node.Content {
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, guard)
}
case yaml.MappingNode:
if isSecretMapPath(path) {
redactAllScalarValues(node, guard)
return
}
for i := 0; i+1 < len(node.Content); i += 2 {
key := node.Content[i].Value
value := node.Content[i+1]
childPath := append(append([]string{}, path...), key)
if isSecretKey(key) {
redactSubtree(value, guard)
continue
}
redactNode(value, childPath, guard)
}
case yaml.ScalarNode:
// Секрет может лежать в URL и без «говорящего» имени ключа.
//
// Раньше здесь стоял фильтр по имени ключа (url / addr / *url), и
// upstream-поле вроде `endpoint: https://user:pass@host/?token=…`
// уезжало в выгрузку целиком. Правильная граница — не имя ключа, а
// сам факт, что значение разбирается как URL: sanitizeURLValue
// возвращает вход без изменений, если это не URL. Ровно так же
// устроен TS-санитайзер в orchestrator/src/lib/redaction.ts.
sanitizeURLScalar(node)
}
}
// sanitizeURLScalar применяет URL-санитайзер к скаляру, не трогая ни его тип,
// ни значения, которые URL не являются.
func sanitizeURLScalar(node *yaml.Node) {
if node.Tag != "" && node.Tag != "!!str" {
return
}
sanitized := sanitizeURLValue(node.Value)
if sanitized == node.Value {
return
}
node.Value = sanitized
node.Tag = "!!str"
node.Style = 0
}
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, guard)
}
}
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], guard)
}
case yaml.SequenceNode, yaml.DocumentNode:
for _, child := range node.Content {
redactSubtree(child, guard)
}
case yaml.ScalarNode:
setRedacted(node)
case yaml.AliasNode:
if guard.enter(node) {
redactSubtree(node.Alias, guard)
}
}
}
func setRedacted(node *yaml.Node) {
node.Value = RedactedPlaceholder
node.Tag = "!!str"
node.Style = 0
}
// sanitizeURLValue убирает из URL встроенные учётные данные и секретные
// query-параметры, сохраняя остальную часть адреса читаемой.
func sanitizeURLValue(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return raw
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" {
return raw
}
if parsed.User != nil {
parsed.User = url.User(RedactedPlaceholder)
}
query := parsed.Query()
changed := false
for key := range query {
if isSecretKey(key) {
query.Set(key, RedactedPlaceholder)
changed = true
}
}
if changed {
parsed.RawQuery = query.Encode()
}
return parsed.String()
}