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,94 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/model/constant"
|
||||
)
|
||||
|
||||
// Адрес Traffic Stats API — межкомпонентный контракт, и админка обязана
|
||||
// заметить его нарушение, а не работать «как будто там loopback».
|
||||
//
|
||||
// Ломается это одной строкой в `/etc/hysteria/config.yaml`: Hysteria начинает
|
||||
// слушать другой адрес, админка продолжает стучаться в 127.0.0.1, и весь контур
|
||||
// доступа выключается fail-closed — ни один пир не проходит авторизацию, потому
|
||||
// что лимит устройств проверить нечем. Отказ обязан называть причину.
|
||||
|
||||
func TestParseTrafficStatsPortAcceptsReachableAddresses(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
listen string
|
||||
want int64
|
||||
}{
|
||||
{"канонический профиль", "127.0.0.1:36712", 36712},
|
||||
{"хост не задан", ":36712", 36712},
|
||||
{"пробелы по краям", " 127.0.0.1:36712 ", 36712},
|
||||
{"другой адрес loopback", "127.0.0.5:36712", 36712},
|
||||
// Wildcard включает loopback: обмен состоится, и отказывать в
|
||||
// обслуживании из-за лишней публикации внутреннего API нельзя —
|
||||
// это отключило бы всех пиров. Запрет на wildcard принадлежит
|
||||
// оркестратору, который конфиг и создаёт.
|
||||
{"wildcard", "0.0.0.0:36712", 36712},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseTrafficStatsPort(tc.listen)
|
||||
if err != nil {
|
||||
t.Fatalf("адрес %q отклонён: %v", tc.listen, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("порт %d, ожидался %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrafficStatsPortRejectsUnreachableAddresses(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
listen string
|
||||
}{
|
||||
{"адрес LAN", "192.168.1.10:36712"},
|
||||
{"публичный адрес", "203.0.113.10:36712"},
|
||||
{"имя хоста", "hysteria.internal:36712"},
|
||||
{"пустая строка", ""},
|
||||
{"порт вне диапазона", "127.0.0.1:70000"},
|
||||
{"без порта", "127.0.0.1"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := parseTrafficStatsPort(tc.listen); err == nil {
|
||||
t.Fatalf("недостижимый адрес %q принят", tc.listen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ обязан называть и адрес, и способ починки: оператор, получивший
|
||||
// «connection refused» без объяснения, ищет проблему в Hysteria.
|
||||
func TestGetHysteria2ApiPortExplainsNonLoopbackListen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
content := "listen: \":443\"\ntrafficStats:\n listen: \"192.168.1.10:36712\"\n secret: \"s\"\n"
|
||||
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 })
|
||||
|
||||
_, err := GetHysteria2ApiPort()
|
||||
if err == nil {
|
||||
t.Fatal("админка приняла адрес, по которому не может обратиться")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "192.168.1.10") {
|
||||
t.Errorf("сообщение не называет фактический адрес: %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reconfigure") {
|
||||
t.Errorf("сообщение не называет способ починки: %q", err.Error())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user