package service import ( "errors" "net/url" "os" "strings" "gopkg.in/yaml.v3" "hy2xs-admin/dao" "hy2xs-admin/model/constant" ) // RedactedPlaceholder — маркер вырезанного секрета в экспортируемом конфиге. const RedactedPlaceholder = "" // GetRawHysteria2Config возвращает исходный YAML серверного конфига без // прохода через типизированную модель. // // Это отдельный слой от GetHysteria2Config намеренно: типизированная модель // отражает известные HY2XS поля и используется для отображения, а сырой YAML // нужен там, где нельзя потерять поля, о которых HY2XS пока не знает. func GetRawHysteria2Config() (string, error) { config, err := dao.GetConfig("key = ?", constant.Hysteria2Config) if err != nil { return "", err } if config.Value != nil && strings.TrimSpace(*config.Value) != "" { return *config.Value, nil } content, readErr := os.ReadFile(constant.Hysteria2ConfigPath) if readErr != nil { return "", readErr } 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) out, err := yaml.Marshal(&document) if err != nil { return nil, err } return out, nil } // isSecretKey — обобщённое правило. Оно важно именно потому, что экспорт // сохраняет неизвестные поля: новое upstream-поле с секретом будет вырезано // ещё до того, как HY2XS про него узнает. func isSecretKey(key string) bool { lowered := strings.ToLower(key) for _, marker := range []string{"password", "passwd", "secret", "token", "credential"} { 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 } func redactNode(node *yaml.Node, path []string) { if node == nil { return } switch node.Kind { case yaml.DocumentNode: for _, child := range node.Content { redactNode(child, path) } case yaml.SequenceNode: for _, child := range node.Content { // Индекс не попадает в путь: правила формулируются по именам полей. redactNode(child, path) } case yaml.MappingNode: if isSecretMapPath(path) { redactAllScalarValues(node) 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) continue } if value.Kind == yaml.ScalarNode && looksLikeURLKey(key) { value.Value = sanitizeURLValue(value.Value) value.Tag = "!!str" value.Style = 0 continue } redactNode(value, childPath) } } } func looksLikeURLKey(key string) bool { lowered := strings.ToLower(key) return lowered == "url" || lowered == "addr" } func redactSubtree(node *yaml.Node) { if node == nil { return } switch node.Kind { case yaml.ScalarNode: setRedacted(node) case yaml.MappingNode, yaml.SequenceNode, yaml.DocumentNode: redactAllScalarValues(node) } } func redactAllScalarValues(node *yaml.Node) { switch node.Kind { case yaml.MappingNode: for i := 0; i+1 < len(node.Content); i += 2 { redactSubtree(node.Content[i+1]) } case yaml.SequenceNode, yaml.DocumentNode: for _, child := range node.Content { redactSubtree(child) } case yaml.ScalarNode: setRedacted(node) } } 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() }