feat:add application management

#823
This commit is contained in:
samwaf
2026-06-08 16:08:54 +08:00
parent 15dbbeae44
commit 4028e9a428
15 changed files with 1873 additions and 0 deletions
+2
View File
@@ -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
)
+491
View File
@@ -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)
}
+47
View File
@@ -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)
+2
View File
@@ -3,4 +3,6 @@ package enums
const (
//隧道
ChanComTypeTunnel = iota
//应用管理
ChanComTypeApp
)
+3
View File
@@ -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
)
+2
View File
@@ -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 // 应用管理引擎
)
+47
View File
@@ -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"`
}
+22
View File
@@ -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"`
}
+59
View File
@@ -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"`
}
+1
View File
@@ -58,6 +58,7 @@ type ApiGroup struct {
WafDataRetentionRouter
WafOwaspRouter
WafHostPathRuleRouter
WafAppRouter
}
type PublicApiGroup struct {
LoginRouter
+28
View File
@@ -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)
}
+309
View File
@@ -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)))
}
+843
View File
@@ -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
}
+16
View File
@@ -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{})
},
},
})
// 执行迁移
+1
View File
@@ -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 访问(安全敏感接口)