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:
+59
-1
@@ -117,6 +117,35 @@ func GetHysteria2ApiPort() (int64, error) {
|
||||
return apiPort, nil
|
||||
}
|
||||
|
||||
// Адрес Traffic Stats API — ОДИН контракт на обе половины продукта.
|
||||
//
|
||||
// Что было. Отсюда доставался только порт, а хост молча отбрасывался: слой
|
||||
// proxy в любом случае собирает `http://127.0.0.1:<порт>`. Пока оркестратор
|
||||
// разрешал в `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` произвольный IPv4, это давало
|
||||
// конфигурацию, при которой Hysteria слушает один адрес, а админка стучится в
|
||||
// другой — и весь контур доступа выключался fail-closed: `/online` недоступен,
|
||||
// значит лимит устройств проверить нельзя, значит ни один пир не пускается.
|
||||
//
|
||||
// Оркестратор теперь фиксирует адрес на `127.0.0.1`, но эта функция читает
|
||||
// ФАЙЛ, а файл может разойтись с оркестратором: правка руками, восстановление
|
||||
// чужой копии, ручной эксперимент. Поэтому расхождение называется здесь, в
|
||||
// первой же точке контакта, а не проявляется отказом соединения без объяснения.
|
||||
//
|
||||
// Молча подставлять loopback вместо прочитанного хоста нельзя ровно потому, что
|
||||
// это и есть тот самый второй контракт: значение, которое админка приняла и
|
||||
// проигнорировала.
|
||||
//
|
||||
// Что принимается:
|
||||
//
|
||||
// ":36712" -> хост не задан, Hysteria слушает loopback;
|
||||
// "127.0.0.1:36712" -> канонический production-профиль;
|
||||
// "127.0.0.5:36712" -> любой loopback достижим по loopback;
|
||||
// "0.0.0.0:36712" -> wildcard включает loopback, обмен состоится.
|
||||
//
|
||||
// Wildcard принимается именно потому, что связь между компонентами при нём
|
||||
// РАБОТАЕТ: отказывать в обслуживании из-за лишней публикации внутреннего API
|
||||
// значило бы наказывать за неё отключением всех пиров. Запрет на wildcard —
|
||||
// решение оркестратора, который конфиг создаёт (см. validateRuntimeConfig).
|
||||
func parseTrafficStatsPort(listen string) (int64, error) {
|
||||
trimmed := strings.TrimSpace(listen)
|
||||
if trimmed == "" {
|
||||
@@ -128,10 +157,13 @@ func parseTrafficStatsPort(listen string) (int64, error) {
|
||||
hostPort = "127.0.0.1" + trimmed
|
||||
}
|
||||
|
||||
_, portStr, err := net.SplitHostPort(hostPort)
|
||||
host, portStr, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := assertTrafficStatsHostReachable(host); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
port, err := strconv.ParseInt(portStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -141,3 +173,29 @@ func parseTrafficStatsPort(listen string) (int64, error) {
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// assertTrafficStatsHostReachable отвечает, сможет ли админка попасть по этому
|
||||
// адресу — а не «нравится ли он профилю».
|
||||
func assertTrafficStatsHostReachable(host string) error {
|
||||
trimmed := strings.TrimSpace(host)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
ip := net.ParseIP(trimmed)
|
||||
if ip == nil {
|
||||
return fmt.Errorf(
|
||||
"trafficStats.listen указывает хост %q: HY2XS ожидает IP-адрес loopback (127.0.0.1)",
|
||||
trimmed,
|
||||
)
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsUnspecified() {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"trafficStats.listen слушает %s, а админка обращается к Traffic Stats API только по loopback. "+
|
||||
"Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают. "+
|
||||
"Верните 127.0.0.1 через `hy2xs-orchestrator reconfigure`",
|
||||
trimmed,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -590,7 +590,7 @@ func TestSaveAccountTrafficCountsLostDeltas(t *testing.T) {
|
||||
func TestCronRunsWhenSystemdSaysStopped(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
||||
withHysteriaRunning(t, false)
|
||||
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
peerUsage(t, id, map[string]interface{}{"disabled": int64(1)})
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ func DashboardSummary() (vo.DashboardSummaryVo, error) {
|
||||
summary := vo.DashboardSummaryVo{CollectedAt: nowMs}
|
||||
summary.Health.Collector = vo.DataHealthVo{Status: "stale", MessageKey: "dashboard.error.collectorStale"}
|
||||
summary.Health.Hysteria = vo.DataHealthVo{Status: "ok"}
|
||||
// До первого снимка сборщика о службе не известно ничего. Нулевое значение
|
||||
// `Running = false` прочиталось бы как «остановлена» — то самое смешение
|
||||
// «неизвестно» и «выключено», от которого избавляет HysteriaServiceState.
|
||||
summary.Hysteria.ServiceState = string(HysteriaServiceUnknown)
|
||||
|
||||
snapshot := DashboardSnapshot()
|
||||
if snapshot.CollectedAt > 0 {
|
||||
@@ -119,9 +123,21 @@ func DashboardSecurity() ([]vo.SecurityRiskVo, error) {
|
||||
return summary.SecurityRisks, nil
|
||||
}
|
||||
|
||||
// DashboardSecurityRisks называет ровно то, что известно.
|
||||
//
|
||||
// «Служба остановлена» и «состояние службы неизвестно» — разные сообщения с
|
||||
// разными действиями оператора. Пока состояние было булевым, второе выдавалось
|
||||
// за первое: сломанный systemctl при живой Hysteria показывал критическую
|
||||
// плашку «служба остановлена», и оператор шёл перезапускать работающий
|
||||
// туннель.
|
||||
func DashboardSecurityRisks(summary vo.DashboardSummaryVo) []vo.SecurityRiskVo {
|
||||
risks := make([]vo.SecurityRiskVo, 0)
|
||||
if !summary.Hysteria.Running {
|
||||
switch summary.Hysteria.ServiceState {
|
||||
case string(HysteriaServiceActive):
|
||||
// Обслуживание идёт — сообщать нечего.
|
||||
case string(HysteriaServiceUnknown):
|
||||
risks = append(risks, vo.SecurityRiskVo{Key: "dashboard.security.hysteriaStateUnknown", Severity: "warning", Dismissible: false})
|
||||
default:
|
||||
risks = append(risks, vo.SecurityRiskVo{Key: "dashboard.security.hysteriaStopped", Severity: "critical", Dismissible: false})
|
||||
}
|
||||
if !summary.Hysteria.ApiReachable {
|
||||
|
||||
+86
-24
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
|
||||
// InitHysteria2 проверяет единственное, что панели действительно нужно от
|
||||
// Hysteria на старте: наличие бинарника, версию которого показывает дашборд и
|
||||
// у которого спрашивает состояние Hysteria2IsRunning.
|
||||
// у которого спрашивает состояние HysteriaServiceState.
|
||||
//
|
||||
// Чтения HYSTERIA2_ENABLE здесь больше нет. Ключ был последним следом модели,
|
||||
// в которой панель сама запускала и останавливала Hysteria: clean install
|
||||
@@ -26,38 +27,99 @@ func InitHysteria2() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Hysteria2IsRunning — ответ systemd о состоянии службы, и НЕ БОЛЬШЕ ТОГО.
|
||||
// Состояние службы имеет ТРИ значения, а не два.
|
||||
//
|
||||
// Точность этого ответа ограничена сверху устройством util.Exec: он схлопывает
|
||||
// «systemctl вернул 3, служба неактивна» и «запустить systemctl не удалось» в
|
||||
// одну ошибку. Различить их здесь нельзя, поэтому false означает «служба
|
||||
// неактивна ИЛИ спросить не получилось».
|
||||
// Что было. Ответ сводился к `bool`:
|
||||
//
|
||||
// Отсюда правило, которое стоило продукту трёх дыр: на этом значении нельзя
|
||||
// строить решения о доступе и о применении операции. Ему место в отображении
|
||||
// (дашборд, признак online в списке), где ошибочное «выключено» стоит одной
|
||||
// неверной плашки. Решения о доступе принимаются по фактическому ответу
|
||||
// Traffic Stats API — см. hysteria2Online и disconnectAuthIDs.
|
||||
// func Hysteria2IsRunning() bool {
|
||||
// _, err := util.Exec("systemctl is-active --quiet hysteria-server")
|
||||
// return err == nil
|
||||
// }
|
||||
//
|
||||
// Третья дыра была самой дорогой и жила в cron: гейт `if !Hysteria2IsRunning()
|
||||
// { return }` стоял перед всем циклом учёта, поэтому сломанный systemctl при
|
||||
// живой Hysteria молча отключал и сбор трафика, и принудительное отключение —
|
||||
// без единой строки в журнале.
|
||||
func Hysteria2IsRunning() bool {
|
||||
return hysteria2IsRunning()
|
||||
// `false` при этом означал сразу две несовместимые вещи — «служба неактивна» и
|
||||
// «спросить не получилось», — потому что util.Exec выбрасывает вывод команды,
|
||||
// как только код возврата не нулевой, а `is-active` отвечает именно кодом 3
|
||||
// вместе со словом состояния в stdout. Прочитать это слово было нечем.
|
||||
//
|
||||
// Цена такой неопределённости оказалась не в одной неверной плашке. Пока
|
||||
// «неизвестно» выглядело как «остановлена», на этом значении стояли решения:
|
||||
// цикл учёта и проверка лимита устройств пропускались при сломанном systemctl
|
||||
// и живой Hysteria. Оба гейта убраны (см. cron.go и Hysteria2Auth), и это
|
||||
// правильно, но сама неопределённость никуда не делась — она осталась в
|
||||
// диагностике, где как раз и вредна:
|
||||
//
|
||||
// Hysteria stopped
|
||||
// Traffic Stats API reachable
|
||||
// 0 online users
|
||||
//
|
||||
// Три утверждения об одной системе, из которых первое и второе не могут быть
|
||||
// верны одновременно. Оператор, увидев такое в аварии, ищет не там.
|
||||
//
|
||||
// Теперь состояние читается словом, а не кодом возврата, и неизвестность
|
||||
// называется неизвестностью.
|
||||
type HysteriaServiceState string
|
||||
|
||||
const (
|
||||
// HysteriaServiceActive — systemd сообщил "active".
|
||||
HysteriaServiceActive HysteriaServiceState = "active"
|
||||
// HysteriaServiceInactive — systemd назвал любое другое известное
|
||||
// состояние: inactive, failed, activating, deactivating.
|
||||
//
|
||||
// Различать их дальше продукту незачем: панель отвечает на вопрос «идёт ли
|
||||
// сейчас обслуживание», а не пересказывает конечный автомат systemd.
|
||||
HysteriaServiceInactive HysteriaServiceState = "inactive"
|
||||
// HysteriaServiceUnknown — спросить не удалось либо ответ не разобран.
|
||||
//
|
||||
// Это НЕ «служба остановлена». Единственное честное следствие — что о
|
||||
// состоянии службы сейчас ничего не известно.
|
||||
HysteriaServiceUnknown HysteriaServiceState = "unknown"
|
||||
)
|
||||
|
||||
// Hysteria2ServiceState — ответ systemd о состоянии службы, и НЕ БОЛЬШЕ ТОГО.
|
||||
//
|
||||
// Значение годится для отображения и ни для чего больше. Решения о доступе
|
||||
// принимаются по фактическому ответу Traffic Stats API — см. hysteria2Online и
|
||||
// disconnectAuthIDs, — и это правило стоило продукту трёх дыр: гейт
|
||||
// `if !Hysteria2IsRunning() { return }` в cron молча отключал и сбор трафика, и
|
||||
// принудительное отключение при сломанном systemctl.
|
||||
func Hysteria2ServiceState() HysteriaServiceState {
|
||||
return hysteriaServiceState()
|
||||
}
|
||||
|
||||
// hysteria2IsRunning — та же проверка в виде значения.
|
||||
// hysteriaServiceState — та же проверка в виде значения.
|
||||
//
|
||||
// Переменная нужна тестам: без неё каждая проверка, зависящая от состояния
|
||||
// службы, доказуема только на живом systemd, то есть не доказуема в сборке
|
||||
// вовсе. Продуктовый путь от этого не меняется — значение по умолчанию и есть
|
||||
// прежняя реализация.
|
||||
var hysteria2IsRunning = func() bool {
|
||||
_, err := util.Exec("systemctl is-active --quiet hysteria-server")
|
||||
return err == nil
|
||||
// реализация.
|
||||
var hysteriaServiceState = func() HysteriaServiceState {
|
||||
// `--quiet` здесь недопустим: он подавляет ровно тот вывод, ради которого
|
||||
// команда вызывается.
|
||||
output, _, err := util.ExecProbe("systemctl is-active hysteria-server")
|
||||
if err != nil {
|
||||
return HysteriaServiceUnknown
|
||||
}
|
||||
return parseSystemdActiveState(output)
|
||||
}
|
||||
|
||||
func ReleaseHysteria2() error {
|
||||
return nil
|
||||
// parseSystemdActiveState разбирает ответ `systemctl is-active`.
|
||||
//
|
||||
// Слово состояния приезжает первой строкой. Неизвестное значение считается
|
||||
// неизвестным состоянием, а не «неактивна»: список состояний systemd
|
||||
// пополнялся и раньше, и молчаливое приведение нового слова к «остановлена»
|
||||
// вернуло бы ровно ту ложь, от которой избавляет этот тип.
|
||||
func parseSystemdActiveState(output string) HysteriaServiceState {
|
||||
state := strings.TrimSpace(output)
|
||||
if idx := strings.IndexAny(state, "\r\n"); idx >= 0 {
|
||||
state = strings.TrimSpace(state[:idx])
|
||||
}
|
||||
|
||||
switch state {
|
||||
case "active":
|
||||
return HysteriaServiceActive
|
||||
case "inactive", "failed", "activating", "deactivating", "reloading", "maintenance":
|
||||
return HysteriaServiceInactive
|
||||
default:
|
||||
return HysteriaServiceUnknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,20 +165,36 @@ func Hysteria2Auth(conPass string) (int64, string, error) {
|
||||
|
||||
// Hysteria2Online — картина подключений ДЛЯ ОТОБРАЖЕНИЯ.
|
||||
//
|
||||
// Терпима к недоступности Hysteria намеренно: дашборду и списку пиров нечего
|
||||
// показать, если Traffic Stats API молчит, и пустая картина — честный ответ на
|
||||
// вопрос «кто сейчас на связи», когда служба остановлена.
|
||||
// Отличается от hysteria2Online ровно ничем, и это результат исправления, а не
|
||||
// упущение. Раньше здесь стоял ярлык
|
||||
//
|
||||
// Этой терпимости НЕТ в решениях о доступе. Ярлык `!hysteria2IsRunning() ->
|
||||
// пусто, ошибки нет` раньше стоял на общем пути, и через него ограничение
|
||||
// устройств обходилось молча: systemctl отвечает через util.Exec, который не
|
||||
// отличает «служба неактивна» от «спросить не удалось», а «никого нет онлайн»
|
||||
// для проверки лимита означает «пускать всех». Отказа при этом не возникало
|
||||
// вовсе — не было даже записи в журнале.
|
||||
// if !hysteria2IsRunning() {
|
||||
// return map[string]int64{}, nil
|
||||
// }
|
||||
//
|
||||
// то есть «пусто, ошибки нет» по мнению systemd. У него было два следствия, и
|
||||
// оба вредные.
|
||||
//
|
||||
// Первое — на пути доступа: ответ systemctl не отличает «служба неактивна» от
|
||||
// «спросить не удалось», а пустая картина при проверке лимита устройств
|
||||
// означает «пускать всех». Эта половина закрыта раньше — авторизация ходит
|
||||
// строгим путём.
|
||||
//
|
||||
// Второе осталось и живёт в диагностике. Пустая карта БЕЗ ошибки неотличима от
|
||||
// «никто не подключён», поэтому сборщик метрик выставлял `ApiReachable = true`
|
||||
// и `OnlineDevices = 0`, ни разу не обратившись к Traffic Stats API, а список
|
||||
// пиров показывал всех офлайн. Дашборд утверждал одновременно «служба
|
||||
// остановлена» и «API доступен, онлайн 0» — два несовместимых факта об одной
|
||||
// системе, полученные из одного и того же ответа systemctl.
|
||||
//
|
||||
// Поэтому ярлыка нет: «кто сейчас на связи» спрашивается у того, кто это
|
||||
// знает. Недоступность остаётся ОШИБКОЙ, а решать, как её показать оператору,
|
||||
// обязан вызывающий — см. CollectMetricsSnapshot и PagePeer, где она
|
||||
// превращается в явное «состояние неизвестно», а не в «все офлайн».
|
||||
//
|
||||
// Функция сохранена отдельно от hysteria2Online как имя для внешнего слоя:
|
||||
// внутри пакета строгий путь остаётся строчным.
|
||||
func Hysteria2Online() (map[string]int64, error) {
|
||||
if !hysteria2IsRunning() {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
return hysteria2Online()
|
||||
}
|
||||
|
||||
@@ -330,7 +346,7 @@ func Hysteria2Url(accountId int64) (string, error) {
|
||||
// несколькими пирами это ровно то различие, которое пользователю и нужно
|
||||
// видеть, и оно не требует ни одной дополнительной настройки. Fallback на
|
||||
// публичный хост нужен для пира без имени — база это допускает (name имеет
|
||||
// DEFAULT ''), а ссылка без имени профиля хуже, чем ссылка с именем сервера.
|
||||
// DEFAULT ”), а ссылка без имени профиля хуже, чем ссылка с именем сервера.
|
||||
func shareRemark(peerName *string, hostname string) string {
|
||||
if peerName != nil {
|
||||
if name := strings.TrimSpace(*peerName); name != "" {
|
||||
|
||||
@@ -61,7 +61,7 @@ func SanitizeHysteria2ConfigYaml(raw string) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
redactNode(&document, nil)
|
||||
redactNode(&document, nil, newAliasGuard())
|
||||
|
||||
out, err := yaml.Marshal(&document)
|
||||
if err != nil {
|
||||
@@ -120,7 +120,53 @@ func isSecretMapPath(path []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func redactNode(node *yaml.Node, path []string) {
|
||||
// Секрет, спрятанный за YAML-якорем, тоже покидал сервер.
|
||||
//
|
||||
// Что было. И redactNode, и redactSubtree разбирали четыре вида узлов:
|
||||
// документ, последовательность, отображение и скаляр. Пятый вид —
|
||||
// yaml.AliasNode — не обрабатывался ни одной из них, а yaml.v3 якоря и ссылки
|
||||
// поддерживает:
|
||||
//
|
||||
// shared: &credential VERY_SECRET_VALUE
|
||||
//
|
||||
// obfs:
|
||||
// type: salamander
|
||||
// salamander:
|
||||
// password: *credential
|
||||
//
|
||||
// Утечек здесь ДВЕ, и обе тихие. Значение под ключом `password` — это
|
||||
// AliasNode, и redactSubtree на нём был no-op. А сам якорь объявлен под
|
||||
// ключом `shared`, который секретоподобным не выглядит, поэтому и он
|
||||
// оставался нетронутым. В выгрузке секрет оказывался дважды.
|
||||
//
|
||||
// Канонический конфиг HY2XS якорей не содержит, но экспорт существует ещё и
|
||||
// для диагностики чужих и правленых руками файлов, а обещание «секреты не
|
||||
// покидают сервер» либо абсолютно, либо его нет.
|
||||
//
|
||||
// Лечится переходом ПО ССЫЛКЕ: alias ведёт к тому же узлу, что и якорь,
|
||||
// поэтому редакция цели закрывает оба вхождения сразу. Защита от циклов
|
||||
// обязательна — YAML допускает ссылку на предка, и обход без неё не завершится.
|
||||
|
||||
// aliasGuard помнит уже посещённые узлы обхода.
|
||||
type aliasGuard map[*yaml.Node]struct{}
|
||||
|
||||
func newAliasGuard() aliasGuard {
|
||||
return aliasGuard{}
|
||||
}
|
||||
|
||||
// enter возвращает false, если узел уже посещался в этом обходе.
|
||||
func (g aliasGuard) enter(node *yaml.Node) bool {
|
||||
if node == nil {
|
||||
return false
|
||||
}
|
||||
if _, seen := g[node]; seen {
|
||||
return false
|
||||
}
|
||||
g[node] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func redactNode(node *yaml.Node, path []string, guard aliasGuard) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
@@ -128,18 +174,25 @@ func redactNode(node *yaml.Node, path []string) {
|
||||
switch node.Kind {
|
||||
case yaml.DocumentNode:
|
||||
for _, child := range node.Content {
|
||||
redactNode(child, path)
|
||||
redactNode(child, path, guard)
|
||||
}
|
||||
|
||||
case yaml.AliasNode:
|
||||
// Обход продолжается по цели ссылки: путь остаётся прежним, потому что
|
||||
// alias стоит ровно там, где стояло бы само значение.
|
||||
if guard.enter(node) {
|
||||
redactNode(node.Alias, path, guard)
|
||||
}
|
||||
|
||||
case yaml.SequenceNode:
|
||||
for _, child := range node.Content {
|
||||
// Индекс не попадает в путь: правила формулируются по именам полей.
|
||||
redactNode(child, path)
|
||||
redactNode(child, path, guard)
|
||||
}
|
||||
|
||||
case yaml.MappingNode:
|
||||
if isSecretMapPath(path) {
|
||||
redactAllScalarValues(node)
|
||||
redactAllScalarValues(node, guard)
|
||||
return
|
||||
}
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
@@ -148,10 +201,10 @@ func redactNode(node *yaml.Node, path []string) {
|
||||
childPath := append(append([]string{}, path...), key)
|
||||
|
||||
if isSecretKey(key) {
|
||||
redactSubtree(value)
|
||||
redactSubtree(value, guard)
|
||||
continue
|
||||
}
|
||||
redactNode(value, childPath)
|
||||
redactNode(value, childPath, guard)
|
||||
}
|
||||
|
||||
case yaml.ScalarNode:
|
||||
@@ -182,30 +235,40 @@ func sanitizeURLScalar(node *yaml.Node) {
|
||||
node.Style = 0
|
||||
}
|
||||
|
||||
func redactSubtree(node *yaml.Node) {
|
||||
func redactSubtree(node *yaml.Node, guard aliasGuard) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
switch node.Kind {
|
||||
case yaml.ScalarNode:
|
||||
setRedacted(node)
|
||||
case yaml.AliasNode:
|
||||
// Редактируется ЦЕЛЬ ссылки: она же является значением якоря, поэтому
|
||||
// одно действие закрывает и `*alias`, и место объявления `&anchor`.
|
||||
if guard.enter(node) {
|
||||
redactSubtree(node.Alias, guard)
|
||||
}
|
||||
case yaml.MappingNode, yaml.SequenceNode, yaml.DocumentNode:
|
||||
redactAllScalarValues(node)
|
||||
redactAllScalarValues(node, guard)
|
||||
}
|
||||
}
|
||||
|
||||
func redactAllScalarValues(node *yaml.Node) {
|
||||
func redactAllScalarValues(node *yaml.Node, guard aliasGuard) {
|
||||
switch node.Kind {
|
||||
case yaml.MappingNode:
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
redactSubtree(node.Content[i+1])
|
||||
redactSubtree(node.Content[i+1], guard)
|
||||
}
|
||||
case yaml.SequenceNode, yaml.DocumentNode:
|
||||
for _, child := range node.Content {
|
||||
redactSubtree(child)
|
||||
redactSubtree(child, guard)
|
||||
}
|
||||
case yaml.ScalarNode:
|
||||
setRedacted(node)
|
||||
case yaml.AliasNode:
|
||||
if guard.enter(node) {
|
||||
redactSubtree(node.Alias, guard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/vo"
|
||||
)
|
||||
|
||||
// Проекция серверного конфига на production-профиль HY2XS.
|
||||
//
|
||||
// Зачем она нужна вместо «показать модель целиком». Продуктом является один
|
||||
// профиль: оркестратор генерирует конфиг из шаблона и сам же проверяет, что
|
||||
// установленный файл этому профилю соответствует
|
||||
// (orchestrator/src/steps/configAssertions.ts). Страница конфигурации при этом
|
||||
// обслуживала одновременно и его, и абстрактный «универсальный сервер
|
||||
// Hysteria» — с вкладками resolver, sniff, acl, outbounds, masquerade, — то
|
||||
// есть показывала как настройки то, что в этой установке не используется и не
|
||||
// поддерживается.
|
||||
//
|
||||
// Достраивать её до полноценного редактора всех возможностей upstream
|
||||
// неправильно: конфигом владеет оркестратор, панель его не пишет, а вопрос
|
||||
// «почему поведение не такое, как ожидалось» решается не списком всех полей, а
|
||||
// ответом «что в файле отличается от профиля». Поэтому здесь ровно две вещи:
|
||||
// значения профиля как они записаны и перечень секций вне профиля.
|
||||
//
|
||||
// Полный документ никуда не делся — он выгружается санитизированным экспортом.
|
||||
|
||||
// hysteria2ProfileSections — секции верхнего уровня, которые описывает
|
||||
// production-профиль HY2XS.
|
||||
//
|
||||
// Список обязан совпадать с whitelist'ом оркестратора
|
||||
// (assertNoUnexpectedSections) с точностью до того, что там режимы TLS
|
||||
// взаимоисключающие, а здесь показываются оба: файл может содержать любой из
|
||||
// них, и оба принадлежат профилю. Совпадение проверяется гейтом приёмки —
|
||||
// иначе два списка разъедутся молча, и панель начнёт называть расхождением то,
|
||||
// что оркестратор считает нормой.
|
||||
var hysteria2ProfileSections = []string{
|
||||
"acme",
|
||||
"auth",
|
||||
"bandwidth",
|
||||
"congestion",
|
||||
"ignoreClientBandwidth",
|
||||
"listen",
|
||||
"obfs",
|
||||
"quic",
|
||||
"tls",
|
||||
"trafficStats",
|
||||
}
|
||||
|
||||
// BuildHysteria2Profile читает файл ОДИН раз и отвечает на оба вопроса сразу:
|
||||
// что записано в секциях профиля и какие секции в профиль не входят.
|
||||
func BuildHysteria2Profile() (vo.Hysteria2ProfileVo, error) {
|
||||
raw, err := GetRawHysteria2Config()
|
||||
if err != nil {
|
||||
return vo.Hysteria2ProfileVo{}, err
|
||||
}
|
||||
|
||||
var config bo.Hysteria2ServerConfig
|
||||
if err := yaml.Unmarshal([]byte(raw), &config); err != nil {
|
||||
return vo.Hysteria2ProfileVo{}, err
|
||||
}
|
||||
|
||||
profile := hysteria2ProfileFromConfig(config)
|
||||
profile.Drift = hysteria2ConfigDrift(raw)
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// hysteria2ConfigDrift перечисляет секции файла вне production-профиля.
|
||||
//
|
||||
// Разбор идёт в свободную карту, а не в типизированную модель: секция, о
|
||||
// которой HY2XS не знает вовсе, обязана попасть в расхождения, а модель её
|
||||
// молча теряет. Именно такие секции и интересны — они означают, что файл правил
|
||||
// не оркестратор.
|
||||
func hysteria2ConfigDrift(raw string) []string {
|
||||
var document map[string]any
|
||||
if err := yaml.Unmarshal([]byte(raw), &document); err != nil {
|
||||
// Файл не разобрался как карта верхнего уровня. Значения профиля уже
|
||||
// прочитаны типизированной моделью; сказать что-либо о расхождениях
|
||||
// нельзя, и выдумывать их нечестно.
|
||||
return []string{}
|
||||
}
|
||||
|
||||
known := make(map[string]struct{}, len(hysteria2ProfileSections))
|
||||
for _, section := range hysteria2ProfileSections {
|
||||
known[section] = struct{}{}
|
||||
}
|
||||
|
||||
drift := make([]string, 0)
|
||||
for key := range document {
|
||||
if _, ok := known[key]; ok {
|
||||
continue
|
||||
}
|
||||
drift = append(drift, key)
|
||||
}
|
||||
// Порядок ключей карты в Go случаен: без сортировки список расхождений
|
||||
// менялся бы при каждом обновлении страницы.
|
||||
sort.Strings(drift)
|
||||
return drift
|
||||
}
|
||||
|
||||
func hysteria2ProfileFromConfig(config bo.Hysteria2ServerConfig) vo.Hysteria2ProfileVo {
|
||||
profile := vo.Hysteria2ProfileVo{
|
||||
Listen: config.Listen,
|
||||
IgnoreClientBandwidth: config.IgnoreClientBandwidth,
|
||||
Drift: []string{},
|
||||
}
|
||||
|
||||
if auth := config.Auth; auth != nil {
|
||||
profile.Auth = &vo.Hysteria2ProfileAuthVo{Type: auth.Type}
|
||||
if auth.HTTP != nil {
|
||||
// URL несёт machine token в query. Санитайзер тот же, что и у
|
||||
// выгрузки: адрес остаётся читаемым, секрет — нет.
|
||||
if auth.HTTP.URL != nil {
|
||||
sanitized := sanitizeURLValue(*auth.HTTP.URL)
|
||||
profile.Auth.Url = &sanitized
|
||||
}
|
||||
profile.Auth.Insecure = auth.HTTP.Insecure
|
||||
}
|
||||
}
|
||||
|
||||
if tls := config.TLS; tls != nil {
|
||||
profile.Tls = &vo.Hysteria2ProfileTlsVo{
|
||||
Cert: tls.Cert,
|
||||
Key: tls.Key,
|
||||
SniGuard: tls.SNIGuard,
|
||||
ClientCA: tls.ClientCA,
|
||||
}
|
||||
}
|
||||
|
||||
if acme := config.ACME; acme != nil {
|
||||
profile.Acme = &vo.Hysteria2ProfileAcmeVo{
|
||||
Domains: acme.Domains,
|
||||
Email: acme.Email,
|
||||
Ca: acme.CA,
|
||||
Dir: acme.Dir,
|
||||
ListenHost: acme.ListenHost,
|
||||
Type: acme.Type,
|
||||
DnsConfigKeys: []string{},
|
||||
}
|
||||
if acme.DNS != nil {
|
||||
profile.Acme.DnsProvider = acme.DNS.Name
|
||||
for key := range acme.DNS.Config {
|
||||
profile.Acme.DnsConfigKeys = append(profile.Acme.DnsConfigKeys, key)
|
||||
}
|
||||
sort.Strings(profile.Acme.DnsConfigKeys)
|
||||
}
|
||||
if profile.Acme.Domains == nil {
|
||||
profile.Acme.Domains = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
if obfs := config.Obfs; obfs != nil {
|
||||
profile.Obfs = &vo.Hysteria2ProfileObfsVo{Type: obfs.Type}
|
||||
// Показывается ветка, соответствующая объявленному типу. Наличие
|
||||
// «второй» ветки рядом — это уже расхождение конфига, и его видно по
|
||||
// самому типу: оркестратор пишет ровно одну.
|
||||
share := config.ObfsShare()
|
||||
profile.Obfs.PasswordSet = strings.TrimSpace(share.Password) != ""
|
||||
if obfs.Gecko != nil && obfs.Type != nil && *obfs.Type == "gecko" {
|
||||
profile.Obfs.MinPacketSize = obfs.Gecko.MinPacketSize
|
||||
profile.Obfs.MaxPacketSize = obfs.Gecko.MaxPacketSize
|
||||
}
|
||||
}
|
||||
|
||||
if bandwidth := config.Bandwidth; bandwidth != nil {
|
||||
profile.Bandwidth = &vo.Hysteria2ProfileBandwidthVo{
|
||||
Up: bandwidth.Up,
|
||||
Down: bandwidth.Down,
|
||||
DisableLossCompensation: bandwidth.DisableLossCompensation,
|
||||
}
|
||||
}
|
||||
|
||||
if congestion := config.Congestion; congestion != nil {
|
||||
profile.Congestion = &vo.Hysteria2ProfileCongestionVo{
|
||||
Type: congestion.Type,
|
||||
BbrProfile: congestion.BBRProfile,
|
||||
}
|
||||
}
|
||||
|
||||
if quic := config.QUIC; quic != nil {
|
||||
profile.Quic = &vo.Hysteria2ProfileQuicVo{
|
||||
InitStreamReceiveWindow: quic.InitStreamReceiveWindow,
|
||||
MaxStreamReceiveWindow: quic.MaxStreamReceiveWindow,
|
||||
InitConnReceiveWindow: quic.InitConnectionReceiveWindow,
|
||||
MaxConnReceiveWindow: quic.MaxConnectionReceiveWindow,
|
||||
MaxIdleTimeout: quic.MaxIdleTimeout,
|
||||
MaxIncomingStreams: quic.MaxIncomingStreams,
|
||||
DisablePathMTUDiscovery: quic.DisablePathMTUDiscovery,
|
||||
DisableStatelessReset: quic.DisableStatelessReset,
|
||||
}
|
||||
}
|
||||
|
||||
if stats := config.TrafficStats; stats != nil {
|
||||
profile.TrafficStats = &vo.Hysteria2ProfileTrafficStatsVo{
|
||||
Listen: stats.Listen,
|
||||
SecretSet: stats.Secret != nil && strings.TrimSpace(*stats.Secret) != "",
|
||||
}
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
@@ -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("отсутствующий конфиг выдан за пустой профиль")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/model/vo"
|
||||
)
|
||||
|
||||
// Дашборд не имеет права утверждать физически несовместимые вещи.
|
||||
//
|
||||
// Что было:
|
||||
//
|
||||
// Hysteria stopped
|
||||
// Traffic Stats API reachable
|
||||
// 0 online users
|
||||
//
|
||||
// Все три строки выводились из ОДНОГО ответа systemctl: общий Hysteria2Online
|
||||
// при неактивной службе отдавал пустую карту без ошибки, и сборщик метрик
|
||||
// выставлял `apiReachable = true`, ни разу не сходив в API.
|
||||
//
|
||||
// Матрица ниже проверяет все четыре клетки: два источника, два исхода каждый.
|
||||
|
||||
func TestParseSystemdActiveState(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
output string
|
||||
want HysteriaServiceState
|
||||
}{
|
||||
{"active", "active\n", HysteriaServiceActive},
|
||||
{"inactive", "inactive\n", HysteriaServiceInactive},
|
||||
{"failed", "failed\n", HysteriaServiceInactive},
|
||||
{"activating", "activating\n", HysteriaServiceInactive},
|
||||
{"deactivating", "deactivating\n", HysteriaServiceInactive},
|
||||
{"пробелы по краям", " active ", HysteriaServiceActive},
|
||||
{"несколько строк", "active\nactive\n", HysteriaServiceActive},
|
||||
{"пустой ответ", "", HysteriaServiceUnknown},
|
||||
// Список состояний systemd пополнялся и раньше. Молчаливое приведение
|
||||
// незнакомого слова к «остановлена» вернуло бы ровно ту ложь, ради
|
||||
// которой заведено третье значение.
|
||||
{"неизвестное состояние", "quantum\n", HysteriaServiceUnknown},
|
||||
{"текст ошибки вместо состояния", "Failed to connect to bus\n", HysteriaServiceUnknown},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := parseSystemdActiveState(tc.output); got != tc.want {
|
||||
t.Fatalf("parseSystemdActiveState(%q) = %q, ожидалось %q", tc.output, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Клетка 1: служба активна, API отвечает.
|
||||
func TestDashboardStateActiveServiceReachableApi(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
|
||||
withHysteriaServiceState(t, HysteriaServiceActive)
|
||||
|
||||
CollectMetricsSnapshot()
|
||||
snapshot := DashboardSnapshot()
|
||||
|
||||
if snapshot.Hysteria.ServiceState != string(HysteriaServiceActive) || !snapshot.Hysteria.Running {
|
||||
t.Fatalf("состояние службы: %+v", snapshot.Hysteria)
|
||||
}
|
||||
if !snapshot.Hysteria.ApiReachable {
|
||||
t.Fatal("API отвечает, но объявлен недоступным")
|
||||
}
|
||||
if snapshot.OnlineDevices != 2 || snapshot.OnlinePeers != 1 {
|
||||
t.Fatalf("картина подключений: пиров %d, устройств %d", snapshot.OnlinePeers, snapshot.OnlineDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// Клетка 2: служба неактивна, API молчит. Оба факта согласованы, и оба верны.
|
||||
func TestDashboardStateInactiveServiceUnreachableApi(t *testing.T) {
|
||||
newTestDB(t)
|
||||
pointHysteriaConfigAtDeadPort(t)
|
||||
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||||
|
||||
CollectMetricsSnapshot()
|
||||
snapshot := DashboardSnapshot()
|
||||
|
||||
if snapshot.Hysteria.ServiceState != string(HysteriaServiceInactive) {
|
||||
t.Fatalf("состояние службы объявлено как %q", snapshot.Hysteria.ServiceState)
|
||||
}
|
||||
if snapshot.Hysteria.ApiReachable {
|
||||
t.Fatal("недоступный Traffic Stats API объявлен доступным")
|
||||
}
|
||||
}
|
||||
|
||||
// Клетка 3 — главная регрессия: спросить systemctl не удалось, но Hysteria
|
||||
// жива и API отвечает.
|
||||
//
|
||||
// Прежний путь показывал «служба остановлена» и «API доступен» одновременно,
|
||||
// причём второе — не сходив в API. Теперь неизвестность названа неизвестностью,
|
||||
// а доступность API получена фактическим обращением.
|
||||
func TestDashboardStateUnknownServiceReachableApi(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
||||
withHysteriaServiceState(t, HysteriaServiceUnknown)
|
||||
|
||||
CollectMetricsSnapshot()
|
||||
snapshot := DashboardSnapshot()
|
||||
|
||||
if snapshot.Hysteria.ServiceState != string(HysteriaServiceUnknown) {
|
||||
t.Fatalf("неизвестное состояние выдано за %q", snapshot.Hysteria.ServiceState)
|
||||
}
|
||||
if snapshot.Hysteria.Running {
|
||||
t.Fatal("неизвестное состояние выдано за работающую службу")
|
||||
}
|
||||
if !snapshot.Hysteria.ApiReachable {
|
||||
t.Fatal("мнение systemd подменило фактический ответ Traffic Stats API")
|
||||
}
|
||||
if snapshot.OnlineDevices != 1 {
|
||||
t.Fatalf("картина подключений потеряна: устройств %d", snapshot.OnlineDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// Клетка 4: служба активна, но API отказывает — например, Hysteria ещё
|
||||
// поднимается или порт занят чужим процессом.
|
||||
func TestDashboardStateActiveServiceFailingApi(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{onlineStatus: http.StatusInternalServerError})
|
||||
withHysteriaServiceState(t, HysteriaServiceActive)
|
||||
|
||||
CollectMetricsSnapshot()
|
||||
snapshot := DashboardSnapshot()
|
||||
|
||||
if !snapshot.Hysteria.Running {
|
||||
t.Fatal("активная служба объявлена остановленной")
|
||||
}
|
||||
if snapshot.Hysteria.ApiReachable {
|
||||
t.Fatal("отказ API объявлен доступностью")
|
||||
}
|
||||
if snapshot.HysteriaState.Status != "error" {
|
||||
t.Fatalf("отказ API не попал в состояние данных: %+v", snapshot.HysteriaState)
|
||||
}
|
||||
}
|
||||
|
||||
// «Состояние неизвестно» и «служба остановлена» — разные сообщения оператору с
|
||||
// разной срочностью и разными действиями.
|
||||
func TestDashboardSecurityRisksDistinguishUnknownFromStopped(t *testing.T) {
|
||||
riskKeys := func(state HysteriaServiceState, apiReachable bool) []string {
|
||||
summary := vo.DashboardSummaryVo{}
|
||||
summary.Hysteria.ServiceState = string(state)
|
||||
summary.Hysteria.Running = state == HysteriaServiceActive
|
||||
summary.Hysteria.ApiReachable = apiReachable
|
||||
|
||||
keys := make([]string, 0)
|
||||
for _, risk := range DashboardSecurityRisks(summary) {
|
||||
keys = append(keys, risk.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
if got := riskKeys(HysteriaServiceActive, true); len(got) != 0 {
|
||||
t.Fatalf("исправная система получила предупреждения: %v", got)
|
||||
}
|
||||
|
||||
stopped := riskKeys(HysteriaServiceInactive, true)
|
||||
if len(stopped) != 1 || stopped[0] != "dashboard.security.hysteriaStopped" {
|
||||
t.Fatalf("остановленная служба: %v", stopped)
|
||||
}
|
||||
|
||||
unknown := riskKeys(HysteriaServiceUnknown, true)
|
||||
if len(unknown) != 1 || unknown[0] != "dashboard.security.hysteriaStateUnknown" {
|
||||
t.Fatalf("неизвестное состояние выдано за остановленную службу: %v", unknown)
|
||||
}
|
||||
|
||||
both := riskKeys(HysteriaServiceInactive, false)
|
||||
if len(both) != 2 {
|
||||
t.Fatalf("два независимых отказа схлопнулись в один: %v", both)
|
||||
}
|
||||
}
|
||||
+195
-23
@@ -4,19 +4,61 @@ import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/util"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
type journalctlLogLine struct {
|
||||
Message string `json:"MESSAGE"`
|
||||
Priority string `json:"PRIORITY"`
|
||||
RealtimeTimestamp string `json:"__REALTIME_TIMESTAMP"`
|
||||
Message journalMessage `json:"MESSAGE"`
|
||||
Priority string `json:"PRIORITY"`
|
||||
RealtimeTimestamp string `json:"__REALTIME_TIMESTAMP"`
|
||||
}
|
||||
|
||||
// journalMessage — поле MESSAGE журнала, которое journald отдаёт ДВУМЯ разными
|
||||
// способами.
|
||||
//
|
||||
// Обычно это строка. Но если сообщение не является корректным UTF-8, `-o json`
|
||||
// отдаёт его массивом байтовых значений — это документированное поведение
|
||||
// journald, а не аномалия. Прежний `Message string` на такой записи ронял
|
||||
// разбор ВСЕЙ строки, и она молча выпадала из журнала: обработчик делал
|
||||
// `continue`. То есть ровно те строки, ради которых журнал открывают чаще
|
||||
// всего — с испорченным выводом чужого процесса, — панель не показывала вовсе.
|
||||
type journalMessage string
|
||||
|
||||
func (m *journalMessage) UnmarshalJSON(data []byte) error {
|
||||
var asString string
|
||||
if err := json.Unmarshal(data, &asString); err == nil {
|
||||
*m = journalMessage(asString)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Массив чисел разбирается через []int, а не []byte: для []byte
|
||||
// encoding/json ожидает base64-строку и на JSON-массиве откажет.
|
||||
var asBytes []int
|
||||
if err := json.Unmarshal(data, &asBytes); err == nil {
|
||||
raw := make([]byte, 0, len(asBytes))
|
||||
for _, value := range asBytes {
|
||||
if value < 0 || value > 255 {
|
||||
continue
|
||||
}
|
||||
raw = append(raw, byte(value))
|
||||
}
|
||||
*m = journalMessage(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Поле отсутствует или имеет неизвестную форму: сообщения нет, но сама
|
||||
// запись остаётся — у неё есть время и приоритет.
|
||||
*m = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeJournalLineLimit(value int, fallback int, max int) int {
|
||||
@@ -91,11 +133,39 @@ func ExportHysteriaJournalLogs(numLine int) (string, error) {
|
||||
return SanitizeLogText(output), nil
|
||||
}
|
||||
|
||||
// Структурный журнал Hysteria разбирается ПО ФАКТИЧЕСКОМУ формату 2.12.2.
|
||||
//
|
||||
// Что было. Запись складывалась прямым json.Unmarshal в vo.LogHysteria2Vo, у
|
||||
// которого `Time string`. JSON-логгер Hysteria объявлен так:
|
||||
//
|
||||
// TimeKey: "time", LevelKey: "level", MessageKey: "msg",
|
||||
// EncodeTime: zapcore.EpochMillisTimeEncoder
|
||||
//
|
||||
// то есть `time` — это ЧИСЛО, причём дробное: энкодер делит наносекунды на
|
||||
// миллисекунду и печатает float64 (`1788321234567.1235`). Разбор падал на
|
||||
// каждой без исключения строке:
|
||||
//
|
||||
// cannot unmarshal number into Go struct field ... of type string
|
||||
//
|
||||
// После чего срабатывал fallback и панель показывала сырой JSON целиком. То
|
||||
// есть `HYSTERIA_LOG_FORMAT=json` в юните был включён, а структурой никто не
|
||||
// пользовался: страница журнала выглядела ровно так же, как без него, только
|
||||
// менее читаемо.
|
||||
//
|
||||
// Замена типа поля на int64 проблему НЕ решает по двум причинам: значение
|
||||
// дробное, а кроме level/time/msg upstream кладёт в запись диагностический
|
||||
// контекст (`listen`, `addr`, `id`, `error`, `tx`, `rx`, …), который при
|
||||
// разборе в три известных поля просто выбрасывался бы. Именно эти поля и
|
||||
// отвечают на вопрос, ради которого журнал открывают.
|
||||
//
|
||||
// Поэтому разбор идёт через map: известные ключи заполняют колонки, остальные
|
||||
// дописываются к сообщению как `msg [key=value …]` в устойчивом порядке.
|
||||
func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
|
||||
fallbackTime := convertJournalTimestamp(item.RealtimeTimestamp)
|
||||
fallbackLevel := mapJournalPriorityToLevel(item.Priority)
|
||||
message := string(item.Message)
|
||||
|
||||
if strings.TrimSpace(item.Message) == "" {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return vo.LogHysteria2Vo{
|
||||
Level: fallbackLevel,
|
||||
Msg: "",
|
||||
@@ -103,25 +173,127 @@ func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
|
||||
}
|
||||
}
|
||||
|
||||
parsed := vo.LogHysteria2Vo{}
|
||||
if err := json.Unmarshal([]byte(item.Message), &parsed); err == nil {
|
||||
if parsed.Level == "" {
|
||||
parsed.Level = fallbackLevel
|
||||
fields := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(message), &fields); err != nil {
|
||||
// Не JSON вовсе: журнал Hysteria в console-формате либо посторонняя
|
||||
// строка от рантайма. Показывается как есть.
|
||||
return vo.LogHysteria2Vo{
|
||||
Level: fallbackLevel,
|
||||
Msg: SanitizeLogText(message),
|
||||
Time: fallbackTime,
|
||||
}
|
||||
if parsed.Time == "" {
|
||||
parsed.Time = fallbackTime
|
||||
}
|
||||
if parsed.Msg == "" {
|
||||
parsed.Msg = item.Message
|
||||
}
|
||||
parsed.Msg = SanitizeLogText(parsed.Msg)
|
||||
return parsed
|
||||
}
|
||||
|
||||
return vo.LogHysteria2Vo{
|
||||
Level: fallbackLevel,
|
||||
Msg: SanitizeLogText(item.Message),
|
||||
Time: fallbackTime,
|
||||
record := vo.LogHysteria2Vo{
|
||||
Level: journalStringField(fields, "level"),
|
||||
Msg: journalStringField(fields, "msg"),
|
||||
Time: hysteriaLogTime(fields["time"]),
|
||||
}
|
||||
if record.Level == "" {
|
||||
record.Level = fallbackLevel
|
||||
}
|
||||
if record.Time == "" {
|
||||
// Время journald — канонический запасной вариант: оно есть у КАЖДОЙ
|
||||
// записи и относится к тому же событию.
|
||||
record.Time = fallbackTime
|
||||
}
|
||||
if record.Msg == "" {
|
||||
// JSON без `msg` — не запись Hysteria. Терять её нельзя.
|
||||
record.Msg = message
|
||||
}
|
||||
record.Msg = SanitizeLogText(record.Msg + hysteriaLogContext(fields))
|
||||
return record
|
||||
}
|
||||
|
||||
// hysteriaLogReservedFields — ключи, которые уже показаны собственными
|
||||
// колонками либо не несут смысла для оператора.
|
||||
//
|
||||
// `logger` объявлен в EncoderConfig Hysteria (NameKey), но именованных
|
||||
// логгеров у сервера нет, поэтому в записи он не появляется; ключ перечислен,
|
||||
// чтобы будущее появление имени не поехало в текст сообщения.
|
||||
var hysteriaLogReservedFields = map[string]struct{}{
|
||||
"level": {},
|
||||
"msg": {},
|
||||
"time": {},
|
||||
"logger": {},
|
||||
}
|
||||
|
||||
// hysteriaLogContext собирает структурный контекст записи в устойчивом порядке.
|
||||
//
|
||||
// Порядок ключей в map случаен, поэтому без сортировки одна и та же запись
|
||||
// выглядела бы по-разному при каждом обновлении страницы.
|
||||
func hysteriaLogContext(fields map[string]any) string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
for key := range fields {
|
||||
if _, reserved := hysteriaLogReservedFields[key]; reserved {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", key, journalFieldValue(fields[key])))
|
||||
}
|
||||
return " [" + strings.Join(parts, " ") + "]"
|
||||
}
|
||||
|
||||
// journalFieldValue приводит значение поля к однострочному виду.
|
||||
func journalFieldValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return typed
|
||||
case bool:
|
||||
return strconv.FormatBool(typed)
|
||||
case float64:
|
||||
return formatJSONNumber(typed)
|
||||
default:
|
||||
// Вложенный объект или массив: JSON компактнее и однозначнее, чем
|
||||
// проценты Go-форматирования.
|
||||
encoded, err := json.Marshal(typed)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", typed)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
// formatJSONNumber печатает число без экспоненты и без хвоста `.0`: в журнале
|
||||
// оператор ожидает `443`, а не `4.43e+02`.
|
||||
func formatJSONNumber(value float64) string {
|
||||
if value == math.Trunc(value) && math.Abs(value) < 1e15 {
|
||||
return strconv.FormatInt(int64(value), 10)
|
||||
}
|
||||
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func journalStringField(fields map[string]any, key string) string {
|
||||
if value, ok := fields[key].(string); ok {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hysteriaLogTime переводит `time` записи в отображаемое время.
|
||||
//
|
||||
// Число трактуется как epoch millis — так его пишет EpochMillisTimeEncoder.
|
||||
// Строка принимается как есть: console-формат и будущая смена энкодера дают
|
||||
// RFC3339, и переписывать его продукт не обязан.
|
||||
func hysteriaLogTime(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
millis := int64(typed)
|
||||
return time.UnixMilli(millis).Format("2006-01-02 15:04:05")
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Разбор журнала проверяется на ФАКТИЧЕСКОМ формате Hysteria 2.12.2.
|
||||
//
|
||||
// Записи в тестах не выдуманы: они собираются тем же способом, каким их пишет
|
||||
// zap с EncoderConfig upstream — `time` числом epoch millis (дробным, потому
|
||||
// что EpochMillisTimeEncoder делит наносекунды на миллисекунду), `level`
|
||||
// строчными буквами, `msg` и произвольный структурный контекст рядом.
|
||||
//
|
||||
// Прежний разбор падал на КАЖДОЙ такой строке (`cannot unmarshal number into
|
||||
// Go struct field ... of type string`) и уходил в fallback, показывая оператору
|
||||
// сырой JSON. То есть `HYSTERIA_LOG_FORMAT=json` в юните был включён, а
|
||||
// структурой никто не пользовался.
|
||||
|
||||
// hysteriaJSONLine собирает запись так же, как её пишет zap Hysteria.
|
||||
func hysteriaJSONLine(t *testing.T, fields map[string]any) string {
|
||||
t.Helper()
|
||||
|
||||
encoded, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать запись журнала: %v", err)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func journalLine(message string) journalctlLogLine {
|
||||
return journalctlLogLine{
|
||||
Message: journalMessage(message),
|
||||
Priority: "6",
|
||||
RealtimeTimestamp: "1788321234567000",
|
||||
}
|
||||
}
|
||||
|
||||
// Главная регрессия: числовое `time` больше не роняет разбор.
|
||||
func TestParseHysteriaJournalRecordReadsNumericEpochMillis(t *testing.T) {
|
||||
// Значение дробное намеренно: EpochMillisTimeEncoder печатает float64.
|
||||
moment := time.Date(2026, 9, 2, 12, 34, 56, 789_012_345, time.Local)
|
||||
millis := float64(moment.UnixNano()) / float64(time.Millisecond)
|
||||
|
||||
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
|
||||
"time": millis,
|
||||
"level": "info",
|
||||
"msg": "server up and running",
|
||||
})))
|
||||
|
||||
if record.Level != "info" {
|
||||
t.Fatalf("уровень %q, ожидался info", record.Level)
|
||||
}
|
||||
if record.Msg != "server up and running" {
|
||||
t.Fatalf("сообщение %q, ожидался разобранный msg", record.Msg)
|
||||
}
|
||||
want := moment.Format("2006-01-02 15:04:05")
|
||||
if record.Time != want {
|
||||
t.Fatalf("время %q, ожидалось %q", record.Time, want)
|
||||
}
|
||||
// Сырой JSON в сообщение не попадает: это и был прежний исход.
|
||||
if strings.Contains(record.Msg, "{") {
|
||||
t.Fatalf("в сообщение уехала сырая запись: %q", record.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Структурный контекст upstream сохраняется: именно он отвечает на вопрос,
|
||||
// ради которого журнал открывают.
|
||||
func TestParseHysteriaJournalRecordKeepsStructuredContext(t *testing.T) {
|
||||
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
|
||||
"time": 1788321234567.1235,
|
||||
"level": "error",
|
||||
"msg": "authentication failed",
|
||||
"addr": "203.0.113.10:51820",
|
||||
"id": "alpha-auth-id",
|
||||
"error": "device limited",
|
||||
"listen": ":443",
|
||||
})))
|
||||
|
||||
for _, want := range []string{
|
||||
"authentication failed",
|
||||
"addr=203.0.113.10:51820",
|
||||
"error=device limited",
|
||||
"id=alpha-auth-id",
|
||||
"listen=:443",
|
||||
} {
|
||||
if !strings.Contains(record.Msg, want) {
|
||||
t.Fatalf("контекст %q потерян: %q", want, record.Msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Порядок полей устойчив: карта в Go обходится случайно, и без сортировки одна
|
||||
// и та же запись выглядела бы по-разному при каждом обновлении страницы.
|
||||
func TestParseHysteriaJournalRecordContextOrderIsStable(t *testing.T) {
|
||||
line := journalLine(hysteriaJSONLine(t, map[string]any{
|
||||
"time": 1788321234567.0,
|
||||
"level": "info",
|
||||
"msg": "client connected",
|
||||
"zulu": "z",
|
||||
"alpha": "a",
|
||||
"mike": "m",
|
||||
}))
|
||||
|
||||
first := parseHysteriaJournalRecord(line).Msg
|
||||
for i := 0; i < 20; i++ {
|
||||
if got := parseHysteriaJournalRecord(line).Msg; got != first {
|
||||
t.Fatalf("порядок контекста не устойчив: %q против %q", got, first)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(first, "[alpha=a mike=m zulu=z]") {
|
||||
t.Fatalf("контекст собран не по алфавиту: %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
// Числа контекста печатаются человеку, а не в экспоненте.
|
||||
func TestParseHysteriaJournalRecordFormatsNumbers(t *testing.T) {
|
||||
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
|
||||
"time": 1788321234567.0,
|
||||
"level": "info",
|
||||
"msg": "traffic",
|
||||
"tx": float64(1_234_567_890),
|
||||
"ratio": 0.5,
|
||||
"ok": true,
|
||||
})))
|
||||
|
||||
for _, want := range []string{"tx=1234567890", "ratio=0.5", "ok=true"} {
|
||||
if !strings.Contains(record.Msg, want) {
|
||||
t.Fatalf("значение %q отформатировано неверно: %q", want, record.Msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Вложенный объект остаётся однострочным и читаемым.
|
||||
func TestParseHysteriaJournalRecordFlattensNestedFields(t *testing.T) {
|
||||
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
|
||||
"time": 1788321234567.0,
|
||||
"level": "warn",
|
||||
"msg": "acl reloaded",
|
||||
"stats": map[string]any{"rules": float64(12)},
|
||||
})))
|
||||
|
||||
if !strings.Contains(record.Msg, `stats={"rules":12}`) {
|
||||
t.Fatalf("вложенный объект потерян: %q", record.Msg)
|
||||
}
|
||||
if strings.Contains(record.Msg, "\n") {
|
||||
t.Fatalf("запись журнала стала многострочной: %q", record.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Секреты вырезаются и из сообщения, и из структурного контекста: HY2_AUTH_URL
|
||||
// несёт access_token, и upstream волен упомянуть его в тексте ошибки.
|
||||
func TestParseHysteriaJournalRecordSanitizesSecrets(t *testing.T) {
|
||||
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
|
||||
"time": 1788321234567.0,
|
||||
"level": "error",
|
||||
"msg": "auth backend request failed",
|
||||
"url": "http://127.0.0.1:8080/internal/hysteria/auth?access_token=super-secret-value",
|
||||
})))
|
||||
|
||||
if strings.Contains(record.Msg, "super-secret-value") {
|
||||
t.Fatalf("секрет уехал в журнал панели: %q", record.Msg)
|
||||
}
|
||||
// Внутри query маркер приезжает percent-кодированным: URL обязан остаться
|
||||
// валидным URL (см. encodedRedactedPlaceholder в log_sanitize.go).
|
||||
if !strings.Contains(record.Msg, RedactedPlaceholder) &&
|
||||
!strings.Contains(record.Msg, encodedRedactedPlaceholder) {
|
||||
t.Fatalf("секрет вырезан без маркера: %q", record.Msg)
|
||||
}
|
||||
// Остальная часть адреса остаётся читаемой: без неё запись перестаёт быть
|
||||
// диагностикой.
|
||||
if !strings.Contains(record.Msg, "/internal/hysteria/auth") {
|
||||
t.Fatalf("адрес вырезан целиком вместо секрета: %q", record.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Не-JSON строка показывается как есть: console-формат и посторонний вывод
|
||||
// рантайма терять нельзя.
|
||||
func TestParseHysteriaJournalRecordFallsBackToRawMessage(t *testing.T) {
|
||||
record := parseHysteriaJournalRecord(journalLine("panic: runtime error: invalid memory address"))
|
||||
|
||||
if record.Msg != "panic: runtime error: invalid memory address" {
|
||||
t.Fatalf("сырое сообщение потеряно: %q", record.Msg)
|
||||
}
|
||||
// Время и уровень берутся у journald: у него они есть всегда.
|
||||
if record.Time == "" {
|
||||
t.Fatal("время journald не подставлено")
|
||||
}
|
||||
if record.Level != "info" {
|
||||
t.Fatalf("уровень journald не подставлен: %q", record.Level)
|
||||
}
|
||||
}
|
||||
|
||||
// JSON без `msg` — не запись Hysteria, и терять её тоже нельзя.
|
||||
func TestParseHysteriaJournalRecordKeepsForeignJSON(t *testing.T) {
|
||||
record := parseHysteriaJournalRecord(journalLine(`{"foo":"bar"}`))
|
||||
|
||||
if !strings.Contains(record.Msg, "foo") {
|
||||
t.Fatalf("посторонний JSON потерян: %q", record.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Запись без собственного времени и уровня добирает их у journald.
|
||||
func TestParseHysteriaJournalRecordUsesJournaldFallbacks(t *testing.T) {
|
||||
line := journalLine(hysteriaJSONLine(t, map[string]any{"msg": "no level, no time"}))
|
||||
line.Priority = "3"
|
||||
|
||||
record := parseHysteriaJournalRecord(line)
|
||||
|
||||
if record.Level != "error" {
|
||||
t.Fatalf("уровень не взят из PRIORITY: %q", record.Level)
|
||||
}
|
||||
if record.Time != convertJournalTimestamp(line.RealtimeTimestamp) {
|
||||
t.Fatalf("время не взято из __REALTIME_TIMESTAMP: %q", record.Time)
|
||||
}
|
||||
}
|
||||
|
||||
// Строковое `time` (console-формат, будущая смена энкодера) принимается как
|
||||
// есть: переписывать чужой формат продукт не обязан.
|
||||
func TestParseHysteriaJournalRecordAcceptsStringTime(t *testing.T) {
|
||||
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
|
||||
"time": "2026-09-02T12:34:56Z",
|
||||
"level": "info",
|
||||
"msg": "server up and running",
|
||||
})))
|
||||
|
||||
if record.Time != "2026-09-02T12:34:56Z" {
|
||||
t.Fatalf("строковое время подменено: %q", record.Time)
|
||||
}
|
||||
}
|
||||
|
||||
// MESSAGE, отданный journald массивом байт (сообщение не является корректным
|
||||
// UTF-8), больше не выбрасывает запись целиком.
|
||||
func TestJournalMessageAcceptsByteArray(t *testing.T) {
|
||||
var line journalctlLogLine
|
||||
// Так journald кодирует «hi\xff» в `-o json`.
|
||||
raw := `{"MESSAGE":[104,105,255],"PRIORITY":"6","__REALTIME_TIMESTAMP":"1788321234567000"}`
|
||||
if err := json.Unmarshal([]byte(raw), &line); err != nil {
|
||||
t.Fatalf("запись с байтовым MESSAGE отвергнута целиком: %v", err)
|
||||
}
|
||||
if string(line.Message) != "hi\xff" {
|
||||
t.Fatalf("байтовое сообщение разобрано неверно: %q", string(line.Message))
|
||||
}
|
||||
if line.Priority != "6" {
|
||||
t.Fatalf("остальные поля записи потеряны: %+v", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalMessageAcceptsString(t *testing.T) {
|
||||
var line journalctlLogLine
|
||||
if err := json.Unmarshal([]byte(`{"MESSAGE":"plain","PRIORITY":"4"}`), &line); err != nil {
|
||||
t.Fatalf("обычная запись отвергнута: %v", err)
|
||||
}
|
||||
if string(line.Message) != "plain" {
|
||||
t.Fatalf("сообщение разобрано неверно: %q", string(line.Message))
|
||||
}
|
||||
}
|
||||
|
||||
// Отсутствующее или неизвестной формы MESSAGE не роняет запись: у неё остаются
|
||||
// время и приоритет.
|
||||
func TestJournalMessageSurvivesUnknownShape(t *testing.T) {
|
||||
var line journalctlLogLine
|
||||
if err := json.Unmarshal([]byte(`{"MESSAGE":{"unexpected":true},"PRIORITY":"6"}`), &line); err != nil {
|
||||
t.Fatalf("запись с неизвестной формой MESSAGE отвергнута: %v", err)
|
||||
}
|
||||
if string(line.Message) != "" {
|
||||
t.Fatalf("неизвестная форма превратилась в сообщение: %q", string(line.Message))
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,16 @@ func CollectMetricsSnapshot() {
|
||||
s.System.DiskTotalBytes = diskInfo.Total
|
||||
}
|
||||
|
||||
s.Hysteria.Running = Hysteria2IsRunning()
|
||||
// Два независимых источника, два независимых вопроса.
|
||||
//
|
||||
// Раньше второй выводился из первого: общий Hysteria2Online отдавал пустую
|
||||
// карту БЕЗ ошибки, когда systemctl говорил «служба неактивна», и сюда
|
||||
// приходило `err == nil` — то есть `ApiReachable = true` выставлялся, ни
|
||||
// разу не сходив в Traffic Stats API. Дашборд утверждал «Hysteria
|
||||
// остановлена» и «API доступен, онлайн 0» одновременно.
|
||||
serviceState := Hysteria2ServiceState()
|
||||
s.Hysteria.ServiceState = string(serviceState)
|
||||
s.Hysteria.Running = serviceState == HysteriaServiceActive
|
||||
s.Hysteria.Version = getCachedHysteriaVersion(time.Now())
|
||||
|
||||
if onlineMap, err := Hysteria2Online(); err == nil {
|
||||
|
||||
+98
-22
@@ -14,15 +14,33 @@ import (
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
|
||||
// PagePeer отдаёт страницу списка пиров и ЧЕСТНО сообщает, известна ли картина
|
||||
// подключений.
|
||||
//
|
||||
// Что было: `onlineUsers, _ := Hysteria2Online()`. Отказ Traffic Stats API
|
||||
// молча превращался в пустую карту, и оператор видел «все офлайн» вместо
|
||||
// «состояние неизвестно». Признак возвращается один на страницу — см.
|
||||
// vo.PeerPageVo.
|
||||
func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, string, error) {
|
||||
peers, total, err := dao.PagePeer(peerPageDto)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
return nil, 0, "", err
|
||||
}
|
||||
onlineUsers, _ := Hysteria2Online()
|
||||
|
||||
onlineState := vo.PeerOnlineStateOk
|
||||
onlineUsers, onlineErr := Hysteria2Online()
|
||||
if onlineErr != nil {
|
||||
// Отказ здесь не является отказом страницы: список пиров и их
|
||||
// сохранённое состояние известны и полезны сами по себе. Неизвестной
|
||||
// становится ровно одна колонка, и об этом сказано явно.
|
||||
logrus.WithError(onlineErr).
|
||||
Warn("список пиров: картина подключений недоступна, признак online не определён")
|
||||
onlineState = vo.PeerOnlineStateUnavailable
|
||||
onlineUsers = map[string]int64{}
|
||||
}
|
||||
|
||||
result := make([]vo.PeerVo, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
item := vo.PeerVo{
|
||||
@@ -46,7 +64,7 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, total, nil
|
||||
return result, total, onlineState, nil
|
||||
}
|
||||
|
||||
// Пир установщика защищён во ВСЕХ путях записи, а не только в импорте.
|
||||
@@ -97,7 +115,7 @@ func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
|
||||
if err != nil {
|
||||
return vo.PeerVo{}, err
|
||||
}
|
||||
authId, err := util.RandomString(18)
|
||||
authId, err := newPeerAuthID()
|
||||
if err != nil {
|
||||
return vo.PeerVo{}, err
|
||||
}
|
||||
@@ -145,17 +163,33 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
|
||||
if peerDto.Name != nil && *peerDto.Name != "" {
|
||||
updates["name"] = *peerDto.Name
|
||||
}
|
||||
if peerDto.Secret != nil && *peerDto.Secret != "" {
|
||||
digest, err := PeerSecretDigest(*peerDto.Secret)
|
||||
if secret, requested := requestedSecret(peerDto.Secret); requested {
|
||||
digest, err := PeerSecretDigest(secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc, err := EncryptPeerSecret(*peerDto.Secret)
|
||||
enc, err := EncryptPeerSecret(secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates["secret_digest"] = digest
|
||||
updates["secret_ciphertext"] = enc
|
||||
|
||||
// Новое поколение учётных данных получает новую идентичность живых
|
||||
// сессий. Без этого сессия, установленная по уже отозванному секрету,
|
||||
// оставалась неотличимой от законной: `auth_id` у неё прежний, пир в
|
||||
// базе есть, доступ открыт — и cron не имел ни одного признака, по
|
||||
// которому её следовало бы завершить. Разбор — в peer_secret.go.
|
||||
//
|
||||
// Разрыв при этом идёт по СТАРОМУ значению (см. ниже): именно им
|
||||
// Hysteria знает сессию, которую отзывает оператор.
|
||||
if credentialGenerationChanged(before.SecretDigest, digest) {
|
||||
rotatedAuthID, err := newPeerAuthID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates["auth_id"] = rotatedAuthID
|
||||
}
|
||||
}
|
||||
if peerDto.QuotaBytes != nil {
|
||||
updates["quota_bytes"] = *peerDto.QuotaBytes
|
||||
@@ -220,7 +254,12 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
|
||||
func updateRequiresReconcile(before entity.Peer, peerDto dto.PeerUpdateDto, now int64) bool {
|
||||
// Смена секрета. Учётные данные, по которым сессия была установлена, с
|
||||
// этого момента недействительны — держать её открытой нечем.
|
||||
if peerDto.Secret != nil && strings.TrimSpace(*peerDto.Secret) != "" {
|
||||
//
|
||||
// Условие смотрит на ЗАПРОС, а не на смену поколения credentials: повтор
|
||||
// того же секрета после неудавшегося разрыва обязан дать вторую попытку,
|
||||
// ровно как повторное «Отключить». Ротация `auth_id` при этом не
|
||||
// выполняется — см. credentialGenerationChanged.
|
||||
if _, requested := requestedSecret(peerDto.Secret); requested {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -364,10 +403,10 @@ func assertBootstrapPeerIdentityUnchanged(existing entity.Peer, peerDto dto.Peer
|
||||
//
|
||||
// Теперь:
|
||||
//
|
||||
// 1. прочитать пира и запомнить его auth ID;
|
||||
// 2. записать disabled=1 — закрыть будущие обращения к HTTP-auth;
|
||||
// 3. завершить живые сессии по запомненному auth ID;
|
||||
// 4. удалить строку.
|
||||
// 1. прочитать пира и запомнить его auth ID;
|
||||
// 2. записать disabled=1 — закрыть будущие обращения к HTTP-auth;
|
||||
// 3. завершить живые сессии по запомненному auth ID;
|
||||
// 4. удалить строку.
|
||||
//
|
||||
// Ключевые исходы:
|
||||
//
|
||||
@@ -647,7 +686,7 @@ func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
|
||||
|
||||
entry.createAuthID = authID
|
||||
if entry.createAuthID == "" {
|
||||
generated, err := util.RandomString(18)
|
||||
generated, err := newPeerAuthID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -695,13 +734,13 @@ func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
|
||||
// изменилось конкретное поле. Это сознательно более простой контракт, чем diff
|
||||
// по семи access-полям:
|
||||
//
|
||||
// 1. импорт и так переписывает состояние целиком;
|
||||
// 2. старый auth ID гарантированно нужен при его замене;
|
||||
// 3. повтор того же импорта после неудавшегося `/kick` обязан снова
|
||||
// попытаться завершить старые сессии;
|
||||
// 4. не появляется ещё одной таблицы правил «какие поля импорта считаются
|
||||
// access-changing» — то есть второго места, где политика может разойтись
|
||||
// с peerAccessDenied.
|
||||
// 1. импорт и так переписывает состояние целиком;
|
||||
// 2. старый auth ID гарантированно нужен при его замене;
|
||||
// 3. повтор того же импорта после неудавшегося `/kick` обязан снова
|
||||
// попытаться завершить старые сессии;
|
||||
// 4. не появляется ещё одной таблицы правил «какие поля импорта считаются
|
||||
// access-changing» — то есть второго места, где политика может разойтись
|
||||
// с peerAccessDenied.
|
||||
//
|
||||
// Цена — существующие пиры из импортируемой партии один раз переподключаются.
|
||||
// Для административной операции переноса это нормальная цена.
|
||||
@@ -781,6 +820,31 @@ func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) (string, erro
|
||||
// Значение читается ДО записи: после неё в строке уже стоит auth ID из
|
||||
// файла, а Hysteria знает пира по прежнему.
|
||||
replaced := authIDOf(existing)
|
||||
|
||||
// Импорт — вторая дверь к смене учётных данных, и правило поколения у
|
||||
// неё то же самое: новый секрет получает новую идентичность сессий.
|
||||
//
|
||||
// Дверь эта закрывалась отдельно, потому что здесь состояние приходит
|
||||
// из файла. Случай, который проходил мимо: запись найдена по ИМЕНИ либо
|
||||
// несёт прежний `auth_id`, а секрет в файле новый. Тогда `auth_id`
|
||||
// оставался прежним, и сессия, установленная по отозванному секрету,
|
||||
// снова выглядела законной — тот же дефект, что и в UpdatePeer.
|
||||
//
|
||||
// Значение `auth_id` из файла при этом перебивается, и это осознанно:
|
||||
// в импорте оно является КЛЮЧОМ ПОИСКА и переносимым состоянием, а не
|
||||
// обещанием сохранить идентичность отозванного поколения. Если файл
|
||||
// несёт ДРУГОЙ `auth_id`, ротация не нужна — идентичность уже сменилась
|
||||
// им самим, и старая сессия становится orphan без нашего участия.
|
||||
if entry.hasExplicitSecret &&
|
||||
credentialGenerationChanged(existing.SecretDigest, entry.explicitDigest) &&
|
||||
resultingAuthID(entry, replaced) == replaced {
|
||||
rotated, err := newPeerAuthID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
updates["auth_id"] = rotated
|
||||
}
|
||||
|
||||
if err := tx.UpdatePeer([]int64{*existing.Id}, updates); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -821,6 +885,18 @@ func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) (string, erro
|
||||
return "", saveErr
|
||||
}
|
||||
|
||||
// resultingAuthID — значение, которое окажется в строке после применения
|
||||
// записи импорта, если ротации не будет.
|
||||
//
|
||||
// Файл волен не нести `auth_id` вовсе: тогда поиск шёл по имени, и прежнее
|
||||
// значение остаётся.
|
||||
func resultingAuthID(entry preparedPeerImport, existing string) string {
|
||||
if entry.authID != "" {
|
||||
return entry.authID
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
// findPeerForImport ищет запись, которую импорт должен обновить.
|
||||
//
|
||||
// Возвращает три состояния, а не два: найдено, не найдено, неизвестно. Раньше
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/vo"
|
||||
)
|
||||
|
||||
// Отзыв доступа проверяется ПРОТИВ НАСТОЯЩЕГО HTTP, а не против подменённого
|
||||
@@ -183,13 +184,13 @@ func pointHysteriaConfigAtDeadPort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// withHysteriaRunning подменяет ответ systemd о состоянии службы.
|
||||
func withHysteriaRunning(t *testing.T, running bool) {
|
||||
// withHysteriaServiceState подменяет ответ systemd о состоянии службы.
|
||||
func withHysteriaServiceState(t *testing.T, state HysteriaServiceState) {
|
||||
t.Helper()
|
||||
|
||||
previous := hysteria2IsRunning
|
||||
hysteria2IsRunning = func() bool { return running }
|
||||
t.Cleanup(func() { hysteria2IsRunning = previous })
|
||||
previous := hysteriaServiceState
|
||||
hysteriaServiceState = func() HysteriaServiceState { return state }
|
||||
t.Cleanup(func() { hysteriaServiceState = previous })
|
||||
}
|
||||
|
||||
func peerByName(t *testing.T, name string) (disabled int64, bannedUntil int64) {
|
||||
@@ -351,7 +352,7 @@ func TestDisconnectAuthIDsReportsApiFailure(t *testing.T) {
|
||||
func TestDisconnectAuthIDsIgnoresSystemdOpinion(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startTrafficStats(t, nil)
|
||||
withHysteriaRunning(t, false)
|
||||
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||||
|
||||
if err := disconnectAuthIDs([]string{"alpha-auth-id"}); err != nil {
|
||||
t.Fatalf("разрыв сессии отказал из-за мнения systemd: %v", err)
|
||||
@@ -576,7 +577,7 @@ func TestHysteria2AuthRejectsWhenTrafficStatsUnreachable(t *testing.T) {
|
||||
func TestHysteria2AuthEnforcesLimitWhenSystemdSaysStopped(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}})
|
||||
withHysteriaRunning(t, false)
|
||||
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||||
@@ -648,19 +649,79 @@ func TestHysteria2AuthRejectsPeerWithoutIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Отображение остаётся терпимым: пустая картина подключений — честный ответ на
|
||||
// вопрос «кто сейчас на связи», когда служба остановлена. Строгость нужна
|
||||
// только там, где принимается решение о доступе.
|
||||
func TestHysteria2OnlineStaysTolerantForDisplay(t *testing.T) {
|
||||
// Путь отображения БОЛЬШЕ НЕ выдаёт «никто не подключён» за ответ.
|
||||
//
|
||||
// Прежний контракт был терпимым: при неактивной по мнению systemd службе
|
||||
// возвращалась пустая карта без ошибки. Выглядело это как честный ответ «сейчас
|
||||
// никого нет», а означало «спросить не удалось либо не спрашивали вовсе» —
|
||||
// ответ systemctl не отличает «служба неактивна» от «systemctl не запустился».
|
||||
//
|
||||
// Цена была не в формулировке. Сборщик метрик получал `err == nil` и выставлял
|
||||
// `apiReachable = true`, ни разу не обратившись к Traffic Stats API, поэтому
|
||||
// дашборд утверждал одновременно «Hysteria остановлена» и «API доступен, онлайн
|
||||
// 0». Список пиров по тому же ответу показывал всех офлайн.
|
||||
//
|
||||
// Теперь недоступность остаётся ошибкой, а как показать её оператору, решает
|
||||
// вызывающий — см. TestPagePeerReportsUnknownOnlineState.
|
||||
func TestHysteria2OnlineReportsFailureInsteadOfEmptyPicture(t *testing.T) {
|
||||
newTestDB(t)
|
||||
pointHysteriaConfigAtDeadPort(t)
|
||||
withHysteriaRunning(t, false)
|
||||
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||||
|
||||
if _, err := Hysteria2Online(); err == nil {
|
||||
t.Fatal("недоступность Traffic Stats API выдана за пустую картину подключений")
|
||||
}
|
||||
}
|
||||
|
||||
// Мнение systemd на картину подключений не влияет вовсе: спрашивают того, кто
|
||||
// знает ответ.
|
||||
func TestHysteria2OnlineIgnoresSystemdOpinion(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
|
||||
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||||
|
||||
online, err := Hysteria2Online()
|
||||
if err != nil {
|
||||
t.Fatalf("отображение подключений стало отказывать: %v", err)
|
||||
t.Fatalf("картина подключений отказала из-за мнения systemd: %v", err)
|
||||
}
|
||||
if len(online) != 0 {
|
||||
t.Fatalf("ожидалась пустая картина подключений, получено %v", online)
|
||||
if online["alpha-auth-id"] != 2 {
|
||||
t.Fatalf("мнение systemd подменило фактический ответ API: %v", online)
|
||||
}
|
||||
}
|
||||
|
||||
// Список пиров при недоступном API сообщает «состояние неизвестно», а не «все
|
||||
// офлайн»: это разные ответы, и первый в аварии ведёт оператора искать
|
||||
// проблему у пользователей.
|
||||
func TestPagePeerReportsUnknownOnlineState(t *testing.T) {
|
||||
newTestDB(t)
|
||||
pointHysteriaConfigAtDeadPort(t)
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
records, total, onlineState, err := PagePeer(dto.PeerPageDto{})
|
||||
if err != nil {
|
||||
t.Fatalf("список пиров отказал из-за недоступного Traffic Stats API: %v", err)
|
||||
}
|
||||
if total != 1 || len(records) != 1 {
|
||||
t.Fatalf("список пиров потерян: записей %d, всего %d", len(records), total)
|
||||
}
|
||||
if onlineState != vo.PeerOnlineStateUnavailable {
|
||||
t.Fatalf("состояние подключений объявлено как %q, ожидалось %q", onlineState, vo.PeerOnlineStateUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagePeerReportsKnownOnlineState(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
records, _, onlineState, err := PagePeer(dto.PeerPageDto{})
|
||||
if err != nil {
|
||||
t.Fatalf("список пиров отказал: %v", err)
|
||||
}
|
||||
if onlineState != vo.PeerOnlineStateOk {
|
||||
t.Fatalf("состояние подключений объявлено как %q, ожидалось %q", onlineState, vo.PeerOnlineStateOk)
|
||||
}
|
||||
if len(records) != 1 || !records[0].Online || records[0].OnlineDevices != 2 {
|
||||
t.Fatalf("картина подключений не доехала до строки: %+v", records)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,104 @@ import (
|
||||
// из-за которого каждая первая загрузка печатала в журнал
|
||||
// `duplicated key not allowed` уровня error на здоровом старте.
|
||||
|
||||
// Идентичность живых сессий привязана к ПОКОЛЕНИЮ учётных данных.
|
||||
//
|
||||
// Что было. `auth_id` создавался один раз и не менялся никогда, а секрет
|
||||
// менялся отдельно от него. Между этими двумя фактами и жил дефект отзыва:
|
||||
//
|
||||
// до: secret S1 -> authId A
|
||||
// после: secret S2 -> authId A
|
||||
//
|
||||
// Отзыв секрета состоит из двух шагов — записать новый digest и завершить
|
||||
// сессии, установленные по старому. Второй шаг может не удаться, и это
|
||||
// нормально: сходимость обязан обеспечить cron. Но сверять ему было нечем.
|
||||
// Сессия называется в `/online` просто `A`, в базе `A` существует, доступ пиру
|
||||
// открыт, устройств не больше разрешённого — то есть по всем признакам это
|
||||
// действующая сессия нового состояния. Признака «установлена по уже отозванному
|
||||
// секрету» в системе не существовало вовсе.
|
||||
//
|
||||
// Хуже того, у этого состояния есть путь БЕЗ единой неудачи. Ответ авторизации
|
||||
// и регистрация соединения в Traffic Stats API — не одна транзакция: Hysteria
|
||||
// сначала дожидается `Authenticate`, и только ПОСЛЕ возврата `ok=true`
|
||||
// выставляет `authenticated = true` и вызывает `LogOnlineState(id, true)`
|
||||
// (исходники app/v2.12.2). Значит `/kick`, прошедший, пока backend-auth ещё
|
||||
// выполнялся, этого соединения увидеть не обязан:
|
||||
//
|
||||
// 1. клиент с S1 начинает авторизацию, Hysteria2Auth читает peer и застревает
|
||||
// внутри GET /online;
|
||||
// 2. оператор меняет секрет, UpdatePeer сохраняет S2 и УСПЕШНО зовёт /kick A;
|
||||
// 3. задержанная авторизация возвращает ALLOW и A;
|
||||
// 4. Hysteria регистрирует сессию A — уже после kick'а.
|
||||
//
|
||||
// Атомарной пары «решение авторизации + регистрация онлайна» upstream API не
|
||||
// даёт, поэтому повторным чтением базы перед `return ALLOW` окно не закрыть: оно
|
||||
// сдвинется, но останется. Закрывается это тем, что отозванная генерация
|
||||
// перестаёт быть валидной ИДЕНТИЧНОСТЬЮ:
|
||||
//
|
||||
// до: S1 -> authId A
|
||||
// ротация: S2 -> authId B, /kick A
|
||||
// A в /online -> в базе только B -> orphan -> kick
|
||||
//
|
||||
// То есть используется уже существующий механизм сходимости
|
||||
// (enforcePeerAccess обходит каждый authID из `/online`), а не заводится
|
||||
// отдельный реестр отозванных поколений, очередь повторов и таблица retry.
|
||||
//
|
||||
// Цена решения названа прямо: сессия, пережившая kick, до следующего цикла
|
||||
// учёта считается сессией НЕИЗВЕСТНОГО пира, поэтому её дельта трафика
|
||||
// приписывается некому и попадает в потери цикла (см. saveAccountTraffic).
|
||||
// Это не более 30 секунд трафика одного пира на одну ротацию, и это осознанный
|
||||
// размен: квота здесь — операционная граница доступа, а не биллинг. Колонка
|
||||
// «прежний auth_id» ради этих 30 секунд ввела бы второй идентификатор сессии,
|
||||
// то есть ровно то состояние, из-за которого отзыв и не сходился.
|
||||
|
||||
// peerAuthIDLength — длина генерируемого `auth_id`.
|
||||
//
|
||||
// Значение объявлено здесь, а не тремя литералами `18` по местам создания:
|
||||
// создание через панель, создание импортом и ротация обязаны давать
|
||||
// идентификатор одного вида.
|
||||
const peerAuthIDLength = 18
|
||||
|
||||
// newPeerAuthID создаёт идентичность живых сессий пира.
|
||||
//
|
||||
// Единственный генератор `auth_id` в продукте. Коллизия при 62^18 вариантах
|
||||
// недостижима практически, а если бы случилась — UNIQUE(auth_id) отклонит
|
||||
// запись ДО обращения к `/kick`, и операция вернёт отказ, не изменив состояния.
|
||||
// Повторная попытка внутри генератора для этого не нужна.
|
||||
func newPeerAuthID() (string, error) {
|
||||
return util.RandomString(peerAuthIDLength)
|
||||
}
|
||||
|
||||
// credentialGenerationChanged отвечает, действительно ли меняется поколение
|
||||
// учётных данных.
|
||||
//
|
||||
// Отдельная функция, потому что вопрос не тот же самый, что «оператор прислал
|
||||
// секрет». Повторная отправка ТОГО ЖЕ секрета — это запрос на повторный отзыв
|
||||
// (и он по-прежнему рвёт сессию), но нового поколения credentials при этом не
|
||||
// возникает, и менять идентичность сессий незачем: смена `auth_id` без смены
|
||||
// секрета обесценила бы накопленную привязку трафика без единой причины.
|
||||
//
|
||||
// Строка без сохранённого digest считается сменой: чем бы ни было её
|
||||
// содержимое, оно не то, что записывается сейчас.
|
||||
func credentialGenerationChanged(storedDigest *string, newDigest string) bool {
|
||||
return storedDigest == nil || *storedDigest != newDigest
|
||||
}
|
||||
|
||||
// requestedSecret приводит присланный секрет к решению «менять или не менять».
|
||||
//
|
||||
// Правило одно на обе задачи — на запись и на разрыв сессии. Раньше их было
|
||||
// два: UpdatePeer проверял `*peerDto.Secret != ""`, а updateRequiresReconcile —
|
||||
// `strings.TrimSpace(...) != ""`. Секрет из одних пробелов, пришедший мимо
|
||||
// нормализации DTO (прямой вызов сервиса, тесты), записывался бы в базу как
|
||||
// новые учётные данные, но сессию бы не рвал — то есть отзыв, о котором
|
||||
// механизм сходимости не знает.
|
||||
func requestedSecret(provided *string) (string, bool) {
|
||||
if provided == nil {
|
||||
return "", false
|
||||
}
|
||||
secret := strings.TrimSpace(*provided)
|
||||
return secret, secret != ""
|
||||
}
|
||||
|
||||
// generatedSecretRandomLength — длина случайной части автогенерируемого
|
||||
// секрета.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
)
|
||||
|
||||
// Отзыв секрета сходится ДАЖЕ ТОГДА, когда `/kick` был бесполезен.
|
||||
//
|
||||
// Проверяемое свойство одно: после смены секрета сессия, установленная по
|
||||
// старому, не может остаться жить неограниченно долго. Способов оказаться в
|
||||
// таком состоянии два, и они разной природы:
|
||||
//
|
||||
// `/kick` вернул 500 — второй шаг операции не удался;
|
||||
// `/kick` вернул 200, но сессии ещё не — второй шаг удался и всё равно
|
||||
// было: backend-auth по старому секрету не подействовал.
|
||||
// ещё выполнялся
|
||||
//
|
||||
// Второй случай — не отказ и не гонка по памяти. Hysteria дожидается ответа
|
||||
// backend-auth и только ПОСЛЕ `ok=true` выставляет `authenticated = true` и
|
||||
// сообщает Traffic Stats API о новом онлайне, поэтому `/kick`, прошедший в это
|
||||
// окно, соединения просто не видит. Атомарной пары «решение авторизации +
|
||||
// регистрация онлайна» upstream API не даёт.
|
||||
//
|
||||
// Сходимость обеспечивается тем, что новое поколение учётных данных получает
|
||||
// новый `auth_id`: пережившая сессия называется старым значением, которого в
|
||||
// базе больше нет, и очередной цикл учёта видит её как orphan.
|
||||
|
||||
// rotationStats — Traffic Stats API, у которого ОДНО названное обращение к
|
||||
// `/online` удерживается до команды теста, а `/kick` при этом продолжает
|
||||
// обслуживаться.
|
||||
//
|
||||
// Обслуживание `/kick` во время удержания обязательно: воспроизводимое
|
||||
// состояние — это именно «отзыв прошёл целиком, пока авторизация висела», и
|
||||
// стенд, сериализующий оба запроса, его бы не собрал.
|
||||
type rotationStats struct {
|
||||
mu sync.Mutex
|
||||
|
||||
online map[string]int64
|
||||
kickStatus int
|
||||
kickedKeys [][]string
|
||||
|
||||
holdOnlineCall int
|
||||
onlineCalls int
|
||||
|
||||
held chan struct{}
|
||||
released chan struct{}
|
||||
}
|
||||
|
||||
func startRotationStats(t *testing.T, online map[string]int64, holdOnlineCall int) *rotationStats {
|
||||
t.Helper()
|
||||
|
||||
if online == nil {
|
||||
online = map[string]int64{}
|
||||
}
|
||||
stats := &rotationStats{
|
||||
online: online,
|
||||
holdOnlineCall: holdOnlineCall,
|
||||
held: make(chan struct{}),
|
||||
released: make(chan struct{}),
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/online":
|
||||
stats.mu.Lock()
|
||||
stats.onlineCalls++
|
||||
call := stats.onlineCalls
|
||||
// Снимок делается на ПРИХОДЕ запроса: к моменту доставки ответа
|
||||
// картина уже другая, и это ровно то, что воспроизводится.
|
||||
snapshot := make(map[string]int64, len(stats.online))
|
||||
for key, value := range stats.online {
|
||||
snapshot[key] = value
|
||||
}
|
||||
stats.mu.Unlock()
|
||||
|
||||
if call == stats.holdOnlineCall {
|
||||
close(stats.held)
|
||||
select {
|
||||
case <-stats.released:
|
||||
case <-time.After(5 * time.Second):
|
||||
// Команды не последовало — отпускаем, чтобы падение было по
|
||||
// существу, а не по таймауту всего прогона.
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(snapshot)
|
||||
case "/traffic":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]bo.Hysteria2UserTraffic{})
|
||||
case "/kick":
|
||||
stats.mu.Lock()
|
||||
defer stats.mu.Unlock()
|
||||
|
||||
var keys []string
|
||||
if err := json.NewDecoder(r.Body).Decode(&keys); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
stats.kickedKeys = append(stats.kickedKeys, keys)
|
||||
if stats.kickStatus != 0 {
|
||||
w.WriteHeader(stats.kickStatus)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
pointHysteriaConfigAt(t, server.URL)
|
||||
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
|
||||
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
func (s *rotationStats) setOnline(online map[string]int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.online = online
|
||||
}
|
||||
|
||||
func (s *rotationStats) setKickStatus(status int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.kickStatus = status
|
||||
}
|
||||
|
||||
func (s *rotationStats) forgetKicks() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.kickedKeys = nil
|
||||
}
|
||||
|
||||
func (s *rotationStats) kicked() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
out := []string{}
|
||||
for _, keys := range s.kickedKeys {
|
||||
out = append(out, keys...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *rotationStats) awaitHeld(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case <-s.held:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("удерживаемое обращение к /online так и не пришло")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *rotationStats) release() { close(s.released) }
|
||||
|
||||
// authIDOfPeer возвращает текущую идентичность сессий пира.
|
||||
func authIDOfPeer(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
|
||||
peer, ok := snapshotPeers(t)[name]
|
||||
if !ok {
|
||||
t.Fatalf("пир %q не найден", name)
|
||||
}
|
||||
return strVal(peer.AuthId)
|
||||
}
|
||||
|
||||
func secretDigestOfPeer(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
|
||||
peer, ok := snapshotPeers(t)[name]
|
||||
if !ok {
|
||||
t.Fatalf("пир %q не найден", name)
|
||||
}
|
||||
return strVal(peer.SecretDigest)
|
||||
}
|
||||
|
||||
// --- Ротация через форму панели ----------------------------------------------
|
||||
|
||||
// Смена секрета меняет идентичность живых сессий, а рвётся при этом СТАРАЯ.
|
||||
func TestUpdatePeerSecretRotationRotatesAuthID(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startRotationStats(t, nil, 0)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("brand-new-secret")}); err != nil {
|
||||
t.Fatalf("смена секрета отказала: %v", err)
|
||||
}
|
||||
|
||||
after := authIDOfPeer(t, "alpha1")
|
||||
if after == "alpha-auth-id" {
|
||||
t.Fatal("новое поколение учётных данных сохранило прежнюю идентичность сессий")
|
||||
}
|
||||
if after == "" {
|
||||
t.Fatal("идентичность сессий стёрта вместо ротации")
|
||||
}
|
||||
|
||||
// Разрыв идёт по СТАРОМУ значению: именно им Hysteria знает отзываемую
|
||||
// сессию. Разрыв по новому не завершил бы ничего.
|
||||
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||||
t.Fatalf("в /kick ушло %v, ожидался старый идентификатор", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Сессия, установленная по уже отозванному секрету ПОСЛЕ успешного `/kick`,
|
||||
// завершается очередным циклом учёта.
|
||||
//
|
||||
// Это главный сценарий: ни одна операция здесь не отказала. Отзыв прошёл
|
||||
// целиком, а соединение всё равно оказалось живым — потому что Hysteria
|
||||
// регистрирует его только после возврата backend-auth.
|
||||
func TestSecretRotationReconcilesInFlightOldCredential(t *testing.T) {
|
||||
newTestDB(t)
|
||||
// Удерживается ПЕРВОЕ обращение к `/online` — то самое, внутри которого
|
||||
// зависает авторизация по старому секрету.
|
||||
stub := startRotationStats(t, map[string]int64{}, 1)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
// 1. Клиент со старым секретом начинает авторизацию и застревает в /online.
|
||||
type authOutcome struct {
|
||||
authID string
|
||||
err error
|
||||
}
|
||||
inflight := make(chan authOutcome, 1)
|
||||
go func() {
|
||||
_, authID, err := Hysteria2Auth("alpha1-secret")
|
||||
inflight <- authOutcome{authID: authID, err: err}
|
||||
}()
|
||||
stub.awaitHeld(t)
|
||||
|
||||
// 2. Оператор меняет секрет. Запись проходит, `/kick` отвечает 200 —
|
||||
// операция успешна целиком.
|
||||
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("brand-new-secret")}); err != nil {
|
||||
t.Fatalf("смена секрета отказала: %v", err)
|
||||
}
|
||||
rotated := authIDOfPeer(t, "alpha1")
|
||||
if rotated == "alpha-auth-id" {
|
||||
t.Fatal("идентичность сессий не сменилась вместе с секретом")
|
||||
}
|
||||
|
||||
// 3. Задержанная авторизация возвращается уже ПОСЛЕ разрыва, и Hysteria
|
||||
// регистрирует сессию под старым идентификатором.
|
||||
stub.release()
|
||||
outcome := <-inflight
|
||||
if outcome.err != nil {
|
||||
t.Fatalf("подготовка сценария: авторизация по старому секрету не дошла до ответа: %v", outcome.err)
|
||||
}
|
||||
if outcome.authID != "alpha-auth-id" {
|
||||
t.Fatalf("подготовка сценария: авторизация вернула %q, ожидался старый идентификатор", outcome.authID)
|
||||
}
|
||||
stub.setOnline(map[string]int64{"alpha-auth-id": 1})
|
||||
stub.forgetKicks()
|
||||
|
||||
// 4. Сходимость: сессия названа значением, которого в базе больше нет.
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||||
t.Fatalf("сессия по отозванному секрету пережила цикл учёта: в /kick ушло %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Тот же исход, когда `/kick` при ротации не удался вовсе.
|
||||
func TestSecretRotationRecoversAfterKickFailure(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startRotationStats(t, map[string]int64{"alpha-auth-id": 1}, 0)
|
||||
stub.setKickStatus(http.StatusInternalServerError)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
requireDisconnectError(t, UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("brand-new-secret")}))
|
||||
|
||||
// Неудача разрыва не откатывает ни новый секрет, ни новую идентичность:
|
||||
// безопасная половина операции достигнута.
|
||||
if authIDOfPeer(t, "alpha1") == "alpha-auth-id" {
|
||||
t.Fatal("идентичность сессий откачена после неудачного разрыва")
|
||||
}
|
||||
|
||||
stub.setKickStatus(0)
|
||||
stub.forgetKicks()
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||||
t.Fatalf("старая сессия не завершена следующим циклом учёта: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Повтор ТОГО ЖЕ секрета — это повторная попытка отзыва, а не новое поколение
|
||||
// учётных данных.
|
||||
//
|
||||
// Разрыв обязан выполниться снова (иначе после неудавшегося `/kick` оператору
|
||||
// пришлось бы придумывать пиру ещё один секрет), а идентичность сессий обязана
|
||||
// остаться прежней: менять её без смены credentials незачем — это обесценило бы
|
||||
// привязку трафика на ровном месте.
|
||||
func TestUpdatePeerSameSecretDoesNotRotateAuthID(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startRotationStats(t, nil, 0)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("alpha1-secret")}); err != nil {
|
||||
t.Fatalf("повторная отправка секрета отказала: %v", err)
|
||||
}
|
||||
|
||||
if got := authIDOfPeer(t, "alpha1"); got != "alpha-auth-id" {
|
||||
t.Fatalf("идентичность сессий сменилась без смены учётных данных: %q", got)
|
||||
}
|
||||
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||||
t.Fatalf("повторный отзыв не дошёл до /kick: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Секрет из одних пробелов означает «не менять» на ОБОИХ путях сразу.
|
||||
//
|
||||
// Раньше правило было записано дважды: запись проверяла `!= ""`, а решение о
|
||||
// разрыве — `TrimSpace(...) != ""`. Такой секрет записывался бы в базу как
|
||||
// новые учётные данные, не разрывая сессий, — то есть отзыв, о котором механизм
|
||||
// сходимости не знает.
|
||||
func TestUpdatePeerBlankSecretChangesNothing(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startRotationStats(t, nil, 0)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
before := secretDigestOfPeer(t, "alpha1")
|
||||
|
||||
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr(" ")}); err != nil {
|
||||
t.Fatalf("правка отказала: %v", err)
|
||||
}
|
||||
|
||||
if got := secretDigestOfPeer(t, "alpha1"); got != before {
|
||||
t.Fatal("секрет из одних пробелов записан как новые учётные данные")
|
||||
}
|
||||
if got := authIDOfPeer(t, "alpha1"); got != "alpha-auth-id" {
|
||||
t.Fatalf("идентичность сессий сменилась без смены секрета: %q", got)
|
||||
}
|
||||
if got := stub.kicked(); len(got) != 0 {
|
||||
t.Fatalf("сессия разорвана без единого изменения: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ротация импортом ---------------------------------------------------------
|
||||
|
||||
// Импорт — вторая дверь к смене учётных данных, и правило поколения у неё то же.
|
||||
//
|
||||
// Случай, проходивший мимо: файл несёт ПРЕЖНИЙ `auth_id` и НОВЫЙ секрет.
|
||||
// Идентичность сессий оставалась той же, и сессия по отозванному секрету
|
||||
// выглядела законной.
|
||||
func TestImportSecretRotationRotatesAuthID(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startRotationStats(t, map[string]int64{"keeper-auth": 1}, 0)
|
||||
seedPeer(t, "keeper", "keeper-auth")
|
||||
|
||||
item := importItem("keeper", "keeper-auth")
|
||||
item.Secret = "brand-new-secret"
|
||||
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
|
||||
t.Fatalf("импорт отказал: %v", err)
|
||||
}
|
||||
|
||||
rotated := authIDOfPeer(t, "keeper")
|
||||
if rotated == "keeper-auth" {
|
||||
t.Fatalf("импорт нового секрета сохранил прежнюю идентичность сессий")
|
||||
}
|
||||
if got := stub.kicked(); len(got) != 1 || got[0] != "keeper-auth" {
|
||||
t.Fatalf("в /kick ушло %v, ожидался старый идентификатор", got)
|
||||
}
|
||||
|
||||
// И сходимость: сессия, пережившая разрыв, становится orphan.
|
||||
stub.forgetKicks()
|
||||
CronHandleAccount()
|
||||
if got := stub.kicked(); len(got) != 1 || got[0] != "keeper-auth" {
|
||||
t.Fatalf("сессия по отозванному импортом секрету пережила цикл учёта: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Совпадение по ИМЕНИ, файл без `auth_id`: идентичность всё равно обязана
|
||||
// смениться вместе с секретом.
|
||||
func TestImportSecretRotationRotatesAuthIDWhenMatchedByName(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startRotationStats(t, nil, 0)
|
||||
seedPeer(t, "keeper", "keeper-auth")
|
||||
|
||||
item := importItem("keeper", "")
|
||||
item.Secret = "brand-new-secret"
|
||||
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
|
||||
t.Fatalf("импорт отказал: %v", err)
|
||||
}
|
||||
|
||||
if got := authIDOfPeer(t, "keeper"); got == "keeper-auth" {
|
||||
t.Fatal("импорт нового секрета по совпадению имени сохранил прежнюю идентичность сессий")
|
||||
}
|
||||
if got := stub.kicked(); len(got) != 1 || got[0] != "keeper-auth" {
|
||||
t.Fatalf("в /kick ушло %v, ожидался старый идентификатор", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Повторный импорт ТОГО ЖЕ файла идентичность не трогает: поколение учётных
|
||||
// данных не изменилось.
|
||||
//
|
||||
// Без этого перенос пиров превращал бы каждую повторную попытку в смену
|
||||
// идентификаторов всех пиров партии.
|
||||
func TestImportSameSecretKeepsAuthID(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startRotationStats(t, nil, 0)
|
||||
seedPeer(t, "keeper", "keeper-auth")
|
||||
|
||||
item := importItem("keeper", "keeper-auth")
|
||||
item.Secret = "keeper-secret" // ровно тот, что уже записан seedPeer
|
||||
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
|
||||
t.Fatalf("импорт отказал: %v", err)
|
||||
}
|
||||
|
||||
if got := authIDOfPeer(t, "keeper"); got != "keeper-auth" {
|
||||
t.Fatalf("повторный импорт того же секрета сменил идентичность сессий: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Файл, несущий СВОЙ новый `auth_id`, ротации не требует: идентичность уже
|
||||
// сменилась им самим, и старая сессия становится orphan без нашего участия.
|
||||
func TestImportKeepsExplicitNewAuthID(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startRotationStats(t, nil, 0)
|
||||
seedPeer(t, "keeper", "keeper-auth")
|
||||
|
||||
item := importItem("keeper", "new-auth-id")
|
||||
item.Secret = "brand-new-secret"
|
||||
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
|
||||
t.Fatalf("импорт отказал: %v", err)
|
||||
}
|
||||
|
||||
if got := authIDOfPeer(t, "keeper"); got != "new-auth-id" {
|
||||
t.Fatalf("значение auth_id из файла подменено ротацией: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Импорт без секрета учётных данных не меняет, поэтому и идентичность остаётся.
|
||||
func TestImportWithoutSecretKeepsAuthID(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startRotationStats(t, nil, 0)
|
||||
seedPeer(t, "keeper", "keeper-auth")
|
||||
|
||||
if err := UpsertPeerExport([]bo.PeerExport{importItem("keeper", "keeper-auth")}); err != nil {
|
||||
t.Fatalf("импорт отказал: %v", err)
|
||||
}
|
||||
|
||||
if got := authIDOfPeer(t, "keeper"); got != "keeper-auth" {
|
||||
t.Fatalf("импорт без секрета сменил идентичность сессий: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Правило поколения --------------------------------------------------------
|
||||
|
||||
func TestCredentialGenerationChanged(t *testing.T) {
|
||||
stored := "digest-1"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
stored *string
|
||||
next string
|
||||
want bool
|
||||
}{
|
||||
{"другой digest", &stored, "digest-2", true},
|
||||
{"тот же digest", &stored, "digest-1", false},
|
||||
{"строка без digest", nil, "digest-1", true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := credentialGenerationChanged(tc.stored, tc.next); got != tc.want {
|
||||
t.Fatalf("credentialGenerationChanged = %v, ожидалось %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestedSecretTreatsBlankAsAbsent(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
provided *string
|
||||
want string
|
||||
requested bool
|
||||
}{
|
||||
{"поле отсутствует", nil, "", false},
|
||||
{"пустая строка", strPtr(""), "", false},
|
||||
{"одни пробелы", strPtr(" "), "", false},
|
||||
{"значение с краевыми пробелами", strPtr(" secret "), "secret", true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
secret, requested := requestedSecret(tc.provided)
|
||||
if requested != tc.requested || secret != tc.want {
|
||||
t.Fatalf("requestedSecret = (%q, %v), ожидалось (%q, %v)",
|
||||
secret, requested, tc.want, tc.requested)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Идентификатор, выданный ротацией, неотличим от выданного при создании: одна
|
||||
// длина, один алфавит, один генератор.
|
||||
func TestNewPeerAuthIDShape(t *testing.T) {
|
||||
seen := map[string]struct{}{}
|
||||
for i := 0; i < 64; i++ {
|
||||
authID, err := newPeerAuthID()
|
||||
if err != nil {
|
||||
t.Fatalf("генерация идентичности отказала: %v", err)
|
||||
}
|
||||
if len(authID) != peerAuthIDLength {
|
||||
t.Fatalf("длина %d, ожидалось %d", len(authID), peerAuthIDLength)
|
||||
}
|
||||
if !peerAuthIDPattern.MatchString(authID) {
|
||||
t.Fatalf("идентификатор %q не проходит собственную проверку продукта", authID)
|
||||
}
|
||||
if _, duplicate := seen[authID]; duplicate {
|
||||
t.Fatalf("генератор повторил значение %q", authID)
|
||||
}
|
||||
seen[authID] = struct{}{}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user