Files
HY2XS_flamy/apps/controller/config.go
T
founder cb20d8d28f fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним,
поэтому сессия, установленная по отозванным учётным данным, была неотличима от
законной, и цикл учёта не имел признака, по которому её следовало завершить. У
состояния есть путь без единой неудачи — Hysteria регистрирует соединение в
Traffic Stats API только после возврата backend-auth, поэтому успешный /kick
может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт
по старому, пережившая сессия становится orphan.

Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4,
админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала
лимит устройств, учёт трафика и принудительное отключение разом. Адрес
зафиксирован, а расхождение файла с ним админка называет.

Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при
ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним
значением, а доступность Traffic Stats API выводилась из него же. Журнал
Hysteria разбирается в фактическом формате upstream (time — дробное число),
страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в
браузер, санитайзер выгрузки следует по YAML-якорям.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
2026-09-02 23:24:01 +05:00

252 lines
12 KiB
Go
Raw 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 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 выгружается отдельным санитизирующим маршрутом.