fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному

Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним,
поэтому сессия, установленная по отозванным учётным данным, была неотличима от
законной, и цикл учёта не имел признака, по которому её следовало завершить. У
состояния есть путь без единой неудачи — Hysteria регистрирует соединение в
Traffic Stats API только после возврата backend-auth, поэтому успешный /kick
может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт
по старому, пережившая сессия становится orphan.

Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4,
админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала
лимит устройств, учёт трафика и принудительное отключение разом. Адрес
зафиксирован, а расхождение файла с ним админка называет.

Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при
ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним
значением, а доступность Traffic Stats API выводилась из него же. Журнал
Hysteria разбирается в фактическом формате upstream (time — дробное число),
страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в
браузер, санитайзер выгрузки следует по YAML-якорям.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
This commit is contained in:
2026-09-02 23:24:01 +05:00
parent 8dcb50a07c
commit cb20d8d28f
66 changed files with 4981 additions and 2684 deletions
+59 -1
View File
@@ -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,
)
}
+94
View File
@@ -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())
}
}
+1 -1
View File
@@ -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)})
+17 -1
View File
@@ -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
View File
@@ -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
}
}
+29 -13
View File
@@ -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 != "" {
+75 -12
View File
@@ -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)
}
}
}
+127
View File
@@ -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
+205
View File
@@ -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
}
+321
View File
@@ -0,0 +1,321 @@
package service
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
)
// Страница конфигурации показывает файл, а не представление о файле.
//
// Прежний ответ отдавал внутреннюю модель целиком, а панель накладывала его на
// полный объект дефолтов. Проверки ниже фиксируют обе половины исправления:
// отсутствие секции остаётся отсутствием, а секции вне production-профиля
// называются расхождением, а не теряются.
func writeHysteriaConfig(t *testing.T, content string) {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("не удалось записать тестовый конфиг: %v", err)
}
previous := constant.Hysteria2ConfigPath
constant.Hysteria2ConfigPath = path
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
}
// canonicalProfileConfig — то, что генерирует оркестратор.
const canonicalProfileConfig = `listen: 0.0.0.0:443
acme:
domains:
- vpn.example.com
email: admin@example.com
ca: letsencrypt
listenHost: 0.0.0.0
dir: /var/lib/hysteria/acme
type: http
auth:
type: http
http:
url: http://127.0.0.1:8080/internal/hysteria/auth?access_token=machine-secret
insecure: false
obfs:
type: gecko
gecko:
password: gecko-obfs-secret
minPacketSize: 512
maxPacketSize: 1200
bandwidth:
up: 50 mbps
down: 50 mbps
disableLossCompensation: false
ignoreClientBandwidth: false
congestion:
type: bbr
bbrProfile: standard
trafficStats:
listen: 127.0.0.1:36712
secret: traffic-stats-secret
quic:
initStreamReceiveWindow: 8388608
maxStreamReceiveWindow: 8388608
initConnReceiveWindow: 20971520
maxConnReceiveWindow: 20971520
maxIdleTimeout: 30s
maxIncomingStreams: 1024
disablePathMTUDiscovery: false
disableStatelessReset: false
`
// profileJSON сериализует ответ ровно так, как его получит браузер.
//
// Проверять секреты по полям недостаточно: утечка происходит в СЕРИАЛИЗАЦИИ, и
// новое поле без `json:"-"` не заметит ни одна проверка по именам.
func profileJSON(t *testing.T, profile vo.Hysteria2ProfileVo) string {
t.Helper()
encoded, err := json.Marshal(profile)
if err != nil {
t.Fatalf("ответ не сериализуется: %v", err)
}
return string(encoded)
}
func TestBuildHysteria2ProfileReadsCanonicalConfig(t *testing.T) {
writeHysteriaConfig(t, canonicalProfileConfig)
profile, err := BuildHysteria2Profile()
if err != nil {
t.Fatalf("построение профиля отказало: %v", err)
}
if profile.Listen == nil || *profile.Listen != "0.0.0.0:443" {
t.Fatalf("listen: %v", profile.Listen)
}
if profile.TrafficStats == nil || profile.TrafficStats.Listen == nil ||
*profile.TrafficStats.Listen != "127.0.0.1:36712" {
t.Fatalf("trafficStats.listen: %+v", profile.TrafficStats)
}
if profile.TrafficStats.SecretSet != true {
t.Fatal("наличие секрета Traffic Stats API не показано")
}
if profile.IgnoreClientBandwidth == nil || *profile.IgnoreClientBandwidth {
t.Fatalf("ignoreClientBandwidth: %v", profile.IgnoreClientBandwidth)
}
if profile.Congestion == nil || profile.Congestion.BbrProfile == nil ||
*profile.Congestion.BbrProfile != "standard" {
t.Fatalf("congestion: %+v", profile.Congestion)
}
if profile.Quic == nil || profile.Quic.MaxIdleTimeout == nil || *profile.Quic.MaxIdleTimeout != "30s" {
t.Fatalf("quic: %+v", profile.Quic)
}
if len(profile.Drift) != 0 {
t.Fatalf("канонический конфиг объявлен расходящимся: %v", profile.Drift)
}
}
// Отсутствующая секция остаётся отсутствующей.
//
// Главная регрессия: без `trafficStats` панель показывала `:9999` — адрес,
// которого в файле нет и никогда не было. Экран, существующий ради
// диагностики, скрывал ровно то состояние, которое надо диагностировать:
// «Traffic API не работает» и «трафик не собирается» объясняются именно этим.
func TestBuildHysteria2ProfileKeepsMissingSectionsMissing(t *testing.T) {
writeHysteriaConfig(t, "listen: 0.0.0.0:443\n")
profile, err := BuildHysteria2Profile()
if err != nil {
t.Fatalf("построение профиля отказало: %v", err)
}
if profile.TrafficStats != nil {
t.Fatalf("отсутствующая секция trafficStats придумана: %+v", profile.TrafficStats)
}
if profile.Acme != nil || profile.Tls != nil {
t.Fatal("отсутствующая конфигурация TLS придумана")
}
if profile.Obfs != nil || profile.Bandwidth != nil || profile.Congestion != nil || profile.Quic != nil {
t.Fatal("отсутствующие секции придуманы")
}
if profile.IgnoreClientBandwidth != nil {
t.Fatalf("отсутствующий ignoreClientBandwidth придуман: %v", *profile.IgnoreClientBandwidth)
}
}
// Явное `false` — это значение, а не отсутствие.
//
// Прежняя панель приводила секции к `!!data.speedTest` и `!!data.disableUDP`,
// поэтому явно выключенная опция выглядела ненастроенной и прятала свою
// вкладку целиком.
func TestBuildHysteria2ProfileDistinguishesFalseFromMissing(t *testing.T) {
writeHysteriaConfig(t, "listen: \":443\"\nignoreClientBandwidth: false\n")
profile, err := BuildHysteria2Profile()
if err != nil {
t.Fatalf("построение профиля отказало: %v", err)
}
if profile.IgnoreClientBandwidth == nil {
t.Fatal("явное false прочитано как «не задано»")
}
if *profile.IgnoreClientBandwidth {
t.Fatal("значение искажено")
}
}
// Секции вне профиля перечисляются поимённо — это и есть диагностика дрейфа.
func TestBuildHysteria2ProfileReportsDrift(t *testing.T) {
writeHysteriaConfig(t, canonicalProfileConfig+`
masquerade:
type: proxy
proxy:
url: https://example.com
resolver:
type: udp
outbounds:
- name: upstream
type: direct
someFutureUpstreamFeature:
enabled: true
`)
profile, err := BuildHysteria2Profile()
if err != nil {
t.Fatalf("построение профиля отказало: %v", err)
}
want := []string{"masquerade", "outbounds", "resolver", "someFutureUpstreamFeature"}
if len(profile.Drift) != len(want) {
t.Fatalf("расхождения: %v, ожидалось %v", profile.Drift, want)
}
for i, section := range want {
if profile.Drift[i] != section {
t.Fatalf("расхождения перечислены не по порядку: %v", profile.Drift)
}
}
}
// Секция, о которой HY2XS не знает вовсе, обязана попасть в расхождения:
// типизированная модель её теряет, а именно такие секции и означают, что файл
// правил не оркестратор.
func TestBuildHysteria2ProfileReportsUnknownSections(t *testing.T) {
writeHysteriaConfig(t, "listen: \":443\"\ntotallyUnknownSection:\n a: 1\n")
profile, err := BuildHysteria2Profile()
if err != nil {
t.Fatalf("построение профиля отказало: %v", err)
}
if len(profile.Drift) != 1 || profile.Drift[0] != "totallyUnknownSection" {
t.Fatalf("неизвестная секция не названа расхождением: %v", profile.Drift)
}
}
// Ни один секрет не покидает сервер через читающий экран.
//
// Раньше `auth` и `trafficStats.secret` были закрыты `json:"-"`, а пароль
// обфускации, токены ACME DNS и учётные данные outbound/masquerade — нет.
// Скачиваемая выгрузка того же конфига их вырезает, и читающий экран не имеет
// права быть щедрее.
func TestBuildHysteria2ProfileCarriesNoSecrets(t *testing.T) {
writeHysteriaConfig(t, canonicalProfileConfig+`
outbounds:
- name: upstream
type: socks5
socks5:
addr: 10.0.0.1:1080
username: proxyuser
password: proxy-password
`)
profile, err := BuildHysteria2Profile()
if err != nil {
t.Fatalf("построение профиля отказало: %v", err)
}
encoded := profileJSON(t, profile)
for _, secret := range []string{
"gecko-obfs-secret",
"traffic-stats-secret",
"machine-secret",
"proxy-password",
} {
if strings.Contains(encoded, secret) {
t.Fatalf("секрет %q уехал в браузер:\n%s", secret, encoded)
}
}
// При этом диагностические факты сохранены: пароль задан, адрес auth
// известен.
if profile.Obfs == nil || !profile.Obfs.PasswordSet {
t.Fatalf("факт «пароль обфускации задан» потерян: %+v", profile.Obfs)
}
if profile.Auth == nil || profile.Auth.Url == nil ||
!strings.Contains(*profile.Auth.Url, "127.0.0.1:8080/internal/hysteria/auth") {
t.Fatalf("адрес backend-auth потерян: %+v", profile.Auth)
}
}
// Имена параметров ACME DNS диагностичны, значения — нет.
func TestBuildHysteria2ProfileShowsAcmeDnsKeysWithoutValues(t *testing.T) {
writeHysteriaConfig(t, `listen: ":443"
acme:
domains:
- vpn.example.com
type: dns
dns:
name: cloudflare
config:
cloudflare_api_token: super-secret-token
zone: example.com
`)
profile, err := BuildHysteria2Profile()
if err != nil {
t.Fatalf("построение профиля отказало: %v", err)
}
if profile.Acme == nil || profile.Acme.DnsProvider == nil || *profile.Acme.DnsProvider != "cloudflare" {
t.Fatalf("провайдер DNS потерян: %+v", profile.Acme)
}
want := []string{"cloudflare_api_token", "zone"}
if len(profile.Acme.DnsConfigKeys) != len(want) {
t.Fatalf("имена параметров DNS: %v", profile.Acme.DnsConfigKeys)
}
for i, key := range want {
if profile.Acme.DnsConfigKeys[i] != key {
t.Fatalf("имена параметров DNS перечислены не по порядку: %v", profile.Acme.DnsConfigKeys)
}
}
if strings.Contains(profileJSON(t, profile), "super-secret-token") {
t.Fatal("значение параметра DNS уехало в браузер")
}
}
// Отсутствие файла — отказ, а не пустой профиль: «конфига нет» и «конфиг пуст»
// для оператора разные ответы.
func TestBuildHysteria2ProfileFailsWithoutFile(t *testing.T) {
previous := constant.Hysteria2ConfigPath
constant.Hysteria2ConfigPath = filepath.Join(t.TempDir(), "missing.yaml")
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
if _, err := BuildHysteria2Profile(); err == nil {
t.Fatal("отсутствующий конфиг выдан за пустой профиль")
}
}
+174
View File
@@ -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
View File
@@ -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 ""
}
}
+271
View File
@@ -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))
}
}
+10 -1
View File
@@ -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
View File
@@ -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 ищет запись, которую импорт должен обновить.
//
// Возвращает три состояния, а не два: найдено, не найдено, неизвестно. Раньше
+76 -15
View File
@@ -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)
}
}
+98
View File
@@ -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 — длина случайной части автогенерируемого
// секрета.
//
+529
View File
@@ -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{}{}
}
}