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-профиль; // "0.0.0.0:36712" -> wildcard принимает соединение на 127.0.0.1; // ":36712" -> тот же wildcard: пустой хост в Go означает ВСЕ // интерфейсы, а не loopback. // // Wildcard принимается именно потому, что связь между компонентами при нём // РАБОТАЕТ: отказывать в обслуживании из-за лишней публикации внутреннего API // значило бы наказывать за неё отключением всех пиров. Сама лишняя публикация // при этом не остаётся незамеченной — о ней сообщает панель. // // Прочие адреса loopback НЕ принимаются, и это не педантизм. Слушатель, // привязанный к конкретному адресу, принимает соединения только на него: // // bind 127.0.0.5:36712 -> dial 127.0.0.1:36712 -> connection refused // // (проверено). То есть `ip.IsLoopback()` здесь слишком широк: `127.0.0.5` // выглядит «локальным», но control plane при нём уже не работает, а молчаливое // принятие такого адреса означало бы отказ авторизации всем пирам без единой // подсказки о причине. // // IPv6-wildcard (`[::]:36712`) тоже не принимается, хотя на хосте с // `net.ipv6.bindv6only=0` соединение на 127.0.0.1 он принимает. Причин две: // HY2XS объявлен IPv4-only (`HY2XS_IPV6_ENABLED` обязан быть false), и // достижимость такого слушателя зависит от настройки ядра, которой продукт не // управляет. Отвечать «достучусь» на основании чужого sysctl нельзя, а // указанное в отказе действие — `reconfigure` — для этой конфигурации всё равно // верное. 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 отвечает, сможет ли админка попасть по этому // адресу — а не «нравится ли он профилю». // // Совпадение точное, без `ip.IsLoopback()`: слушатель на конкретном адресе // принимает соединения только на него, поэтому «какой-нибудь loopback» и // «достижим по 127.0.0.1» — разные утверждения. Разбор — в комментарии выше. func assertTrafficStatsHostReachable(host string) error { switch strings.TrimSpace(host) { case "", "127.0.0.1", "0.0.0.0": return nil } return fmt.Errorf( "trafficStats.listen слушает %s, а админка обращается к Traffic Stats API строго через 127.0.0.1: "+ "слушатель на конкретном адресе соединения на 127.0.0.1 не принимает. "+ "Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают. "+ "Верните 127.0.0.1 через `hy2xs-orchestrator reconfigure`", strings.TrimSpace(host), ) }