From cc3316c4b8b093188d42c035b0cfe3f491fade93 Mon Sep 17 00:00:00 2001 From: engigu Date: Fri, 27 Mar 2026 14:51:29 +0800 Subject: [PATCH] feat: add secret feature --- README.md | 23 +++- docs/guide/changelog.md | 6 + docs/guide/configuration.md | 22 ++++ internal/bootstrap/bootstrap.go | 1 + internal/constant/constant.go | 5 + internal/controllers/env_controller.go | 31 ++++- internal/executor/scheduler.go | 8 +- internal/models/env.go | 1 + internal/models/vo/sys_vo.go | 9 +- internal/router/api_routes.go | 1 + internal/services/config_service.go | 4 + internal/services/env_service.go | 114 ++++++++++++++++- internal/services/tasks/executor_service.go | 24 ++-- internal/services/tasks/tiny_log.go | 12 +- internal/utils/crypto.go | 114 +++++++++++++++++ web/src/api/index.ts | 5 +- web/src/layouts/MainLayout.vue | 2 +- web/src/views/environments/Environments.vue | 130 ++++++++++++++------ web/src/views/tasks/TaskDialog.vue | 11 +- 19 files changed, 447 insertions(+), 76 deletions(-) create mode 100644 internal/utils/crypto.go diff --git a/README.md b/README.md index 876f63d..7f36032 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ### 最近更新 +**2026.03.27** - **安全机密管理 (GitHub Secrets 风格)**:新增系统级机密(Secret)管理功能。支持 AES-GCM 工业级加密存储,秘钥内存留存销毁;支持执行日志自动脱敏打码;支持仅在计划任务调度时按需注入,终端与测试运行物理隔离,全面提升敏感配置安全性。 **2026.03.19** - **仓库同步增强**:新增对青龙仓库格式指令的深度兼容,支持从远程 Git 仓库自动同步脚本并基于注释解析自动创建面板任务,支持白名单、黑名单、依赖保留等高级筛选特性。 **2026.03.05** - **API 文档重构** 重构 OpenAPI 认证体系,支持站点级 Token 配置与 Basic Auth 保护。 **2026.03.04** - 新增内置消息推送系统:全新原生支持企业微信、钉钉、飞书、Telegram、Bark、邮件等十余种主流渠道的推送,接入系统级事件通知自动捕获,告别原有必配外部推送服务的繁琐历史 @@ -46,7 +47,8 @@ - **脚本管理:** 在线代码编辑器,支持文件上传、压缩包解压 - **在线终端:** WebSocket 实时终端,命令执行结果实时输出 - **消息推送:** 内置强大消息推送与通知引擎,无缝兼容主流渠道,支持系统级事件告警 -- **环境变量:** 安全存储敏感配置,任务执行时自动注入 +- **机密管理:** **(New)** 类似 GitHub Secrets 的安全存储,支持 AES-GCM 加密,日志自动打码,仅在调度时注入 +- **环境变量:** 存储普通配置,任务执行时自动注入 - **现代UI:** 响应式设计,深色/浅色主题切换 - **移动端:** 适配移动小屏样式 - **远程执行:** 支持远程agent执行任务,展示执行结果 @@ -88,10 +90,12 @@ - 支持系统级事件条件触发通知(例如任务失败报警、服务下线提醒) - 自动生成跨语言调用 API 示例代码供脚本集成 -### 环境变量 -- 安全存储敏感配置 -- 变量值脱敏显示 -- 任务执行时自动注入 +### 变量与机密 +- 支持普通环境变量与安全机密(Secret)分类管理 +- 机密使用 **AES-GCM** 加密存储,数据库不存明文 +- 秘钥仅在内存中留存,启动读取后立即销毁(Unset) +- 执行日志自动搜索并**屏蔽(********)**敏感机密内容 +- 严格权限隔离:机密仅在定时任务调度时注入,终端/测试环境不可见 ### 仓库任务同步 (New) - 支持 青龙 仓库命令格式快捷导入 @@ -194,6 +198,7 @@ docker run -d \ -e BH_DB_TYPE=sqlite \ -e BH_DB_PATH=/app/data/baihu.db \ -e BH_DB_TABLE_PREFIX=baihu_ \ + -e BAIHU_SECRET_KEY=your_secret_key_here \ --restart unless-stopped \ ghcr.io/engigu/baihu:latest ``` @@ -223,6 +228,7 @@ services: - BH_DB_TYPE=sqlite - BH_DB_PATH=/app/data/baihu.db - BH_DB_TABLE_PREFIX=baihu_ + - BAIHU_SECRET_KEY=your_secret_key_here # - BH_SERVER_URL_PREFIX=/baihu # 可选:配置 URL 前缀用于反向代理 logging: driver: json-file @@ -250,6 +256,7 @@ docker run -d \ -e BH_DB_PASSWORD=your_password \ -e BH_DB_NAME=baihu \ -e BH_DB_TABLE_PREFIX=baihu_ \ + -e BAIHU_SECRET_KEY=your_secret_key_here \ --restart unless-stopped \ ghcr.io/engigu/baihu:latest ``` @@ -280,6 +287,7 @@ services: - BH_DB_PASSWORD=your_password - BH_DB_NAME=baihu - BH_DB_TABLE_PREFIX=baihu_ + - BAIHU_SECRET_KEY=your_secret_key_here logging: driver: json-file options: @@ -305,6 +313,7 @@ docker run -d \ -v $(pwd)/configs:/app/configs \ -v $(pwd)/envs:/app/envs \ -e TZ=Asia/Shanghai \ + -e BAIHU_SECRET_KEY=your_secret_key_here \ --restart unless-stopped \ ghcr.io/engigu/baihu:latest ``` @@ -324,6 +333,7 @@ services: - ./envs:/app/envs environment: - TZ=Asia/Shanghai + - BAIHU_SECRET_KEY=your_secret_key_here logging: driver: json-file options: @@ -382,6 +392,7 @@ services: - BH_DB_TYPE=sqlite - BH_DB_PATH=/app/data/baihu.db - BH_DB_TABLE_PREFIX=baihu_ + - BAIHU_SECRET_KEY=your_secret_key_here logging: driver: json-file options: @@ -438,6 +449,7 @@ services: - BH_DB_PASSWORD=your_password # 修改为你的 MySQL 密码 - BH_DB_NAME=baihu - BH_DB_TABLE_PREFIX=baihu_ + - BAIHU_SECRET_KEY=your_secret_key_here logging: driver: json-file options: @@ -663,6 +675,7 @@ table_prefix = baihu_ | `BH_DB_NAME` | database.dbname | 数据库名称 | ql_panel | | `BH_DB_PATH` | database.path | SQLite 文件路径 | ./data/baihu.db | | `BH_DB_TABLE_PREFIX` | database.table_prefix | 表前缀 | baihu_ | +| `BAIHU_SECRET_KEY` | - | 系统加密秘钥,用于机密功能(**注:仅支持环境变量设置,不支持配置文件**) | - | ### URL 前缀配置 diff --git a/docs/guide/changelog.md b/docs/guide/changelog.md index ecaf58f..5b26b65 100644 --- a/docs/guide/changelog.md +++ b/docs/guide/changelog.md @@ -4,6 +4,12 @@ ## 最近更新概览 +### 2026.03.27 - 安全机密管理 (GitHub Secrets 风格) +- **安全机密功能 (New)**:引入类似 GitHub Actions 的机密管理机制。支持使用 **AES-GCM** 加密存储敏感变量,数据库不存明文,保障配置安全。 +- **秘钥内存常驻销毁 (Safe-Unset)**:系统启动从环境变量读取秘钥后会立即执行 `Unset` 操作,确保秘钥仅保留在内存中,不暴露在进程环境内。 +- **日志自动脱敏**:实时流水日志自动扫描并掩码(Mask)脱敏显示机密内容(`********`),防止执行时通过任务输出泄露机密。 +- **严格隔离机制**:机密**仅在定时调度任务**时生效,终端命令执行、手动测试、调试等入口物理隔离机密。 + ### 2026.03.19 - 仓库同步功能增强 - **青龙指令深度兼容**:支持直接粘贴青龙格式的仓库同步指令,自动解析并创建任务。 diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 5f82b4f..fb42917 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -22,6 +22,7 @@ | `BH_DB_PATH` | database.path | SQLite 物理文件存储路径 | ./data/baihu.db | | `BH_DB_DSN` | database.dsn | 数据库 DSN (仅 mysql/postgres, 优先级高。**需对应设置 type**) | - | | `BH_DB_TABLE_PREFIX` | database.table_prefix | 数据库表前缀 | baihu_ | +| `BAIHU_SECRET_KEY` | - | 系统加密秘钥,用于机密变量功能(**注:仅支持环境变量设置,不支持配置文件**) | - | --- @@ -62,3 +63,24 @@ table_prefix = baihu_ - **Worker 数量** (默认 4):同时在后端并发运行的任务进程数。 - **队列大小** (默认 100):待处理任务队列的最大容量。 - **速率间隔** (默认 200 ms):控制两个任务启动之间的最小等待时长。 + +--- + +## 机密管理 (Secret Management) + +白虎面板提供了一套基于 **AES-GCM** 工业级标准的安全机密管理系统,其设计理念参考了 GitHub Actions Secrets。 + +### 核心特性 + +1. **强加密存储**:所有标记为“机密”的变量在数据库中均以加密密文形式存储。 +2. **秘钥安全**:通过环境变量 `BAIHU_SECRET_KEY` 注入加密秘钥。系统读取秘钥后会立即将其从进程环境变量中销毁(Unset),确保秘钥仅驻留在内存中。 +3. **日志自动脱敏**:系统会自动扫描任务执行生成的实时日志流。一旦发现机密明文,将自动替换为 `********`,防止敏感信息通过日志泄露。 +4. **严格权限隔离**: + - 机密内容**仅在计划任务由调度器定时执行时**才会注入到环境。 + - 通过**终端命令**、**测试运行**或**调试运行**调起的临时进程无法获取机密内容,保障核心资产安全。 + +### 配置建议 + +- 建议在 Docker/Compose 启动项中设置 `BAIHU_SECRET_KEY` 为一个复杂的随机字符串。 +- 不要将该秘钥写入 `config.ini` 或提交到版本控制系统。 + diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 2d0918a..aaedcc7 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -33,6 +33,7 @@ func New() *App { func InitBasic() *App { app := &App{} utils.InitRuntime() + utils.InitSecretKey() // 自动加载配置 (内部会自动处理 BH_CONFIG_PATH 环境变量与默认路径的优先级) app.initConfigWithPath("") diff --git a/internal/constant/constant.go b/internal/constant/constant.go index 6b12cb1..f305418 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -123,6 +123,7 @@ const ( // Agent 状态 AgentStatusOnline = "online" AgentStatusOffline = "offline" + // AppLog 分类 LogCategoryDefault = "default" LogCategorySystemNotice = "system_notice" @@ -139,6 +140,10 @@ const ( LogStatusRead = "read" LogStatusSuccess = "success" LogStatusFailed = "failed" + + // Env Type + EnvTypeNormal = "normal" + EnvTypeSecret = "secret" ) // TablePrefix 表前缀,从配置文件读取 diff --git a/internal/controllers/env_controller.go b/internal/controllers/env_controller.go index 18d8cc7..4d2de9c 100644 --- a/internal/controllers/env_controller.go +++ b/internal/controllers/env_controller.go @@ -1,7 +1,6 @@ package controllers import ( - "github.com/engigu/baihu-panel/internal/models/vo" "github.com/engigu/baihu-panel/internal/services" "github.com/engigu/baihu-panel/internal/utils" @@ -17,6 +16,18 @@ func NewEnvController(envService *services.EnvService) *EnvController { return &EnvController{envService: envService} } +// GetSecretStatus 获取加密秘钥状态 +// @Summary 获取加密秘钥状态 +// @Description 返回系统是否已配置加密秘钥 +// @Tags Env +// @Produce json +// @Success 200 {object} vo.Response{data=bool} "成功" +// @Router /env/secret-status [get] +// @Security BearerAuth +func (ec *EnvController) GetSecretStatus(c *gin.Context) { + utils.Success(c, utils.IsSecretKeySet()) +} + // CreateEnvVar 创建环境变量 // @Summary 创建环境变量 // @Description 创建一个新的环境变量 @@ -34,6 +45,7 @@ func (ec *EnvController) CreateEnvVar(c *gin.Context) { Name string `json:"name" binding:"required"` Value string `json:"value" binding:"required"` Remark string `json:"remark"` + Type string `json:"type"` Hidden *bool `json:"hidden"` Enabled *bool `json:"enabled"` } @@ -43,6 +55,10 @@ func (ec *EnvController) CreateEnvVar(c *gin.Context) { return } + if req.Type == "" { + req.Type = "normal" + } + hidden := true if req.Hidden != nil { hidden = *req.Hidden @@ -53,7 +69,7 @@ func (ec *EnvController) CreateEnvVar(c *gin.Context) { enabled = *req.Enabled } - envVar := ec.envService.CreateEnvVar(req.Name, req.Value, req.Remark, hidden, enabled, userID) + envVar := ec.envService.CreateEnvVar(req.Name, req.Value, req.Remark, req.Type, hidden, enabled, userID) utils.Success(c, vo.ToEnvVO(envVar)) } @@ -67,13 +83,15 @@ func (ec *EnvController) CreateEnvVar(c *gin.Context) { // @Param name query string false "按名称模糊查询" // @Param page query int false "页码" // @Param page_size query int false "每页数量" +// @Param type query string false "按类型筛选" // @Success 200 {object} utils.Response{data=utils.PaginationData{data=[]vo.EnvVO}} // @Router /env [get] func (ec *EnvController) GetEnvVars(c *gin.Context) { userID := c.GetString("userID") p := utils.ParsePagination(c) name := c.DefaultQuery("name", "") - envVars, total := ec.envService.GetEnvVarsWithPagination(userID, name, p.Page, p.PageSize) + envType := c.DefaultQuery("type", "") + envVars, total := ec.envService.GetEnvVarsWithPagination(userID, name, envType, p.Page, p.PageSize) utils.PaginatedResponse(c, vo.ToEnvVOListFromModels(envVars), total, p) } @@ -142,6 +160,7 @@ func (ec *EnvController) UpdateEnvVar(c *gin.Context) { Name string `json:"name"` Value string `json:"value"` Remark string `json:"remark"` + Type string `json:"type"` Hidden *bool `json:"hidden"` Enabled *bool `json:"enabled"` } @@ -151,6 +170,10 @@ func (ec *EnvController) UpdateEnvVar(c *gin.Context) { return } + if req.Type == "" { + req.Type = "normal" + } + // 对于更新,获取现有数据 existing := ec.envService.GetEnvVarByID(id) if existing == nil { @@ -168,7 +191,7 @@ func (ec *EnvController) UpdateEnvVar(c *gin.Context) { enabled = *req.Enabled } - envVar := ec.envService.UpdateEnvVar(id, req.Name, req.Value, req.Remark, hidden, enabled) + envVar := ec.envService.UpdateEnvVar(id, req.Name, req.Value, req.Remark, req.Type, hidden, enabled) if envVar == nil { utils.NotFound(c, "环境变量不存在") return diff --git a/internal/executor/scheduler.go b/internal/executor/scheduler.go index c24c956..c5a2898 100644 --- a/internal/executor/scheduler.go +++ b/internal/executor/scheduler.go @@ -69,6 +69,7 @@ type ExecutionRequest struct { Command string // 命令 WorkDir string // 工作目录 Envs []string // 环境变量 + Secrets []string // 需要脱敏的密码 Timeout int // 超时时间(分钟) Languages []map[string]string // 语言环境配置 UseMise bool // 是否使用 mise @@ -467,9 +468,12 @@ func (s *Scheduler) executeTask(req *ExecutionRequest) (*ExecutionResult, error) LogID: req.LogID, // 传递 LogID } + // 统一获取输出并调用封装的脱敏函数 + rawStr := utils.MaskSecrets(combinedBuf.String(), req.Secrets) + if execResult != nil { result.Success = execResult.Status == constant.TaskStatusSuccess - result.Output = combinedBuf.String() + result.Output = rawStr result.Status = execResult.Status result.Duration = execResult.Duration result.ExitCode = execResult.ExitCode @@ -481,7 +485,7 @@ func (s *Scheduler) executeTask(req *ExecutionRequest) (*ExecutionResult, error) result.StartTime = start result.EndTime = time.Now() result.Duration = result.EndTime.Sub(result.StartTime).Milliseconds() - result.Output = combinedBuf.String() + result.Output = rawStr } if execErr != nil { diff --git a/internal/models/env.go b/internal/models/env.go index 0139580..69d1207 100644 --- a/internal/models/env.go +++ b/internal/models/env.go @@ -12,6 +12,7 @@ type EnvironmentVariable struct { Name string `json:"name" gorm:"size:255;not null"` Value BigText `json:"value"` Remark string `json:"remark" gorm:"size:500"` + Type string `json:"type" gorm:"size:20;default:'normal'"` Hidden bool `json:"hidden" gorm:"default:true"` Enabled bool `json:"enabled" gorm:"default:true"` UserID string `json:"user_id" gorm:"size:20;index"` diff --git a/internal/models/vo/sys_vo.go b/internal/models/vo/sys_vo.go index 92a76fb..12f1af4 100644 --- a/internal/models/vo/sys_vo.go +++ b/internal/models/vo/sys_vo.go @@ -1,6 +1,7 @@ package vo import ( + "github.com/engigu/baihu-panel/internal/constant" "github.com/engigu/baihu-panel/internal/models" ) @@ -35,6 +36,7 @@ type EnvVO struct { Name string `json:"name"` Value string `json:"value"` Remark string `json:"remark"` + Type string `json:"type"` Hidden bool `json:"hidden"` Enabled bool `json:"enabled"` CreatedAt models.LocalTime `json:"created_at"` @@ -46,11 +48,16 @@ func ToEnvVO(env *models.EnvironmentVariable) *EnvVO { if env == nil { return nil } + val := string(env.Value) + if env.Type == constant.EnvTypeSecret { + val = "********" + } return &EnvVO{ ID: env.ID, Name: env.Name, - Value: string(env.Value), + Value: val, Remark: env.Remark, + Type: env.Type, Hidden: env.Hidden, Enabled: env.Enabled, CreatedAt: env.CreatedAt, diff --git a/internal/router/api_routes.go b/internal/router/api_routes.go index ba38f84..6e11eef 100644 --- a/internal/router/api_routes.go +++ b/internal/router/api_routes.go @@ -89,6 +89,7 @@ func registerTaskRoutes(g *gin.RouterGroup, c *Controllers) { func registerEnvRoutes(g *gin.RouterGroup, c *Controllers) { env := g.Group("/env") { + env.GET("/secret-status", c.Env.GetSecretStatus) env.POST("", c.Env.CreateEnvVar) env.GET("", c.Env.GetEnvVars) env.GET("/all", c.Env.GetAllEnvVars) diff --git a/internal/services/config_service.go b/internal/services/config_service.go index e9f7c01..1eb40d2 100644 --- a/internal/services/config_service.go +++ b/internal/services/config_service.go @@ -45,6 +45,7 @@ var Config *AppConfig func getEnvStr(key string, target *string) { if v := os.Getenv(key); v != "" { *target = v + _ = os.Unsetenv(key) } } @@ -54,6 +55,7 @@ func getEnvBool(key string, target *bool) { if b, err := strconv.ParseBool(v); err == nil { *target = b } + _ = os.Unsetenv(key) } } @@ -63,6 +65,7 @@ func getEnvInt(key string, target *int) { if n, err := strconv.Atoi(v); err == nil { *target = n } + _ = os.Unsetenv(key) } } @@ -130,6 +133,7 @@ func LoadConfig(path string) (*AppConfig, error) { if v := os.Getenv("BH_DEMO_MODE"); v == "true" || v == "1" { constant.DemoMode = true logger.Info("[Config] 演示模式已启用") + _ = os.Unsetenv("BH_DEMO_MODE") } // 输出配置信息(隐藏敏感信息) diff --git a/internal/services/env_service.go b/internal/services/env_service.go index 7f8ff71..dffb306 100644 --- a/internal/services/env_service.go +++ b/internal/services/env_service.go @@ -3,6 +3,7 @@ package services import ( "strings" + "github.com/engigu/baihu-panel/internal/constant" "github.com/engigu/baihu-panel/internal/database" "github.com/engigu/baihu-panel/internal/models" "github.com/engigu/baihu-panel/internal/utils" @@ -16,12 +17,19 @@ func NewEnvService() *EnvService { return &EnvService{} } -func (es *EnvService) CreateEnvVar(name, value, remark string, hidden, enabled bool, userID string) *models.EnvironmentVariable { +func (es *EnvService) CreateEnvVar(name, value, remark, envType string, hidden, enabled bool, userID string) *models.EnvironmentVariable { + if envType == constant.EnvTypeSecret { + if encValue, err := utils.Encrypt(value); err == nil { + value = encValue + } + } + env := &models.EnvironmentVariable{ ID: utils.GenerateID(), Name: name, Value: models.BigText(value), Remark: remark, + Type: envType, Hidden: hidden, Enabled: enabled, UserID: userID, @@ -44,7 +52,7 @@ func (es *EnvService) GetFormattedEnvVarsByUserID(userID string) []string { return es.formatEnvVars(envs) } -func (es *EnvService) GetEnvVarsWithPagination(userID string, name string, page, pageSize int) ([]models.EnvironmentVariable, int64) { +func (es *EnvService) GetEnvVarsWithPagination(userID string, name string, envType string, page, pageSize int) ([]models.EnvironmentVariable, int64) { var envs []models.EnvironmentVariable var total int64 @@ -52,6 +60,9 @@ func (es *EnvService) GetEnvVarsWithPagination(userID string, name string, page, if name != "" { query = query.Where("name LIKE ?", "%"+name+"%") } + if envType != "" { + query = query.Where("type = ?", envType) + } query.Count(&total) query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&envs) @@ -67,16 +78,27 @@ func (es *EnvService) GetEnvVarByID(id string) *models.EnvironmentVariable { return &env } -func (es *EnvService) UpdateEnvVar(id string, name, value, remark string, hidden, enabled bool) *models.EnvironmentVariable { +func (es *EnvService) UpdateEnvVar(id string, name, value, remark, envType string, hidden, enabled bool) *models.EnvironmentVariable { var env models.EnvironmentVariable res := database.DB.Where("id = ?", id).Limit(1).Find(&env) if res.Error != nil || res.RowsAffected == 0 { return nil } + + if envType == constant.EnvTypeSecret && value != "********" && value != "" { + if encValue, err := utils.Encrypt(value); err == nil { + value = encValue + } + } else if envType == constant.EnvTypeSecret && (value == "********" || value == "") { + // Keep the original encrypted value + value = string(env.Value) + } + updates := map[string]interface{}{ "name": name, "value": models.BigText(value), "remark": remark, + "type": envType, "hidden": hidden, "enabled": enabled, } @@ -146,7 +168,25 @@ func (es *EnvService) GetEnvVarsByIDs(envIDs string) []string { return es.formatEnvVars(envs) } -// GetAllEnvVars 获取系统中所有的环境变量,并按 NAME=VALUE 格式返回(支持重名合并) +// GetEnvVarsAndSecretsByIDs 根据逗号分隔的ID字符串获取环境变量列表和安全机密值列表 +func (es *EnvService) GetEnvVarsAndSecretsByIDs(envIDs string) ([]string, []string) { + if envIDs == "" { + return nil, nil + } + + ids := splitEnvIDs(envIDs) + var envs []models.EnvironmentVariable + for _, id := range ids { + env := es.GetEnvVarByID(id) + if env != nil { + envs = append(envs, *env) + } + } + + return es.formatEnvVarsAndSecrets(envs) +} + +// GetAllEnvVars获取系统中所有的环境变量,并按 NAME=VALUE 格式返回 func (es *EnvService) GetAllEnvVars() []string { var envs []models.EnvironmentVariable if err := database.DB.Find(&envs).Error; err != nil { @@ -155,7 +195,16 @@ func (es *EnvService) GetAllEnvVars() []string { return es.formatEnvVars(envs) } -// formatEnvVars 将环境变量列表格式化为 NAME=VALUE 数组,并处理重名合并 +// GetAllEnvVarsAndSecrets 获取系统中所有的环境变量和安全机密值列表 +func (es *EnvService) GetAllEnvVarsAndSecrets() ([]string, []string) { + var envs []models.EnvironmentVariable + if err := database.DB.Find(&envs).Error; err != nil { + return nil, nil + } + return es.formatEnvVarsAndSecrets(envs) +} + +// formatEnvVars 将环境变量列表格式化为 NAME=VALUE 数组,并处理重名合并 (过滤掉所有的 Secret) func (es *EnvService) formatEnvVars(envs []models.EnvironmentVariable) []string { if len(envs) == 0 { return nil @@ -169,6 +218,11 @@ func (es *EnvService) formatEnvVars(envs []models.EnvironmentVariable) []string nameToIndex := make(map[string]int) for _, env := range envs { + // 非调度器入口,直接当做没有(跳过 Secret) + if env.Type == constant.EnvTypeSecret { + continue + } + value := string(env.Value) if !env.Enabled { value = "" @@ -185,13 +239,61 @@ func (es *EnvService) formatEnvVars(envs []models.EnvironmentVariable) []string } } + var result []string + for _, item := range mergedList { + val := strings.Join(item.values, "&") + result = append(result, item.name+"="+val) + } + return result +} + +// formatEnvVarsAndSecrets 将环境变量列表格式化为 NAME=VALUE 数组,并提取明文安全机密列表 +func (es *EnvService) formatEnvVarsAndSecrets(envs []models.EnvironmentVariable) ([]string, []string) { + if len(envs) == 0 { + return nil, nil + } + + type mergedEnv struct { + name string + values []string + } + var mergedList []mergedEnv + var secrets []string + nameToIndex := make(map[string]int) + + for _, env := range envs { + value := string(env.Value) + if env.Type == constant.EnvTypeSecret { + if decValue, err := utils.Decrypt(value); err == nil { + value = decValue + if env.Enabled && value != "" { + secrets = append(secrets, value) + } + } + } + + if !env.Enabled { + value = "" + } + + if idx, ok := nameToIndex[env.Name]; ok { + mergedList[idx].values = append(mergedList[idx].values, value) + } else { + nameToIndex[env.Name] = len(mergedList) + mergedList = append(mergedList, mergedEnv{ + name: env.Name, + values: []string{value}, + }) + } + } + var result []string for _, item := range mergedList { // 多个值使用 & 拼接 val := strings.Join(item.values, "&") result = append(result, item.name+"="+val) } - return result + return result, secrets } // splitEnvIDs 解析逗号分隔的ID字符串 diff --git a/internal/services/tasks/executor_service.go b/internal/services/tasks/executor_service.go index 7eeefaa..fd8ba23 100644 --- a/internal/services/tasks/executor_service.go +++ b/internal/services/tasks/executor_service.go @@ -38,6 +38,8 @@ type SettingsService interface { type EnvService interface { GetEnvVarsByIDs(ids string) []string GetAllEnvVars() []string + GetEnvVarsAndSecretsByIDs(ids string) ([]string, []string) + GetAllEnvVarsAndSecrets() ([]string, []string) } type ExecutorService struct { @@ -143,7 +145,7 @@ func (h *ServerSchedulerHandler) OnTaskExecuting(req *executor.ExecutionRequest) req.Metadata.GoID = goid // 3. 创建 TinyLog 实时日志收集器 - tl, err := NewTinyLog(taskLog.ID) + tl, err := NewTinyLog(taskLog.ID, req.Secrets) if err != nil { h.es.RemoveRunningGo(task.ID, goid) // 回滚运行状态 return nil, nil, fmt.Errorf("创建日志收集器失败: %v", err) @@ -374,13 +376,14 @@ func (es *ExecutorService) HandleTaskRetry(task *models.Task, req *executor.Exec return nil } - newEnvs := es.loadEnvVars(latestTask.ID, string(latestTask.Envs)) + newEnvs, newSecrets := es.loadEnvVars(latestTask.ID, string(latestTask.Envs)) return &executor.ExecutionRequest{ TaskID: req.TaskID, Name: latestTask.Name, Command: string(latestTask.Command), WorkDir: latestTask.WorkDir, Envs: newEnvs, + Secrets: newSecrets, Timeout: latestTask.Timeout, Languages: []map[string]string(latestTask.Languages), UseMise: latestTask.UseMise(), @@ -507,7 +510,7 @@ func (es *ExecutorService) AddCronTask(task *models.Task) error { return nil } // 在加入调度器前,预先加载好环境信息 - task.RuntimeEnvs = es.loadEnvVars(task.ID, string(task.Envs)) + task.RuntimeEnvs, _ = es.loadEnvVars(task.ID, string(task.Envs)) return es.cronManager.AddTask(task) } @@ -588,7 +591,7 @@ func (es *ExecutorService) ExecuteTask(taskID string, extraEnvs []string) *execu } } - envs := es.loadEnvVars(task.ID, string(task.Envs)) + envs, secrets := es.loadEnvVars(task.ID, string(task.Envs)) if len(extraEnvs) > 0 { envs = append(envs, extraEnvs...) } @@ -599,6 +602,7 @@ func (es *ExecutorService) ExecuteTask(taskID string, extraEnvs []string) *execu Command: string(task.Command), WorkDir: task.WorkDir, Envs: envs, + Secrets: secrets, Timeout: task.Timeout, Languages: []map[string]string(task.Languages), UseMise: task.UseMise(), @@ -1001,8 +1005,8 @@ func (es *ExecutorService) BuildRepoCommand(task *models.Task) (string, string) return buildRepoCommandEnvPrefix()+cmdStr, filepath.Dir(exePath) } -// loadEnvVars 加载环境变量,支持全局注入及重名合并 -func (es *ExecutorService) loadEnvVars(taskID string, envIDs string) []string { +// loadEnvVars 加载环境变量和掩码信息,支持全局注入及重名合并 +func (es *ExecutorService) loadEnvVars(taskID string, envIDs string) ([]string, []string) { // 1. 检查是否开启了注入全部环境变量 if taskID != "" && es.taskService != nil { task := es.taskService.GetTaskByID(taskID) @@ -1011,7 +1015,7 @@ func (es *ExecutorService) loadEnvVars(taskID string, envIDs string) []string { if err := json.Unmarshal([]byte(task.Config), &config); err == nil { if config.AllEnvs { if es.envService != nil { - return es.envService.GetAllEnvVars() + return es.envService.GetAllEnvVarsAndSecrets() } } } @@ -1020,14 +1024,14 @@ func (es *ExecutorService) loadEnvVars(taskID string, envIDs string) []string { // 2. 否则按 ID 列表进行加载(支持合并逻辑在 envService 中处理) if envIDs == "" { - return nil + return nil, nil } if es.envService != nil { - return es.envService.GetEnvVarsByIDs(envIDs) + return es.envService.GetEnvVarsAndSecretsByIDs(envIDs) } - return nil + return nil, nil } func (es *ExecutorService) ResolvePath(path string) string { diff --git a/internal/services/tasks/tiny_log.go b/internal/services/tasks/tiny_log.go index ba252b5..1a8e0f3 100644 --- a/internal/services/tasks/tiny_log.go +++ b/internal/services/tasks/tiny_log.go @@ -56,11 +56,12 @@ type TinyLog struct { writer *bufio.Writer subscribers []chan []byte remainder []byte // Leftover bytes from previous write (partial multi-byte characters) + masks []string // Secrets to mask closed bool } -// NewTinyLog 创建一个新的 TinyLog 实例(基于临时文件存储)并注册它 -func NewTinyLog(logID string) (*TinyLog, error) { +// NewTinyLog 创建一个新的 TinyLog 实例(基于临时文件存储)并注册它,支持将配置的 masks 替换为 ******** +func NewTinyLog(logID string, masks []string) (*TinyLog, error) { f, err := os.CreateTemp("", "task_log_*.log") if err != nil { return nil, err @@ -72,6 +73,7 @@ func NewTinyLog(logID string) (*TinyLog, error) { path: f.Name(), writer: bufio.NewWriter(f), subscribers: make([]chan []byte, 0), + masks: masks, } globalTinyLogManager.Register(tl) return tl, nil @@ -115,8 +117,8 @@ func (l *TinyLog) Write(p []byte) (n int, err error) { return originalInputLen, nil } - // 3. 仅将完整的部分转换为 UTF-8 - text := utils.ToUTF8(payload[:lastSafe]) + // 3. 仅将完整的部分转换为 UTF-8,并调用封装的函数进行脱敏处理 + text := utils.MaskSecrets(utils.ToUTF8(payload[:lastSafe]), l.masks) data := []byte(text) // 4. 写入文件缓冲区 @@ -179,7 +181,7 @@ func (l *TinyLog) Close() error { // 处理剩余的字节 if len(l.remainder) > 0 { - text := utils.ToUTF8(l.remainder) + text := utils.MaskSecrets(utils.ToUTF8(l.remainder), l.masks) data := []byte(text) _, _ = l.writer.Write(data) diff --git a/internal/utils/crypto.go b/internal/utils/crypto.go new file mode 100644 index 0000000..4e1c083 --- /dev/null +++ b/internal/utils/crypto.go @@ -0,0 +1,114 @@ +package utils + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + "os" + "strings" +) + +var ( + masterSecretKey []byte + ErrKeyNotSet = errors.New("加密秘钥未配置,请按照文档使用 BAIHU_SECRET_KEY 环境变量启动服务配置秘钥") +) + +// InitSecretKey initialized the master secret key from the environment and unsets it +func InitSecretKey() { + key := os.Getenv("BAIHU_SECRET_KEY") + if key != "" { + hash := sha256.Sum256([]byte(key)) + masterSecretKey = hash[:] + // Ensure it's only in memory by unsetting the environment variable + os.Unsetenv("BAIHU_SECRET_KEY") + } +} + +// IsSecretKeySet returns true if the master secret key is configured +func IsSecretKeySet() bool { + return len(masterSecretKey) > 0 +} + +// Encrypt encrypts a plaintext string using AES-GCM +func Encrypt(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + if !IsSecretKeySet() { + return "", ErrKeyNotSet + } + + block, err := aes.NewCipher(masterSecretKey) + if err != nil { + return "", err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, aesGCM.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt decrypts a ciphertext string using AES-GCM +// Returns the original string if decryption fails or if it wasn't encrypted +func Decrypt(ciphertext string) (string, error) { + if ciphertext == "" { + return "", nil + } + if !IsSecretKeySet() { + return ciphertext, ErrKeyNotSet + } + + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return ciphertext, err + } + + block, err := aes.NewCipher(masterSecretKey) + if err != nil { + return ciphertext, err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return ciphertext, err + } + + nonceSize := aesGCM.NonceSize() + if len(data) < nonceSize { + return ciphertext, errors.New("ciphertext too short") + } + + nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] + plaintext, err := aesGCM.Open(nil, nonce, ciphertextBytes, nil) + if err != nil { + return ciphertext, err + } + + return string(plaintext), nil +} + +// MaskSecrets 将文本中的所有敏感机密值替换为脱敏字符串 "********" +func MaskSecrets(text string, secrets []string) string { + if len(secrets) == 0 || text == "" { + return text + } + for _, mask := range secrets { + if mask != "" { + text = strings.ReplaceAll(text, mask, "********") + } + } + return text +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 373d33d..56566ce 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -90,13 +90,15 @@ export const api = { delete: (id: string) => request(`/scripts/${id}`, { method: 'DELETE' }) }, env: { - list: (params?: { page?: number; page_size?: number; name?: string }) => { + list: (params?: { page?: number; page_size?: number; name?: string; type?: string }) => { const query = new URLSearchParams() if (params?.page) query.set('page', String(params.page)) if (params?.page_size) query.set('page_size', String(params.page_size)) if (params?.name) query.set('name', params.name) + if (params?.type && params.type !== 'all') query.set('type', params.type) return request(`/env?${query}`) }, + secretStatus: () => request('/env/secret-status'), all: () => request('/env/all'), tasks: (id: string) => request(`/env/${id}/tasks`), create: (data: Partial) => request('/env', { method: 'POST', body: JSON.stringify(data) }), @@ -399,6 +401,7 @@ export interface EnvVar { name: string value: string remark: string + type: string hidden: boolean enabled: boolean } diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue index b3298aa..457e545 100644 --- a/web/src/layouts/MainLayout.vue +++ b/web/src/layouts/MainLayout.vue @@ -58,7 +58,7 @@ const navItems = [ { to: '/agents', icon: Server, label: '远程执行', exact: true }, { to: '/editor', icon: FileCode, label: '脚本编辑', exact: false }, { to: '/history', icon: ScrollText, label: '执行历史', exact: true }, - { to: '/environments', icon: Variable, label: '环境变量', exact: true }, + { to: '/environments', icon: Variable, label: '变量机密', exact: true }, { to: '/languages', icon: Globe, label: '语言依赖', exact: true }, { to: '/terminal', icon: Terminal, label: '终端命令', exact: true }, { to: '/notify', icon: Bell, label: '消息推送', exact: true }, diff --git a/web/src/views/environments/Environments.vue b/web/src/views/environments/Environments.vue index 4ddf19d..9f2e223 100644 --- a/web/src/views/environments/Environments.vue +++ b/web/src/views/environments/Environments.vue @@ -7,12 +7,13 @@ import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' import Pagination from '@/components/Pagination.vue' -import { Plus, Pencil, Trash2, Eye, EyeOff, Search, AlertTriangle, Terminal, Zap, ZapOff } from 'lucide-vue-next' +import { Plus, Pencil, Trash2, Eye, EyeOff, Search, AlertTriangle, Terminal, Zap, ZapOff, Shield } from 'lucide-vue-next' import TextOverflow from '@/components/TextOverflow.vue' import { api, type EnvVar } from '@/api' import { toast } from 'vue-sonner' import { useSiteSettings } from '@/composables/useSiteSettings' import { Switch } from '@/components/ui/switch' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' const { pageSize } = useSiteSettings() @@ -34,11 +35,24 @@ let textareaResizeObserver: ResizeObserver | null = null const filterName = ref('') const currentPage = ref(1) const total = ref(0) +const activeTab = ref('normal') +const isSecretSet = ref(true) let searchTimer: ReturnType | null = null +async function checkSecretStatus() { + try { + isSecretSet.value = await api.env.secretStatus() + if (!isSecretSet.value) { + toast.warning('未检测到加密机密秘钥,请在启动时配置 BAIHU_SECRET_KEY 环境变量') + } + } catch (error) { + console.error('检查秘钥状态失败', error) + } +} + async function loadEnvVars() { try { - const res = await api.env.list({ page: currentPage.value, page_size: pageSize.value, name: filterName.value || undefined }) + const res = await api.env.list({ page: currentPage.value, page_size: pageSize.value, name: filterName.value || undefined, type: activeTab.value }) envVars.value = res.data total.value = res.total // 初始化显示状态,根据数据库的 hidden 状态同步显示 @@ -60,20 +74,25 @@ function handleSearch() { }, 300) } +watch(activeTab, () => { + currentPage.value = 1 + loadEnvVars() +}) + function handlePageChange(page: number) { currentPage.value = page loadEnvVars() } function openCreate() { - editingEnv.value = { name: '', value: '', remark: '', hidden: true, enabled: true } + editingEnv.value = { name: '', value: '', remark: '', type: activeTab.value, hidden: true, enabled: true } isEdit.value = false showDialog.value = true void updateVisualLineNumbers() } function openEdit(env: EnvVar) { - editingEnv.value = { ...env } + editingEnv.value = { ...env, value: env.type === 'secret' ? '' : env.value } isEdit.value = true showDialog.value = true void updateVisualLineNumbers() @@ -120,10 +139,10 @@ async function saveEnv() { try { if (isEdit.value && editingEnv.value.id) { await api.env.update(editingEnv.value.id, editingEnv.value) - toast.success('变量已更新') + toast.success(editingEnv.value.type === 'secret' ? '机密已更新' : '变量已更新') } else { await api.env.create(editingEnv.value) - toast.success('变量已创建') + toast.success(editingEnv.value.type === 'secret' ? '机密已创建' : '变量已创建') } showDialog.value = false loadEnvVars() @@ -137,7 +156,7 @@ async function confirmDelete(id: string) { associatedTasks.value = res || [] showDeleteDialog.value = true } catch { - toast.error('检查变量引用失败') + toast.error('检查机密引用失败') } } @@ -156,7 +175,7 @@ async function deleteEnv(force = false) { isDeleting.value = false return } - toast.success('变量已删除') + toast.success(activeTab.value === 'secret' ? '机密已删除' : '变量已删除') loadEnvVars() showDeleteDialog.value = false } catch { @@ -210,7 +229,10 @@ function maskValue(value: string) { return '•'.repeat(Math.min(value.length, 20)) } -onMounted(loadEnvVars) +onMounted(() => { + checkSecretStatus() + loadEnvVars() +}) onBeforeUnmount(() => { textareaResizeObserver?.disconnect() @@ -219,46 +241,69 @@ onBeforeUnmount(() => {