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:
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -232,6 +233,132 @@ func TestSanitizeHysteria2ConfigYaml_LeavesNonURLScalarsIntact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Якоря и ссылки YAML ------------------------------------------------------
|
||||
|
||||
// Секрет, спрятанный за якорем, покидал сервер ДВАЖДЫ.
|
||||
//
|
||||
// redactNode и redactSubtree разбирали документ, последовательность,
|
||||
// отображение и скаляр, но не yaml.AliasNode. Значение под ключом `password`
|
||||
// было ссылкой — и redactSubtree на ней не делал ничего; сам якорь объявлен под
|
||||
// ключом `shared`, секретоподобным не выглядящим, — и его не трогал никто.
|
||||
//
|
||||
// Канонический конфиг HY2XS якорей не содержит, но выгрузка существует ещё и
|
||||
// для диагностики правленых руками файлов, а обещание «секреты не покидают
|
||||
// сервер» либо абсолютно, либо его нет.
|
||||
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredSecrets(t *testing.T) {
|
||||
raw := `shared: &credential VERY_SECRET_VALUE
|
||||
|
||||
listen: 0.0.0.0:443
|
||||
|
||||
obfs:
|
||||
type: salamander
|
||||
salamander:
|
||||
password: *credential
|
||||
`
|
||||
|
||||
sanitized := sanitizeForTest(t, raw)
|
||||
|
||||
if strings.Contains(sanitized, "VERY_SECRET_VALUE") {
|
||||
t.Fatalf("секрет за якорем уехал в выгрузку:\n%s", sanitized)
|
||||
}
|
||||
// Документ обязан остаться валидным YAML: ссылка без якоря его сломала бы.
|
||||
var parsed map[string]any
|
||||
if err := yaml.Unmarshal([]byte(sanitized), &parsed); err != nil {
|
||||
t.Fatalf("выгрузка перестала быть валидным YAML: %v\n%s", err, sanitized)
|
||||
}
|
||||
obfs, ok := parsed["obfs"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("секция obfs потеряна:\n%s", sanitized)
|
||||
}
|
||||
salamander, ok := obfs["salamander"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("подсекция salamander потеряна: %+v", obfs)
|
||||
}
|
||||
if salamander["password"] != RedactedPlaceholder {
|
||||
t.Fatalf("значение по ссылке не вырезано: %+v", salamander)
|
||||
}
|
||||
if parsed["shared"] != RedactedPlaceholder {
|
||||
t.Fatalf("объявление якоря осталось нетронутым: %+v", parsed["shared"])
|
||||
}
|
||||
}
|
||||
|
||||
// URL с учётными данными, спрятанный за якорем, — тот же случай.
|
||||
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredURLCredentials(t *testing.T) {
|
||||
raw := `shared: &endpoint https://svc-user:svc-p4ss@relay.example.com/?access_token=endpoint-token
|
||||
|
||||
someFutureUpstreamFeature:
|
||||
endpoint: *endpoint
|
||||
`
|
||||
|
||||
sanitized := sanitizeForTest(t, raw)
|
||||
|
||||
for _, secret := range []string{"svc-p4ss", "endpoint-token"} {
|
||||
if strings.Contains(sanitized, secret) {
|
||||
t.Fatalf("секрет %q уехал в выгрузку через якорь:\n%s", secret, sanitized)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(sanitized, "relay.example.com") {
|
||||
t.Fatalf("адрес вырезан целиком вместо учётных данных:\n%s", sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
// Ссылка на составной узел: редактируется всё поддерево цели.
|
||||
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredMapping(t *testing.T) {
|
||||
raw := `shared: &creds
|
||||
username: proxyuser
|
||||
password: proxy-password
|
||||
|
||||
outbounds:
|
||||
- name: upstream
|
||||
type: socks5
|
||||
socks5: *creds
|
||||
`
|
||||
|
||||
sanitized := sanitizeForTest(t, raw)
|
||||
|
||||
if strings.Contains(sanitized, "proxy-password") {
|
||||
t.Fatalf("пароль в составном якоре уехал в выгрузку:\n%s", sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
// Ссылка на предка образует цикл. Без защиты обход не завершился бы вовсе —
|
||||
// это отказ обслуживания на выгрузке конфига, а не утечка.
|
||||
func TestSanitizeHysteria2ConfigYaml_SurvivesRecursiveAlias(t *testing.T) {
|
||||
raw := `root: &root
|
||||
password: cycle-secret
|
||||
self: *root
|
||||
`
|
||||
|
||||
type outcome struct {
|
||||
sanitized string
|
||||
err error
|
||||
}
|
||||
done := make(chan outcome, 1)
|
||||
go func() {
|
||||
out, err := SanitizeHysteria2ConfigYaml(raw)
|
||||
done <- outcome{sanitized: string(out), err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case got := <-done:
|
||||
// yaml.v3 строит на такой записи ДЕЙСТВИТЕЛЬНО циклический граф узлов
|
||||
// (alias указывает на объемлющее отображение) и умеет его же обратно
|
||||
// сериализовать, поэтому отказ здесь был бы регрессией, а не
|
||||
// допустимым исходом.
|
||||
if got.err != nil {
|
||||
t.Fatalf("выгрузка отказала на рекурсивной ссылке: %v", got.err)
|
||||
}
|
||||
if strings.Contains(got.sanitized, "cycle-secret") {
|
||||
t.Fatalf("секрет в рекурсивной структуре уехал в выгрузку:\n%s", got.sanitized)
|
||||
}
|
||||
if !strings.Contains(got.sanitized, RedactedPlaceholder) {
|
||||
t.Fatalf("секрет исчез вместе со структурой вместо редакции:\n%s", got.sanitized)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("санитайзер зациклился на ссылке, указывающей на предка")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeURLValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
Reference in New Issue
Block a user