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
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
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("отсутствующий конфиг выдан за пустой профиль")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user