From ce7a380e6e8dcef40c770e8e2ea28cb3fdd10064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Tue, 25 Jul 2023 03:21:00 +0800 Subject: [PATCH] feat(cron): support backup --- app/console/commands/panel.go | 195 ++- app/http/controllers/cron_controller.go | 87 +- app/services/cron.go | 4 + pkg/tools/system.go | 26 + public/index.html | 6 +- public/panel/adminui/src/modules/admin.js | 1637 +++++++++++---------- public/panel/views/cron.html | 136 +- 7 files changed, 1241 insertions(+), 850 deletions(-) diff --git a/app/console/commands/panel.go b/app/console/commands/panel.go index eb65d90d..e8b9eef5 100644 --- a/app/console/commands/panel.go +++ b/app/console/commands/panel.go @@ -2,11 +2,15 @@ package commands import ( "os" + "path/filepath" + "sort" + "strings" "github.com/gookit/color" "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/console/command" "github.com/goravel/framework/facades" + "github.com/goravel/framework/support/carbon" "github.com/spf13/cast" "panel/app/models" @@ -39,6 +43,8 @@ func (receiver *Panel) Handle(ctx console.Context) error { action := ctx.Argument(0) arg1 := ctx.Argument(1) arg2 := ctx.Argument(2) + arg3 := ctx.Argument(3) + arg4 := ctx.Argument(4) switch action { case "init": @@ -130,6 +136,15 @@ func (receiver *Panel) Handle(ctx console.Context) error { case "getEntrance": color.Greenln("面板入口: " + services.NewSettingImpl().Get(models.SettingKeyEntrance, "/")) + case "deleteEntrance": + err := services.NewSettingImpl().Set(models.SettingKeyEntrance, "/") + if err != nil { + color.Redln("删除面板入口失败") + return nil + } + + color.Greenln("删除面板入口成功") + case "writePlugin": slug := arg1 version := arg2 @@ -198,6 +213,176 @@ func (receiver *Panel) Handle(ctx console.Context) error { color.Greenln("清理任务成功") case "backup": + backupType := arg1 + name := arg2 + path := arg3 + save := arg4 + hr := `+----------------------------------------------------` + if len(backupType) == 0 || len(name) == 0 || len(path) == 0 || len(save) == 0 { + color.Redln("参数错误") + return nil + } + + color.Greenln(hr) + color.Greenln("★ 开始备份 [" + carbon.Now().ToDateTimeString() + "]") + color.Greenln(hr) + + if !tools.Exists(path) { + tools.Mkdir(path, 0644) + } + + switch backupType { + case "website": + color.Yellowln("|-目标网站: " + name) + var website models.Website + if err := facades.Orm().Query().Where("name", name).FirstOrFail(&website); err != nil { + color.Redln("|-网站不存在") + color.Greenln(hr) + return nil + } + + backupFile := path + "/" + website.Name + "_" + carbon.Now().ToShortDateTimeString() + ".zip" + tools.ExecShell(`cd '` + website.Path + `' && zip -r '` + backupFile + `' .`) + color.Greenln("|-备份成功") + + case "mysql": + rootPassword := services.NewSettingImpl().Get(models.SettingKeyMysqlRootPassword) + backupFile := name + "_" + carbon.Now().ToShortDateTimeString() + ".sql" + + err := os.Setenv("MYSQL_PWD", rootPassword) + if err != nil { + color.Redln("|-备份MySQL数据库失败: " + err.Error()) + color.Greenln(hr) + return nil + } + + color.Greenln("|-目标MySQL数据库: " + name) + color.Greenln("|-开始导出") + tools.ExecShell(`mysqldump -uroot ` + name + ` > /tmp/` + backupFile + ` 2>&1`) + color.Greenln("|-导出成功") + color.Greenln("|-开始压缩") + tools.ExecShell("cd /tmp && zip -r " + backupFile + ".zip " + backupFile) + tools.RemoveFile("/tmp/" + backupFile) + color.Greenln("|-压缩成功") + color.Greenln("|-开始移动") + tools.Mv("/tmp/"+backupFile+".zip", path+"/"+backupFile+".zip") + color.Greenln("|-移动成功") + _ = os.Unsetenv("MYSQL_PWD") + color.Greenln("|-备份成功") + + case "postgresql": + backupFile := name + "_" + carbon.Now().ToShortDateTimeString() + ".sql" + check := tools.ExecShell(`su - postgres -c "psql -l" 2>&1`) + if strings.Contains(check, name) { + color.Redln("|-数据库不存在") + color.Greenln(hr) + return nil + } + + color.Greenln("|-目标PostgreSQL数据库: " + name) + color.Greenln("|-开始导出") + tools.ExecShell(`su - postgres -c "pg_dump '` + name + `'" > /tmp/` + backupFile + ` 2>&1`) + color.Greenln("|-导出成功") + color.Greenln("|-开始压缩") + tools.ExecShell("cd /tmp && zip -r " + backupFile + ".zip " + backupFile) + tools.RemoveFile("/tmp/" + backupFile) + color.Greenln("|-压缩成功") + color.Greenln("|-开始移动") + tools.Mv("/tmp/"+backupFile+".zip", path+"/"+backupFile+".zip") + color.Greenln("|-移动成功") + color.Greenln("|-备份成功") + } + + color.Greenln(hr) + files, err := os.ReadDir(path) + if err != nil { + color.Redln("|-清理失败: " + err.Error()) + return nil + } + var filteredFiles []os.FileInfo + for _, file := range files { + if strings.HasPrefix(file.Name(), name) && strings.HasSuffix(file.Name(), ".zip") { + fileInfo, err := os.Stat(filepath.Join(path, file.Name())) + if err != nil { + continue + } + filteredFiles = append(filteredFiles, fileInfo) + } + } + sort.Slice(filteredFiles, func(i, j int) bool { + return filteredFiles[i].ModTime().After(filteredFiles[j].ModTime()) + }) + for i := cast.ToInt(save); i < len(filteredFiles); i++ { + fileToDelete := filepath.Join(path, filteredFiles[i].Name()) + color.Yellowln("|-清理备份: " + fileToDelete) + tools.RemoveFile(fileToDelete) + } + color.Greenln("|-清理完成") + color.Greenln(hr) + color.Greenln("☆ 备份完成 [" + carbon.Now().ToDateTimeString() + "]") + color.Greenln(hr) + + case "cutoff": + name := arg1 + save := arg2 + hr := `+----------------------------------------------------` + if len(name) == 0 || len(save) == 0 { + color.Redln("参数错误") + return nil + } + + color.Greenln(hr) + color.Greenln("★ 开始切割 [" + carbon.Now().ToDateTimeString() + "]") + color.Greenln(hr) + + color.Yellowln("|-目标网站: " + name) + var website models.Website + if err := facades.Orm().Query().Where("name", name).FirstOrFail(&website); err != nil { + color.Redln("|-网站不存在") + color.Greenln(hr) + return nil + } + + logPath := "/www/wwwlogs/" + website.Name + ".log" + if !tools.Exists(logPath) { + color.Redln("|-日志文件不存在") + color.Greenln(hr) + return nil + } + + backupPath := "/www/wwwlogs/" + website.Name + "_" + carbon.Now().ToShortDateTimeString() + ".log.zip" + tools.ExecShell(`cd /www/wwwlogs && zip -r ` + backupPath + ` ` + website.Name + ".log") + tools.ExecShell(`echo "" > ` + logPath) + color.Greenln("|-切割成功") + + color.Greenln(hr) + files, err := os.ReadDir("/www/wwwlogs") + if err != nil { + color.Redln("|-清理失败: " + err.Error()) + return nil + } + var filteredFiles []os.FileInfo + for _, file := range files { + if strings.HasPrefix(file.Name(), website.Name) && strings.HasSuffix(file.Name(), ".log.zip") { + fileInfo, err := os.Stat(filepath.Join("/www/wwwlogs", file.Name())) + if err != nil { + continue + } + filteredFiles = append(filteredFiles, fileInfo) + } + } + sort.Slice(filteredFiles, func(i, j int) bool { + return filteredFiles[i].ModTime().After(filteredFiles[j].ModTime()) + }) + for i := cast.ToInt(save); i < len(filteredFiles); i++ { + fileToDelete := filepath.Join("/www/wwwlogs", filteredFiles[i].Name()) + color.Yellowln("|-清理日志: " + fileToDelete) + tools.RemoveFile(fileToDelete) + } + color.Greenln("|-清理完成") + color.Greenln(hr) + color.Greenln("☆ 切割完成 [" + carbon.Now().ToDateTimeString() + "]") + color.Greenln(hr) case "writeSite": name := arg1 @@ -290,12 +475,14 @@ func (receiver *Panel) Handle(ctx console.Context) error { default: color.Yellowln(facades.Config().GetString("panel.name") + "命令行工具 - " + facades.Config().GetString("panel.version")) color.Greenln("请使用以下命令:") - color.Greenln("panel update {proxy} 更新/修复面板到最新版本") + color.Greenln("panel update {proxy} 更新 / 修复面板到最新版本") color.Greenln("panel getInfo 重新初始化面板账号信息") color.Greenln("panel getPort 获取面板访问端口") color.Greenln("panel getEntrance 获取面板访问入口") - color.Greenln("panel cleanTask 清理面板运行中和等待中的任务") - color.Greenln("panel backup {website/mysql/postgresql} {name} {path} 备份网站/MySQL数据库/PostgreSQL数据库到指定目录") + color.Greenln("panel deleteEntrance 删除面板访问入口") + color.Greenln("panel cleanTask 清理面板运行中和等待中的任务[任务卡住时使用]") + color.Greenln("panel backup {website/mysql/postgresql} {name} {path} {save_copies} 备份网站 / MySQL数据库 / PostgreSQL数据库到指定目录并保留指定数量") + color.Greenln("panel cutoff {website_name} {save_copies} 切割网站日志并保留指定数量") color.Redln("以下命令请在开发者指导下使用:") color.Yellowln("panel init 初始化面板") color.Yellowln("panel writePlugin {slug} {version} 写入插件安装状态") @@ -303,7 +490,7 @@ func (receiver *Panel) Handle(ctx console.Context) error { color.Yellowln("panel writeMysqlPassword {password} 写入MySQL root密码") color.Yellowln("panel writeSite {name} {status} {path} {php} {ssl} 写入网站数据到面板") color.Yellowln("panel deleteSite {name} 删除面板网站数据") - color.Yellowln("panel writeSetting {name} {value} 写入/更新面板设置数据") + color.Yellowln("panel writeSetting {name} {value} 写入 / 更新面板设置数据") color.Yellowln("panel deleteSetting {name} 删除面板设置数据") } diff --git a/app/http/controllers/cron_controller.go b/app/http/controllers/cron_controller.go index 7309079b..03b15bb0 100644 --- a/app/http/controllers/cron_controller.go +++ b/app/http/controllers/cron_controller.go @@ -7,23 +7,25 @@ import ( "github.com/goravel/framework/contracts/http" "github.com/goravel/framework/facades" "github.com/goravel/framework/support/carbon" - + "github.com/spf13/cast" "panel/app/models" "panel/app/services" "panel/pkg/tools" ) type CronController struct { - cron services.Cron + cron services.Cron + setting services.Setting } func NewCronController() *CronController { return &CronController{ - cron: services.NewCronImpl(), + cron: services.NewCronImpl(), + setting: services.NewSettingImpl(), } } -func (r *CronController) List(ctx http.Context) { +func (c *CronController) List(ctx http.Context) { limit := ctx.Request().QueryInt("limit") page := ctx.Request().QueryInt("page") @@ -42,11 +44,13 @@ func (r *CronController) List(ctx http.Context) { }) } -func (r *CronController) Add(ctx http.Context) { +func (c *CronController) Add(ctx http.Context) { validator, err := ctx.Request().Validate(map[string]string{ - "name": "required|min_len:1|max_len:255", - "time": "required", - "script": "required", + "name": "required|min_len:1|max_len:255", + "time": "required", + "script": "required", + "type": "required|in:shell,backup,cutoff", + "backup_type": "required_if:type,backup|in:website,mysql,postgresql", }) if err != nil { Error(ctx, http.StatusBadRequest, err.Error()) @@ -63,7 +67,46 @@ func (r *CronController) Add(ctx http.Context) { return } - // 写入shell + shell := ctx.Request().Input("script") + cronType := ctx.Request().Input("type") + if cronType == "backup" { + backupType := ctx.Request().Input("backup_type") + backupName := ctx.Request().Input("backup_database") + if backupType == "website" { + backupName = ctx.Request().Input("website") + } + backupPath := ctx.Request().Input("backup_path", c.setting.Get(models.SettingKeyBackupPath)+"/"+backupType) + backupSave := ctx.Request().InputInt("save", 10) + shell = `#!/bin/bash +export PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:$PATH + +# 耗子面板 - 数据备份脚本 + +type=` + backupType + ` +path=` + backupPath + ` +name=` + backupName + ` +save=` + cast.ToString(backupSave) + ` + +# 执行备份 +panel backup ${type} ${name} ${path} ${save} 2>&1 +` + } + if cronType == "cutoff" { + website := ctx.Request().Input("website") + save := ctx.Request().InputInt("save", 180) + shell = `#!/bin/bash +export PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:$PATH + +# 耗子面板 - 日志切割脚本 + +name=` + website + ` +save=` + cast.ToString(save) + ` + +# 执行切割 +panel cutoff ${name} ${save} 2>&1 +` + } + shellDir := "/www/server/cron/" shellLogDir := "/www/server/cron/logs/" if !tools.Exists(shellDir) { @@ -77,7 +120,7 @@ func (r *CronController) Add(ctx http.Context) { return } shellFile := strconv.Itoa(int(carbon.Now().Timestamp())) + tools.RandomString(16) - if !tools.WriteFile(shellDir+shellFile+".sh", ctx.Request().Input("script"), 0700) { + if !tools.WriteFile(shellDir+shellFile+".sh", shell, 0700) { facades.Log().Error("[面板][CronController] 创建计划任务脚本失败 ", err) Error(ctx, http.StatusInternalServerError, "系统内部错误") return @@ -86,7 +129,7 @@ func (r *CronController) Add(ctx http.Context) { var cron models.Cron cron.Name = ctx.Request().Input("name") - cron.Type = "shell" + cron.Type = ctx.Request().Input("type") cron.Status = true cron.Time = ctx.Request().Input("time") cron.Shell = shellDir + shellFile + ".sh" @@ -99,7 +142,7 @@ func (r *CronController) Add(ctx http.Context) { return } - r.cron.AddToSystem(cron) + c.cron.AddToSystem(cron) Success(ctx, http.Json{ "id": cron.ID, @@ -107,7 +150,7 @@ func (r *CronController) Add(ctx http.Context) { } // Script 获取脚本内容 -func (r *CronController) Script(ctx http.Context) { +func (c *CronController) Script(ctx http.Context) { var cron models.Cron err := facades.Orm().Query().Where("id", ctx.Request().Input("id")).FirstOrFail(&cron) if err != nil { @@ -118,7 +161,7 @@ func (r *CronController) Script(ctx http.Context) { Success(ctx, tools.ReadFile(cron.Shell)) } -func (r *CronController) Update(ctx http.Context) { +func (c *CronController) Update(ctx http.Context) { validator, err := ctx.Request().Validate(map[string]string{ "name": "required|min_len:1|max_len:255", "time": "required", @@ -167,15 +210,15 @@ func (r *CronController) Update(ctx http.Context) { } tools.ExecShell("dos2unix " + cron.Shell) - r.cron.DeleteFromSystem(cron) + c.cron.DeleteFromSystem(cron) if cron.Status { - r.cron.AddToSystem(cron) + c.cron.AddToSystem(cron) } Success(ctx, nil) } -func (r *CronController) Delete(ctx http.Context) { +func (c *CronController) Delete(ctx http.Context) { var cron models.Cron err := facades.Orm().Query().Where("id", ctx.Request().Input("id")).FirstOrFail(&cron) if err != nil { @@ -183,7 +226,7 @@ func (r *CronController) Delete(ctx http.Context) { return } - r.cron.DeleteFromSystem(cron) + c.cron.DeleteFromSystem(cron) tools.RemoveFile(cron.Shell) _, err = facades.Orm().Query().Delete(&cron) @@ -196,7 +239,7 @@ func (r *CronController) Delete(ctx http.Context) { Success(ctx, nil) } -func (r *CronController) Status(ctx http.Context) { +func (c *CronController) Status(ctx http.Context) { validator, err := ctx.Request().Validate(map[string]string{ "status": "bool", }) @@ -224,15 +267,15 @@ func (r *CronController) Status(ctx http.Context) { return } - r.cron.DeleteFromSystem(cron) + c.cron.DeleteFromSystem(cron) if cron.Status { - r.cron.AddToSystem(cron) + c.cron.AddToSystem(cron) } Success(ctx, nil) } -func (r *CronController) Log(ctx http.Context) { +func (c *CronController) Log(ctx http.Context) { var cron models.Cron err := facades.Orm().Query().Where("id", ctx.Request().Input("id")).FirstOrFail(&cron) if err != nil { diff --git a/app/services/cron.go b/app/services/cron.go index 6573a42d..c7f40d29 100644 --- a/app/services/cron.go +++ b/app/services/cron.go @@ -23,8 +23,10 @@ func NewCronImpl() *CronImpl { func (r *CronImpl) AddToSystem(cron models.Cron) { if tools.IsRHEL() { tools.ExecShell("echo \"" + cron.Time + " " + cron.Shell + " >> " + cron.Log + " 2>&1\" >> /var/spool/cron/root") + tools.ExecShell("systemctl restart crond") } else { tools.ExecShell("echo \"" + cron.Time + " " + cron.Shell + " >> " + cron.Log + " 2>&1\" >> /var/spool/cron/crontabs/root") + tools.ExecShell("systemctl restart cron") } } @@ -34,7 +36,9 @@ func (r *CronImpl) DeleteFromSystem(cron models.Cron) { cron.Shell = strings.ReplaceAll(cron.Shell, "/", "\\/") if tools.IsRHEL() { tools.ExecShell("sed -i '/" + cron.Shell + "/d' /var/spool/cron/root") + tools.ExecShell("systemctl restart crond") } else { tools.ExecShell("sed -i '/" + cron.Shell + "/d' /var/spool/cron/crontabs/root") + tools.ExecShell("systemctl restart cron") } } diff --git a/pkg/tools/system.go b/pkg/tools/system.go index d0c31299..4c0db8a8 100644 --- a/pkg/tools/system.go +++ b/pkg/tools/system.go @@ -124,3 +124,29 @@ func Empty(path string) bool { return len(files) == 0 } + +// Mv 移动路径 +func Mv(src, dst string) bool { + cmd := exec.Command("mv", src, dst) + + err := cmd.Run() + if err != nil { + facades.Log().Errorf("[面板][Helpers] 移动 %s 到 %s 失败: %s", src, dst, err.Error()) + return false + } + + return true +} + +// Cp 复制路径 +func Cp(src, dst string) bool { + cmd := exec.Command("cp", "-r", src, dst) + + err := cmd.Run() + if err != nil { + facades.Log().Errorf("[面板][Helpers] 复制 %s 到 %s 失败: %s", src, dst, err.Error()) + return false + } + + return true +} diff --git a/public/index.html b/public/index.html index 336b099b..1f7db824 100644 --- a/public/index.html +++ b/public/index.html @@ -20,7 +20,7 @@ // `=---=' // // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // // 佛祖保佑 永无Bug 永不宕机 // -// Name: 耗子Linux面板 Author: 耗子 Date: 2023-06-22 // +// Name: 耗子Linux面板 Author: 耗子 Date: 2023-07-25 // //////////////////////////////////////////////////////////////////// --> @@ -32,11 +32,11 @@ - +
- + + +
请选择网站
+ + + +
@@ -26,11 +95,12 @@ Date: 2023-07-21
请务必正确填写执行周期
-
+
# 在此输入你要执行的脚本内容
+ style="height: 250px;"># 在此输入你要执行的脚本内容 +
@@ -52,13 +122,22 @@ Date: 2023-07-21 编辑 删除 + +
@@ -94,10 +173,11 @@ Date: 2023-07-21 , cols: [[ {field: 'id', hide: true, title: 'ID'} , {field: 'name', width: 150, title: '任务名', sort: true} - , {field: 'type', width: 150, title: '任务类型', sort: true} + , {field: 'type', width: 150, title: '任务类型', templet: '#cron-table-type', sort: true} , {field: 'status', title: '启用', width: 100, templet: '#cron-table-status', unresize: true} , {field: 'time', width: 200, title: '任务周期(cron表达式)'} - , {field: 'updated_at', title: '上次运行时间'} + , {field: 'created_at', title: '创建时间'} + , {field: 'updated_at', title: '最后更新时间'} , { field: 'edit', width: 180, @@ -120,8 +200,6 @@ Date: 2023-07-21 }; } }); - - // 工具条 table.on('tool(panel-cron)', function (obj) { let data = obj.data; if (obj.event === 'log') { @@ -233,6 +311,46 @@ Date: 2023-07-21 } }); + form.on('select(cron-type)', function (data) { + if (data.value === 'shell') { + $("#cron-add-backup-type-input").hide(); + $("#cron-add-save-input").hide(); + $('#cron-add-website-input').hide(); + $('#cron-add-backup-database-input').hide(); + $('#cron-add-backup-path-input').hide(); + $("#cron-add-shell").show(); + } else if (data.value === 'backup') { + let selectedType = $('input[name="backup_type"]:checked').val(); + if (selectedType === 'website') { + $('#cron-add-website-input').show(); + $('#cron-add-backup-database-input').hide(); + } else { + $('#cron-add-website-input').hide(); + $('#cron-add-backup-database-input').show(); + } + $("#cron-add-backup-type-input").show(); + $('#cron-add-backup-path-input').show(); + $("#cron-add-save-input").show(); + $("#cron-add-shell").hide(); + } else if (data.value === 'cutoff') { + $('#cron-add-website-input').show(); + $('#cron-add-save-input').show(); + $("#cron-add-shell").hide(); + $('#cron-add-backup-database-input').hide(); + $("#cron-add-backup-type-input").hide(); + $('#cron-add-backup-path-input').hide(); + } + }); + form.on('radio(cron-add-backup-type-radio)', function (data) { + if (data.value == 'website') { + $('#cron-add-website-input').show(); + $('#cron-add-backup-database-input').hide(); + } else { + $('#cron-add-website-input').hide(); + $('#cron-add-backup-database-input').show(); + } + }); + form.on('switch(cron-status)', function (obj) { let $ = layui.$; let id = $(this).data('id'); @@ -263,7 +381,6 @@ Date: 2023-07-21 , data: data.field , success: function (result) { if (result.code !== 0) { - console.log('耗子Linux面板:计划任务添加失败,接口返回' + result); layer.msg('计划任务添加失败!') return false; } @@ -274,7 +391,6 @@ Date: 2023-07-21 , btn: ['确定'] , yes: function (index) { layer.closeAll(); - //location.reload(); } }); }