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
322 lines
12 KiB
Go
322 lines
12 KiB
Go
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("отсутствующий конфиг выдан за пустой профиль")
|
||
}
|
||
}
|