package service import ( "sort" "strings" "gopkg.in/yaml.v3" "hy2xs-admin/model/bo" "hy2xs-admin/model/vo" ) // Проекция серверного конфига на production-профиль HY2XS. // // Зачем она нужна вместо «показать модель целиком». Продуктом является один // профиль: оркестратор генерирует конфиг из шаблона и сам же проверяет, что // установленный файл этому профилю соответствует // (orchestrator/src/steps/configAssertions.ts). Страница конфигурации при этом // обслуживала одновременно и его, и абстрактный «универсальный сервер // Hysteria» — с вкладками resolver, sniff, acl, outbounds, masquerade, — то // есть показывала как настройки то, что в этой установке не используется и не // поддерживается. // // Достраивать её до полноценного редактора всех возможностей upstream // неправильно: конфигом владеет оркестратор, панель его не пишет, а вопрос // «почему поведение не такое, как ожидалось» решается не списком всех полей, а // ответом «что в файле отличается от профиля». Поэтому здесь ровно две вещи: // значения профиля как они записаны и перечень секций вне профиля. // // Полный документ никуда не делся — он выгружается санитизированным экспортом. // hysteria2ProfileSections — секции верхнего уровня, которые описывает // production-профиль HY2XS. // // Список обязан совпадать с whitelist'ом оркестратора // (assertNoUnexpectedSections) с точностью до того, что там режимы TLS // взаимоисключающие, а здесь показываются оба: файл может содержать любой из // них, и оба принадлежат профилю. Совпадение проверяется гейтом приёмки — // иначе два списка разъедутся молча, и панель начнёт называть расхождением то, // что оркестратор считает нормой. var hysteria2ProfileSections = []string{ "acme", "auth", "bandwidth", "congestion", "ignoreClientBandwidth", "listen", "obfs", "quic", "tls", "trafficStats", } // BuildHysteria2Profile читает файл ОДИН раз и отвечает на оба вопроса сразу: // что записано в секциях профиля и какие секции в профиль не входят. func BuildHysteria2Profile() (vo.Hysteria2ProfileVo, error) { raw, err := GetRawHysteria2Config() if err != nil { return vo.Hysteria2ProfileVo{}, err } var config bo.Hysteria2ServerConfig if err := yaml.Unmarshal([]byte(raw), &config); err != nil { return vo.Hysteria2ProfileVo{}, err } profile := hysteria2ProfileFromConfig(config) profile.Drift = hysteria2ConfigDrift(raw) return profile, nil } // hysteria2ConfigDrift перечисляет секции файла вне production-профиля. // // Разбор идёт в свободную карту, а не в типизированную модель: секция, о // которой HY2XS не знает вовсе, обязана попасть в расхождения, а модель её // молча теряет. Именно такие секции и интересны — они означают, что файл правил // не оркестратор. func hysteria2ConfigDrift(raw string) []string { var document map[string]any if err := yaml.Unmarshal([]byte(raw), &document); err != nil { // Файл не разобрался как карта верхнего уровня. Значения профиля уже // прочитаны типизированной моделью; сказать что-либо о расхождениях // нельзя, и выдумывать их нечестно. return []string{} } known := make(map[string]struct{}, len(hysteria2ProfileSections)) for _, section := range hysteria2ProfileSections { known[section] = struct{}{} } drift := make([]string, 0) for key := range document { if _, ok := known[key]; ok { continue } drift = append(drift, key) } // Порядок ключей карты в Go случаен: без сортировки список расхождений // менялся бы при каждом обновлении страницы. sort.Strings(drift) return drift } func hysteria2ProfileFromConfig(config bo.Hysteria2ServerConfig) vo.Hysteria2ProfileVo { profile := vo.Hysteria2ProfileVo{ Listen: config.Listen, IgnoreClientBandwidth: config.IgnoreClientBandwidth, Drift: []string{}, } if auth := config.Auth; auth != nil { profile.Auth = &vo.Hysteria2ProfileAuthVo{Type: auth.Type} if auth.HTTP != nil { // URL несёт machine token в query. Санитайзер тот же, что и у // выгрузки: адрес остаётся читаемым, секрет — нет. if auth.HTTP.URL != nil { sanitized := sanitizeURLValue(*auth.HTTP.URL) profile.Auth.Url = &sanitized } profile.Auth.Insecure = auth.HTTP.Insecure } } if tls := config.TLS; tls != nil { profile.Tls = &vo.Hysteria2ProfileTlsVo{ Cert: tls.Cert, Key: tls.Key, SniGuard: tls.SNIGuard, ClientCA: tls.ClientCA, } } if acme := config.ACME; acme != nil { profile.Acme = &vo.Hysteria2ProfileAcmeVo{ Domains: acme.Domains, Email: acme.Email, Ca: acme.CA, Dir: acme.Dir, ListenHost: acme.ListenHost, Type: acme.Type, DnsConfigKeys: []string{}, } if acme.DNS != nil { profile.Acme.DnsProvider = acme.DNS.Name for key := range acme.DNS.Config { profile.Acme.DnsConfigKeys = append(profile.Acme.DnsConfigKeys, key) } sort.Strings(profile.Acme.DnsConfigKeys) } if profile.Acme.Domains == nil { profile.Acme.Domains = []string{} } } if obfs := config.Obfs; obfs != nil { profile.Obfs = &vo.Hysteria2ProfileObfsVo{Type: obfs.Type} // Показывается ветка, соответствующая объявленному типу. Наличие // «второй» ветки рядом — это уже расхождение конфига, и его видно по // самому типу: оркестратор пишет ровно одну. share := config.ObfsShare() profile.Obfs.PasswordSet = strings.TrimSpace(share.Password) != "" if obfs.Gecko != nil && obfs.Type != nil && *obfs.Type == "gecko" { profile.Obfs.MinPacketSize = obfs.Gecko.MinPacketSize profile.Obfs.MaxPacketSize = obfs.Gecko.MaxPacketSize } } if bandwidth := config.Bandwidth; bandwidth != nil { profile.Bandwidth = &vo.Hysteria2ProfileBandwidthVo{ Up: bandwidth.Up, Down: bandwidth.Down, DisableLossCompensation: bandwidth.DisableLossCompensation, } } if congestion := config.Congestion; congestion != nil { profile.Congestion = &vo.Hysteria2ProfileCongestionVo{ Type: congestion.Type, BbrProfile: congestion.BBRProfile, } } if quic := config.QUIC; quic != nil { profile.Quic = &vo.Hysteria2ProfileQuicVo{ InitStreamReceiveWindow: quic.InitStreamReceiveWindow, MaxStreamReceiveWindow: quic.MaxStreamReceiveWindow, InitConnReceiveWindow: quic.InitConnectionReceiveWindow, MaxConnReceiveWindow: quic.MaxConnectionReceiveWindow, MaxIdleTimeout: quic.MaxIdleTimeout, MaxIncomingStreams: quic.MaxIncomingStreams, DisablePathMTUDiscovery: quic.DisablePathMTUDiscovery, DisableStatelessReset: quic.DisableStatelessReset, } } if stats := config.TrafficStats; stats != nil { profile.TrafficStats = &vo.Hysteria2ProfileTrafficStatsVo{ Listen: stats.Listen, SecretSet: stats.Secret != nil && strings.TrimSpace(*stats.Secret) != "", } } return profile }