package service import ( "encoding/json" "os" "path/filepath" "strings" "testing" "hy2xs-admin/model/constant" "hy2xs-admin/model/vo" ) // Страница конфигурации показывает файл, а не представление о файле. // // Прежний ответ отдавал внутреннюю модель целиком, а панель накладывала его на // полный объект дефолтов. Проверки ниже фиксируют обе половины исправления: // отсутствие секции остаётся отсутствием, а секции вне production-профиля // называются расхождением, а не теряются. func writeHysteriaConfig(t *testing.T, content string) { t.Helper() path := filepath.Join(t.TempDir(), "config.yaml") if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("не удалось записать тестовый конфиг: %v", err) } previous := constant.Hysteria2ConfigPath constant.Hysteria2ConfigPath = path t.Cleanup(func() { constant.Hysteria2ConfigPath = previous }) } // canonicalProfileConfig — то, что генерирует оркестратор. const canonicalProfileConfig = `listen: 0.0.0.0:443 acme: domains: - vpn.example.com email: admin@example.com ca: letsencrypt listenHost: 0.0.0.0 dir: /var/lib/hysteria/acme type: http auth: type: http http: url: http://127.0.0.1:8080/internal/hysteria/auth?access_token=machine-secret insecure: false obfs: type: gecko gecko: password: gecko-obfs-secret minPacketSize: 512 maxPacketSize: 1200 bandwidth: up: 50 mbps down: 50 mbps disableLossCompensation: false ignoreClientBandwidth: false congestion: type: bbr bbrProfile: standard trafficStats: listen: 127.0.0.1:36712 secret: traffic-stats-secret quic: initStreamReceiveWindow: 8388608 maxStreamReceiveWindow: 8388608 initConnReceiveWindow: 20971520 maxConnReceiveWindow: 20971520 maxIdleTimeout: 30s maxIncomingStreams: 1024 disablePathMTUDiscovery: false disableStatelessReset: false ` // profileJSON сериализует ответ ровно так, как его получит браузер. // // Проверять секреты по полям недостаточно: утечка происходит в СЕРИАЛИЗАЦИИ, и // новое поле без `json:"-"` не заметит ни одна проверка по именам. func profileJSON(t *testing.T, profile vo.Hysteria2ProfileVo) string { t.Helper() encoded, err := json.Marshal(profile) if err != nil { t.Fatalf("ответ не сериализуется: %v", err) } return string(encoded) } func TestBuildHysteria2ProfileReadsCanonicalConfig(t *testing.T) { writeHysteriaConfig(t, canonicalProfileConfig) profile, err := BuildHysteria2Profile() if err != nil { t.Fatalf("построение профиля отказало: %v", err) } if profile.Listen == nil || *profile.Listen != "0.0.0.0:443" { t.Fatalf("listen: %v", profile.Listen) } if profile.TrafficStats == nil || profile.TrafficStats.Listen == nil || *profile.TrafficStats.Listen != "127.0.0.1:36712" { t.Fatalf("trafficStats.listen: %+v", profile.TrafficStats) } if profile.TrafficStats.SecretSet != true { t.Fatal("наличие секрета Traffic Stats API не показано") } if profile.IgnoreClientBandwidth == nil || *profile.IgnoreClientBandwidth { t.Fatalf("ignoreClientBandwidth: %v", profile.IgnoreClientBandwidth) } if profile.Congestion == nil || profile.Congestion.BbrProfile == nil || *profile.Congestion.BbrProfile != "standard" { t.Fatalf("congestion: %+v", profile.Congestion) } if profile.Quic == nil || profile.Quic.MaxIdleTimeout == nil || *profile.Quic.MaxIdleTimeout != "30s" { t.Fatalf("quic: %+v", profile.Quic) } if len(profile.Drift) != 0 { t.Fatalf("канонический конфиг объявлен расходящимся: %v", profile.Drift) } } // Отсутствующая секция остаётся отсутствующей. // // Главная регрессия: без `trafficStats` панель показывала `:9999` — адрес, // которого в файле нет и никогда не было. Экран, существующий ради // диагностики, скрывал ровно то состояние, которое надо диагностировать: // «Traffic API не работает» и «трафик не собирается» объясняются именно этим. func TestBuildHysteria2ProfileKeepsMissingSectionsMissing(t *testing.T) { writeHysteriaConfig(t, "listen: 0.0.0.0:443\n") profile, err := BuildHysteria2Profile() if err != nil { t.Fatalf("построение профиля отказало: %v", err) } if profile.TrafficStats != nil { t.Fatalf("отсутствующая секция trafficStats придумана: %+v", profile.TrafficStats) } if profile.Acme != nil || profile.Tls != nil { t.Fatal("отсутствующая конфигурация TLS придумана") } if profile.Obfs != nil || profile.Bandwidth != nil || profile.Congestion != nil || profile.Quic != nil { t.Fatal("отсутствующие секции придуманы") } if profile.IgnoreClientBandwidth != nil { t.Fatalf("отсутствующий ignoreClientBandwidth придуман: %v", *profile.IgnoreClientBandwidth) } } // Явное `false` — это значение, а не отсутствие. // // Прежняя панель приводила секции к `!!data.speedTest` и `!!data.disableUDP`, // поэтому явно выключенная опция выглядела ненастроенной и прятала свою // вкладку целиком. func TestBuildHysteria2ProfileDistinguishesFalseFromMissing(t *testing.T) { writeHysteriaConfig(t, "listen: \":443\"\nignoreClientBandwidth: false\n") profile, err := BuildHysteria2Profile() if err != nil { t.Fatalf("построение профиля отказало: %v", err) } if profile.IgnoreClientBandwidth == nil { t.Fatal("явное false прочитано как «не задано»") } if *profile.IgnoreClientBandwidth { t.Fatal("значение искажено") } } // Секции вне профиля перечисляются поимённо — это и есть диагностика дрейфа. func TestBuildHysteria2ProfileReportsDrift(t *testing.T) { writeHysteriaConfig(t, canonicalProfileConfig+` masquerade: type: proxy proxy: url: https://example.com resolver: type: udp outbounds: - name: upstream type: direct someFutureUpstreamFeature: enabled: true `) profile, err := BuildHysteria2Profile() if err != nil { t.Fatalf("построение профиля отказало: %v", err) } want := []string{"masquerade", "outbounds", "resolver", "someFutureUpstreamFeature"} if len(profile.Drift) != len(want) { t.Fatalf("расхождения: %v, ожидалось %v", profile.Drift, want) } for i, section := range want { if profile.Drift[i] != section { t.Fatalf("расхождения перечислены не по порядку: %v", profile.Drift) } } } // Секция, о которой HY2XS не знает вовсе, обязана попасть в расхождения: // типизированная модель её теряет, а именно такие секции и означают, что файл // правил не оркестратор. func TestBuildHysteria2ProfileReportsUnknownSections(t *testing.T) { writeHysteriaConfig(t, "listen: \":443\"\ntotallyUnknownSection:\n a: 1\n") profile, err := BuildHysteria2Profile() if err != nil { t.Fatalf("построение профиля отказало: %v", err) } if len(profile.Drift) != 1 || profile.Drift[0] != "totallyUnknownSection" { t.Fatalf("неизвестная секция не названа расхождением: %v", profile.Drift) } } // Ни один секрет не покидает сервер через читающий экран. // // Раньше `auth` и `trafficStats.secret` были закрыты `json:"-"`, а пароль // обфускации, токены ACME DNS и учётные данные outbound/masquerade — нет. // Скачиваемая выгрузка того же конфига их вырезает, и читающий экран не имеет // права быть щедрее. func TestBuildHysteria2ProfileCarriesNoSecrets(t *testing.T) { writeHysteriaConfig(t, canonicalProfileConfig+` outbounds: - name: upstream type: socks5 socks5: addr: 10.0.0.1:1080 username: proxyuser password: proxy-password `) profile, err := BuildHysteria2Profile() if err != nil { t.Fatalf("построение профиля отказало: %v", err) } encoded := profileJSON(t, profile) for _, secret := range []string{ "gecko-obfs-secret", "traffic-stats-secret", "machine-secret", "proxy-password", } { if strings.Contains(encoded, secret) { t.Fatalf("секрет %q уехал в браузер:\n%s", secret, encoded) } } // При этом диагностические факты сохранены: пароль задан, адрес auth // известен. if profile.Obfs == nil || !profile.Obfs.PasswordSet { t.Fatalf("факт «пароль обфускации задан» потерян: %+v", profile.Obfs) } if profile.Auth == nil || profile.Auth.Url == nil || !strings.Contains(*profile.Auth.Url, "127.0.0.1:8080/internal/hysteria/auth") { t.Fatalf("адрес backend-auth потерян: %+v", profile.Auth) } } // Имена параметров ACME DNS диагностичны, значения — нет. func TestBuildHysteria2ProfileShowsAcmeDnsKeysWithoutValues(t *testing.T) { writeHysteriaConfig(t, `listen: ":443" acme: domains: - vpn.example.com type: dns dns: name: cloudflare config: cloudflare_api_token: super-secret-token zone: example.com `) profile, err := BuildHysteria2Profile() if err != nil { t.Fatalf("построение профиля отказало: %v", err) } if profile.Acme == nil || profile.Acme.DnsProvider == nil || *profile.Acme.DnsProvider != "cloudflare" { t.Fatalf("провайдер DNS потерян: %+v", profile.Acme) } want := []string{"cloudflare_api_token", "zone"} if len(profile.Acme.DnsConfigKeys) != len(want) { t.Fatalf("имена параметров DNS: %v", profile.Acme.DnsConfigKeys) } for i, key := range want { if profile.Acme.DnsConfigKeys[i] != key { t.Fatalf("имена параметров DNS перечислены не по порядку: %v", profile.Acme.DnsConfigKeys) } } if strings.Contains(profileJSON(t, profile), "super-secret-token") { t.Fatal("значение параметра DNS уехало в браузер") } } // Отсутствие файла — отказ, а не пустой профиль: «конфига нет» и «конфиг пуст» // для оператора разные ответы. func TestBuildHysteria2ProfileFailsWithoutFile(t *testing.T) { previous := constant.Hysteria2ConfigPath constant.Hysteria2ConfigPath = filepath.Join(t.TempDir(), "missing.yaml") t.Cleanup(func() { constant.Hysteria2ConfigPath = previous }) if _, err := BuildHysteria2Profile(); err == nil { t.Fatal("отсутствующий конфиг выдан за пустой профиль") } }