Files
HY2XS_flamy/apps/service/hysteria2_profile_test.go
T
founder cb20d8d28f fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `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
2026-09-02 23:24:01 +05:00

322 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("отсутствующий конфиг выдан за пустой профиль")
}
}