package controller import ( "fmt" "sort" "strings" "time" "github.com/gin-gonic/gin" "hy2xs-admin/model/constant" "hy2xs-admin/model/dto" "hy2xs-admin/model/vo" "hy2xs-admin/service" ) // Доступ операторского API к таблице `config` — строго по allowlist. // // Что было. Проверка работала denylist'ом из трёх orchestrator-ключей, а // GetConfig/ListConfig принимали произвольную строку. В той же таблице лежат // JWT_SECRET, PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY и // HYSTERIA2_TRAFFIC_STATS_SECRET, поэтому авторизованный запрос // `?key=PEER_SECRET_ENCRYPTION_KEY` отдавал master-key шифрования секретов // пиров, а updateConfigs позволял подменить JWT_SECRET и оба peer-ключа. // // То есть опасность, ради которой удаляли generic export/import таблицы // `config`, никуда не делась — она осталась в точечном API. // // Список ключей ведётся в model/constant/config.go: там же, где сами ключи, а // не в слое HTTP. func denyUnknownConfigKey(key string, allowed []string, operation string, c *gin.Context) { sort.Strings(allowed) vo.Fail( fmt.Sprintf( "config key %q is not available for %s via API (allowed: %s)", key, operation, strings.Join(allowed, ", "), ), c, ) } // UpdateConfigs применяет партию настроек по принципу «всё или ничего». // // Три прохода, и каждый отвечает за своё: // // 1. проверка партии целиком — права на ключ, дубликаты, значения; // 2. одна транзакция базы; // 3. применение к рантайму. // // Раньше проходов не было вовсе: цикл проверял очередной элемент и тут же его // записывал. Партия «разрешённый ключ + запрещённый» применяла первый и // возвращала ошибку на втором — оператор получал отказ на запрос, который // систему уже изменил. // // Второе изменение того же места: смена расписания больше не роняет // HTTP-сервер. Раньше здесь стоял `go service.StopServer()`, а точка входа // крутила runServer в цикле, из-за чего на процессе накапливались планировщики // (см. service/cron_scheduler.go). func UpdateConfigs(c *gin.Context) { configsUpdateDto, err := validateField(c, dto.ConfigsUpdateDto{}) if err != nil { return } updates := make([]service.ConfigUpdate, 0, len(configsUpdateDto.ConfigUpdateDtos)) seen := make(map[string]struct{}, len(configsUpdateDto.ConfigUpdateDtos)) for _, item := range configsUpdateDto.ConfigUpdateDtos { key := *item.Key value := *item.Value // Отдельное сообщение для ключей, которыми владеет оркестратор: их // отказ — это не «нет такого ключа», а указание на владельца. if isOrchestratorManagedConfigKey(key) { vo.Fail(fmt.Sprintf("%s managed by orchestrator: use hy2xs-orchestrator reconfigure", key), c) return } if !constant.IsPublicWritableConfigKey(key) { denyUnknownConfigKey(key, constant.PublicWritableConfigKeys(), "write", c) return } // Один ключ дважды в одной партии — неоднозначный запрос: какое из // двух значений считать намерением оператора, определить нельзя. if _, duplicate := seen[key]; duplicate { vo.Fail(fmt.Sprintf("config key %q appears more than once in the batch", key), c) return } seen[key] = struct{}{} if err = service.ValidateConfigValue(key, value); err != nil { vo.Fail(err.Error(), c) return } updates = append(updates, service.ConfigUpdate{Key: key, Value: value}) } if err = service.UpdateConfigs(updates); err != nil { vo.Fail(err.Error(), c) return } if err = applyRuntimeConfigUpdates(updates); err != nil { vo.Fail(err.Error(), c) return } vo.Success(nil, c) } // applyRuntimeConfigUpdates доносит уже сохранённые настройки до рантайма. // // Отказ здесь недостижим по построению: значения проверены тем же парсером, // которым планировщик их разбирает, а сам планировщик поднимается в runServer // до старта HTTP-сервера — то есть к моменту обработки запроса он всегда // запущен. Проверка оставлена именно поэтому: если этот инвариант когда-нибудь // сломают, отказ должен быть громким, а не молча пропавшей джобой. func applyRuntimeConfigUpdates(updates []service.ConfigUpdate) error { for _, item := range updates { if item.Key != constant.ResetTrafficCron { continue } if err := service.RescheduleResetTraffic(item.Value); err != nil { return err } } return nil } // isOrchestratorManagedConfigKey — ключи, которыми владеет install-оркестратор. // // Формально они и так не входят в allowlist, но отказ по ним обязан объяснять // ПОЧЕМУ: «этим значением владеет оркестратор» — это другой ответ, чем «такого // ключа в API нет», и он ведёт оператора к `hy2xs-orchestrator reconfigure`. // // HYSTERIA2_ENABLE и HYSTERIA2_CONFIG отсюда убраны вместе с самими ключами: // первым никто не управлял, второй был вторым источником истины рядом с // /etc/hysteria/config.yaml. Serverный конфиг по-прежнему принадлежит // оркестратору — просто теперь он живёт только в файле, а не ещё и в SQLite. func isOrchestratorManagedConfigKey(key string) bool { switch key { case constant.Hysteria2TrafficStatsSecret: return true default: return false } } // Маршрута GET /config/getConfig здесь больше нет. // // Он принимал произвольный ключ и был вторым, менее заметным входом в ту же // таблицу секретов, что и удалённый generic export. При этом ни одного // потребителя у него не было: панель читает настройки только через listConfig. // Маршрут не оставлен с фильтром, а удалён — точка входа, которой никто не // пользуется, не должна существовать. func ListConfig(c *gin.Context) { configsDto, err := validateField(c, dto.ConfigsDto{}) if err != nil { return } // Проверка идёт до обращения к базе: отказ не должен зависеть от того, // существует ли строка с таким ключом. for _, key := range configsDto.Keys { if !constant.IsPublicReadableConfigKey(key) { denyUnknownConfigKey(key, constant.PublicReadableConfigKeys(), "read", c) return } } configs, err := service.ListConfig(configsDto.Keys) if err != nil { vo.Fail(err.Error(), c) return } var configVos []vo.ConfigVo for _, item := range configs { configVo := vo.ConfigVo{ Key: *item.Key, Value: *item.Value, } configVos = append(configVos, configVo) } vo.Success(configVos, c) } // GetHysteria2Config отдаёт панели конфигурацию в терминах production-профиля. // // Что было: `vo.Success(service.GetHysteria2Config(), c)` — внутренняя модель // серверного конфига сериализовалась в браузер целиком. У этого было два // следствия. // // Первое — секреты. `auth` и `trafficStats.secret` закрыты `json:"-"`, но // пароль обфускации, токены ACME DNS (`acme.dns.config`), учётные данные // outbound-прокси и masquerade уезжали в открытом виде. Скачиваемая выгрузка // того же конфига их вырезает, и читающий экран не имеет права быть щедрее. // Привилегий это не повышало — маршрут под admin JWT, — но и нужды в этих // значениях у read-only экрана нет. // // Второе — смысл ответа. Модель отдавала «все известные HY2XS поля», а панель // накладывала их на полный объект дефолтов, поэтому экран показывал не файл, а // файл, дополненный выдумкой: отсутствующий `trafficStats` превращался в // `:9999`. Ровно тот дрейф, который экран обязан показывать, он и скрывал. // // Теперь ответ описывает профиль явно, отличает «не задано» от значения и // отдельно перечисляет секции вне профиля. Полный документ доступен // санитизированной выгрузкой ниже. func GetHysteria2Config(c *gin.Context) { profile, err := service.BuildHysteria2Profile() if err != nil { vo.Fail(err.Error(), c) return } vo.Success(profile, c) } // ExportHysteria2Config отдаёт оператору фактический серверный конфиг. // // Экспорт работает от исходного YAML, а не от типизированной модели: поля, // о которых HY2XS ещё не знает, обязаны пережить выгрузку. Секреты при этом // вырезаются — файл покидает сервер. func ExportHysteria2Config(c *gin.Context) { sanitized, err := service.ExportHysteria2ConfigYaml() if err != nil { vo.Fail(err.Error(), c) return } fileName := fmt.Sprintf("Hysteria2Config-%s.yaml", time.Now().Format("20060102150405")) c.Header("Content-Type", "application/octet-stream") c.Header("Content-Transfer-Encoding", "binary") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) c.Data(200, "application/octet-stream", sanitized) } // Generic-выгрузки и загрузки таблицы `config` здесь нет намеренно. // // Она отдавала таблицу целиком, исключая только сырой Hysteria YAML, а в той // же таблице лежат JWT_SECRET, PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY и // HYSTERIA2_TRAFFIC_STATS_SECRET. Кнопка «Export» в панели выгружала их в // открытом виде, а зеркальный импорт позволял их подменить — включая ключ // шифрования, без которого перестают расшифровываться секреты уже // существующих пиров. // // Осмысленного production-сценария у этой пары не было: конфигурацией сервера // владеет install-оркестратор, перенос пиров делают ImportPeer/ExportPeer, а // серверный конфиг Hysteria выгружается отдельным санитизирующим маршрутом.