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
95 lines
3.8 KiB
Go
95 lines
3.8 KiB
Go
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())
|
|
}
|
|
}
|