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
206 lines
8.4 KiB
Go
206 lines
8.4 KiB
Go
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
|
||
}
|