mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-09-19 01:27:58 +08:00
@@ -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 参数
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+5
-4
@@ -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格式
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SamWaf 紧急恢复</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f0f2f5; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||
.container { background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); padding: 32px; width: 100%; max-width: 680px; }
|
||||
h1 { font-size: 20px; color: #1f2329; margin-bottom: 6px; }
|
||||
.subtitle { color: #646a73; font-size: 14px; margin-bottom: 24px; }
|
||||
.alert { background: #fff7e6; border: 1px solid #ffd591; border-radius: 6px; padding: 12px 16px; margin-bottom: 20px; color: #874d00; font-size: 14px; }
|
||||
.alert.danger { background: #fff2f0; border-color: #ffccc7; color: #a8071a; }
|
||||
.alert.success { background: #f6ffed; border-color: #b7eb8f; color: #135200; }
|
||||
label { display: block; font-size: 14px; color: #1f2329; margin-bottom: 6px; font-weight: 500; }
|
||||
input { width: 100%; padding: 8px 12px; border: 1px solid #dcdfe6; border-radius: 6px; font-size: 14px; outline: none; transition: border-color .2s; }
|
||||
input:focus { border-color: #0052d9; }
|
||||
.btn { display: inline-block; padding: 8px 20px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; transition: opacity .2s; }
|
||||
.btn-primary { background: #0052d9; color: #fff; }
|
||||
.btn-primary:hover { opacity: .85; }
|
||||
.btn-danger { background: #e34d59; color: #fff; }
|
||||
.btn-danger:hover { opacity: .85; }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.form-item { margin-bottom: 16px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 13px; }
|
||||
th { text-align: left; padding: 10px 12px; background: #f5f7fa; color: #646a73; font-weight: 500; border-bottom: 1px solid #e8eaed; }
|
||||
td { padding: 10px 12px; border-bottom: 1px solid #f0f2f5; color: #1f2329; }
|
||||
tr:hover td { background: #f5f7fa; }
|
||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; background: #e6f0ff; color: #0052d9; }
|
||||
.tag.unknown { background: #f0f2f5; color: #646a73; }
|
||||
#loginSection, #backupSection, #msgSection { display: none; }
|
||||
#loginSection.active, #backupSection.active, #msgSection.active { display: block; }
|
||||
.actions { display: flex; gap: 10px; margin-top: 20px; }
|
||||
.loading { color: #646a73; font-size: 14px; padding: 20px 0; text-align: center; }
|
||||
.confirm-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 100; align-items: center; justify-content: center; }
|
||||
.confirm-overlay.active { display: flex; }
|
||||
.confirm-box { background: #fff; border-radius: 8px; padding: 24px; max-width: 400px; width: 90%; }
|
||||
.confirm-box h3 { margin-bottom: 12px; color: #1f2329; }
|
||||
.confirm-box p { color: #646a73; font-size: 14px; margin-bottom: 20px; }
|
||||
.confirm-box .actions { justify-content: flex-end; }
|
||||
.version-bar { display: flex; align-items: center; justify-content: space-between; background: #f5f7fa; border: 1px solid #e8eaed; border-radius: 6px; padding: 10px 14px; margin-bottom: 16px; font-size: 13px; color: #1f2329; }
|
||||
.version-bar .ver-label { color: #646a73; margin-right: 6px; }
|
||||
.version-bar .ver-val { font-weight: 600; color: #0052d9; }
|
||||
.btn-back { background: #f0f2f5; color: #646a73; }
|
||||
.btn-back:hover { opacity: .85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>SamWaf 紧急恢复模式</h1>
|
||||
<p class="subtitle">当前版本无法正常访问时,可在此执行版本回退</p>
|
||||
|
||||
<div id="alertBox"></div>
|
||||
|
||||
<!-- 登录区域 -->
|
||||
<div id="loginSection">
|
||||
<div class="alert">请使用管理员账号登录以继续操作</div>
|
||||
<div class="form-item">
|
||||
<label>账号</label>
|
||||
<input id="inputAccount" type="text" placeholder="管理员账号" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>密码</label>
|
||||
<input id="inputPassword" type="password" placeholder="密码" />
|
||||
</div>
|
||||
<div class="form-item" id="otpRow" style="display:none">
|
||||
<label>安全码 (OTP)</label>
|
||||
<input id="inputOtp" type="password" placeholder="动态安全码" />
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btnLogin">登录</button>
|
||||
</div>
|
||||
|
||||
<!-- 备份列表区域 -->
|
||||
<div id="backupSection">
|
||||
<div id="versionBar" class="version-bar" style="display:none">
|
||||
<div><span class="ver-label">当前运行版本</span><span class="ver-val" id="verValue">-</span><span class="ver-label" id="verName" style="margin-left:8px"></span></div>
|
||||
</div>
|
||||
<div class="alert">选择要回退到的版本,回退后服务将自动重启</div>
|
||||
<div id="backupTableWrap"><div class="loading">加载中...</div></div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" id="btnRefresh" onclick="loadBackups()">刷新列表</button>
|
||||
<button class="btn btn-back" id="btnBack" style="display:none" onclick="goBack()">返回管理界面</button>
|
||||
<button class="btn" onclick="doLogout()" style="background:#f0f2f5;color:#646a73">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息区域 -->
|
||||
<div id="msgSection">
|
||||
<div id="msgContent" class="alert success"></div>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<button class="btn btn-primary" onclick="location.reload()">刷新页面</button>
|
||||
<button class="btn btn-back" id="btnBackMsg" style="display:none" onclick="goBack()">返回管理界面</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认对话框 -->
|
||||
<div class="confirm-overlay" id="confirmOverlay">
|
||||
<div class="confirm-box">
|
||||
<h3>确认回退版本</h3>
|
||||
<p id="confirmMsg"></p>
|
||||
<div class="actions">
|
||||
<button class="btn" onclick="closeConfirm()" style="background:#f0f2f5;color:#646a73">取消</button>
|
||||
<button class="btn btn-danger" id="btnConfirmRollback">确认回退</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 从当前 URL 推断 API 基础路径(兼容安全路径前缀)
|
||||
var EMERGENCY_PATH = '{{EMERGENCY_PATH}}';
|
||||
(function() {
|
||||
var fullPath = window.location.pathname;
|
||||
var suffix = '/' + EMERGENCY_PATH;
|
||||
window._apiBase = fullPath.endsWith(suffix)
|
||||
? fullPath.slice(0, fullPath.length - suffix.length)
|
||||
: '';
|
||||
})();
|
||||
|
||||
function apiUrl(path) {
|
||||
return window._apiBase + path;
|
||||
}
|
||||
|
||||
// 简单 UUID 生成(不依赖 crypto,每次调用必须不同)
|
||||
function genUUID() {
|
||||
var t = Date.now();
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
var r = (t + Math.random() * 16) % 16 | 0;
|
||||
t = Math.floor(t / 16);
|
||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
// 构造每次请求必须携带的标准请求头
|
||||
function reqHeaders(extra) {
|
||||
var h = Object.assign({
|
||||
'X-Request-Time': Math.floor(Date.now() / 1000).toString(),
|
||||
'X-Request-Id': genUUID()
|
||||
}, extra || {});
|
||||
return h;
|
||||
}
|
||||
|
||||
// ─── AES-CBC 解密(Web Crypto API,与后端 wafsec/aes.go 对称)───────────────
|
||||
// 格式:Base64(随机16字节IV ‖ 密文),PKCS7 padding,Key = "7E@u*has$d*@s5YX"
|
||||
var _AES_KEY_RAW = '7E@u*has$d*@s5YX';
|
||||
|
||||
function _b64ToBytes(b64) {
|
||||
var bin = atob(b64);
|
||||
var bytes = new Uint8Array(bin.length);
|
||||
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function _bytesToB64(bytes) {
|
||||
var bin = '';
|
||||
for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
var _cryptoKeyPromise = null;
|
||||
function _getAesKey() {
|
||||
if (!_cryptoKeyPromise) {
|
||||
var keyBytes = new TextEncoder().encode(_AES_KEY_RAW);
|
||||
_cryptoKeyPromise = crypto.subtle.importKey('raw', keyBytes, { name: 'AES-CBC' }, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
return _cryptoKeyPromise;
|
||||
}
|
||||
|
||||
function aesDecrypt(base64Str) {
|
||||
return _getAesKey().then(function(key) {
|
||||
var data = _b64ToBytes(base64Str);
|
||||
var iv = data.slice(0, 16);
|
||||
var ciphertext = data.slice(16);
|
||||
return crypto.subtle.decrypt({ name: 'AES-CBC', iv: iv }, key, ciphertext);
|
||||
}).then(function(plainBuf) {
|
||||
// 去掉 PKCS7 padding
|
||||
var plain = new Uint8Array(plainBuf);
|
||||
var pad = plain[plain.length - 1];
|
||||
if (pad > 0 && pad <= 16) plain = plain.slice(0, plain.length - pad);
|
||||
return new TextDecoder().decode(plain);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 统一 fetch 封装:自动解密 code===0 时的 data 字段 ─────────────────────
|
||||
function apiFetch(url, options) {
|
||||
return fetch(url, options)
|
||||
.then(function(r) {
|
||||
if (r.status === 401 || r.status === 403) {
|
||||
doLogout();
|
||||
throw new Error('认证失败,请重新登录');
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(function(json) {
|
||||
if (json.code === 0 && json.data && typeof json.data === 'string' && json.data.length > 0) {
|
||||
return aesDecrypt(json.data).then(function(plain) {
|
||||
json.data = JSON.parse(plain);
|
||||
return json;
|
||||
});
|
||||
}
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
// 从 ?back= 参数读取回跳 URL(App.vue enterEmergencyMode 传入)
|
||||
var backUrl = (function() {
|
||||
try {
|
||||
var p = new URLSearchParams(window.location.search);
|
||||
var u = p.get('back');
|
||||
if (!u) return '';
|
||||
// 仅允许同源或相对路径,防止开放重定向
|
||||
var parsed = new URL(u, window.location.origin);
|
||||
return parsed.origin === window.location.origin ? parsed.href : '';
|
||||
} catch (e) { return ''; }
|
||||
})();
|
||||
|
||||
function goBack() {
|
||||
if (backUrl) window.location.href = backUrl;
|
||||
}
|
||||
|
||||
function _showBackBtn() {
|
||||
if (backUrl) {
|
||||
document.getElementById('btnBack').style.display = '';
|
||||
document.getElementById('btnBackMsg').style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
var token = '';
|
||||
|
||||
function showAlert(msg, type) {
|
||||
var el = document.getElementById('alertBox');
|
||||
el.innerHTML = msg ? '<div class="alert ' + (type||'') + '">' + escHtml(msg) + '</div>' : '';
|
||||
}
|
||||
|
||||
function showSection(name) {
|
||||
['loginSection','backupSection','msgSection'].forEach(function(s) {
|
||||
document.getElementById(s).classList.remove('active');
|
||||
});
|
||||
document.getElementById(name).classList.add('active');
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
token = '';
|
||||
showSection('loginSection');
|
||||
showAlert('');
|
||||
}
|
||||
|
||||
var currentVersion = '';
|
||||
|
||||
function loadVersion() {
|
||||
return apiFetch(apiUrl('/api/v1/sysinfo/version'), {
|
||||
headers: reqHeaders({ 'X-Token': token })
|
||||
}).then(function(res) {
|
||||
if (res.code === 0 && res.data) {
|
||||
currentVersion = res.data.version || '';
|
||||
var name = res.data.version_name ? '(' + res.data.version_name + ')' : '';
|
||||
document.getElementById('verValue').textContent = currentVersion || '-';
|
||||
document.getElementById('verName').textContent = name;
|
||||
document.getElementById('versionBar').style.display = '';
|
||||
}
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
function enterBackupSection() {
|
||||
showSection('backupSection');
|
||||
_showBackBtn();
|
||||
// 先拿版本号再渲染备份列表,避免比对时版本还未就绪
|
||||
loadVersion().then(function() { loadBackups(); });
|
||||
}
|
||||
|
||||
// 初始化:检查 localStorage 里是否已有 token
|
||||
(function init() {
|
||||
var t = localStorage.getItem('access_token');
|
||||
if (t) {
|
||||
token = t;
|
||||
enterBackupSection();
|
||||
} else {
|
||||
showSection('loginSection');
|
||||
}
|
||||
document.getElementById('btnLogin').addEventListener('click', doLogin);
|
||||
})();
|
||||
|
||||
function doLogin() {
|
||||
var account = document.getElementById('inputAccount').value.trim();
|
||||
var password = document.getElementById('inputPassword').value;
|
||||
var otp = document.getElementById('inputOtp').value;
|
||||
if (!account || !password) { showAlert('请填写账号和密码', 'danger'); return; }
|
||||
|
||||
// 登录请求发送明文 JSON(后端 SecApi 中间件不解密普通 web JSON POST)
|
||||
apiFetch(apiUrl('/api/v1/login'), {
|
||||
method: 'POST',
|
||||
headers: reqHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ login_account: account, login_password: password, login_otp_secret_code: otp })
|
||||
})
|
||||
.then(function(res) {
|
||||
if (res.code === 0) {
|
||||
token = res.data.access_token;
|
||||
localStorage.setItem('access_token', token);
|
||||
showAlert('');
|
||||
enterBackupSection();
|
||||
} else if (res.code === -2) {
|
||||
document.getElementById('otpRow').style.display = '';
|
||||
showAlert(res.msg || '需要安全码', 'danger');
|
||||
} else {
|
||||
showAlert(res.msg || '登录失败', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(function(e) { showAlert('请求失败: ' + e.message, 'danger'); });
|
||||
}
|
||||
|
||||
function loadBackups() {
|
||||
var wrap = document.getElementById('backupTableWrap');
|
||||
wrap.innerHTML = '<div class="loading">加载中...</div>';
|
||||
|
||||
apiFetch(apiUrl('/api/v1/sysinfo/rollbacklist'), {
|
||||
headers: reqHeaders({ 'X-Token': token })
|
||||
})
|
||||
.then(function(res) {
|
||||
if (res.code !== 0) { showAlert(res.msg || '获取失败', 'danger'); wrap.innerHTML = ''; return; }
|
||||
var list = res.data;
|
||||
if (!list || list.length === 0) {
|
||||
wrap.innerHTML = '<div class="loading">暂无备份版本</div>';
|
||||
return;
|
||||
}
|
||||
var html = '<table><thead><tr><th>版本</th><th>备份时间</th><th>大小</th><th>操作</th></tr></thead><tbody>';
|
||||
list.forEach(function(b, i) {
|
||||
var isCurrent = currentVersion && b.version === currentVersion;
|
||||
var vTag = b.version === 'unknown'
|
||||
? '<span class="tag unknown">unknown</span>'
|
||||
: '<span class="tag">' + escHtml(b.version) + '</span>';
|
||||
if (isCurrent) vTag += ' <span class="tag" style="background:#f6ffed;color:#135200;margin-left:4px">运行中</span>';
|
||||
var ts = new Date(b.backup_time).toLocaleString('zh-CN');
|
||||
var size = (b.file_size / 1024 / 1024).toFixed(2) + ' MB';
|
||||
var btn = isCurrent
|
||||
? '<button class="btn btn-danger" disabled title="当前正在运行此版本,无需回退">回退</button>'
|
||||
: '<button class="btn btn-danger" onclick="askRollback(' + i + ')">回退</button>';
|
||||
html += '<tr>'
|
||||
+ '<td>' + vTag + '</td>'
|
||||
+ '<td>' + ts + '</td>'
|
||||
+ '<td>' + size + '</td>'
|
||||
+ '<td>' + btn + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
wrap.innerHTML = html;
|
||||
wrap._list = list;
|
||||
})
|
||||
.catch(function(e) { showAlert(e.message, 'danger'); wrap.innerHTML = ''; });
|
||||
}
|
||||
|
||||
var pendingVersion = '';
|
||||
function askRollback(idx) {
|
||||
var list = document.getElementById('backupTableWrap')._list || [];
|
||||
var b = list[idx];
|
||||
if (!b) return;
|
||||
pendingVersion = b.version;
|
||||
document.getElementById('confirmMsg').textContent =
|
||||
'确定将程序回退到版本 ' + b.version + '(' + b.file_name + ')?\n回退后服务将自动重启,请耐心等待。';
|
||||
document.getElementById('confirmOverlay').classList.add('active');
|
||||
document.getElementById('btnConfirmRollback').onclick = doRollback;
|
||||
}
|
||||
|
||||
function closeConfirm() {
|
||||
document.getElementById('confirmOverlay').classList.remove('active');
|
||||
}
|
||||
|
||||
function doRollback() {
|
||||
closeConfirm();
|
||||
var btn = document.getElementById('btnRefresh');
|
||||
btn.disabled = true;
|
||||
showAlert('正在执行回退,请稍候...', '');
|
||||
|
||||
apiFetch(apiUrl('/api/v1/sysinfo/rollback?version=' + encodeURIComponent(pendingVersion)), {
|
||||
headers: reqHeaders({ 'X-Token': token })
|
||||
})
|
||||
.then(function(res) {
|
||||
btn.disabled = false;
|
||||
if (res.code === 0) {
|
||||
document.getElementById('msgContent').textContent = '回退已触发,服务正在重启,请稍后手动刷新页面...';
|
||||
showSection('msgSection');
|
||||
} else {
|
||||
showAlert('回退失败: ' + (res.msg || '未知错误'), 'danger');
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
btn.disabled = false;
|
||||
showAlert('请求失败: ' + e.message, 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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...)
|
||||
|
||||
+153
-12
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user