fix(admin): убрать каналы утечки секретов и остатки H UI из runtime

Экспорт в панели формировался через os.Create в /var/lib/hy2xs-admin/export,
и файл там оставался навсегда. При includeSecrets=true это означало
расшифрованные секреты пиров — фактические учётные данные доступа — в
открытом виде на диске, накапливающиеся с каждым нажатием кнопки. Выгрузки
формируются в памяти, каталога export/ больше нет.

Generic export/import таблицы config удалён целиком. Он исключал только сырой
Hysteria YAML, а в той же таблице лежат JWT_SECRET, PEER_SECRET_KEY,
PEER_SECRET_ENCRYPTION_KEY и HYSTERIA2_TRAFFIC_STATS_SECRET: кнопка Export
выгружала их открытым текстом, импорт позволял подменить. Для
PEER_SECRET_ENCRYPTION_KEY подмена ломает расшифровку секретов уже
существующих пиров. Production-сценария у этой пары не было.

Импорт пиров шёл мимо всей валидации, которую проходит обычное создание пира:
в базу попадало имя любой длины и с любыми символами, disabled с произвольным
числом, отрицательные счётчики. Файл применялся построчно, поэтому ошибка в
середине оставляла список наполовину изменённым, а импорт мог перезаписать
bootstrap-admin-peer, чей секрет продублирован в bootstrap-admin.secret.
Партия проверяется целиком до первой записи, неизвестные поля отклоняются.

Убран слой сетевых настроек панели: H_UI_WEB_PORT, H_UI_WEB_CONTEXT,
H_UI_CRT_PATH, H_UI_KEY_PATH и собственный TLS. Оркестратор передавал порт
аргументом, админка писала его в SQLite и тут же читала обратно, а UI
показывал поля disabled — второй источник истины, из которого ничего нельзя
было изменить. HUI_DATA/HUI_LOG заменены на HY2XS_DATA_DIR/HY2XS_LOG_DIR,
база переименована в hy2xs-admin.db, reference-схема — в schema.sql.

API namespace разделён по природе маршрутов: операторский API на /api,
machine-auth Hysteria на /internal/hysteria/auth. Путь machine-auth —
runtime-контракт, он уезжает в config.yaml и post-install.env, поэтому
объявлен одной константой на компонент.

Go-санитайзер экспорта вырезал секреты из URL только у ключей url/addr:
будущее upstream-поле с другим именем уносило учётные данные и access_token
целиком, а URL внутри списков не обрабатывались вовсе. Граница определяется
значением, а не именем ключа — как в TS-санитайзере оркестратора.

Заодно индикатор загрузки и цвета 401/404 переведены на брендовый токен:
NProgress приходил со своим #29d и был единственным элементом вне палитры.
This commit is contained in:
2026-08-27 20:49:07 +05:00
parent 3a4ce9c751
commit b52fac1394
33 changed files with 677 additions and 608 deletions
+4 -2
View File
@@ -10,6 +10,10 @@ import (
// importHysteria2Config), перезапуска панели и загрузки сертификатов удалены,
// а не оставлены заглушками: маршрут, который всегда отвечает «feature
// disabled», вводит в заблуждение и остаётся точкой входа.
//
// По той же причине здесь нет generic exportConfig/importConfig: они отдавали
// и принимали таблицу `config` целиком, вместе с JWT- и peer-ключами.
// См. комментарий в controller/config.go.
func initConfigRouter(configApi *gin.RouterGroup) {
config := configApi.Group("/config")
{
@@ -18,7 +22,5 @@ func initConfigRouter(configApi *gin.RouterGroup) {
config.POST("/listConfig", controller.ListConfig)
config.GET("/getHysteria2Config", controller.GetHysteria2Config)
config.POST("/exportHysteria2Config", controller.ExportHysteria2Config)
config.POST("/exportConfig", controller.ExportConfig)
config.POST("/importConfig", controller.ImportConfig)
}
}
+6 -5
View File
@@ -9,9 +9,10 @@ import (
// и перезапуск выполняются install-оркестратором. Маршрутов updater'а здесь
// нет намеренно — API-контракт не должен обещать операцию, которой у продукта
// принципиально не существует, и не должен нести лишнюю attack surface.
func initHysteria2MachineAuthRouter(hysteria2Api *gin.RouterGroup) {
hysteria2 := hysteria2Api.Group("/hysteria2")
{
hysteria2.POST("/auth", controller.Hysteria2Auth)
}
//
// Единственный маршрут этой группы — machine-auth. Группа уже смонтирована на
// constant.HysteriaMachineAuthPath, поэтому относительный путь здесь пустой:
// полный путь объявлен ровно в одном месте.
func initHysteria2MachineAuthRouter(machineAuthAPI *gin.RouterGroup) {
machineAuthAPI.POST("", controller.Hysteria2Auth)
}
+26 -22
View File
@@ -7,10 +7,16 @@ import (
"hy2xs-admin/middleware"
"hy2xs-admin/model/constant"
"os"
"strings"
)
func Router(router *gin.Engine, huiWebContext *string) {
// Router собирает HTTP-контур админки.
//
// Панель всегда живёт в корне: настраиваемый web-контекст был возможностью H UI
// разворачивать панель по произвольному пути, а HY2XS слушает только loopback
// и отдаёт панель через SSH-туннель или reverse proxy. Настройка, которую UI
// уже не позволял менять, а backend продолжал читать из SQLite, — это не
// точка расширения, а лишний источник истины.
func Router(router *gin.Engine) {
router.GET("/healthz", func(c *gin.Context) {
sqliteReady := dao.IsSqliteReady()
configReadable := false
@@ -36,34 +42,32 @@ func Router(router *gin.Engine, huiWebContext *string) {
})
})
relativePath := "/"
if huiWebContext != nil && strings.HasPrefix(*huiWebContext, "/") {
relativePath = *huiWebContext
}
frontend.InitFrontend(router, "/")
frontend.InitFrontend(router, relativePath)
// Machine-контур. Не часть операторского API: это внутренний канал между
// Hysteria и админкой, поэтому у него отдельное пространство имён,
// собственный middleware и никакого JWT.
machineAPI := router.Group(constant.HysteriaMachineAuthPath)
machineAPI.Use(middleware.LocalOnlyHandler(), middleware.MachineAuthHandler(), middleware.LogHandler())
initHysteria2MachineAuthRouter(machineAPI)
globalGroup := router.Group(relativePath)
api := router.Group(constant.AdminAPIBase)
machineApi := globalGroup.Group("/hui")
machineApi.Use(middleware.LocalOnlyHandler(), middleware.MachineAuthHandler(), middleware.LogHandler())
initHysteria2MachineAuthRouter(machineApi)
authAPI := api.Group("")
authAPI.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler())
initAuthRouter(authAPI)
authApi := globalGroup.Group("/hui")
authApi.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler())
initAuthRouter(authApi)
huiAdminApi := globalGroup.Group("/hui")
huiAdminApi.Use(
adminAPI := api.Group("")
adminAPI.Use(
middleware.FilterHandler(),
middleware.LogHandler(),
middleware.RateLimiterHandler(),
middleware.JWTHandler(),
middleware.AdminHandler(),
)
initAdminRouter(huiAdminApi)
initDashboardRouter(huiAdminApi)
initPeerRouter(huiAdminApi)
initConfigRouter(huiAdminApi)
initLogRouter(huiAdminApi)
initAdminRouter(adminAPI)
initDashboardRouter(adminAPI)
initPeerRouter(adminAPI)
initConfigRouter(adminAPI)
initLogRouter(adminAPI)
}