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
312 lines
11 KiB
Go
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()
|
|
}
|