Files
HY2XS_flamy/apps/service/config.go
T
founder b315001288 fix(admin): различать достижимость Traffic Stats API и соответствие профилю
Признак на странице конфигурации отвечал только на вопрос «достучится ли
админка», поэтому 0.0.0.0 показывался как норма — хотя внутренний control plane
при нём опубликован на всех интерфейсах, а оркестратор такой конфигурации не
создаёт. Состояний теперь четыре: канон профиля, wildcard, не-канонический
loopback и недостижимый адрес.

Backend не тронут: он по-прежнему отвечает только на вопрос достижимости —
превращать лишнюю публикацию в отказ обслуживания значило бы отключить всех
пиров. Исправлено ложное утверждение в его комментарии: пустой хост `:36712` в
Go означает все интерфейсы, а не loopback.

Удалены мёртвые фразы common.wait/enableSuccess/disableSuccess — остатки
операций запуска, остановки и смены версии Hysteria, которых у панели нет.
2026-09-03 03:39:42 +05:00

211 lines
10 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 вместо прочитанного хоста нельзя ровно потому, что
// это и есть тот самый второй контракт: значение, которое админка приняла и
// проигнорировала.
//
// Вопрос здесь ровно один: ДОСТУЧИТСЯ ли админка по этому адресу. Соответствие
// production-профилю — вопрос другой и решается там, где конфиг создаётся
// (validateRuntimeConfig) и где он показывается оператору (страница
// конфигурации отдельно называет wildcard и не-канонический loopback).
//
// Что принимается:
//
// "127.0.0.1:36712" -> канонический production-профиль;
// "127.0.0.5:36712" -> любой loopback достижим по loopback;
// "0.0.0.0:36712" -> wildcard включает loopback, обмен состоится;
// ":36712" -> тот же wildcard: пустой хост в Go означает ВСЕ
// интерфейсы, а не loopback.
//
// Wildcard принимается именно потому, что связь между компонентами при нём
// РАБОТАЕТ: отказывать в обслуживании из-за лишней публикации внутреннего API
// значило бы наказывать за неё отключением всех пиров. Сама лишняя публикация
// при этом не остаётся незамеченной — о ней сообщает панель.
func parseTrafficStatsPort(listen string) (int64, error) {
trimmed := strings.TrimSpace(listen)
if trimmed == "" {
return 0, errors.New("empty listen")
}
// Пустой хост подставляется только затем, чтобы SplitHostPort принял
// строку: сам по себе `:36712` означает все интерфейсы, и loopback среди
// них, поэтому подстановка не меняет ответа на вопрос о достижимости.
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,
)
}