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, ) }