feat(v1): Gecko-обфускация, latest-stable Hysteria на сборке и forward-compatible admin
Сквозная миграция HY2XS на современную Hysteria (2.12.2) и переход на v1. Build: - версия Hysteria резолвится на этапе сборки из HyNetworks/hysteria и замораживается в metadata пакета (version + immutable url + sha256); - compatibility gate: реальный бинарник должен принять канонический конфиг HY2XS для gecko и salamander до создания пакета; - сборка прогоняет тесты оркестратора и админки. Конфигурационный контракт: - HY2XS_CONFIG_SCHEMA_VERSION=2, чужая схема отклоняется fail-fast; - obfs стал настоящим union gecko|salamander, gecko — default; - obfs-блок рендерится оркестратором целиком, два подтипа одновременно структурно невозможны; - современный baseline: congestion bbr/standard, disableLossCompensation=false, disableStatelessReset=false, полный quic-блок. Исправления: - share URI для gecko: генератор был завязан на Obfs.Salamander.Password и выдавал нерабочую ссылку при любой другой обфускации; - SNI брался только из ACME-блока и уходил пустым при HY2XS_TLS_MODE=file; - экспорт конфига выносил trafficStats.secret, access_token и obfs-пароль; - экспорт терял неизвестные upstream-поля при round-trip через типизированную модель; - renderRuntimeEnv печатал тип обфускации литералом, расходясь с конфигом; - namedotcom удалён из ACME-реестра (нет в Hysteria с 2.11.0). Тесты: - 95 тестов оркестратора: env, рендер, семантика профиля, резолвер, rollover; - тесты URI и экспорта в Go; - tools/test/e2e-hysteria.sh с реальным клиентом Hysteria. UX: - подсказки и примеры в форме создания пира. Прочее: CHANGELOG.md, .gitattributes (LF для target-side файлов), документация на русском.
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
)
|
||||
|
||||
// RedactedPlaceholder — маркер вырезанного секрета в экспортируемом конфиге.
|
||||
const RedactedPlaceholder = "<redacted>"
|
||||
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user