feat:add version rollback

#250 #801
This commit is contained in:
samwaf
2026-05-11 16:58:52 +08:00
parent c758a09187
commit a1b22cd437
9 changed files with 740 additions and 16 deletions
+43
View File
@@ -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 参数
+36
View File
@@ -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
+70
View File
@@ -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
View File
@@ -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格式
+3
View File
@@ -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)
}
+15
View File
@@ -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()
+397
View File
@@ -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 paddingKey = "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= 参数读取回跳 URLApp.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>
</body>
</html>
+18
View File
@@ -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
View File
@@ -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
}