Files
founder b9d3c03f8d fix(admin): считать достижимым только тот адрес Traffic Stats API, который админка действительно опрашивает
Проверка принимала любой ip.IsLoopback(), то есть считала рабочим и 127.0.0.5.
Это неверно: слушатель на конкретном адресе принимает соединения только на него,
а слой proxy обращается строго к http://127.0.0.1:<port>.

  bind 127.0.0.5:38712  ->  dial 127.0.0.1:38712  ->  connection refused
  bind 0.0.0.0:38713    ->  dial 127.0.0.1:38713  ->  connected

Такой адрес выглядел локальным, ломал контур доступа целиком (лимит устройств
fail-closed => не подключается никто) и не вызывал у админки ни одного
возражения. Принимаются ровно 127.0.0.1, 0.0.0.0 и пустой хост.

IPv6-wildcard не принимается сознательно: соединение он принял бы, но HY2XS
объявлен IPv4-only, а зависеть в ответе «достучусь» от net.ipv6.bindv6only
нельзя.

На странице конфигурации мягкое состояние nonCanonicalLoopback убрано: прочий
loopback — это ошибка, а не предупреждение. Осталось три состояния: канон
профиля, wildcard, недостижим.

Свойство закреплено тестом с настоящими сокетами, а гейт приёмки запрещает
возврат IsLoopback() и требует негативного случая 127.0.0.5 в тестах.
2026-09-03 03:53:46 +05:00

223 lines
12 KiB
Go
Raw Permalink 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-профиль;
// "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),
)
}