Files
HY2XS_flamy/apps/service/config.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

202 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"errors"
"fmt"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"net"
"os"
"strconv"
"strings"
)
// ConfigUpdate — одна пара «ключ/значение» из партии операторского API.
type ConfigUpdate struct {
Key string
Value string
}
func UpdateConfig(key string, value string) error {
return dao.UpdateConfig([]string{key}, map[string]interface{}{"value": value})
}
// UpdateConfigs применяет партию настроек целиком или не применяет вовсе.
//
// Проверка значений в неё не входит намеренно: партия обязана быть проверена
// ПОЛНОСТЬЮ до открытия транзакции. Транзакция, внутри которой ещё может
// возникнуть отказ по содержимому, — это откат вместо отказа, то есть лишняя
// работа с базой на каждом неверно заполненном поле формы.
func UpdateConfigs(updates []ConfigUpdate) error {
if len(updates) == 0 {
return nil
}
return dao.WithConfigTx(func(tx dao.ConfigTx) error {
for _, item := range updates {
if err := tx.UpdateConfig([]string{item.Key}, map[string]interface{}{"value": item.Value}); err != nil {
return err
}
}
return nil
})
}
// ValidateConfigValue проверяет ЗНАЧЕНИЕ настройки до записи в базу.
//
// Раньше такой проверки не было вовсе: DTO ограничивал длину 128 символами, а
// поле расписания в панели — el-select с allow-create, то есть строка
// произвольная. Невалидное cron-выражение сохранялось, API отвечал успехом, а
// планировщик при следующем старте просто логировал ошибку и продолжал без
// джобы. Для оператора это выглядело как «настройка принята, сброс трафика
// молча перестал работать».
func ValidateConfigValue(key string, value string) error {
switch key {
case constant.ResetTrafficCron:
return ValidateResetTrafficCron(value)
default:
return nil
}
}
func GetConfig(key string) (entity.Config, error) {
return dao.GetConfig("key = ?", key)
}
func ListConfig(keys []string) ([]entity.Config, error) {
return dao.ListConfig("key in ?", keys)
}
// GetHysteria2Config читает ТОЛЬКО /etc/hysteria/config.yaml.
//
// Ветки «сначала посмотреть в SQLite, а если пусто — в файл» здесь больше нет.
// Ключ HYSTERIA2_CONFIG остался от предыдущего поколения, где панель сама
// владела серверным конфигом. В HY2XS владелец ровно один — install-оркестратор,
// маршруты записи конфига удалены, и писателя у этого ключа не осталось: clean
// install создавал его пустым, и пустым он оставался навсегда.
//
// Опасна была не бесполезность, а приоритет: БД читалась ПЕРВОЙ. Любое
// значение, попавшее в неё в обход продукта, молча становилось тем, что панель
// показывает оператору и из чего генерирует клиентские ссылки, пока Hysteria
// продолжала работать по файлу. То есть ключ был готовым вторым источником
// истины — ровно тем, что проект последовательно убирает.
func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) {
var serverConfig bo.Hysteria2ServerConfig
content, err := os.ReadFile(constant.Hysteria2ConfigPath)
if err != nil {
return serverConfig, err
}
if err = yaml.Unmarshal(content, &serverConfig); err != nil {
return serverConfig, err
}
return serverConfig, nil
}
func GetHysteria2ApiPort() (int64, error) {
hysteria2Config, err := GetHysteria2Config()
if err != nil {
return 0, err
}
// Сообщение передаётся аргументом, а не форматной строкой: значение
// приходит из /etc/hysteria/config.yaml, и знак процента в нём превращал
// запись журнала в мусор вида `%!s(MISSING)`.
if hysteria2Config.TrafficStats == nil || hysteria2Config.TrafficStats.Listen == nil {
errMsg := "hysteria2 Traffic Stats API (HTTP) Listen is nil"
logrus.Errorf("%s", errMsg)
return 0, errors.New(errMsg)
}
apiPort, err := parseTrafficStatsPort(*hysteria2Config.TrafficStats.Listen)
if err != nil {
errMsg := fmt.Sprintf("apiPort: %s is invalid: %v", *hysteria2Config.TrafficStats.Listen, err)
logrus.Errorf("%s", errMsg)
return 0, errors.New(errMsg)
}
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 == "" {
return 0, errors.New("empty listen")
}
hostPort := trimmed
if strings.HasPrefix(trimmed, ":") {
hostPort = "127.0.0.1" + trimmed
}
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
}
if port <= 0 || port > 65535 {
return 0, errors.New("port out of range")
}
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,
)
}