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:
2026-09-02 23:24:01 +05:00
parent 8dcb50a07c
commit cb20d8d28f
66 changed files with 4981 additions and 2684 deletions
+321
View File
@@ -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("отсутствующий конфиг выдан за пустой профиль")
}
}