package service import ( "errors" "net/url" "os" "strings" "gopkg.in/yaml.v3" "hy2xs-admin/model/constant" ) // RedactedPlaceholder — маркер вырезанного секрета в экспортируемом конфиге. const RedactedPlaceholder = "" // 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() }