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, ) } func UpdateConfigs(c *gin.Context) { configsUpdateDto, err := validateField(c, dto.ConfigsUpdateDto{}) if err != nil { return } needRestart := false 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 key == constant.ResetTrafficCron { resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron) if err != nil { vo.Fail(err.Error(), c) return } if *resetTrafficCron.Value != value { needRestart = true } } if err = service.UpdateConfig(key, value); err != nil { vo.Fail(err.Error(), c) return } } if needRestart { go func() { _ = service.StopServer() }() } vo.Success(nil, c) } // isOrchestratorManagedConfigKey — ключи, которыми владеет install-оркестратор. // // Формально они и так не входят в allowlist, но отказ по ним обязан объяснять // ПОЧЕМУ: «этим значением владеет оркестратор» — это другой ответ, чем «такого // ключа в API нет», и он ведёт оператора к `hy2xs-orchestrator reconfigure`. func isOrchestratorManagedConfigKey(key string) bool { switch key { case constant.Hysteria2Enable, constant.Hysteria2Config, 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) } func GetHysteria2Config(c *gin.Context) { config, err := service.GetHysteria2Config() if err != nil { vo.Fail(err.Error(), c) return } vo.Success(config, 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 выгружается отдельным санитизирующим маршрутом.