diff --git a/api/waf_sys_info.go b/api/waf_sys_info.go index 51b7be1..a322910 100644 --- a/api/waf_sys_info.go +++ b/api/waf_sys_info.go @@ -168,6 +168,49 @@ func (w *WafSysInfoApi) CheckVersionApi(c *gin.Context) { } +// SystemParamsApi 返回认证后才能获取的系统参数(可扩展) +// GET /api/v1/sysinfo/systemparams +func (w *WafSysInfoApi) SystemParamsApi(c *gin.Context) { + response.OkWithDetailed(gin.H{ + "emergency_path": "/" + global.GWAF_SECURITY_EMERGENCY_PATH, + }, "获取成功", c) +} + +// RollbackListApi 列出所有可回退的备份版本 +// GET /api/v1/sysinfo/rollbacklist +func (w *WafSysInfoApi) RollbackListApi(c *gin.Context) { + list, err := wafupdate.ListBackups() + if err != nil { + response.FailWithMessage("获取备份列表失败: "+err.Error(), c) + return + } + response.OkWithDetailed(list, "获取成功", c) +} + +// RollbackApi 触发版本回退并重启 +// GET /api/v1/sysinfo/rollback?version=v1.x.x +func (w *WafSysInfoApi) RollbackApi(c *gin.Context) { + if global.GWAF_RUNTIME_IS_UPDATETING { + response.FailWithMessage("正在升级/回退中,请稍后", c) + return + } + version := c.Query("version") + global.GWAF_RUNTIME_IS_UPDATETING = true + err := wafupdate.RollbackExecutable(version) + if err != nil { + global.GWAF_RUNTIME_IS_UPDATETING = false + response.FailWithMessage("回退失败: "+err.Error(), c) + return + } + global.GQEQUE_MESSAGE_DB.Enqueue(innerbean.UpdateResultMessageInfo{ + BaseMessageInfo: innerbean.BaseMessageInfo{OperaType: "系统即将重启", Server: global.GWAF_CUSTOM_SERVER_NAME}, + Msg: "版本回退成功,等待重启", + Success: "true", + }) + global.GWAF_CHAN_UPDATE <- 1 + response.OkWithMessage("已发起回退,等待通知结果", c) +} + // 去升级 func (w *WafSysInfoApi) UpdateApi(c *gin.Context) { // 获取请求中的 channel 参数 diff --git a/build_test_update.bat b/build_test_update.bat new file mode 100644 index 0000000..5cc2fff --- /dev/null +++ b/build_test_update.bat @@ -0,0 +1,36 @@ +@echo off +setlocal + +set "CURDIR=%~dp0" +set "CURDIR=%CURDIR:~0,-1%" + +SET CGO_ENABLED=1 +SET GOOS=windows +SET GOARCH=amd64 +SET GIN_MODE=release + +:: ---- Step 1: Build v1.1.0 ---- +echo [1/3] Building v1.1.0... +if not exist "%CURDIR%\release\githubci\v1.1.0" mkdir "%CURDIR%\release\githubci\v1.1.0" +go build -ldflags="-X SamWaf/global.GWAF_RELEASE=true -X SamWaf/global.GWAF_RELEASE_VERSION_NAME=20260224 -X SamWaf/global.GWAF_RELEASE_VERSION=v1.1.0 -X SamWaf/global.GUPDATE_VERSION_URL=http://127.0.0.1:8111/ -s -w" -o "%CURDIR%\release\githubci\v1.1.0\SamWaf64.exe" ./cmd/samwaf/main.go +if %ERRORLEVEL% neq 0 ( echo FAILED: v1.1.0 build error & pause & exit /b 1 ) +echo OK: release\githubci\v1.1.0\SamWaf64.exe + +:: ---- Step 2: Build v1.1.1 ---- +echo [2/3] Building v1.1.1... +if not exist "%CURDIR%\release\githubci\v1.1.1" mkdir "%CURDIR%\release\githubci\v1.1.1" +go build -ldflags="-X SamWaf/global.GWAF_RELEASE=true -X SamWaf/global.GWAF_RELEASE_VERSION_NAME=20260224 -X SamWaf/global.GWAF_RELEASE_VERSION=v1.1.1 -X SamWaf/global.GUPDATE_VERSION_URL=http://127.0.0.1:8111/ -s -w" -o "%CURDIR%\release\githubci\v1.1.1\SamWaf64.exe" ./cmd/samwaf/main.go +if %ERRORLEVEL% neq 0 ( echo FAILED: v1.1.1 build error & pause & exit /b 1 ) +echo OK: release\githubci\v1.1.1\SamWaf64.exe + +:: ---- Step 3: Package v1.1.1 update ---- +echo [3/3] Packaging v1.1.1... +"%CURDIR%\setup\go_gen_updatefile\go_gen_updatefile.exe" -desc "local-test-1.1.1" -o "%CURDIR%\release\web\samwaf_update" -platform windows-amd64 "%CURDIR%\release\githubci\v1.1.1\SamWaf64.exe" v1.1.1 +if %ERRORLEVEL% neq 0 ( echo FAILED: package error & pause & exit /b 1 ) +echo OK: release\web\samwaf_update\v1.1.1\windows-amd64.gz + +echo. +echo All done. Start v1.1.0 to test upgrade. +echo. +pause +endlocal diff --git a/cmd/samwaf/main.go b/cmd/samwaf/main.go index 50b9f10..3f4f453 100644 --- a/cmd/samwaf/main.go +++ b/cmd/samwaf/main.go @@ -29,6 +29,7 @@ import ( "SamWaf/wafsnowflake" "SamWaf/waftask" "SamWaf/waftunnelengine" + "SamWaf/wafupdate" "crypto/tls" "embed" _ "embed" @@ -926,6 +927,74 @@ func main() { fmt.Println("\n💻 SQL 执行工具") fmt.Println("可以在指定数据库上执行 SQL 语句\n") wafdb.ExecuteSQLCommand("") + case "rollback": //版本回退 + fmt.Println("================================================") + fmt.Println(" SamWaf 版本回退工具") + fmt.Println("================================================") + fmt.Printf("当前运行版本: %s\n\n", global.GWAF_RELEASE_VERSION) + + list, err := wafupdate.ListBackups() + if err != nil { + fmt.Println("获取备份列表失败:", err) + return + } + if len(list) == 0 { + fmt.Println("没有可用的备份版本,无法回退") + return + } + + fmt.Printf("%-4s %-15s %-22s %-10s %s\n", "序号", "版本", "备份时间", "大小(MB)", "备注") + fmt.Println("------------------------------------------------------------------------") + for i, b := range list { + note := "" + if b.Version == global.GWAF_RELEASE_VERSION { + note = "[当前版本]" + } + fmt.Printf("%-4d %-15s %-22s %-10.2f %s\n", + i+1, + b.Version, + b.BackupTime.Format("2006-01-02 15:04:05"), + float64(b.FileSize)/(1024*1024), + note) + } + fmt.Println("------------------------------------------------------------------------") + + fmt.Print("\n请输入要回退的序号,或输入 'q' 退出: ") + var input string + fmt.Scanln(&input) + if input == "q" || input == "Q" { + fmt.Println("已退出版本回退工具") + return + } + + idx := 0 + _, parseErr := fmt.Sscanf(input, "%d", &idx) + if parseErr != nil || idx < 1 || idx > len(list) { + fmt.Printf("无效的序号: %s\n", input) + return + } + + target := list[idx-1] + if target.Version == global.GWAF_RELEASE_VERSION { + fmt.Printf("所选版本 %s 与当前运行版本相同,无需回退\n", target.Version) + return + } + + fmt.Printf("\n即将回退到: %s(%s)\n", target.Version, target.BackupTime.Format("2006-01-02 15:04:05")) + fmt.Print("确认回退?回退后需要手动重启服务 (y/n): ") + var confirm string + fmt.Scanln(&confirm) + if confirm != "y" && confirm != "Y" { + fmt.Println("已取消") + return + } + + fmt.Printf("正在回退到版本 %s...\n", target.Version) + if rollbackErr := wafupdate.RollbackExecutable(target.Version); rollbackErr != nil { + fmt.Println("回退失败:", rollbackErr) + return + } + fmt.Println("回退成功,请重启服务 (samwaf start 或 samwaf restart)") default: fmt.Printf("Command '%s' is not recognized.\n", command) fmt.Println("\n可用命令:") @@ -938,6 +1007,7 @@ func main() { fmt.Println(" resetotp - 重置安全码") fmt.Println(" repairdb - 修复损坏的数据库") fmt.Println(" execsql - 执行SQL语句(支持SELECT/UPDATE/DELETE等)") + fmt.Println(" rollback - 回退到历史版本 (--list 列出, --version=v1.x.x 指定版本)") fmt.Println("") } return diff --git a/global/global.go b/global/global.go index 8b641c2..f7cdf92 100644 --- a/global/global.go +++ b/global/global.go @@ -76,10 +76,11 @@ var ( GWAF_TENANT_ID string = "SamWafCom" // 当前租户ID //管理端访问控制 - GWAF_IP_WHITELIST string = "0.0.0.0/0,::/0" //IP白名单 后台默认放行所有 - GWAF_SSL_ENABLE bool = false //是否启用SSL证书 - GWAF_SECURITY_ENTRY_ENABLE bool = false //是否启用安全路径入口 - GWAF_SECURITY_ENTRY_PATH string = "" //安全路径(18位随机码) + GWAF_IP_WHITELIST string = "0.0.0.0/0,::/0" //IP白名单 后台默认放行所有 + GWAF_SSL_ENABLE bool = false //是否启用SSL证书 + GWAF_SECURITY_ENTRY_ENABLE bool = false //是否启用安全路径入口 + GWAF_SECURITY_ENTRY_PATH string = "" //安全路径(18位随机码) + GWAF_SECURITY_EMERGENCY_PATH string = "" //应急恢复路径(随机生成,首次启动自动写入 conf/config.yml) //zlog 日志相关信息 GWAF_LOG_OUTPUT_FORMAT string = "console" //zlog输出格式 控制台格式console,json格式 diff --git a/router/waf_sysinfo.go b/router/waf_sysinfo.go index b4c5bb8..6ea117e 100644 --- a/router/waf_sysinfo.go +++ b/router/waf_sysinfo.go @@ -15,4 +15,7 @@ func (receiver *WebSysInfoRouter) InitSysInfoRouter(group *gin.RouterGroup) { router.GET("/api/v1/sysinfo/checkversion", api.CheckVersionApi) router.GET("/api/v1/sysinfo/update", api.UpdateApi) router.GET("/api/v1/sysinfo/announcement", api.GetAnnouncementApi) + router.GET("/api/v1/sysinfo/systemparams", api.SystemParamsApi) + router.GET("/api/v1/sysinfo/rollbacklist", api.RollbackListApi) + router.GET("/api/v1/sysinfo/rollback", api.RollbackApi) } diff --git a/wafconfig/wafconfig.go b/wafconfig/wafconfig.go index fda5c7e..4566725 100644 --- a/wafconfig/wafconfig.go +++ b/wafconfig/wafconfig.go @@ -187,6 +187,21 @@ func LoadAndInitConfig() { fmt.Printf("%s\tINFO\t安全路径入口已启用,自动生成访问码: %s\n", currentTime, global.GWAF_SECURITY_ENTRY_PATH) } + //配置和提取应急路径 + if config.IsSet("security.emergency_path") { + global.GWAF_SECURITY_EMERGENCY_PATH = config.GetString("security.emergency_path") + } else { + config.Set("security.emergency_path", "") + configChanged = true + } + //应急路径为空时自动生成(首次启动或手动清空后重启均会重新生成) + if global.GWAF_SECURITY_EMERGENCY_PATH == "" { + global.GWAF_SECURITY_EMERGENCY_PATH = generateSecurityEntryPath() + config.Set("security.emergency_path", global.GWAF_SECURITY_EMERGENCY_PATH) + configChanged = true + fmt.Printf("%s\tINFO\t应急恢复路径已生成: %s\n", currentTime, global.GWAF_SECURITY_EMERGENCY_PATH) + } + // 只有在配置发生变化时才写入文件 if configChanged { err := config.WriteConfig() diff --git a/wafmangeweb/emergency_page.html b/wafmangeweb/emergency_page.html new file mode 100644 index 0000000..a2c7433 --- /dev/null +++ b/wafmangeweb/emergency_page.html @@ -0,0 +1,397 @@ + + + + + +SamWaf 紧急恢复 + + + +
+

SamWaf 紧急恢复模式

+

当前版本无法正常访问时,可在此执行版本回退

+ +
+ + +
+
请使用管理员账号登录以继续操作
+
+ + +
+
+ + +
+ + +
+ + +
+ +
选择要回退到的版本,回退后服务将自动重启
+
加载中...
+
+ + + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+

确认回退版本

+

+
+ + +
+
+
+ + + + diff --git a/wafmangeweb/localserver.go b/wafmangeweb/localserver.go index 149a77c..c68813a 100644 --- a/wafmangeweb/localserver.go +++ b/wafmangeweb/localserver.go @@ -11,6 +11,7 @@ import ( "SamWaf/wafmangeweb/static" "context" "crypto/tls" + _ "embed" "errors" "fmt" "io" @@ -20,12 +21,16 @@ import ( "os" "path/filepath" "strconv" + "strings" "time" "github.com/gin-contrib/pprof" "github.com/gin-gonic/gin" ) +//go:embed emergency_page.html +var emergencyPageHTML string + type WafWebManager struct { HttpServer *http.Server R *gin.Engine @@ -170,6 +175,19 @@ func (web *WafWebManager) initRouter(r *gin.Engine) { // 保存 gin.Engine 引用供 API 文档生成使用 api.GinEngineRef = r + // 应急恢复页面(随机路径,无需认证,先于 NoRoute/静态文件注册) + if global.GWAF_SECURITY_EMERGENCY_PATH != "" { + emergencyPath := "/" + global.GWAF_SECURITY_EMERGENCY_PATH + // 注入应急路径占位符,供页面 JS 推导 API 基础路径 + renderedPage := strings.ReplaceAll(emergencyPageHTML, "{{EMERGENCY_PATH}}", global.GWAF_SECURITY_EMERGENCY_PATH) + r.GET(emergencyPath, func(c *gin.Context) { + c.Header("Content-Type", "text/html; charset=utf-8") + c.Header("Cache-Control", "no-cache, no-store, must-revalidate") + c.String(200, renderedPage) + }) + zlog.Info(web.LogName, "emergency page registered at: "+emergencyPath) + } + if global.GWAF_RELEASE == "true" { static.Static(r, func(handlers ...gin.HandlerFunc) { r.NoRoute(handlers...) diff --git a/wafupdate/selfupdate.go b/wafupdate/selfupdate.go index 02e0461..32f196e 100644 --- a/wafupdate/selfupdate.go +++ b/wafupdate/selfupdate.go @@ -19,6 +19,7 @@ import ( "os" "path/filepath" "runtime" + "sort" "strings" "time" ) @@ -690,30 +691,170 @@ func writeTime(path string, t time.Time) bool { return ioutil.WriteFile(path, []byte(t.Format(time.RFC3339)), 0644) == nil } -// BackupExecutable 备份当前可执行文件 +// BackupExecutable 备份当前可执行文件,并写入版本号 sidecar 文件 func BackupExecutable() error { - // 获取当前可执行文件路径 execPath, err := os.Executable() if err != nil { return err } - - // 如果是符号链接,获取实际路径 if resolvedPath, err := filepath.EvalSymlinks(execPath); err == nil { execPath = resolvedPath } - // 获取当前目录 currentDir := utils.GetCurrentDir() - - // 创建备份目录 backupDir := filepath.Join(currentDir, "data", "backups_bin") - - // 获取文件名(不带路径) fileName := filepath.Base(execPath) fileNameWithoutExt := strings.TrimSuffix(fileName, filepath.Ext(fileName)) - // 备份文件 - _, err = utils.BackupFile(execPath, backupDir, fileNameWithoutExt, 5) - return err + backupPath, err := utils.BackupFile(execPath, backupDir, fileNameWithoutExt, 5) + if err != nil { + return err + } + // 写版本 sidecar,供 ListBackups 读取 + sidecarPath := strings.TrimSuffix(backupPath, filepath.Ext(backupPath)) + ".version" + _ = os.WriteFile(sidecarPath, []byte(global.GWAF_RELEASE_VERSION), 0644) + return nil +} + +// BackupInfo 描述一个备份版本的元信息 +type BackupInfo struct { + FileName string `json:"file_name"` + Version string `json:"version"` // 来自 sidecar;旧备份无 sidecar 时为 "unknown" + BackupTime time.Time `json:"backup_time"` // 文件 ModTime + FileSize int64 `json:"file_size"` +} + +// ListBackups 列出所有可用的备份版本,按时间倒序(最新在前) +func ListBackups() ([]BackupInfo, error) { + currentDir := utils.GetCurrentDir() + backupDir := filepath.Join(currentDir, "data", "backups_bin") + + entries, err := os.ReadDir(backupDir) + if err != nil { + if os.IsNotExist(err) { + return []BackupInfo{}, nil + } + return nil, err + } + + execPath, err := os.Executable() + if err != nil { + return nil, err + } + if resolvedPath, err := filepath.EvalSymlinks(execPath); err == nil { + execPath = resolvedPath + } + fileName := filepath.Base(execPath) + fileNameWithoutExt := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + + var list []BackupInfo + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + // 跳过 sidecar 文件 + if strings.HasSuffix(name, ".version") { + continue + } + // 只处理与当前程序同名前缀的备份 + if !strings.HasPrefix(name, fileNameWithoutExt+"_") { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + bi := BackupInfo{ + FileName: name, + Version: "unknown", + BackupTime: info.ModTime(), + FileSize: info.Size(), + } + // 尝试读取版本 sidecar + sidecarPath := filepath.Join(backupDir, strings.TrimSuffix(name, filepath.Ext(name))+".version") + if versionBytes, err := os.ReadFile(sidecarPath); err == nil { + bi.Version = strings.TrimSpace(string(versionBytes)) + } + list = append(list, bi) + } + + sort.Slice(list, func(i, j int) bool { + return list[i].BackupTime.After(list[j].BackupTime) + }) + return list, nil +} + +// RollbackExecutable 将可执行文件回退到指定版本的备份 +// version 为空时取最新备份 +func RollbackExecutable(version string) error { + list, err := ListBackups() + if err != nil { + return fmt.Errorf("列出备份失败: %w", err) + } + if len(list) == 0 { + return fmt.Errorf("没有可用的备份版本") + } + + var target *BackupInfo + if version == "" { + target = &list[0] + } else { + for i := range list { + if list[i].Version == version { + target = &list[i] + break + } + } + } + if target == nil { + return fmt.Errorf("未找到版本 %s 的备份", version) + } + + execPath, err := os.Executable() + if err != nil { + return err + } + if resolvedPath, err := filepath.EvalSymlinks(execPath); err == nil { + execPath = resolvedPath + } + + updateDir := filepath.Dir(execPath) + filename := filepath.Base(execPath) + + currentDir := utils.GetCurrentDir() + backupDir := filepath.Join(currentDir, "data", "backups_bin") + backupFilePath := filepath.Join(backupDir, target.FileName) + + // 将备份复制到临时文件 + rollbackPath := filepath.Join(updateDir, fmt.Sprintf(".%s.rollback", filename)) + src, err := os.Open(backupFilePath) + if err != nil { + return fmt.Errorf("打开备份文件失败: %w", err) + } + dst, err := os.OpenFile(rollbackPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + src.Close() + return fmt.Errorf("创建临时文件失败: %w", err) + } + _, copyErr := io.Copy(dst, src) + src.Close() + dst.Close() + if copyErr != nil { + return fmt.Errorf("复制备份文件失败: %w", copyErr) + } + + // 与 fromStream() 相同的 rename 技巧(Windows 运行中的 exe 允许 rename 但不允许 overwrite) + oldPath := filepath.Join(updateDir, fmt.Sprintf(".%s.old", filename)) + _ = os.Remove(oldPath) + + if err = os.Rename(execPath, oldPath); err != nil { + return fmt.Errorf("重命名当前程序失败: %w", err) + } + if err = os.Rename(rollbackPath, execPath); err != nil { + // 尝试恢复 + _ = os.Rename(oldPath, execPath) + return fmt.Errorf("替换程序失败: %w", err) + } + return nil }