Files
HY2XS_flamy/apps/service/hysteria2.go
T
founder cb20d8d28f 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
2026-09-02 23:24:01 +05:00

126 lines
7.1 KiB
Go

package service
import (
"errors"
"strings"
"hy2xs-admin/util"
)
// Жизненный цикл Hysteria принадлежит systemd и install-оркестратору.
// Панель умеет только наблюдать состояние: функций Start/Stop/Restart и
// смены версии здесь нет намеренно, а не «временно отключены».
// InitHysteria2 проверяет единственное, что панели действительно нужно от
// Hysteria на старте: наличие бинарника, версию которого показывает дашборд и
// у которого спрашивает состояние HysteriaServiceState.
//
// Чтения HYSTERIA2_ENABLE здесь больше нет. Ключ был последним следом модели,
// в которой панель сама запускала и останавливала Hysteria: clean install
// создавал его со значением "0", записать "1" было некому, а вся ветка
// сводилась к строке в журнале. Настройка, которая ничем не управляет, но
// выглядит выключателем, хуже отсутствующей.
func InitHysteria2() error {
if !util.Exists(util.GetHysteria2BinPath()) {
return errors.New("systemd-managed hysteria binary not found")
}
return nil
}
// Состояние службы имеет ТРИ значения, а не два.
//
// Что было. Ответ сводился к `bool`:
//
// func Hysteria2IsRunning() bool {
// _, err := util.Exec("systemctl is-active --quiet hysteria-server")
// return err == nil
// }
//
// `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()
}
// hysteriaServiceState — та же проверка в виде значения.
//
// Переменная нужна тестам: без неё каждая проверка, зависящая от состояния
// службы, доказуема только на живом systemd, то есть не доказуема в сборке
// вовсе. Продуктовый путь от этого не меняется — значение по умолчанию и есть
// реализация.
var hysteriaServiceState = func() HysteriaServiceState {
// `--quiet` здесь недопустим: он подавляет ровно тот вывод, ради которого
// команда вызывается.
output, _, err := util.ExecProbe("systemctl is-active hysteria-server")
if err != nil {
return HysteriaServiceUnknown
}
return parseSystemdActiveState(output)
}
// 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
}
}