From 4028e9a4280d9646214405c2fae0e92088eb3286 Mon Sep 17 00:00:00 2001 From: samwaf Date: Mon, 8 Jun 2026 16:08:54 +0800 Subject: [PATCH] feat:add application management #823 --- api/entrance.go | 2 + api/waf_app_api.go | 491 ++++++++++++++ cmd/samwaf/main.go | 47 ++ enums/chan_common_enum.go | 2 + enums/op_type.go | 3 + globalobj/globalobj.go | 2 + model/request/waf_app_req.go | 47 ++ model/waf_app.go | 22 + model/wafappmodel/app_runtime.go | 59 ++ router/entrance.go | 1 + router/waf_app_router.go | 28 + service/waf_service/waf_app_service.go | 309 +++++++++ wafappengine/waf_app_engine.go | 843 +++++++++++++++++++++++++ wafdb/migrations_core.go | 16 + wafmangeweb/localserver.go | 1 + 15 files changed, 1873 insertions(+) create mode 100644 api/waf_app_api.go create mode 100644 model/request/waf_app_req.go create mode 100644 model/waf_app.go create mode 100644 model/wafappmodel/app_runtime.go create mode 100644 router/waf_app_router.go create mode 100644 service/waf_service/waf_app_service.go create mode 100644 wafappengine/waf_app_engine.go diff --git a/api/entrance.go b/api/entrance.go index b6c0a2c..2335e47 100644 --- a/api/entrance.go +++ b/api/entrance.go @@ -60,6 +60,7 @@ type APIGroup struct { WafDataRetentionApi WafOwaspApi WafHostPathRuleApi + WafAppApi } var APIGroupAPP = new(APIGroup) @@ -130,4 +131,5 @@ var ( wafDataRetentionService = waf_service.WafDataRetentionServiceApp wafHostPathRuleService = waf_service.WafHostPathRuleServiceApp + wafAppService = waf_service.WafAppServiceApp ) diff --git a/api/waf_app_api.go b/api/waf_app_api.go new file mode 100644 index 0000000..c0ab53c --- /dev/null +++ b/api/waf_app_api.go @@ -0,0 +1,491 @@ +package api + +import ( + "SamWaf/enums" + "SamWaf/global" + "SamWaf/globalobj" + "SamWaf/model/common/response" + "SamWaf/model/request" + "SamWaf/model/spec" + "github.com/gin-gonic/gin" +) + +type WafAppApi struct{} + +// AddApi 新增应用 +// @Summary 新增应用 +// @Description 新增一个由 SamWaf 托管的应用进程配置 +// @Tags 应用管理 +// @Accept json +// @Produce json +// @Param data body request.WafAppAddReq true "应用配置" +// @Success 200 {object} response.Response "添加成功" +// @Security ApiKeyAuth +// @Router /application/app/add [post] +func (w *WafAppApi) AddApi(c *gin.Context) { + var req request.WafAppAddReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + if req.Name == "" || req.StartCmd == "" { + response.FailWithMessage("应用名称和启动命令不能为空", c) + return + } + if wafAppService.CheckIsExist(req.Name) > 0 { + response.FailWithMessage("应用名称已存在", c) + return + } + app, err := wafAppService.AddApi(req) + if err != nil { + response.FailWithMessage("添加失败: "+err.Error(), c) + return + } + global.GWAF_CHAN_COMMON_MSG <- spec.ChanCommon{ + Type: enums.ChanComTypeApp, + OpType: enums.OP_TYPE_NEW, + Content: *app, + } + response.OkWithMessage("添加成功", c) +} + +// GetListApi 获取应用列表 +// @Summary 获取应用列表 +// @Description 分页查询所有托管应用,返回列表及实时运行状态(RunStatus / PID / 重启次数) +// @Tags 应用管理 +// @Accept json +// @Produce json +// @Param data body request.WafAppSearchReq true "分页参数" +// @Success 200 {object} response.Response{data=object} "查询成功,data.list 为应用列表,data.total 为总数" +// @Security ApiKeyAuth +// @Router /application/app/list [post] +func (w *WafAppApi) GetListApi(c *gin.Context) { + var req request.WafAppSearchReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + if req.PageIndex <= 0 { + req.PageIndex = 1 + } + list, total := wafAppService.GetListApi(req) + + // 附加运行时状态 + type AppWithStatus struct { + Id string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + AppDir string `json:"app_dir"` + StartCmd string `json:"start_cmd"` + AutoStart int `json:"auto_start"` + StartStatus int `json:"start_status"` + StopMode string `json:"stop_mode"` + StopTimeout int `json:"stop_timeout"` + RestartPolicy string `json:"restart_policy"` + RestartDelay int `json:"restart_delay"` + MaxRestartCount int `json:"max_restart_count"` + LogMaxLines int `json:"log_max_lines"` + Remarks string `json:"remarks"` + RunStatus int `json:"run_status"` + Pid int `json:"pid"` + RestartCount int `json:"restart_count"` + } + + result := make([]AppWithStatus, 0, len(list)) + for _, app := range list { + rt := globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.GetRuntimeStatus(app.Code) + result = append(result, AppWithStatus{ + Id: app.Id, + Code: app.Code, + Name: app.Name, + AppDir: app.AppDir, + StartCmd: app.StartCmd, + AutoStart: app.AutoStart, + StartStatus: app.StartStatus, + StopMode: app.StopMode, + StopTimeout: app.StopTimeout, + RestartPolicy: app.RestartPolicy, + RestartDelay: app.RestartDelay, + MaxRestartCount: app.MaxRestartCount, + LogMaxLines: app.LogMaxLines, + Remarks: app.Remarks, + RunStatus: rt.Status, + Pid: rt.Pid, + RestartCount: rt.RestartCount, + }) + } + response.OkWithDetailed(gin.H{"list": result, "total": total}, "查询成功", c) +} + +// GetDetailApi 获取应用详情 +// @Summary 获取应用详情 +// @Description 根据 id 获取单个应用的完整配置信息 +// @Tags 应用管理 +// @Accept json +// @Produce json +// @Param id query string true "应用 ID" +// @Success 200 {object} response.Response{data=model.WafApp} "获取成功" +// @Security ApiKeyAuth +// @Router /application/app/detail [get] +func (w *WafAppApi) GetDetailApi(c *gin.Context) { + var req request.WafAppDetailReq + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + response.OkWithData(wafAppService.GetDetailApi(req), c) +} + +// ModifyApi 修改应用配置 +// @Summary 修改应用配置 +// @Description 修改已有应用的配置,若应用正在运行则自动重启 +// @Tags 应用管理 +// @Accept json +// @Produce json +// @Param data body request.WafAppEditReq true "应用配置(含 id)" +// @Success 200 {object} response.Response "修改成功" +// @Security ApiKeyAuth +// @Router /application/app/edit [post] +func (w *WafAppApi) ModifyApi(c *gin.Context) { + var req request.WafAppEditReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + if err := wafAppService.ModifyApi(req); err != nil { + response.FailWithMessage("修改失败: "+err.Error(), c) + return + } + app := wafAppService.GetDetailApi(request.WafAppDetailReq{Id: req.Id}) + global.GWAF_CHAN_COMMON_MSG <- spec.ChanCommon{ + Type: enums.ChanComTypeApp, + OpType: enums.OP_TYPE_UPDATE, + Content: *app, + } + response.OkWithMessage("修改成功", c) +} + +// DelApi 删除应用 +// @Summary 删除应用 +// @Description 删除应用配置,若进程正在运行则先停止再删除 +// @Tags 应用管理 +// @Accept json +// @Produce json +// @Param id query string true "应用 ID" +// @Success 200 {object} response.Response "删除成功" +// @Security ApiKeyAuth +// @Router /application/app/del [get] +func (w *WafAppApi) DelApi(c *gin.Context) { + var req request.WafAppDelReq + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + app := wafAppService.GetDetailApi(request.WafAppDetailReq{Id: req.Id}) + if err := wafAppService.DelApi(req); err != nil { + response.FailWithMessage("删除失败", c) + return + } + global.GWAF_CHAN_COMMON_MSG <- spec.ChanCommon{ + Type: enums.ChanComTypeApp, + OpType: enums.OP_TYPE_DELETE, + OldContent: *app, + } + response.OkWithMessage("删除成功", c) +} + +// StartApi 启动应用 +// @Summary 启动应用 +// @Description 向引擎发送启动指令(异步),使用 /application/app/status 接口轮询确认启动结果 +// @Tags 应用管理 +// @Accept json +// @Produce json +// @Param code query string true "应用唯一编码" +// @Success 200 {object} response.Response "启动指令已发送" +// @Security ApiKeyAuth +// @Router /application/app/start [get] +func (w *WafAppApi) StartApi(c *gin.Context) { + var req request.WafAppCodeReq + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + global.GWAF_CHAN_COMMON_MSG <- spec.ChanCommon{ + Type: enums.ChanComTypeApp, + OpType: enums.OP_TYPE_APP_START, + Content: req.Code, + } + response.OkWithMessage("启动指令已发送", c) +} + +// StopApi 停止应用 +// @Summary 停止应用 +// @Description 向引擎发送停止指令(异步),使用 /application/app/status 接口轮询确认停止结果 +// @Tags 应用管理 +// @Accept json +// @Produce json +// @Param code query string true "应用唯一编码" +// @Success 200 {object} response.Response "停止指令已发送" +// @Security ApiKeyAuth +// @Router /application/app/stop [get] +func (w *WafAppApi) StopApi(c *gin.Context) { + var req request.WafAppCodeReq + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + global.GWAF_CHAN_COMMON_MSG <- spec.ChanCommon{ + Type: enums.ChanComTypeApp, + OpType: enums.OP_TYPE_APP_STOP, + Content: req.Code, + } + response.OkWithMessage("停止指令已发送", c) +} + +// RestartApi 重启应用 +// @Summary 重启应用 +// @Description 向引擎发送重启指令(异步),使用 /application/app/status 接口轮询确认重启结果 +// @Tags 应用管理 +// @Accept json +// @Produce json +// @Param code query string true "应用唯一编码" +// @Success 200 {object} response.Response "重启指令已发送" +// @Security ApiKeyAuth +// @Router /application/app/restart [get] +func (w *WafAppApi) RestartApi(c *gin.Context) { + var req request.WafAppCodeReq + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + global.GWAF_CHAN_COMMON_MSG <- spec.ChanCommon{ + Type: enums.ChanComTypeApp, + OpType: enums.OP_TYPE_APP_RESTART, + Content: req.Code, + } + response.OkWithMessage("重启指令已发送", c) +} + +// GetStatusApi 查询应用运行状态 +// @Summary 查询应用运行状态 +// @Description 实时查询应用进程的运行状态(同步),run_status: 0=已停止 1=运行中 2=已崩溃 +// @Tags 应用管理 +// @Accept json +// @Produce json +// @Param code query string true "应用唯一编码" +// @Success 200 {object} response.Response{data=object} "查询成功,data 含 pid/run_status/start_time/restart_count" +// @Security ApiKeyAuth +// @Router /application/app/status [get] +func (w *WafAppApi) GetStatusApi(c *gin.Context) { + var req request.WafAppCodeReq + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + rt := globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.GetRuntimeStatus(req.Code) + response.OkWithData(gin.H{ + "code": rt.Code, + "pid": rt.Pid, + "run_status": rt.Status, + "start_time": rt.StartTime, + "restart_count": rt.RestartCount, + }, c) +} + +// GetLogsApi 获取应用日志 +// @Summary 获取应用日志 +// @Description 获取应用进程最近的 stdout/stderr 日志(内存中最多 LogMaxLines 行) +// @Tags 应用管理 +// @Accept json +// @Produce json +// @Param code query string true "应用唯一编码" +// @Success 200 {object} response.Response{data=object} "查询成功,data.logs 为字符串数组" +// @Security ApiKeyAuth +// @Router /application/app/logs [get] +func (w *WafAppApi) GetLogsApi(c *gin.Context) { + var req request.WafAppCodeReq + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + logs := globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.GetLogs(req.Code) + if logs == nil { + logs = []string{} + } + response.OkWithData(gin.H{"logs": logs}, c) +} + +// ClearLogsApi 清空应用日志 +// @Summary 清空应用日志 +// @Description 清空应用的内存日志及磁盘日志文件({AppDir}/app.log) +// @Tags 应用管理 +// @Accept json +// @Produce json +// @Param data body request.WafAppCodeReq true "应用唯一编码" +// @Success 200 {object} response.Response "日志已清空" +// @Security ApiKeyAuth +// @Router /application/app/clearlogs [post] +func (w *WafAppApi) ClearLogsApi(c *gin.Context) { + var req request.WafAppCodeReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.ClearLogs(req.Code) + response.OkWithMessage("日志已清空", c) +} + +// UploadFileApi 上传文件到应用目录 +// @Summary 上传文件到应用目录 +// @Description 上传文件到应用工作目录(不升级,不重启),可选 SHA256 哈希校验 +// @Tags 应用管理 +// @Accept multipart/form-data +// @Produce json +// @Param code formData string true "应用唯一编码" +// @Param hash formData string false "文件 SHA256 哈希值(可选,不传则跳过校验)" +// @Param file formData file true "上传的文件" +// @Success 200 {object} response.Response "上传成功" +// @Security ApiKeyAuth +// @Router /application/app/upload [post] +func (w *WafAppApi) UploadFileApi(c *gin.Context) { + code := c.PostForm("code") + expectedHash := c.PostForm("hash") + if code == "" { + response.FailWithMessage("code 不能为空", c) + return + } + file, header, err := c.Request.FormFile("file") + if err != nil { + response.FailWithMessage("获取文件失败: "+err.Error(), c) + return + } + defer file.Close() + + if err := wafAppService.UploadFile(code, header.Filename, file, expectedHash); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + response.OkWithMessage("上传成功", c) +} + +// UpgradeApi 升级应用(停止→备份→替换→重启) +// @Summary 升级应用 +// @Description 同步执行升级流程:停止应用→备份旧文件→上传新文件(含 SHA256 校验)→重启应用。因需等待进程退出,请适当设置客户端超时 +// @Tags 应用管理 +// @Accept multipart/form-data +// @Produce json +// @Param code formData string true "应用唯一编码" +// @Param hash formData string false "文件 SHA256 哈希值(可选,不传则跳过校验)" +// @Param file formData file true "新版本文件" +// @Success 200 {object} response.Response "升级成功,应用已重新启动" +// @Security ApiKeyAuth +// @Router /application/app/upgrade [post] +func (w *WafAppApi) UpgradeApi(c *gin.Context) { + code := c.PostForm("code") + expectedHash := c.PostForm("hash") + if code == "" { + response.FailWithMessage("code 不能为空", c) + return + } + file, header, err := c.Request.FormFile("file") + if err != nil { + response.FailWithMessage("获取文件失败: "+err.Error(), c) + return + } + defer file.Close() + + // 同步停止,等进程真正退出后才能替换文件(Windows 不允许覆盖运行中的可执行文件) + _ = globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.StopApp(code) + + if err := wafAppService.UpgradeApp(code, header.Filename, file, expectedHash); err != nil { + response.FailWithMessage("升级失败: "+err.Error(), c) + // 升级失败也尝试恢复启动 + _ = globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.StartApp(code) + return + } + + _ = globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.StartApp(code) + response.OkWithMessage("升级成功,应用已重新启动", c) +} + +// RollbackApi 回滚应用到备份版本 +// @Summary 回滚应用 +// @Description 同步执行回滚流程:停止应用→从备份文件恢复→重启应用 +// @Tags 应用管理 +// @Accept json +// @Produce json +// @Param code query string true "应用唯一编码" +// @Param filename query string true "备份文件名(从备份列表接口获取)" +// @Success 200 {object} response.Response "回滚成功,应用已重新启动" +// @Security ApiKeyAuth +// @Router /application/app/rollback [get] +func (w *WafAppApi) RollbackApi(c *gin.Context) { + var req request.WafAppRollbackReq + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + + // 同步停止,等进程真正退出后才能覆盖文件 + _ = globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.StopApp(req.Code) + + if err := wafAppService.RollbackApp(req.Code, req.Filename); err != nil { + response.FailWithMessage("回滚失败: "+err.Error(), c) + _ = globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.StartApp(req.Code) + return + } + + _ = globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.StartApp(req.Code) + response.OkWithMessage("回滚成功,应用已重新启动", c) +} + +// GetBackupsApi 获取备份文件列表 +// @Summary 获取备份文件列表 +// @Description 列出应用工作目录下 backup/ 子目录中的所有备份文件,包含文件名、大小和备份时间 +// @Tags 应用管理 +// @Accept json +// @Produce json +// @Param code query string true "应用唯一编码" +// @Success 200 {object} response.Response{data=object} "查询成功,data.list 为 BackupInfo 数组" +// @Security ApiKeyAuth +// @Router /application/app/backups [get] +func (w *WafAppApi) GetBackupsApi(c *gin.Context) { + var req request.WafAppCodeReq + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + backups, err := wafAppService.ListBackups(req.Code) + if err != nil { + response.FailWithMessage("获取备份列表失败: "+err.Error(), c) + return + } + response.OkWithData(gin.H{"list": backups}, c) +} + +// GetNetStatsApi 查询应用端口与连接 IP +// @Summary 查询应用端口与连接 IP +// @Description 查询应用进程树(含子进程)当前占用的端口及建立的 TCP 连接,结果缓存 30 秒,最多返回 1000 条连接记录 +// @Tags 应用管理 +// @Accept json +// @Produce json +// @Param code query string true "应用唯一编码" +// @Success 200 {object} response.Response{data=wafappmodel.NetStatsResult} "查询成功" +// @Security ApiKeyAuth +// @Router /application/app/network [get] +func (w *WafAppApi) GetNetStatsApi(c *gin.Context) { + var req request.WafAppCodeReq + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMessage("解析失败", c) + return + } + result, err := globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.GetNetStats(req.Code) + if err != nil { + response.FailWithMessage("查询失败: "+err.Error(), c) + return + } + response.OkWithData(result, c) +} diff --git a/cmd/samwaf/main.go b/cmd/samwaf/main.go index c95b0f8..a59cddb 100644 --- a/cmd/samwaf/main.go +++ b/cmd/samwaf/main.go @@ -14,6 +14,7 @@ import ( "SamWaf/model/wafenginmodel" "SamWaf/plugin" "SamWaf/utils" + "SamWaf/wafappengine" "SamWaf/wafconfig" "SamWaf/wafdb" "SamWaf/wafenginecore" @@ -439,6 +440,9 @@ func (m *wafSystenService) run() { //启动隧道 globalobj.GWAF_RUNTIME_OBJ_TUNNEL_ENGINE = waftunnelengine.NewWafTunnelEngine() globalobj.GWAF_RUNTIME_OBJ_TUNNEL_ENGINE.StartTunnel() + //启动应用管理引擎 + globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE = wafappengine.NewWafAppEngine() + globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.StartApps() //启动管理界面 webmanager = &wafmangeweb.WafWebManager{LogName: "WebManager"} go func() { @@ -734,6 +738,41 @@ func (m *wafSystenService) run() { globalobj.GWAF_RUNTIME_OBJ_TUNNEL_ENGINE.RemoveTunnel(tunnelDelete) break } + } else if common.Type == enums.ChanComTypeApp { + // 应用管理类型 + if globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE != nil { + switch common.OpType { + case enums.OP_TYPE_NEW: + // 新增:如果 AutoStart=1,则启动 + appNew := common.Content.(model.WafApp) + if appNew.AutoStart == 1 && appNew.StartStatus == 1 { + if err := globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.StartApp(appNew.Code); err != nil { + zlog.Error("自动启动应用失败", "code", appNew.Code, "error", err.Error()) + } + } + case enums.OP_TYPE_UPDATE: + appNew := common.Content.(model.WafApp) + globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.LoadApp(appNew) + case enums.OP_TYPE_DELETE: + appDel := common.OldContent.(model.WafApp) + globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.RemoveApp(appDel.Code) + case enums.OP_TYPE_APP_START: + code := common.Content.(string) + if err := globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.StartApp(code); err != nil { + zlog.Error("启动应用失败", "code", code, "error", err.Error()) + } + case enums.OP_TYPE_APP_STOP: + code := common.Content.(string) + if err := globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.StopApp(code); err != nil { + zlog.Error("停止应用失败", "code", code, "error", err.Error()) + } + case enums.OP_TYPE_APP_RESTART: + code := common.Content.(string) + if err := globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.RestartApp(code); err != nil { + zlog.Error("重启应用失败", "code", code, "error", err.Error()) + } + } + } } case engineStatus := <-global.GWAF_CHAN_ENGINE: if engineStatus == 1 { @@ -856,6 +895,14 @@ func (m *wafSystenService) stopSamWaf() { zlog.Warn("Tunnel Engine is nil, skipping shutdown") } + zlog.Info("Shutdown SamWaf App Engine...") + if globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE != nil { + globalobj.GWAF_RUNTIME_OBJ_APP_ENGINE.StopApps() + zlog.Info("Shutdown SamWaf App Engine finished") + } else { + zlog.Warn("App Engine is nil, skipping shutdown") + } + zlog.Info("Shutdown SamWaf Queue Processors...") // 关闭信号通道,通知所有队列处理协程退出 close(global.GWAF_QUEUE_SHUTDOWN_SIGNAL) diff --git a/enums/chan_common_enum.go b/enums/chan_common_enum.go index 2ac0bb7..6055f1b 100644 --- a/enums/chan_common_enum.go +++ b/enums/chan_common_enum.go @@ -3,4 +3,6 @@ package enums const ( //隧道 ChanComTypeTunnel = iota + //应用管理 + ChanComTypeApp ) diff --git a/enums/op_type.go b/enums/op_type.go index 2c0ac6a..baeeff0 100644 --- a/enums/op_type.go +++ b/enums/op_type.go @@ -4,4 +4,7 @@ const ( OP_TYPE_NEW = iota OP_TYPE_UPDATE OP_TYPE_DELETE + OP_TYPE_APP_START + OP_TYPE_APP_STOP + OP_TYPE_APP_RESTART ) diff --git a/globalobj/globalobj.go b/globalobj/globalobj.go index 4f9400a..fdb7437 100644 --- a/globalobj/globalobj.go +++ b/globalobj/globalobj.go @@ -2,6 +2,7 @@ package globalobj import ( "SamWaf/plugin/manager" + "SamWaf/wafappengine" "SamWaf/wafenginecore" "SamWaf/waftask" "SamWaf/waftunnelengine" @@ -16,4 +17,5 @@ var ( GWAF_RUNTIME_OBJ_WAF_TaskRegistry *waftask.TaskRegistry // 任务执行器 GWAF_RUNTIME_OBJ_WAF_TaskScheduler *waftask.TaskScheduler // 任务计划 GWAF_RUNTIME_OBJ_PLUGIN_MANAGER *manager.PluginManager // 插件管理器 + GWAF_RUNTIME_OBJ_APP_ENGINE *wafappengine.WafAppEngine // 应用管理引擎 ) diff --git a/model/request/waf_app_req.go b/model/request/waf_app_req.go new file mode 100644 index 0000000..4ec35a1 --- /dev/null +++ b/model/request/waf_app_req.go @@ -0,0 +1,47 @@ +package request + +import "SamWaf/model/common/request" + +type WafAppAddReq struct { + Code string `json:"code" form:"code"` + Name string `json:"name" form:"name"` + AppDir string `json:"app_dir" form:"app_dir"` + StartCmd string `json:"start_cmd" form:"start_cmd"` + Env string `json:"env" form:"env"` + AutoStart int `json:"auto_start" form:"auto_start"` + StartStatus int `json:"start_status" form:"start_status"` + StopMode string `json:"stop_mode" form:"stop_mode"` + StopCmd string `json:"stop_cmd" form:"stop_cmd"` + StopTimeout int `json:"stop_timeout" form:"stop_timeout"` + RestartPolicy string `json:"restart_policy" form:"restart_policy"` + RestartDelay int `json:"restart_delay" form:"restart_delay"` + MaxRestartCount int `json:"max_restart_count" form:"max_restart_count"` + LogMaxLines int `json:"log_max_lines" form:"log_max_lines"` + Remarks string `json:"remarks" form:"remarks"` +} + +type WafAppEditReq struct { + Id string `json:"id"` + WafAppAddReq +} + +type WafAppDetailReq struct { + Id string `json:"id" form:"id"` +} + +type WafAppDelReq struct { + Id string `json:"id" form:"id"` +} + +type WafAppSearchReq struct { + request.PageInfo +} + +type WafAppCodeReq struct { + Code string `json:"code" form:"code"` +} + +type WafAppRollbackReq struct { + Code string `json:"code" form:"code"` + Filename string `json:"filename" form:"filename"` +} diff --git a/model/waf_app.go b/model/waf_app.go new file mode 100644 index 0000000..8cb0e09 --- /dev/null +++ b/model/waf_app.go @@ -0,0 +1,22 @@ +package model + +import "SamWaf/model/baseorm" + +type WafApp struct { + baseorm.BaseOrm + Code string `gorm:"size:64;uniqueIndex" json:"code"` + Name string `gorm:"size:128" json:"name"` + AppDir string `gorm:"size:512" json:"app_dir"` + StartCmd string `gorm:"size:1024" json:"start_cmd"` + Env string `gorm:"size:2048" json:"env"` + AutoStart int `json:"auto_start"` + StartStatus int `json:"start_status"` + StopMode string `gorm:"size:16" json:"stop_mode"` + StopCmd string `gorm:"size:1024" json:"stop_cmd"` + StopTimeout int `json:"stop_timeout"` + RestartPolicy string `gorm:"size:16" json:"restart_policy"` + RestartDelay int `json:"restart_delay"` + MaxRestartCount int `json:"max_restart_count"` + LogMaxLines int `json:"log_max_lines"` + Remarks string `gorm:"size:512" json:"remarks"` +} diff --git a/model/wafappmodel/app_runtime.go b/model/wafappmodel/app_runtime.go new file mode 100644 index 0000000..9a429d0 --- /dev/null +++ b/model/wafappmodel/app_runtime.go @@ -0,0 +1,59 @@ +package wafappmodel + +import ( + "os/exec" + "sync" + "time" +) + +// 保留 time 供 StartTime 字段使用 + +const ( + AppStatusStopped = 0 + AppStatusRunning = 1 + AppStatusCrashed = 2 +) + +type AppRuntime struct { + Code string + Pid int + Status int + StartTime time.Time + RestartCount int + LogLines []string + LogMu sync.Mutex + Cmd *exec.Cmd + StopChan chan struct{} // 关闭后阻止 monitorApp 自动重启 + Done chan struct{} // monitorApp 退出时关闭,供 StopApp 等待 +} + +type BackupInfo struct { + Filename string `json:"filename"` + Size int64 `json:"size"` + CreatedAt string `json:"created_at"` +} + +type PortInfo struct { + Protocol string `json:"protocol"` + LocalAddr string `json:"local_addr"` + Port int `json:"port"` + State string `json:"state"` + Pid int `json:"pid"` +} + +type ConnInfo struct { + Protocol string `json:"protocol"` + LocalAddr string `json:"local_addr"` + RemoteAddr string `json:"remote_addr"` + RemoteIP string `json:"remote_ip"` + State string `json:"state"` + Pid int `json:"pid"` +} + +type NetStatsResult struct { + Ports []PortInfo `json:"ports"` + Connections []ConnInfo `json:"connections"` + CachedAt string `json:"cached_at"` + Pid int `json:"pid"` + Pids []int `json:"pids"` +} diff --git a/router/entrance.go b/router/entrance.go index 707cead..0afc709 100644 --- a/router/entrance.go +++ b/router/entrance.go @@ -58,6 +58,7 @@ type ApiGroup struct { WafDataRetentionRouter WafOwaspRouter WafHostPathRuleRouter + WafAppRouter } type PublicApiGroup struct { LoginRouter diff --git a/router/waf_app_router.go b/router/waf_app_router.go new file mode 100644 index 0000000..ddee4d9 --- /dev/null +++ b/router/waf_app_router.go @@ -0,0 +1,28 @@ +package router + +import ( + "SamWaf/api" + "github.com/gin-gonic/gin" +) + +type WafAppRouter struct{} + +func (r *WafAppRouter) InitWafAppRouter(group *gin.RouterGroup) { + appApi := api.APIGroupAPP.WafAppApi + group.POST("/api/v1/application/app/add", appApi.AddApi) + group.POST("/api/v1/application/app/list", appApi.GetListApi) + group.GET("/api/v1/application/app/detail", appApi.GetDetailApi) + group.POST("/api/v1/application/app/edit", appApi.ModifyApi) + group.GET("/api/v1/application/app/del", appApi.DelApi) + group.GET("/api/v1/application/app/start", appApi.StartApi) + group.GET("/api/v1/application/app/stop", appApi.StopApi) + group.GET("/api/v1/application/app/restart", appApi.RestartApi) + group.GET("/api/v1/application/app/status", appApi.GetStatusApi) + group.GET("/api/v1/application/app/logs", appApi.GetLogsApi) + group.POST("/api/v1/application/app/clearlogs", appApi.ClearLogsApi) + group.POST("/api/v1/application/app/upload", appApi.UploadFileApi) + group.POST("/api/v1/application/app/upgrade", appApi.UpgradeApi) + group.GET("/api/v1/application/app/rollback", appApi.RollbackApi) + group.GET("/api/v1/application/app/backups", appApi.GetBackupsApi) + group.GET("/api/v1/application/app/network", appApi.GetNetStatsApi) +} diff --git a/service/waf_service/waf_app_service.go b/service/waf_service/waf_app_service.go new file mode 100644 index 0000000..18fbf5e --- /dev/null +++ b/service/waf_service/waf_app_service.go @@ -0,0 +1,309 @@ +package waf_service + +import ( + "SamWaf/common/uuid" + "SamWaf/common/zlog" + "SamWaf/customtype" + "SamWaf/global" + "SamWaf/model" + "SamWaf/model/baseorm" + "SamWaf/model/request" + "SamWaf/model/wafappmodel" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +type WafAppService struct{} + +var WafAppServiceApp = new(WafAppService) + +func (s *WafAppService) AddApi(req request.WafAppAddReq) (*model.WafApp, error) { + if req.Name == "" { + return nil, errors.New("应用名称不能为空") + } + if req.StartCmd == "" { + return nil, errors.New("启动命令不能为空") + } + + bean := &model.WafApp{ + BaseOrm: baseorm.BaseOrm{ + Id: uuid.GenUUID(), + USER_CODE: global.GWAF_USER_CODE, + Tenant_ID: global.GWAF_TENANT_ID, + CREATE_TIME: customtype.JsonTime(time.Now()), + UPDATE_TIME: customtype.JsonTime(time.Now()), + }, + Code: req.Code, + Name: req.Name, + AppDir: req.AppDir, + StartCmd: req.StartCmd, + Env: req.Env, + AutoStart: req.AutoStart, + StartStatus: req.StartStatus, + StopMode: req.StopMode, + StopCmd: req.StopCmd, + StopTimeout: req.StopTimeout, + RestartPolicy: req.RestartPolicy, + RestartDelay: req.RestartDelay, + MaxRestartCount: req.MaxRestartCount, + LogMaxLines: req.LogMaxLines, + Remarks: req.Remarks, + } + if bean.Code == "" { + bean.Code = bean.Id + } + if bean.StopMode == "" { + bean.StopMode = "signal" + } + if bean.RestartPolicy == "" { + bean.RestartPolicy = "no" + } + if bean.StopTimeout == 0 { + bean.StopTimeout = 30 + } + if bean.LogMaxLines == 0 { + bean.LogMaxLines = 1000 + } + if bean.AppDir == "" { + bean.AppDir = "data/applications/" + bean.Code + } + + if err := os.MkdirAll(bean.AppDir, 0755); err != nil { + zlog.Warn("创建应用目录失败", "dir", bean.AppDir, "err", err.Error()) + } + + if err := global.GWAF_LOCAL_DB.Create(bean).Error; err != nil { + return nil, err + } + return bean, nil +} + +func (s *WafAppService) CheckIsExist(name string) int { + var total int64 + global.GWAF_LOCAL_DB.Model(&model.WafApp{}).Where("name = ?", name).Count(&total) + return int(total) +} + +func (s *WafAppService) ModifyApi(req request.WafAppEditReq) error { + var total int64 + global.GWAF_LOCAL_DB.Model(&model.WafApp{}).Where("name = ? AND id != ?", req.Name, req.Id).Count(&total) + if total > 0 { + return errors.New("应用名称已存在") + } + + updates := map[string]interface{}{ + "name": req.Name, + "app_dir": req.AppDir, + "start_cmd": req.StartCmd, + "env": req.Env, + "auto_start": req.AutoStart, + "start_status": req.StartStatus, + "stop_mode": req.StopMode, + "stop_cmd": req.StopCmd, + "stop_timeout": req.StopTimeout, + "restart_policy": req.RestartPolicy, + "restart_delay": req.RestartDelay, + "max_restart_count": req.MaxRestartCount, + "log_max_lines": req.LogMaxLines, + "remarks": req.Remarks, + "update_time": customtype.JsonTime(time.Now()), + } + return global.GWAF_LOCAL_DB.Model(&model.WafApp{}).Where("id = ?", req.Id).Updates(updates).Error +} + +func (s *WafAppService) DelApi(req request.WafAppDelReq) error { + return global.GWAF_LOCAL_DB.Where("id = ?", req.Id).Delete(&model.WafApp{}).Error +} + +func (s *WafAppService) GetDetailApi(req request.WafAppDetailReq) *model.WafApp { + var bean model.WafApp + global.GWAF_LOCAL_DB.Where("id = ?", req.Id).First(&bean) + return &bean +} + +func (s *WafAppService) GetDetailByCodeApi(code string) *model.WafApp { + var bean model.WafApp + global.GWAF_LOCAL_DB.Where("code = ?", code).First(&bean) + return &bean +} + +func (s *WafAppService) GetListApi(req request.WafAppSearchReq) ([]model.WafApp, int64) { + var list []model.WafApp + var total int64 + global.GWAF_LOCAL_DB.Model(&model.WafApp{}).Count(&total) + pageSize := req.PageSize + if pageSize <= 0 { + pageSize = 10 + } + pageIndex := req.PageIndex + if pageIndex <= 0 { + pageIndex = 1 + } + global.GWAF_LOCAL_DB.Limit(pageSize).Offset((pageIndex - 1) * pageSize).Find(&list) + return list, total +} + +// UploadFile 上传文件并校验 SHA256 +func (s *WafAppService) UploadFile(code string, filename string, src io.Reader, expectedHash string) error { + var app model.WafApp + if err := global.GWAF_LOCAL_DB.Where("code = ?", code).First(&app).Error; err != nil { + return fmt.Errorf("应用不存在: %s", code) + } + appDir := app.AppDir + if appDir == "" { + appDir = "data/applications/" + code + } + if err := os.MkdirAll(appDir, 0755); err != nil { + return err + } + + // 安全文件名,防止路径穿越 + safeFilename := filepath.Base(filename) + destPath := filepath.Join(appDir, safeFilename) + + tmpPath := destPath + ".tmp" + f, err := os.Create(tmpPath) + if err != nil { + return fmt.Errorf("创建临时文件失败: %w", err) + } + defer func() { os.Remove(tmpPath) }() + + h := sha256.New() + writer := io.MultiWriter(f, h) + if _, err := io.Copy(writer, src); err != nil { + f.Close() + return fmt.Errorf("写入文件失败: %w", err) + } + f.Close() + + actualHash := hex.EncodeToString(h.Sum(nil)) + if expectedHash != "" && !equalIgnoreCase(actualHash, expectedHash) { + return fmt.Errorf("文件哈希校验失败,期望: %s,实际: %s", expectedHash, actualHash) + } + + if err := os.Rename(tmpPath, destPath); err != nil { + return fmt.Errorf("保存文件失败: %w", err) + } + return nil +} + +// UpgradeApp 升级应用:备份当前文件后替换 +func (s *WafAppService) UpgradeApp(code string, filename string, src io.Reader, expectedHash string) error { + var app model.WafApp + if err := global.GWAF_LOCAL_DB.Where("code = ?", code).First(&app).Error; err != nil { + return fmt.Errorf("应用不存在: %s", code) + } + appDir := app.AppDir + if appDir == "" { + appDir = "data/applications/" + code + } + safeFilename := filepath.Base(filename) + destPath := filepath.Join(appDir, safeFilename) + + // 备份已有文件 + if _, err := os.Stat(destPath); err == nil { + backupDir := filepath.Join(appDir, "backup") + if err2 := os.MkdirAll(backupDir, 0755); err2 == nil { + backupName := safeFilename + "." + time.Now().Format("20060102150405") + backupPath := filepath.Join(backupDir, backupName) + if copyErr := copyFile(destPath, backupPath); copyErr != nil { + // 备份失败时移除不完整的空文件,不阻断升级 + os.Remove(backupPath) + zlog.Warn("备份文件失败,继续升级", "file", destPath, "error", copyErr.Error()) + } + } + } + + return s.UploadFile(code, filename, src, expectedHash) +} + +// RollbackApp 回滚到备份文件 +func (s *WafAppService) RollbackApp(code string, backupFilename string) error { + var app model.WafApp + if err := global.GWAF_LOCAL_DB.Where("code = ?", code).First(&app).Error; err != nil { + return fmt.Errorf("应用不存在: %s", code) + } + appDir := app.AppDir + if appDir == "" { + appDir = "data/applications/" + code + } + safeBackup := filepath.Base(backupFilename) + backupPath := filepath.Join(appDir, "backup", safeBackup) + + if _, err := os.Stat(backupPath); err != nil { + return fmt.Errorf("备份文件不存在: %s", safeBackup) + } + + // 从备份文件名推断原文件名(去掉时间戳后缀 .20060102150405) + origName := safeBackup + if len(safeBackup) > 15 { + origName = safeBackup[:len(safeBackup)-15] + } + destPath := filepath.Join(appDir, origName) + + return copyFile(backupPath, destPath) +} + +// ListBackups 列出备份文件 +func (s *WafAppService) ListBackups(code string) ([]wafappmodel.BackupInfo, error) { + var app model.WafApp + if err := global.GWAF_LOCAL_DB.Where("code = ?", code).First(&app).Error; err != nil { + return nil, fmt.Errorf("应用不存在: %s", code) + } + appDir := app.AppDir + if appDir == "" { + appDir = "data/applications/" + code + } + backupDir := filepath.Join(appDir, "backup") + entries, err := os.ReadDir(backupDir) + if err != nil { + if os.IsNotExist(err) { + return []wafappmodel.BackupInfo{}, nil + } + return nil, err + } + + var result []wafappmodel.BackupInfo + for _, entry := range entries { + if entry.IsDir() { + continue + } + fullPath := filepath.Join(backupDir, entry.Name()) + info, err2 := os.Stat(fullPath) + if err2 != nil { + continue + } + result = append(result, wafappmodel.BackupInfo{ + Filename: entry.Name(), + Size: info.Size(), + CreatedAt: info.ModTime().Format("2006-01-02 15:04:05"), + }) + } + return result, nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +func equalIgnoreCase(a, b string) bool { + return len(a) == len(b) && (a == b || + len(a) > 0 && string([]byte(a)) == string([]byte(b))) +} diff --git a/wafappengine/waf_app_engine.go b/wafappengine/waf_app_engine.go new file mode 100644 index 0000000..613489b --- /dev/null +++ b/wafappengine/waf_app_engine.go @@ -0,0 +1,843 @@ +package wafappengine + +import ( + "SamWaf/common/zlog" + "SamWaf/global" + "SamWaf/model" + "SamWaf/model/wafappmodel" + "bufio" + "context" + "fmt" + "io" + "net" + "os" + "os/exec" + "regexp" + "runtime" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "golang.org/x/text/encoding/simplifiedchinese" +) + +type netCacheEntry struct { + result *wafappmodel.NetStatsResult + fetchedAt time.Time +} + +const netCacheTTL = 30 * time.Second + +type WafAppEngine struct { + runtimes map[string]*wafappmodel.AppRuntime + mu sync.RWMutex + netCache map[string]*netCacheEntry + cacheMu sync.RWMutex +} + +func NewWafAppEngine() *WafAppEngine { + return &WafAppEngine{ + runtimes: make(map[string]*wafappmodel.AppRuntime), + netCache: make(map[string]*netCacheEntry), + } +} + +// StartApps 启动所有 AutoStart=1 且 StartStatus=1 的应用 +func (e *WafAppEngine) StartApps() { + var apps []model.WafApp + global.GWAF_LOCAL_DB.Where("auto_start = 1 AND start_status = 1").Find(&apps) + for _, app := range apps { + if err := e.StartApp(app.Code); err != nil { + zlog.Error("自动启动应用失败", "code", app.Code, "name", app.Name, "error", err.Error()) + } + } +} + +// StopApps 优雅关闭所有运行中的应用 +func (e *WafAppEngine) StopApps() { + e.mu.RLock() + codes := make([]string, 0, len(e.runtimes)) + for code := range e.runtimes { + codes = append(codes, code) + } + e.mu.RUnlock() + + var wg sync.WaitGroup + for _, code := range codes { + wg.Add(1) + go func(c string) { + defer wg.Done() + if err := e.StopApp(c); err != nil { + zlog.Error("关闭应用失败", "code", c, "error", err.Error()) + } + }(code) + } + wg.Wait() +} + +// StartApp 启动单个应用 +func (e *WafAppEngine) StartApp(code string) error { + var app model.WafApp + if err := global.GWAF_LOCAL_DB.Where("code = ?", code).First(&app).Error; err != nil { + return fmt.Errorf("应用不存在: %s", code) + } + if app.StartStatus == 0 { + return fmt.Errorf("应用已停用,不允许启动") + } + + e.mu.Lock() + rt, exists := e.runtimes[code] + if exists && rt.Status == wafappmodel.AppStatusRunning { + e.mu.Unlock() + return fmt.Errorf("应用已在运行中") + } + stopChan := make(chan struct{}) + rt = &wafappmodel.AppRuntime{ + Code: code, + Status: wafappmodel.AppStatusStopped, + StopChan: stopChan, + } + if app.LogMaxLines <= 0 { + app.LogMaxLines = 1000 + } + e.runtimes[code] = rt + e.mu.Unlock() + + return e.startProcess(app, rt) +} + +func (e *WafAppEngine) startProcess(app model.WafApp, rt *wafappmodel.AppRuntime) error { + appDir := app.AppDir + if appDir == "" { + appDir = "data/applications/" + app.Code + } + if err := os.MkdirAll(appDir, 0755); err != nil { + return fmt.Errorf("创建工作目录失败: %w", err) + } + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/C", app.StartCmd) + } else { + cmd = exec.Command("/bin/sh", "-c", app.StartCmd) + } + cmd.Dir = appDir + + envs := append(os.Environ(), "") + if app.Env != "" { + for _, pair := range strings.Split(app.Env, ",") { + pair = strings.TrimSpace(pair) + if pair != "" { + envs = append(envs, pair) + } + } + } + cmd.Env = envs + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("获取stdout失败: %w", err) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("获取stderr失败: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("启动进程失败: %w", err) + } + + rt.Cmd = cmd + rt.Pid = cmd.Process.Pid + rt.Status = wafappmodel.AppStatusRunning + rt.StartTime = time.Now() + + logFile, _ := os.OpenFile(appDir+"/app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + + logMaxLines := app.LogMaxLines + if logMaxLines <= 0 { + logMaxLines = 1000 + } + + rt.Done = make(chan struct{}) + go e.pipeReader(stdoutPipe, rt, logFile, logMaxLines) + go e.pipeReader(stderrPipe, rt, logFile, logMaxLines) + go e.monitorApp(app, rt) + + zlog.Info("应用已启动", "code", app.Code, "name", app.Name, "pid", rt.Pid) + return nil +} + +func decodeOutputLine(raw []byte) string { + if utf8.Valid(raw) { + return string(raw) + } + if runtime.GOOS == "windows" { + if decoded, err := simplifiedchinese.GBK.NewDecoder().Bytes(raw); err == nil { + return string(decoded) + } + } + return string(raw) +} + +func (e *WafAppEngine) pipeReader(r io.Reader, rt *wafappmodel.AppRuntime, logFile *os.File, maxLines int) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := time.Now().Format("2006-01-02 15:04:05") + " " + decodeOutputLine(scanner.Bytes()) + rt.LogMu.Lock() + rt.LogLines = append(rt.LogLines, line) + if len(rt.LogLines) > maxLines { + rt.LogLines = rt.LogLines[len(rt.LogLines)-maxLines:] + } + rt.LogMu.Unlock() + if logFile != nil { + logFile.WriteString(line + "\n") + } + } +} + +func (e *WafAppEngine) monitorApp(app model.WafApp, rt *wafappmodel.AppRuntime) { + defer close(rt.Done) + for { + err := rt.Cmd.Wait() + + select { + case <-rt.StopChan: + e.mu.Lock() + rt.Status = wafappmodel.AppStatusStopped + rt.Pid = 0 + e.mu.Unlock() + zlog.Info("应用已主动停止", "code", app.Code, "name", app.Name) + return + default: + } + + e.mu.Lock() + rt.Status = wafappmodel.AppStatusCrashed + rt.Pid = 0 + e.mu.Unlock() + zlog.Warn("应用进程退出", "code", app.Code, "name", app.Name, "error", err) + + // 重新从数据库读取最新配置(可能已被更改) + var latestApp model.WafApp + if dbErr := global.GWAF_LOCAL_DB.Where("code = ?", app.Code).First(&latestApp).Error; dbErr != nil { + zlog.Error("读取应用配置失败,停止监控", "code", app.Code, "name", app.Name) + return + } + app = latestApp + + if app.StartStatus == 0 { + return + } + if app.RestartPolicy == "no" || app.RestartPolicy == "" { + return + } + if app.RestartPolicy == "on-failure" && err == nil { + return + } + if app.MaxRestartCount > 0 && rt.RestartCount >= app.MaxRestartCount { + zlog.Warn("已达最大重启次数,停止重启", "code", app.Code, "name", app.Name, "max", app.MaxRestartCount) + return + } + + delay := app.RestartDelay + if delay <= 0 { + delay = 5 + } + zlog.Info("等待后重启应用", "code", app.Code, "name", app.Name, "delay_sec", delay) + + select { + case <-rt.StopChan: + e.mu.Lock() + rt.Status = wafappmodel.AppStatusStopped + e.mu.Unlock() + return + case <-time.After(time.Duration(delay) * time.Second): + } + + // 重新检查 StopChan(等待期间可能已被关闭) + select { + case <-rt.StopChan: + e.mu.Lock() + rt.Status = wafappmodel.AppStatusStopped + e.mu.Unlock() + return + default: + } + + rt.RestartCount++ + zlog.Info("正在重启应用", "code", app.Code, "name", app.Name, "attempt", rt.RestartCount) + + appDir := app.AppDir + if appDir == "" { + appDir = "data/applications/" + app.Code + } + var newCmd *exec.Cmd + if runtime.GOOS == "windows" { + newCmd = exec.Command("cmd", "/C", app.StartCmd) + } else { + newCmd = exec.Command("/bin/sh", "-c", app.StartCmd) + } + newCmd.Dir = appDir + envs := append(os.Environ(), "") + if app.Env != "" { + for _, pair := range strings.Split(app.Env, ",") { + pair = strings.TrimSpace(pair) + if pair != "" { + envs = append(envs, pair) + } + } + } + newCmd.Env = envs + + stdoutPipe, _ := newCmd.StdoutPipe() + stderrPipe, _ := newCmd.StderrPipe() + if startErr := newCmd.Start(); startErr != nil { + zlog.Error("重启应用失败", "code", app.Code, "name", app.Name, "error", startErr.Error()) + continue + } + + logMaxLines := app.LogMaxLines + if logMaxLines <= 0 { + logMaxLines = 1000 + } + + e.mu.Lock() + rt.Cmd = newCmd + rt.Pid = newCmd.Process.Pid + rt.Status = wafappmodel.AppStatusRunning + rt.StartTime = time.Now() + e.mu.Unlock() + + logFile, _ := os.OpenFile(appDir+"/app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + go e.pipeReader(stdoutPipe, rt, logFile, logMaxLines) + go e.pipeReader(stderrPipe, rt, logFile, logMaxLines) + zlog.Info("应用重启成功", "code", app.Code, "name", app.Name, "pid", rt.Pid) + } +} + +// StopApp 停止单个应用 +func (e *WafAppEngine) StopApp(code string) error { + e.mu.RLock() + rt, exists := e.runtimes[code] + e.mu.RUnlock() + if !exists || rt.Cmd == nil { + return nil + } + + var app model.WafApp + global.GWAF_LOCAL_DB.Where("code = ?", code).First(&app) + + // 1. 关闭 StopChan,阻止 monitorApp 重启循环 + select { + case <-rt.StopChan: + default: + close(rt.StopChan) + } + + // 2. 进程尚未启动,直接更新状态 + if rt.Cmd.Process == nil { + e.mu.Lock() + rt.Status = wafappmodel.AppStatusStopped + rt.Pid = 0 + e.mu.Unlock() + return nil + } + + timeout := app.StopTimeout + if timeout <= 0 { + timeout = 30 + } + + // 3. 发送优雅关闭信号(不在此调用 Wait(),由 monitorApp 负责) + if app.StopMode == "cmd" && app.StopCmd != "" { + var stopCmd *exec.Cmd + if runtime.GOOS == "windows" { + stopCmd = exec.Command("cmd", "/C", app.StopCmd) + } else { + stopCmd = exec.Command("/bin/sh", "-c", app.StopCmd) + } + stopCmd.Dir = app.AppDir + _ = stopCmd.Run() + } else { + if runtime.GOOS == "windows" { + // 发送 WM_CLOSE 给进程,给其机会优雅退出(不带 /F) + exec.Command("taskkill", "/PID", strconv.Itoa(rt.Pid)).Run() + } else { + rt.Cmd.Process.Signal(os.Interrupt) + } + } + + // 4. 等待 monitorApp 检测到进程退出并关闭 rt.Done;超时则强制终止整棵进程树 + select { + case <-rt.Done: + zlog.Info("应用已优雅停止", "code", code, "name", app.Name) + case <-time.After(time.Duration(timeout) * time.Second): + if runtime.GOOS == "windows" { + // /F 强制 + /T 终止整棵子进程树,解决 cmd.exe 子进程孤儿问题 + exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(rt.Pid)).Run() + } else { + if rt.Cmd.Process != nil { + rt.Cmd.Process.Kill() + } + } + zlog.Warn("应用强制终止", "code", code, "name", app.Name) + // 等待 monitorApp 确认退出(最多再等 5s) + select { + case <-rt.Done: + case <-time.After(5 * time.Second): + } + } + + e.mu.Lock() + rt.Status = wafappmodel.AppStatusStopped + rt.Pid = 0 + e.mu.Unlock() + + e.cacheMu.Lock() + delete(e.netCache, code) + e.cacheMu.Unlock() + return nil +} + +// RestartApp 重启单个应用 +func (e *WafAppEngine) RestartApp(code string) error { + _ = e.StopApp(code) + // 重置 runtime,让 StartApp 重新创建 + e.mu.Lock() + delete(e.runtimes, code) + e.mu.Unlock() + return e.StartApp(code) +} + +// GetRuntimeStatus 获取运行时状态(线程安全的快照) +func (e *WafAppEngine) GetRuntimeStatus(code string) wafappmodel.AppRuntime { + e.mu.RLock() + defer e.mu.RUnlock() + rt, exists := e.runtimes[code] + if !exists { + return wafappmodel.AppRuntime{Code: code, Status: wafappmodel.AppStatusStopped} + } + return wafappmodel.AppRuntime{ + Code: rt.Code, + Pid: rt.Pid, + Status: rt.Status, + StartTime: rt.StartTime, + RestartCount: rt.RestartCount, + } +} + +// GetLogs 获取最近日志行 +func (e *WafAppEngine) GetLogs(code string) []string { + e.mu.RLock() + rt, exists := e.runtimes[code] + e.mu.RUnlock() + if !exists { + return nil + } + rt.LogMu.Lock() + defer rt.LogMu.Unlock() + result := make([]string, len(rt.LogLines)) + copy(result, rt.LogLines) + return result +} + +// ClearLogs 清空内存和文件日志 +func (e *WafAppEngine) ClearLogs(code string) { + e.mu.RLock() + rt, exists := e.runtimes[code] + e.mu.RUnlock() + if exists { + rt.LogMu.Lock() + rt.LogLines = nil + rt.LogMu.Unlock() + } + + var app model.WafApp + if global.GWAF_LOCAL_DB.Where("code = ?", code).First(&app).Error == nil { + appDir := app.AppDir + if appDir == "" { + appDir = "data/applications/" + code + } + os.Truncate(appDir+"/app.log", 0) + } +} + +// LoadApp 热加载(新增/修改应用时调用) +func (e *WafAppEngine) LoadApp(app model.WafApp) { + // 如果当前在运行,重启;否则忽略(等待手动启动) + e.mu.RLock() + rt, exists := e.runtimes[app.Code] + e.mu.RUnlock() + if exists && rt.Status == wafappmodel.AppStatusRunning { + _ = e.RestartApp(app.Code) + } +} + +// RemoveApp 移除应用(删除时调用) +func (e *WafAppEngine) RemoveApp(code string) { + _ = e.StopApp(code) + e.mu.Lock() + delete(e.runtimes, code) + e.mu.Unlock() +} + +// GetNetStats 返回应用占用的端口及连接 IP,结果缓存 30s +func (e *WafAppEngine) GetNetStats(code string) (*wafappmodel.NetStatsResult, error) { + empty := &wafappmodel.NetStatsResult{ + Ports: []wafappmodel.PortInfo{}, + Connections: []wafappmodel.ConnInfo{}, + } + + e.mu.RLock() + rt, exists := e.runtimes[code] + e.mu.RUnlock() + if !exists || rt.Pid == 0 || rt.Status != wafappmodel.AppStatusRunning { + return empty, nil + } + pid := rt.Pid + + e.cacheMu.RLock() + cached := e.netCache[code] + e.cacheMu.RUnlock() + if cached != nil && time.Since(cached.fetchedAt) < netCacheTTL { + return cached.result, nil + } + + result, err := fetchNetStats(pid) + if err != nil { + return empty, err + } + result.CachedAt = time.Now().Format("2006-01-02 15:04:05") + result.Pid = pid + + e.cacheMu.Lock() + e.netCache[code] = &netCacheEntry{result: result, fetchedAt: time.Now()} + e.cacheMu.Unlock() + return result, nil +} + +// fetchNetStats 查询进程树的端口和连接信息 +func fetchNetStats(rootPid int) (*wafappmodel.NetStatsResult, error) { + pids := getDescendantPIDs(rootPid) + pidSet := make(map[int]bool, len(pids)) + for _, p := range pids { + pidSet[p] = true + } + var result *wafappmodel.NetStatsResult + var err error + if runtime.GOOS == "windows" { + result, err = fetchNetStatsWindows(pidSet) + } else { + result, err = fetchNetStatsLinux(pidSet) + } + if err != nil { + return nil, err + } + result.Pids = pids + return result, nil +} + +// getDescendantPIDs BFS 遍历进程树,返回所有子孙 PID(含自身) +func getDescendantPIDs(rootPid int) []int { + childMap := buildChildMap() + var result []int + queue := []int{rootPid} + for len(queue) > 0 { + pid := queue[0] + queue = queue[1:] + result = append(result, pid) + queue = append(queue, childMap[pid]...) + } + if len(result) == 0 { + result = []int{rootPid} + } + return result +} + +// buildChildMap 构建 parentPID→[]childPID 映射 +func buildChildMap() map[int][]int { + m := make(map[int][]int) + if runtime.GOOS == "windows" { + buildChildMapWindows(m) + } else { + buildChildMapLinux(m) + } + return m +} + +func buildChildMapWindows(m map[int][]int) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // 列名字母序:ParentProcessId 排在 ProcessId 前面 + out, err := exec.CommandContext(ctx, "wmic", "process", "get", "ParentProcessId,ProcessId").Output() + if err != nil { + return + } + lines := strings.Split(strings.ReplaceAll(string(out), "\r", ""), "\n") + for i, line := range lines { + if i == 0 { + continue // 跳过表头 + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + ppid, e1 := strconv.Atoi(fields[0]) + pid, e2 := strconv.Atoi(fields[1]) + if e1 != nil || e2 != nil || pid == ppid || ppid == 0 { + continue + } + m[ppid] = append(m[ppid], pid) + } +} + +func buildChildMapLinux(m map[int][]int) { + entries, err := os.ReadDir("/proc") + if err != nil { + return + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) + if err != nil { + continue + } + for _, statusLine := range strings.Split(string(data), "\n") { + if strings.HasPrefix(statusLine, "PPid:") { + ppid, _ := strconv.Atoi(strings.TrimSpace(statusLine[5:])) + if ppid > 0 { + m[ppid] = append(m[ppid], pid) + } + break + } + } + } +} + +func fetchNetStatsWindows(pidSet map[int]bool) (*wafappmodel.NetStatsResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "netstat", "-ano").Output() + if err != nil { + return nil, fmt.Errorf("netstat failed: %w", err) + } + + var ports []wafappmodel.PortInfo + var conns []wafappmodel.ConnInfo + + for _, line := range strings.Split(strings.ReplaceAll(string(out), "\r", ""), "\n") { + line = strings.TrimSpace(line) + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + proto := strings.ToUpper(fields[0]) + localAddr := fields[1] + var state string + var pid int + + switch proto { + case "TCP": + if len(fields) < 5 { + continue + } + state = strings.ToUpper(fields[3]) + pid, _ = strconv.Atoi(fields[4]) + case "UDP": + state = "LISTEN" + pid, _ = strconv.Atoi(fields[3]) + default: + continue + } + + if pid == 0 || !pidSet[pid] { + continue + } + port := parseNetPort(localAddr) + + switch state { + case "LISTENING", "LISTEN": + ports = append(ports, wafappmodel.PortInfo{ + Protocol: proto, LocalAddr: localAddr, Port: port, State: "LISTEN", Pid: pid, + }) + case "ESTABLISHED": + remoteAddr := fields[2] + conns = append(conns, wafappmodel.ConnInfo{ + Protocol: proto, LocalAddr: localAddr, RemoteAddr: remoteAddr, + RemoteIP: parseNetIP(remoteAddr), State: state, Pid: pid, + }) + } + } + + if ports == nil { + ports = []wafappmodel.PortInfo{} + } + if conns == nil { + conns = []wafappmodel.ConnInfo{} + } + return &wafappmodel.NetStatsResult{Ports: ports, Connections: conns}, nil +} + +func fetchNetStatsLinux(pidSet map[int]bool) (*wafappmodel.NetStatsResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // ss -tanp: TCP all-states, numeric, with-process + out, err := exec.CommandContext(ctx, "ss", "-tanp").Output() + if err != nil { + // 降级到 netstat + return fetchNetStatsLinuxNetstat(pidSet) + } + return parseSSOutput(string(out), pidSet), nil +} + +func parseSSOutput(data string, pidSet map[int]bool) *wafappmodel.NetStatsResult { + pidRe := regexp.MustCompile(`pid=(\d+)`) + var ports []wafappmodel.PortInfo + var conns []wafappmodel.ConnInfo + + lines := strings.Split(data, "\n") + for i, line := range lines { + if i == 0 { + continue // 跳过表头 + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + state := strings.ToUpper(fields[0]) + localAddr := fields[3] + peerAddr := fields[4] + + var pid int + for j := 5; j < len(fields); j++ { + if m := pidRe.FindStringSubmatch(fields[j]); m != nil { + pid, _ = strconv.Atoi(m[1]) + break + } + } + if pid == 0 || !pidSet[pid] { + continue + } + port := parseNetPort(localAddr) + + switch state { + case "LISTEN": + ports = append(ports, wafappmodel.PortInfo{ + Protocol: "TCP", LocalAddr: localAddr, Port: port, State: "LISTEN", Pid: pid, + }) + case "ESTAB", "ESTABLISHED": + conns = append(conns, wafappmodel.ConnInfo{ + Protocol: "TCP", LocalAddr: localAddr, RemoteAddr: peerAddr, + RemoteIP: parseNetIP(peerAddr), State: "ESTABLISHED", Pid: pid, + }) + } + } + + if ports == nil { + ports = []wafappmodel.PortInfo{} + } + if conns == nil { + conns = []wafappmodel.ConnInfo{} + } + return &wafappmodel.NetStatsResult{Ports: ports, Connections: conns} +} + +func fetchNetStatsLinuxNetstat(pidSet map[int]bool) (*wafappmodel.NetStatsResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // netstat -tnap: TCP numeric all programs + out, err := exec.CommandContext(ctx, "netstat", "-tnap").Output() + if err != nil { + return nil, fmt.Errorf("netstat failed: %w", err) + } + + var ports []wafappmodel.PortInfo + var conns []wafappmodel.ConnInfo + + lines := strings.Split(string(out), "\n") + for i, line := range lines { + if i <= 1 { + continue // 跳过两行表头 + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + // Proto Recv-Q Send-Q Local Foreign State PID/prog + if len(fields) < 7 { + continue + } + proto := strings.ToLower(fields[0]) + if proto != "tcp" && proto != "tcp6" { + continue + } + localAddr := fields[3] + peerAddr := fields[4] + state := strings.ToUpper(fields[5]) + pidProc := fields[6] // "1234/myapp" or "-" + + var pid int + if parts := strings.SplitN(pidProc, "/", 2); len(parts) >= 1 { + pid, _ = strconv.Atoi(parts[0]) + } + if pid == 0 || !pidSet[pid] { + continue + } + port := parseNetPort(localAddr) + + switch state { + case "LISTEN": + ports = append(ports, wafappmodel.PortInfo{ + Protocol: "TCP", LocalAddr: localAddr, Port: port, State: "LISTEN", Pid: pid, + }) + case "ESTABLISHED": + conns = append(conns, wafappmodel.ConnInfo{ + Protocol: "TCP", LocalAddr: localAddr, RemoteAddr: peerAddr, + RemoteIP: parseNetIP(peerAddr), State: state, Pid: pid, + }) + } + } + + if ports == nil { + ports = []wafappmodel.PortInfo{} + } + if conns == nil { + conns = []wafappmodel.ConnInfo{} + } + return &wafappmodel.NetStatsResult{Ports: ports, Connections: conns}, nil +} + +func parseNetPort(addr string) int { + _, portStr, err := net.SplitHostPort(addr) + if err != nil { + return 0 + } + port, _ := strconv.Atoi(portStr) + return port +} + +func parseNetIP(addr string) string { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return addr + } + return host +} diff --git a/wafdb/migrations_core.go b/wafdb/migrations_core.go index f92103b..b14412f 100644 --- a/wafdb/migrations_core.go +++ b/wafdb/migrations_core.go @@ -957,6 +957,22 @@ func RunCoreDBMigrations(db *gorm.DB) error { return nil }, }, + // 迁移24: 创建应用管理表 + { + ID: "202606080002_add_waf_apps_table", + Migrate: func(tx *gorm.DB) error { + zlog.Info("迁移 202606080002: 创建 waf_apps 表") + if err := tx.AutoMigrate(&model.WafApp{}); err != nil { + return fmt.Errorf("创建 waf_apps 表失败: %w", err) + } + zlog.Info("waf_apps 表创建成功") + return nil + }, + Rollback: func(tx *gorm.DB) error { + zlog.Info("回滚 202606080002: 删除 waf_apps 表") + return tx.Migrator().DropTable(&model.WafApp{}) + }, + }, }) // 执行迁移 diff --git a/wafmangeweb/localserver.go b/wafmangeweb/localserver.go index a6f5d41..67d37e7 100644 --- a/wafmangeweb/localserver.go +++ b/wafmangeweb/localserver.go @@ -155,6 +155,7 @@ func (web *WafWebManager) initRouter(r *gin.Engine) { router.ApiGroupApp.InitWafDataRetentionRouter(RouterGroup) router.ApiGroupApp.InitWafOwaspRouter(RouterGroup) router.ApiGroupApp.InitWafHostPathRuleRouter(RouterGroup) + router.ApiGroupApp.InitWafAppRouter(RouterGroup) } // 仅允许后台 Token 登录访问,拒绝 API Key 访问(安全敏感接口)