mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-31 02:01:44 +08:00
+35
-7
@@ -3,6 +3,7 @@ setlocal
|
||||
|
||||
set "CURDIR=%~dp0"
|
||||
set "CURDIR=%CURDIR:~0,-1%"
|
||||
set "TESTDIR=%CURDIR%\release\testupdate"
|
||||
|
||||
SET CGO_ENABLED=1
|
||||
SET GOOS=windows
|
||||
@@ -10,27 +11,54 @@ SET GOARCH=amd64
|
||||
SET GIN_MODE=release
|
||||
|
||||
:: ---- Step 1: Build v1.1.0 ----
|
||||
echo [1/3] Building v1.1.0...
|
||||
echo [1/6] 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...
|
||||
:: ---- Step 2: Stop test backend and deploy v1.1.0 ----
|
||||
echo [2/6] Stopping test backend and deploying v1.1.0...
|
||||
if not exist "%TESTDIR%" mkdir "%TESTDIR%"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$d='%TESTDIR%'; $p=Get-Process -Name SamWaf64 -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($d, 'OrdinalIgnoreCase') }; if ($p) { Write-Host ('stopping ' + $p.Count + ' process(es)'); $p | Stop-Process -Force; Start-Sleep -Milliseconds 1500 } else { Write-Host 'no running process' }"
|
||||
copy /y "%CURDIR%\release\githubci\v1.1.0\SamWaf64.exe" "%TESTDIR%\SamWaf64.exe" >nul
|
||||
if %ERRORLEVEL% neq 0 ( echo FAILED: copy to test backend error ^(file locked?^) & pause & exit /b 1 )
|
||||
echo OK: %TESTDIR%\SamWaf64.exe
|
||||
|
||||
:: ---- Step 3: Build v1.1.1 ----
|
||||
echo [3/6] 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...
|
||||
:: ---- Step 4: Package v1.1.1 update ----
|
||||
echo [4/6] 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
|
||||
|
||||
:: ---- Step 5: Start test backend service ----
|
||||
echo [5/6] Starting test backend v1.1.0...
|
||||
pushd "%TESTDIR%"
|
||||
.\SamWaf64.exe start
|
||||
if %ERRORLEVEL% neq 0 ( echo WARN: SamWaf64.exe start failed ^(service not installed or no admin rights?^) & pause )
|
||||
popd
|
||||
|
||||
:: ---- Step 6: Start local update HTTP server ----
|
||||
echo [6/6] Starting HTTP server...
|
||||
if not exist "%CURDIR%\release\web" mkdir "%CURDIR%\release\web"
|
||||
where python >nul 2>nul
|
||||
if %ERRORLEVEL% neq 0 ( echo FAILED: python not found in PATH & pause & exit /b 1 )
|
||||
|
||||
echo.
|
||||
echo All done. Start v1.1.0 to test upgrade.
|
||||
echo All done. Test backend v1.1.0 is running in %TESTDIR%.
|
||||
echo.
|
||||
pause
|
||||
echo HTTP server started: http://127.0.0.1:8111/
|
||||
echo Root: %CURDIR%\release\web
|
||||
echo Press Ctrl+C to stop.
|
||||
echo.
|
||||
pushd "%CURDIR%\release\web"
|
||||
python -m http.server 8111
|
||||
popd
|
||||
endlocal
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
:: =====================================================================
|
||||
:: Reproduce the Windows self-update "Access is denied" bug.
|
||||
::
|
||||
:: Why build_test_update.bat can NOT reproduce it:
|
||||
:: that script kills every SamWaf64 process and overwrites the exe on
|
||||
:: EVERY run, so the test dir is always clean (no leftover .old, no
|
||||
:: long-lived old Supervisor). It only ever exercises the FIRST upgrade.
|
||||
::
|
||||
:: The bug needs TWO upgrades in a row with NO process kill in between:
|
||||
:: upgrade #1 renames SamWaf64.exe -> .SamWaf64.exe.old and succeeds,
|
||||
:: but the Supervisor never exits, so its running image IS that .old
|
||||
:: file and it can never be deleted (Windows). Upgrade #2 then tries to
|
||||
:: rename onto that still-in-use .old and gets ERROR_ACCESS_DENIED.
|
||||
::
|
||||
:: This script prepares the ground and then gets out of the way: it
|
||||
:: publishes ONLY v1.1.1 so upgrade #1 is a real single step. Publish
|
||||
:: v1.1.2 later with publish_test_version.bat to trigger upgrade #2.
|
||||
:: =====================================================================
|
||||
|
||||
set "CURDIR=%~dp0"
|
||||
set "CURDIR=%CURDIR:~0,-1%"
|
||||
set "TESTDIR=%CURDIR%\release\testupdate"
|
||||
set "WEBDIR=%CURDIR%\release\web"
|
||||
set "GENTOOL=%CURDIR%\setup\go_gen_updatefile\go_gen_updatefile.exe"
|
||||
set "VERNAME=20260224"
|
||||
set "UPURL=http://127.0.0.1:8111/"
|
||||
|
||||
SET CGO_ENABLED=1
|
||||
SET GOOS=windows
|
||||
SET GOARCH=amd64
|
||||
SET GIN_MODE=release
|
||||
|
||||
:: ---- Step 1: Build v1.1.0 / v1.1.1 / v1.1.2 ----
|
||||
echo [1/6] Building v1.1.0, v1.1.1, v1.1.2...
|
||||
call :build v1.1.0 || exit /b 1
|
||||
call :build v1.1.1 || exit /b 1
|
||||
call :build v1.1.2 || exit /b 1
|
||||
|
||||
:: ---- Step 2: Stop test backend, wipe leftovers, deploy v1.1.0 ----
|
||||
:: This is the ONLY point where processes are killed. After this the test
|
||||
:: must run untouched, otherwise the bug cannot surface.
|
||||
echo [2/6] Stopping test backend, cleaning leftovers, deploying v1.1.0...
|
||||
if not exist "%TESTDIR%" mkdir "%TESTDIR%"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$d='%TESTDIR%'; $p=Get-Process -Name SamWaf64 -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($d, 'OrdinalIgnoreCase') }; if ($p) { Write-Host ('stopping ' + $p.Count + ' process(es)'); $p | Stop-Process -Force; Start-Sleep -Milliseconds 1500 } else { Write-Host 'no running process' }"
|
||||
|
||||
:: Wipe .SamWaf64.exe.old / .new left by earlier runs so the test starts
|
||||
:: from a genuinely clean state (they are HIDDEN, so -Force is required).
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$d='%TESTDIR%'; $f=Get-ChildItem -Force -LiteralPath $d -Filter '.SamWaf64.exe.*' -ErrorAction SilentlyContinue; if ($f) { foreach ($i in $f) { try { $i.Attributes='Normal'; Remove-Item -Force -LiteralPath $i.FullName -ErrorAction Stop; Write-Host ('wiped ' + $i.Name) } catch { Write-Host ('CANNOT WIPE ' + $i.Name + ' -> ' + $_.Exception.Message); Write-Host 'A process is still holding it. Stop it and re-run this script.'; exit 1 } } } else { Write-Host 'no leftover .old/.new' }"
|
||||
if %ERRORLEVEL% neq 0 ( echo FAILED: leftover cleanup & pause & exit /b 1 )
|
||||
|
||||
copy /y "%CURDIR%\release\githubci\v1.1.0\SamWaf64.exe" "%TESTDIR%\SamWaf64.exe" >nul
|
||||
if %ERRORLEVEL% neq 0 ( echo FAILED: copy to test backend error ^(file locked?^) & pause & exit /b 1 )
|
||||
echo OK: %TESTDIR%\SamWaf64.exe is v1.1.0
|
||||
|
||||
:: ---- Step 3: Publish ONLY v1.1.1 ----
|
||||
:: windows-amd64.json holds a single target version, so publishing v1.1.2
|
||||
:: now would make v1.1.0 jump straight to v1.1.2 and skip the two-step.
|
||||
echo [3/6] Publishing v1.1.1 to the local update server...
|
||||
call :publish v1.1.1 || exit /b 1
|
||||
|
||||
:: ---- Step 4: Start test backend ----
|
||||
echo [4/6] Starting test backend v1.1.0...
|
||||
pushd "%TESTDIR%"
|
||||
.\SamWaf64.exe start
|
||||
if %ERRORLEVEL% neq 0 ( echo WARN: SamWaf64.exe start failed ^(service not installed or no admin rights?^) & pause )
|
||||
popd
|
||||
|
||||
:: ---- Step 5: Baseline snapshot ----
|
||||
echo [5/6] Baseline state:
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%CURDIR%\check_upgrade_state.ps1" -Dir "%TESTDIR%"
|
||||
|
||||
:: ---- Step 6: Instructions + HTTP server ----
|
||||
echo [6/6] Starting HTTP server...
|
||||
where python >nul 2>nul
|
||||
if %ERRORLEVEL% neq 0 ( echo FAILED: python not found in PATH & pause & exit /b 1 )
|
||||
|
||||
echo.
|
||||
echo =====================================================================
|
||||
echo DO NOT kill any SamWaf64 process from here on, and DO NOT re-run
|
||||
echo this script until the whole sequence below is finished.
|
||||
echo.
|
||||
echo 1. Open the admin UI and upgrade v1.1.0 -^> v1.1.1. Expect SUCCESS.
|
||||
echo.
|
||||
echo 2. In another window, inspect the state:
|
||||
echo powershell -File check_upgrade_state.ps1
|
||||
echo Expected (this is what proves the root cause):
|
||||
echo - .SamWaf64.exe.old exists and is HIDDEN
|
||||
echo - the Supervisor process Path now points AT that .old file
|
||||
echo - deleting it by hand fails with "Access is denied"
|
||||
echo.
|
||||
echo 3. Publish the next version:
|
||||
echo publish_test_version.bat v1.1.2
|
||||
echo.
|
||||
echo 4. Upgrade v1.1.1 -^> v1.1.2 in the UI. Expect FAILURE:
|
||||
echo upgrade error:rename ...\SamWaf64.exe ...\.SamWaf64.exe.old:
|
||||
echo Access is denied.
|
||||
echo =====================================================================
|
||||
echo.
|
||||
echo HTTP server started: %UPURL%
|
||||
echo Root: %WEBDIR%
|
||||
echo Press Ctrl+C to stop.
|
||||
echo.
|
||||
pushd "%WEBDIR%"
|
||||
python -m http.server 8111
|
||||
popd
|
||||
endlocal
|
||||
exit /b 0
|
||||
|
||||
:: ---------------------------------------------------------------------
|
||||
:build
|
||||
if not exist "%CURDIR%\release\githubci\%~1" mkdir "%CURDIR%\release\githubci\%~1"
|
||||
go build -ldflags="-X SamWaf/global.GWAF_RELEASE=true -X SamWaf/global.GWAF_RELEASE_VERSION_NAME=%VERNAME% -X SamWaf/global.GWAF_RELEASE_VERSION=%~1 -X SamWaf/global.GUPDATE_VERSION_URL=%UPURL% -s -w" -o "%CURDIR%\release\githubci\%~1\SamWaf64.exe" ./cmd/samwaf/main.go
|
||||
if %ERRORLEVEL% neq 0 ( echo FAILED: %~1 build error & pause & exit /b 1 )
|
||||
echo OK: release\githubci\%~1\SamWaf64.exe
|
||||
exit /b 0
|
||||
|
||||
:publish
|
||||
"%GENTOOL%" -desc "local-test-%~1" -o "%WEBDIR%\samwaf_update" -platform windows-amd64 "%CURDIR%\release\githubci\%~1\SamWaf64.exe" %~1
|
||||
if %ERRORLEVEL% neq 0 ( echo FAILED: publish %~1 error & pause & exit /b 1 )
|
||||
echo OK: release\web\samwaf_update\%~1\windows-amd64.gz
|
||||
exit /b 0
|
||||
@@ -0,0 +1,89 @@
|
||||
# 升级现场观测脚本 —— 用来确认 Windows 自升级 "Access is denied" 的根因。
|
||||
#
|
||||
# powershell -File check_upgrade_state.ps1
|
||||
# powershell -File check_upgrade_state.ps1 -TryDelete
|
||||
#
|
||||
# 三项观测:
|
||||
# 1) SamWaf64 进程及其映像路径 —— 升级一次后,Supervisor 的 Path 会变成
|
||||
# .SamWaf64.exe.old(rename 只改目录项,进程仍映射同一个文件对象)
|
||||
# 2) .SamWaf64.exe.old* 残留清单与 HIDDEN 属性
|
||||
# 3) -TryDelete 时手工删一次 .old,预期"拒绝访问"
|
||||
#
|
||||
# 本脚本只读(除非显式加 -TryDelete),不会动任何进程。
|
||||
|
||||
param(
|
||||
[string] $Dir = (Join-Path $PSScriptRoot 'release\testupdate'),
|
||||
[switch] $TryDelete
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$resolved = Resolve-Path -LiteralPath $Dir -ErrorAction SilentlyContinue
|
||||
if (-not $resolved) {
|
||||
Write-Host "目录不存在: $Dir 请先跑 build_test_upgrade_twice.bat" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$Dir = $resolved.Path
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "=== 观测目录: $Dir ===" -ForegroundColor Cyan
|
||||
|
||||
# --- 1) 进程与映像路径 -------------------------------------------------
|
||||
Write-Host ''
|
||||
Write-Host '[1] SamWaf64 进程(关注 Path 是不是 .old)' -ForegroundColor Yellow
|
||||
$procs = Get-Process -Name SamWaf64 -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Path -and $_.Path.StartsWith($Dir, 'OrdinalIgnoreCase') }
|
||||
if (-not $procs) {
|
||||
Write-Host ' (无) 该目录下没有运行中的 SamWaf64'
|
||||
} else {
|
||||
foreach ($p in $procs) {
|
||||
$name = Split-Path $p.Path -Leaf
|
||||
$pinned = $name -like '*.old*'
|
||||
$tag = if ($pinned) { ' <== 钉住了 .old,第二次升级会失败' } else { '' }
|
||||
$line = ' pid={0,-6} started={1} {2}{3}' -f $p.Id, $p.StartTime.ToString('HH:mm:ss'), $p.Path, $tag
|
||||
if ($pinned) { Write-Host $line -ForegroundColor Red } else { Write-Host $line }
|
||||
}
|
||||
}
|
||||
|
||||
# --- 2) 残留文件 -------------------------------------------------------
|
||||
Write-Host ''
|
||||
Write-Host '[2] .SamWaf64.exe.* 残留(-Force 才看得到 HIDDEN)' -ForegroundColor Yellow
|
||||
$leftovers = Get-ChildItem -Force -LiteralPath $Dir -Filter '.SamWaf64.exe.*' -ErrorAction SilentlyContinue
|
||||
if (-not $leftovers) {
|
||||
Write-Host ' (无)'
|
||||
} else {
|
||||
foreach ($f in $leftovers) {
|
||||
$line = ' {0,-40} {1,12:N0} bytes attrs={2} {3}' -f `
|
||||
$f.Name, $f.Length, $f.Attributes, $f.LastWriteTime.ToString('MM-dd HH:mm:ss')
|
||||
Write-Host $line -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
# --- 3) 当前更新服务器指向的版本 ---------------------------------------
|
||||
$manifest = Join-Path (Split-Path $Dir -Parent) 'web\samwaf_update\windows-amd64.json'
|
||||
Write-Host ''
|
||||
Write-Host '[3] 本地更新服务器当前发布的版本' -ForegroundColor Yellow
|
||||
if (Test-Path -LiteralPath $manifest) {
|
||||
$j = Get-Content -Raw -LiteralPath $manifest | ConvertFrom-Json
|
||||
Write-Host (' Version={0} Desc={1} UpdateTime={2}' -f $j.Version, $j.Desc, $j.UpdateTime)
|
||||
} else {
|
||||
Write-Host ' (未发布)'
|
||||
}
|
||||
|
||||
# --- 4) 可选:手工删 .old,验证"拒绝访问" -----------------------------
|
||||
if ($TryDelete -and $leftovers) {
|
||||
Write-Host ''
|
||||
Write-Host '[4] 手工删除 .old(预期:拒绝访问)' -ForegroundColor Yellow
|
||||
foreach ($f in $leftovers) {
|
||||
try {
|
||||
Remove-Item -Force -LiteralPath $f.FullName -ErrorAction Stop
|
||||
Write-Host (' {0} -> 删除成功(说明没被进程钉住)' -f $f.Name) -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host (' {0} -> {1}' -f $f.Name, $_.Exception.Message) -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '判据:升级一次后若 [1] 出现红色的 .old 路径、[2] 有 HIDDEN 残留、' -ForegroundColor Cyan
|
||||
Write-Host ' 且 -TryDelete 报拒绝访问,则第二次升级必然 Access is denied。' -ForegroundColor Cyan
|
||||
Write-Host ''
|
||||
@@ -561,6 +561,11 @@ func (m *wafSystenService) run() {
|
||||
// cron 任务调度同样是单例(避免新旧 Worker 重复执行定时任务);takeover 模式延迟到 ACTIVATE 再启动。
|
||||
if !global.GWAF_RUNTIME_IS_TAKEOVER {
|
||||
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskScheduler.Start()
|
||||
// 独占单例已在本函数内联启动完毕,这里把 activateOnce 消费掉,
|
||||
// 使后续任何 ACTIVATE 都成为空操作。Supervisor 重启后"原地认领"存活 Worker 时
|
||||
// 会补发一次 ACTIVATE(它无从判断该 Worker 当初是否已接管过单例),
|
||||
// 没有这道守卫就会把隧道/应用/cron 重复启动一遍。
|
||||
markSingletonsActivated()
|
||||
}
|
||||
|
||||
//脱敏处理初始化
|
||||
@@ -1064,6 +1069,11 @@ func main() {
|
||||
if isWorker, _, _, _ := parseWorkerRole(); !isWorker {
|
||||
zlog.FileName = "supervisor.log"
|
||||
}
|
||||
// 自升级交接助手是与 Supervisor 并存的第三个短命进程,同理单独写一个日志文件,
|
||||
// 免得三个进程抢同一个 supervisor.log 的滚动 rename。
|
||||
if len(os.Args) > 1 && os.Args[1] == supervisor.HandoffRestartArg {
|
||||
zlog.FileName = "handoff.log"
|
||||
}
|
||||
//初始化日志
|
||||
zlog.InitZLog(global.GWAF_LOG_DEBUG_ENABLE, global.GWAF_LOG_OUTPUT_FORMAT)
|
||||
if v := recover(); v != nil {
|
||||
@@ -1138,6 +1148,9 @@ func main() {
|
||||
ExePath: exePath,
|
||||
DataDir: dataDir,
|
||||
DrainTimeout: int(global.GCONFIG_RECORD_DRAIN_TIMEOUT),
|
||||
// 自升级交接(Windows)靠"退出 + 助手把服务重新 start",前台运行时退出等于停服,
|
||||
// 故把"是否由服务管理器托管"传下去,由 Supervisor 决定是否交接。
|
||||
ServiceManaged: !service.Interactive(),
|
||||
})
|
||||
prg := &supervisorService{sup: sup}
|
||||
s, err := service.New(prg, svcConfig)
|
||||
@@ -1166,6 +1179,11 @@ func main() {
|
||||
}
|
||||
fmt.Printf("Samwaf has successfully executed the '%s' command.\n", command)
|
||||
break
|
||||
case supervisor.HandoffRestartArg:
|
||||
// 自升级交接助手(Windows,隐藏命令,由 Supervisor 自己拉起)。
|
||||
// 老 Supervisor 交接前起本进程后就退出了;本进程轮询把服务重新 start 起来,
|
||||
// 让 Supervisor 换到新二进制。期间 Worker 作为孤儿继续转发业务,不中断。
|
||||
runHandoffRestart(s)
|
||||
case "rolling-restart": // 零停机滚动重启:通知运行中的 Supervisor 换 Worker(业务不中断),亦用于测试升级编排
|
||||
if err := supervisor.TriggerUpgrade(dataDir); err != nil {
|
||||
fmt.Println("滚动重启触发失败:", err)
|
||||
@@ -1348,6 +1366,46 @@ type workerCtrl struct {
|
||||
// gWorkerCtrl 当前进程的 Worker 控制客户端(仅 worker 角色下非 nil)。
|
||||
var gWorkerCtrl *workerCtrl
|
||||
|
||||
// runHandoffRestart 是 Supervisor 自升级交接的"重启助手"(Windows 隐藏命令)。
|
||||
//
|
||||
// Windows 没有 execve,Supervisor 换二进制只能退出再被拉起来。这里不依赖 SCM 的
|
||||
// 失败恢复动作(那是服务安装时写入的,老版本装的服务可能压根没配),而是由老
|
||||
// Supervisor 先起本进程、随后自己干净退出,本进程轮询把服务 start 回来。
|
||||
//
|
||||
// 期间 Worker 作为孤儿继续转发业务,新 Supervisor 起来后靠 supervisor.state 里的
|
||||
// ctrl 端口 + token 把它收编回去(同版本原地认领,不重起 Worker)。
|
||||
func runHandoffRestart(s service.Service) {
|
||||
const (
|
||||
interval = 1 * time.Second
|
||||
timeout = 60 * time.Second
|
||||
)
|
||||
zlog.Info("[交接助手] 等待旧 Supervisor 退出后把服务重新拉起...")
|
||||
deadline := time.Now().Add(timeout)
|
||||
sawStopped := false
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(interval)
|
||||
if st, err := s.Status(); err == nil && st == service.StatusRunning {
|
||||
if sawStopped {
|
||||
// 服务停过又跑起来了——可能是 SCM 的失败恢复动作先我们一步。目的已达成。
|
||||
zlog.Info("[交接助手] 服务已重新运行(由服务管理器先行拉起),交接完成")
|
||||
return
|
||||
}
|
||||
// 旧 Supervisor 还没退干净,继续等。
|
||||
continue
|
||||
}
|
||||
sawStopped = true
|
||||
if err := service.Control(s, "start"); err != nil {
|
||||
zlog.Debug("[交接助手] start 暂未成功,稍后重试: " + err.Error())
|
||||
continue
|
||||
}
|
||||
zlog.Info("[交接助手] 服务已以新二进制重新启动,交接完成")
|
||||
return
|
||||
}
|
||||
// 拉不起来不代表业务断了:Worker 仍在转发流量,只是暂时没有监护进程。
|
||||
zlog.Error("[交接助手] " + timeout.String() + " 内未能把服务拉起来。Worker 仍在服务," +
|
||||
"但监护进程缺席,请人工执行 SamWaf64.exe start")
|
||||
}
|
||||
|
||||
// activateOnce 保证独占单例(应用/隧道/cron)只被接管启动一次。
|
||||
var activateOnce sync.Once
|
||||
|
||||
@@ -1369,6 +1427,13 @@ func activateSingletons() {
|
||||
})
|
||||
}
|
||||
|
||||
// markSingletonsActivated 把 activateOnce 消费掉而不做任何事,
|
||||
// 供"独占单例已在 run() 内联启动"的非 takeover 路径调用,让之后收到的
|
||||
// ACTIVATE 变成空操作(幂等),避免隧道/应用/cron 被重复启动。
|
||||
func markSingletonsActivated() {
|
||||
activateOnce.Do(func() {})
|
||||
}
|
||||
|
||||
// startWorkerControl 启动 Worker 控制通道客户端(后台重连循环)。
|
||||
func startWorkerControl(addr, token string, prg *wafSystenService) {
|
||||
gWorkerCtrl = &workerCtrl{addr: addr, token: token, prg: prg}
|
||||
|
||||
+6
-2
@@ -17,7 +17,10 @@ import (
|
||||
// zlog.Debug("hello", zap.String("name", "Kevin"), zap.Any("arbitraryObj", dummyObject))
|
||||
// zlog.Info("hello", zap.String("name", "Kevin"), zap.Any("arbitraryObj", dummyObject))
|
||||
// zlog.Warn("hello", zap.String("name", "Kevin"), zap.Any("arbitraryObj", dummyObject))
|
||||
var logger *zap.Logger
|
||||
// logger 默认是 Nop 而非 nil:InitZLog 之前就打日志的代码路径(早期启动、
|
||||
// 一次性 CLI 命令、单元测试)不该因为 nil 指针把进程打崩,丢几条早期日志即可。
|
||||
// InitZLog 会把它换成真正的 logger。
|
||||
var logger = zap.NewNop()
|
||||
|
||||
// baseCores 保存 InitZLog 创建的基础 cores,供 AddCore 重建时使用
|
||||
var baseCores []zapcore.Core
|
||||
@@ -63,7 +66,8 @@ func InitZLog(debugEnable bool, outputFormat string) {
|
||||
// AddCore 在现有 logger 上追加一个额外的 zapcore.Core(通常在 zlog 初始化之后调用)
|
||||
// 追加后原有的所有 baseCores 依然保留,新 core 并联写入
|
||||
func AddCore(extra zapcore.Core) {
|
||||
if logger == nil || extra == nil {
|
||||
// baseCores 为空表示 InitZLog 还没跑过(logger 此时是 Nop),追加没有意义。
|
||||
if len(baseCores) == 0 || extra == nil {
|
||||
return
|
||||
}
|
||||
all := make([]zapcore.Core, 0, len(baseCores)+1)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
:: Publish one already-built test version to the local update server, so
|
||||
:: the running instance sees it as the next upgrade target.
|
||||
:: Companion to build_test_upgrade_twice.bat -- it deliberately publishes
|
||||
:: only ONE version at a time, because samwaf_update\windows-amd64.json
|
||||
:: holds a single target version.
|
||||
::
|
||||
:: publish_test_version.bat v1.1.2
|
||||
::
|
||||
:: Never kills or touches any running process.
|
||||
|
||||
set "CURDIR=%~dp0"
|
||||
set "CURDIR=%CURDIR:~0,-1%"
|
||||
set "WEBDIR=%CURDIR%\release\web"
|
||||
set "GENTOOL=%CURDIR%\setup\go_gen_updatefile\go_gen_updatefile.exe"
|
||||
|
||||
set "VER=%~1"
|
||||
if "%VER%"=="" set "VER=v1.1.2"
|
||||
|
||||
if not exist "%CURDIR%\release\githubci\%VER%\SamWaf64.exe" (
|
||||
echo FAILED: release\githubci\%VER%\SamWaf64.exe not found.
|
||||
echo Run build_test_upgrade_twice.bat first, or build that version.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
"%GENTOOL%" -desc "local-test-%VER%" -o "%WEBDIR%\samwaf_update" -platform windows-amd64 "%CURDIR%\release\githubci\%VER%\SamWaf64.exe" %VER%
|
||||
if %ERRORLEVEL% neq 0 ( echo FAILED: publish %VER% error & pause & exit /b 1 )
|
||||
|
||||
echo OK: published %VER%
|
||||
type "%WEBDIR%\samwaf_update\windows-amd64.json"
|
||||
echo.
|
||||
echo Now trigger the upgrade from the admin UI.
|
||||
endlocal
|
||||
@@ -0,0 +1,7 @@
|
||||
package supervisor
|
||||
|
||||
// HandoffRestartArg 是 Windows 下拉起"重启助手"用的隐藏命令行参数。
|
||||
//
|
||||
// 助手是一个脱离服务的短命进程,唯一职责是等本服务停干净后再把它 start 起来,
|
||||
// 让 Supervisor 换到新二进制。定义在跨平台文件里,供 cmd/samwaf 的命令分发引用。
|
||||
const HandoffRestartArg = "handoff-restart"
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"SamWaf/common/zlog"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// handoffToNewBinary 让 Supervisor 自身换到磁盘上的新二进制(设计文档 §4.6)。
|
||||
//
|
||||
// Unix 上直接 execve 原地替换进程映像:
|
||||
// - PID 不变 ⇒ systemd 完全无感,不会走 Restart= 逻辑,也不会因为
|
||||
// 默认的 KillMode=control-group 把整个 cgroup(含 Worker)连带杀掉;
|
||||
// - 子进程(Worker)不受 exec 影响,继续转发业务;
|
||||
// - 新映像重新 Run(),从 supervisor.state 复用 ctrl 端口 + token,
|
||||
// Worker 断线后每 2s 重连即可连回来(adoptOrphans 同版本原地认领)。
|
||||
//
|
||||
// serviceManaged 在 Unix 上不影响决策:前台跑也能安全 re-exec。
|
||||
//
|
||||
// 成功时本函数不返回(进程映像已被替换)。
|
||||
func handoffToNewBinary(exePath string, serviceManaged bool) error {
|
||||
zlog.Info("[Supervisor] 自升级交接:re-exec 到新二进制 " + exePath + "(PID 不变,Worker 不受影响)")
|
||||
// 监听 fd 带 CLOEXEC,exec 时自动关闭;新映像会用同一端口重新监听。
|
||||
return syscall.Exec(exePath, os.Args, os.Environ())
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"SamWaf/common/wafexec"
|
||||
"SamWaf/common/zlog"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// handoffToNewBinary 让 Supervisor 自身换到磁盘上的新二进制(设计文档 §4.6)。
|
||||
//
|
||||
// Windows 没有 execve,只能"退出 + 由外部把服务重新拉起来"。这里不依赖 SCM 的
|
||||
// 失败恢复动作(OnFailure=restart)——那是服务**安装时**写入的,2026-01 之前装的
|
||||
// 服务可能根本没配,一旦退出就再也起不来。改为先起一个脱离的助手进程去 start,
|
||||
// 确定性更高。
|
||||
//
|
||||
// Worker 不受影响:Windows 没有 Job Object,Supervisor 退出后 Worker 变成孤儿
|
||||
// 继续转发业务;新 Supervisor 起来后靠 supervisor.state 里的 ctrl 端口 + token
|
||||
// 把它们收编回来(adoptOrphans,已实测过的路径)。
|
||||
//
|
||||
// 成功时本函数不返回(进程退出)。
|
||||
func handoffToNewBinary(exePath string, serviceManaged bool) error {
|
||||
if !serviceManaged {
|
||||
// 前台运行时没有服务管理器兜底,退出就等于把 WAF 停了。
|
||||
return errors.New("前台(交互式)运行,无服务管理器兜底")
|
||||
}
|
||||
|
||||
cmd := exec.Command(exePath, HandoffRestartArg)
|
||||
// 助手不接管任何标准流,三个都是 nil —— 精简版 Windows 缺失 NUL 空设备时
|
||||
// os/exec 会在 Start() 前就报 "open NUL",所以三个流都要兜底(FixStdio 而非 FixStdin)。
|
||||
wafexec.FixStdio(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
// 助手已就位,不等它(它的活儿要等本进程退出后才能干成)。
|
||||
_ = cmd.Process.Release()
|
||||
|
||||
zlog.Info("[Supervisor] 自升级交接:重启助手 pid=" + strconv.Itoa(cmd.Process.Pid) +
|
||||
" 已启动,本进程即将退出,Worker 继续服务;助手会把服务以新二进制重新拉起")
|
||||
// 干净退出(0):不触发 SCM 的失败恢复动作,完全交给助手,行为可预期。
|
||||
// 注意这里必须绕过 Shutdown()——绝不能让 Worker 跟着退出,否则业务就断了。
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
+171
-3
@@ -12,6 +12,7 @@ package supervisor
|
||||
import (
|
||||
"SamWaf/common/wafexec"
|
||||
"SamWaf/common/zlog"
|
||||
"SamWaf/global"
|
||||
"SamWaf/wafipc"
|
||||
"SamWaf/wafupdate"
|
||||
"encoding/json"
|
||||
@@ -33,6 +34,10 @@ type Options struct {
|
||||
Token string // 鉴权 token,空则自动生成
|
||||
DrainTimeout int // Worker 排空超时(秒)
|
||||
ReadyTimeout int // 升级时等待新 Worker READY 的超时(秒)
|
||||
// ServiceManaged 表示本进程由服务管理器(SCM/systemd)托管。
|
||||
// Windows 的自升级交接靠"退出 + 助手把服务重新 start",前台运行时退出等于停服,
|
||||
// 故仅在托管模式下才交接。
|
||||
ServiceManaged bool
|
||||
}
|
||||
|
||||
type workerState struct {
|
||||
@@ -133,6 +138,12 @@ func (s *Supervisor) Run() error {
|
||||
zlog.Info("[Supervisor] 控制通道监听于 " + s.ctrlAddr)
|
||||
s.writeState()
|
||||
|
||||
// 清扫上一轮升级/回退遗留的临时二进制(.<exe>.old* / .new / .rollback)。
|
||||
// 只在这里扫、不在 Worker 里扫:Supervisor 常驻不为升级退出,升级后它自身的
|
||||
// 映像文件就是某个 .old、在它活着时(Windows)删不掉;只有"服务重启后、新 Supervisor
|
||||
// 刚起来"这一刻上一代 Supervisor 已退出,旧映像才真正可删。
|
||||
wafupdate.CleanupLegacyLeftovers()
|
||||
|
||||
go s.acceptLoop()
|
||||
|
||||
// 若上次遗留存活 Worker(孤儿),零停机收编为受管新 Worker;否则正常拉起首个 Worker。
|
||||
@@ -349,6 +360,60 @@ func (s *Supervisor) monitor(w *workerState) {
|
||||
s.handleCrashRespawn()
|
||||
}
|
||||
|
||||
// adoptedPollInterval 被原地认领的 Worker 的存活探测间隔。
|
||||
const adoptedPollInterval = 2 * time.Second
|
||||
|
||||
// monitorAdopted 监护"被原地认领"的 Worker。
|
||||
//
|
||||
// 这类 Worker 不是本 Supervisor 的子进程(它是上一代 Supervisor 拉起来的),
|
||||
// 拿不到 os.Process 句柄,monitor() 依赖的 Process.Wait() 用不了
|
||||
// (Unix 上对非子进程直接返回 ECHILD,会被误判成"已退出")。
|
||||
// 改用跨平台的 isProcessAlive 轮询,保证崩溃自愈对它同样生效。
|
||||
//
|
||||
// 拿不到退出码,故用 state 判定是否为计划内退出:DRAIN/SHUTDOWN 会把 state 置为
|
||||
// draining,其余情况(进程被杀/崩溃)一律按崩溃处理并重拉。
|
||||
func (s *Supervisor) monitorAdopted(w *workerState) {
|
||||
// 先起一个"收尸"协程。Unix 上 re-exec 交接后 PID 不变,被认领的 Worker 其实仍是本
|
||||
// 进程的子进程;它退出后若无人 wait,会一直挂成僵尸,而 kill(pid,0) 对僵尸返回成功
|
||||
// —— 存活探测就会永远为真,崩溃自愈失效。
|
||||
// 对非子进程(服务重启后收编的真孤儿)Wait 会立刻返回 ECHILD,无副作用。
|
||||
if p, err := os.FindProcess(w.pid); err == nil {
|
||||
go func() { _, _ = p.Wait() }()
|
||||
}
|
||||
|
||||
for {
|
||||
time.Sleep(adoptedPollInterval)
|
||||
|
||||
s.mu.Lock()
|
||||
_, registered := s.workers[w.pid]
|
||||
down := s.shuttingDown
|
||||
s.mu.Unlock()
|
||||
if !registered || down {
|
||||
return // 已被别的路径清理,或正在停止
|
||||
}
|
||||
if isProcessAlive(w.pid) {
|
||||
continue
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
planned := w.state == "draining"
|
||||
wasActive := w.pid == s.activePID
|
||||
delete(s.workers, w.pid)
|
||||
down = s.shuttingDown
|
||||
s.mu.Unlock()
|
||||
s.writeState()
|
||||
|
||||
zlog.Info("[Supervisor] 被认领的 Worker pid=" + strconv.Itoa(w.pid) +
|
||||
" 已消失 planned=" + strconv.FormatBool(planned))
|
||||
|
||||
if down || planned || !wasActive {
|
||||
return
|
||||
}
|
||||
s.handleCrashRespawn()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// upgradeProbationWindow 升级观察期:升级切到新版本后,仅在此时间窗内的连续崩溃才判为坏版本并回滚二进制;
|
||||
// 超出该窗口的崩溃视为环境/配置问题,只重拉、不回滚,避免把运行已久的好版本误降级。
|
||||
const upgradeProbationWindow = 5 * time.Minute
|
||||
@@ -472,6 +537,52 @@ func (s *Supervisor) doUpgrade() {
|
||||
nw.send(wafipc.Message{Type: wafipc.MsgActivate})
|
||||
}
|
||||
zlog.Info("[Supervisor] 滚动升级完成,active Worker pid=" + strconv.Itoa(newPID))
|
||||
|
||||
// 业务已在新版本上跑起来了,最后让 Supervisor 自己也换到新二进制。
|
||||
// 放在最后、且只在升级确认成功后做:失败也只是"Supervisor 继续跑旧版本",业务不受影响。
|
||||
s.selfHandoff(newPID)
|
||||
}
|
||||
|
||||
// selfHandoff 让 Supervisor 自身也换到新二进制(设计文档 §4.6 Supervisor 自我升级)。
|
||||
//
|
||||
// 不做这件事的后果有两层:
|
||||
// 1. supervisor / wafipc / main.go 角色分流之前的代码改了不生效,要等人工重启服务;
|
||||
// 2. Windows 上 Supervisor 的映像文件就是被换下来的那个 .old,永远删不掉、永远钉住
|
||||
// —— 这正是"第二次升级 Access is denied"的根因(现已由 wafupdate 的唯一 .old 命名兜住,
|
||||
// 但残留仍会一直堆着)。
|
||||
//
|
||||
// 端口在 Worker 手里,Supervisor 不持有任何业务/管理端口,所以它可以随时换人而不动业务。
|
||||
func (s *Supervisor) selfHandoff(newPID int) {
|
||||
w := s.getWorker(newPID)
|
||||
if w == nil || w.version == "" {
|
||||
zlog.Info("[Supervisor] 自升级交接:新 Worker 版本未知,跳过")
|
||||
return
|
||||
}
|
||||
self := global.GWAF_RELEASE_VERSION
|
||||
if w.version == self {
|
||||
zlog.Info("[Supervisor] 自升级交接:Supervisor 与新 Worker 同为 " + self + ",无需交接")
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
stopping := s.shuttingDown
|
||||
s.mu.Unlock()
|
||||
if stopping {
|
||||
zlog.Info("[Supervisor] 自升级交接:正在停止,跳过")
|
||||
return
|
||||
}
|
||||
|
||||
// 交接前务必把 ctrl 地址 / token / Worker PID 落盘:
|
||||
// 新 Supervisor 全靠这份 state 复用端口 + token,才能把留下来的 Worker 收编回去。
|
||||
s.writeState()
|
||||
|
||||
zlog.Info("[Supervisor] 自升级交接:Supervisor 仍是旧版本 " + self + ",新 Worker 已是 " + w.version + ",开始交接")
|
||||
if err := handoffToNewBinary(s.opts.ExePath, s.opts.ServiceManaged); err != nil {
|
||||
// 交接不成不是故障:Worker 已经是新版本,业务照常。Supervisor 留在旧版本,
|
||||
// 等下次服务重启再换即可。
|
||||
zlog.Warn("[Supervisor] 自升级交接未执行(" + err.Error() + ");业务不受影响," +
|
||||
"Supervisor 仍为 " + self + ",将在下次服务重启时换到新二进制")
|
||||
}
|
||||
}
|
||||
|
||||
// adoptOrphans 处理 Supervisor 自身重启后遗留的、仍存活的 Worker(孤儿)。
|
||||
@@ -509,6 +620,40 @@ func (s *Supervisor) adoptOrphans(prev *stateFile) bool {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// 先等孤儿重连(每 2s 一次),拿到它们 HELLO 上报的版本号再决定怎么收编。
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if s.allConnected(candidates) {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
// 同版本原地认领(设计文档 §4.6"不重复 spawn"):
|
||||
// 恰好一个孤儿、已重连鉴权通过、且版本与本 Supervisor 一致 —— 说明它就是本
|
||||
// Supervisor 自升级交接前留下的那个 Worker,直接接管即可,不必再起一个新 Worker
|
||||
// 把它换掉(那会白白多一次独占单例(应用/隧道/cron)的秒级重启)。
|
||||
if pid, ok := s.singleSameVersionOrphan(candidates); ok {
|
||||
s.mu.Lock()
|
||||
s.activePID = pid
|
||||
if w := s.workers[pid]; w != nil {
|
||||
w.state = "active"
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.writeState()
|
||||
zlog.Info("[Supervisor] 同版本原地认领遗留 Worker pid=" + strconv.Itoa(pid) +
|
||||
" version=" + global.GWAF_RELEASE_VERSION + ",不再拉起新 Worker(业务全程无感)")
|
||||
if w := s.getWorker(pid); w != nil {
|
||||
// 若该 Worker 是"takeover 起来但还没等到 ACTIVATE"就成了孤儿的,这里补发一次
|
||||
// 让它接管独占单例;已接管过的由 Worker 侧的 activateOnce 吞掉,重复发无副作用。
|
||||
w.send(wafipc.Message{Type: wafipc.MsgActivate})
|
||||
// 它不是本进程的子进程,Process.Wait() 用不了,必须走轮询监护,
|
||||
// 否则崩溃自愈对这个长期存活的 active Worker 会失效。
|
||||
go s.monitorAdopted(w)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 起受管新 Worker(takeover:与孤儿 REUSEPORT 并存、跳过 :26666 单实例探测、延迟独占单例到 ACTIVATE)
|
||||
newPID, err := s.spawn(true)
|
||||
if err != nil {
|
||||
@@ -541,9 +686,11 @@ func (s *Supervisor) adoptOrphans(prev *stateFile) bool {
|
||||
s.mu.Unlock()
|
||||
s.writeState()
|
||||
|
||||
// 给候选孤儿一点时间重连(其 loop 每 2s 重连一次),便于走优雅 DRAIN
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
// 再给还没重连上的孤儿一点时间(其 loop 每 2s 重连一次)。
|
||||
// spawn 之前已经等过一轮(为了拿版本号做原地认领判定),这里是补等:
|
||||
// 起新 Worker 又花了些时间,晚重连的孤儿也该露面了。已连上的会立刻返回,不白等。
|
||||
waitDeadline := time.Now().Add(8 * time.Second)
|
||||
for time.Now().Before(waitDeadline) {
|
||||
if s.allConnected(candidates) {
|
||||
break
|
||||
}
|
||||
@@ -583,6 +730,27 @@ func (s *Supervisor) adoptOrphans(prev *stateFile) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// singleSameVersionOrphan 判断候选孤儿里是否"恰好一个、已重连鉴权通过、且版本与本
|
||||
// Supervisor 一致"。满足时可直接原地认领,不必再起新 Worker 把它换掉。
|
||||
//
|
||||
// 之所以要求"恰好一个":多于一个说明上次留下了并存的新旧 Worker(升级半途夭折),
|
||||
// 独占单例(应用/隧道/cron)归属不清,还是走 takeover 编排逐个收尾更稳妥。
|
||||
func (s *Supervisor) singleSameVersionOrphan(candidates []int) (int, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(candidates) != 1 {
|
||||
return 0, false
|
||||
}
|
||||
w := s.workers[candidates[0]]
|
||||
if w == nil || w.conn == nil {
|
||||
return 0, false
|
||||
}
|
||||
if w.version == "" || w.version != global.GWAF_RELEASE_VERSION {
|
||||
return 0, false
|
||||
}
|
||||
return w.pid, true
|
||||
}
|
||||
|
||||
// allConnected 判断给定 PID 是否都已建立控制连接(线程安全)。
|
||||
func (s *Supervisor) allConnected(pids []int) bool {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package wafupdate
|
||||
|
||||
import (
|
||||
"SamWaf/common/zlog"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 二进制原地替换底座。
|
||||
//
|
||||
// Windows 语义前提:正在运行的 exe 允许被 rename(只改目录项),但不允许被删除/覆盖
|
||||
// ——映像段仍挂着文件对象,DeleteFile 返回 ERROR_ACCESS_DENIED。
|
||||
//
|
||||
// SamWaf 是 Supervisor + Worker 双进程,且 Supervisor 按设计"永不为升级退出",
|
||||
// 于是升级后 Supervisor 的映像文件就是被换下来的那个 .old、永远删不掉。
|
||||
// 旧实现用固定名 ".SamWaf64.exe.old" 做 rename 目标:
|
||||
//
|
||||
// 升级#1 rename 成功 → .old 删不掉 → 隐藏残留
|
||||
// 升级#2 rename 目标已存在且是运行中映像 → MoveFileEx 无法删除目标 → ACCESS_DENIED
|
||||
//
|
||||
// 所以 .old 必须"每次唯一",rename 的目标才永不预先存在。
|
||||
//
|
||||
// Linux 不受影响:unlink 只摘目录项(inode 等最后一个引用释放才回收),
|
||||
// 且 rename(2) 对已存在目标是原子替换——两道关都过得去。但代码统一走这里。
|
||||
//
|
||||
// fromStream() 与 RollbackExecutable() 共用本文件,不要再各写一套 rename 技巧。
|
||||
|
||||
const (
|
||||
stageNewSuffix = ".new"
|
||||
stageOldSuffix = ".old"
|
||||
stageRollbackSuffix = ".rollback"
|
||||
|
||||
// rename 失败多为永久性(目标被占用/权限),少试几次尽快回错;
|
||||
// remove 失败常见于杀软/EDR/索引器对刚落盘文件的瞬时占用,可以多试。
|
||||
renameAttempts = 3
|
||||
removeAttempts = 5
|
||||
retryBackoff = 120 * time.Millisecond
|
||||
retryBackoffMax = 800 * time.Millisecond
|
||||
|
||||
// 清扫时跳过刚创建的 .old,避开"另一个进程正在进行替换"的竞争窗口
|
||||
// (例如服务运行中同时执行 samwaf rollback)。
|
||||
sweepSkipRecent = 60 * time.Second
|
||||
// 残留数量超过该值时告警,提示运维在维护窗口重启服务。
|
||||
sweepKeepWarn = 5
|
||||
)
|
||||
|
||||
// stagePath 返回可执行文件同目录下的临时文件路径,形如 .SamWaf64.exe.new
|
||||
func stagePath(dir, exeName, suffix string) string {
|
||||
return filepath.Join(dir, "."+exeName+suffix)
|
||||
}
|
||||
|
||||
// oldPrefix 返回所有 .old 变体的公共前缀,形如 .SamWaf64.exe.old
|
||||
func oldPrefix(exeName string) string {
|
||||
return "." + exeName + stageOldSuffix
|
||||
}
|
||||
|
||||
// uniqueOldPath 生成一个当前目录中一定不存在的 .old 路径,形如
|
||||
//
|
||||
// .SamWaf64.exe.old.20260728153012.4712
|
||||
//
|
||||
// 这是本模块的核心:rename 的目标永不预先存在 ⇒ MoveFileEx 无需删除目标 ⇒ 不会 ACCESS_DENIED。
|
||||
func uniqueOldPath(dir, exeName string) string {
|
||||
base := filepath.Join(dir, oldPrefix(exeName))
|
||||
pid := os.Getpid()
|
||||
for i := 0; i < 50; i++ {
|
||||
p := fmt.Sprintf("%s.%s.%d", base, time.Now().Format("20060102150405"), pid)
|
||||
if i > 0 {
|
||||
p = fmt.Sprintf("%s.%d", p, i)
|
||||
}
|
||||
if _, err := os.Lstat(p); os.IsNotExist(err) {
|
||||
return p
|
||||
}
|
||||
// 同一秒内的冲突由上面的 .i 序号消解,无需等待下一秒。
|
||||
}
|
||||
// 理论上到不了这里;兜底加纳秒,保证调用方总能拿到一个路径。
|
||||
return fmt.Sprintf("%s.%d.%d", base, time.Now().UnixNano(), pid)
|
||||
}
|
||||
|
||||
// retryFileOp 有限次重试 + 退避执行文件操作,用于对抗杀软/EDR 对刚落盘文件的瞬时占用。
|
||||
// 文件不存在视为成功(删除幂等);确定性错误立即返回,不做无谓等待。
|
||||
func retryFileOp(desc string, attempts int, op func() error) error {
|
||||
backoff := retryBackoff
|
||||
var err error
|
||||
for i := 0; i < attempts; i++ {
|
||||
err = op()
|
||||
if err == nil || os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if !isTransientFileError(err) {
|
||||
return err
|
||||
}
|
||||
if i == attempts-1 {
|
||||
break
|
||||
}
|
||||
zlog.Debug("[升级] " + desc + " 第 " + fmt.Sprint(i+1) + " 次失败,重试: " + err.Error())
|
||||
time.Sleep(backoff)
|
||||
if backoff < retryBackoffMax {
|
||||
backoff *= 2
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// removeFileForce 尽力删除:先清 READONLY/HIDDEN/SYSTEM 属性(否则 Windows 上删除会被拒),再带重试删。
|
||||
func removeFileForce(path string) error {
|
||||
if _, err := os.Lstat(path); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
_ = clearFileAttrs(path)
|
||||
return retryFileOp("删除 "+filepath.Base(path), removeAttempts, func() error {
|
||||
return os.Remove(path)
|
||||
})
|
||||
}
|
||||
|
||||
// disposeOldBinary 处置被替换下来的旧二进制。
|
||||
//
|
||||
// 优先删除;删不掉的绝大多数情况是"仍在运行的 Supervisor 自身映像",
|
||||
// 此时隐藏它并登记重启时删除。全过程记日志——旧实现在这里丢弃了错误,
|
||||
// 导致 .old 悄悄堆积、第二次升级才以 ACCESS_DENIED 的形式爆出来。
|
||||
func disposeOldBinary(oldPath string) {
|
||||
err := removeFileForce(oldPath)
|
||||
if err == nil {
|
||||
zlog.Info("[升级] 旧二进制已删除: " + oldPath)
|
||||
return
|
||||
}
|
||||
zlog.Info("[升级] 旧二进制暂时删不掉(通常是常驻 Supervisor 仍映射着它): " +
|
||||
oldPath + " -> " + err.Error())
|
||||
|
||||
if err := hideFile(oldPath); err != nil {
|
||||
zlog.Warn("[升级] 隐藏旧二进制失败: " + err.Error())
|
||||
}
|
||||
if err := markDeleteOnReboot(oldPath); err != nil {
|
||||
zlog.Info("[升级] 登记重启时删除失败(不影响升级): " + err.Error())
|
||||
} else {
|
||||
zlog.Info("[升级] 已登记重启时删除: " + oldPath)
|
||||
}
|
||||
}
|
||||
|
||||
// sweepLeftovers 清扫可执行文件目录下的历史残留临时文件。
|
||||
//
|
||||
// includeStaging 为 true 时连 .new/.rollback 一起清(仅用于进程启动)。
|
||||
// 删不掉的一律忽略,只计数,绝不阻塞调用方。
|
||||
func sweepLeftovers(dir, exeName string, includeStaging bool) (removed, kept int) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
// 用前缀匹配而不是 filepath.Glob——exe 名可能含 '[' 等 glob 元字符。
|
||||
oldPre := oldPrefix(exeName)
|
||||
newPath := stagePath(dir, exeName, stageNewSuffix)
|
||||
rollbackPath := stagePath(dir, exeName, stageRollbackSuffix)
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(dir, e.Name())
|
||||
isOld := strings.HasPrefix(e.Name(), oldPre)
|
||||
isStaging := includeStaging && (full == newPath || full == rollbackPath)
|
||||
if !isOld && !isStaging {
|
||||
continue
|
||||
}
|
||||
// 跳过刚创建的 .old:可能是另一个进程正在进行中的替换。
|
||||
if info, err := e.Info(); err == nil && time.Since(info.ModTime()) < sweepSkipRecent {
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
if err := removeFileForce(full); err != nil {
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
}
|
||||
if removed > 0 || kept > 0 {
|
||||
zlog.Info("[升级] 清扫遗留临时二进制: 删除 " + fmt.Sprint(removed) + " 个, 保留 " + fmt.Sprint(kept) + " 个")
|
||||
}
|
||||
if kept > sweepKeepWarn {
|
||||
zlog.Warn("[升级] " + dir + " 下有 " + fmt.Sprint(kept) +
|
||||
" 个删不掉的旧二进制残留,建议在维护窗口重启一次 SamWaf 服务以释放并清理")
|
||||
}
|
||||
return removed, kept
|
||||
}
|
||||
|
||||
// CleanupLegacyLeftovers 供进程启动时调用:清扫上一轮升级/回退遗留的临时二进制。
|
||||
// 幂等、不返回错误、不阻塞启动。
|
||||
func CleanupLegacyLeftovers() {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if resolved, e := filepath.EvalSymlinks(exePath); e == nil {
|
||||
exePath = resolved
|
||||
}
|
||||
sweepLeftovers(filepath.Dir(exePath), filepath.Base(exePath), true)
|
||||
}
|
||||
|
||||
// replaceExecutable 用 stagedPath 处已就绪的新二进制原地替换 exePath。
|
||||
// 这是升级(fromStream)与回退(RollbackExecutable)唯一共用的替换实现。
|
||||
//
|
||||
// 返回 (err, errRecover):err 非空表示替换失败;errRecover 非空表示连回滚原程序都失败了(需人工介入)。
|
||||
func replaceExecutable(exePath, stagedPath string) (err error, errRecover error) {
|
||||
updateDir := filepath.Dir(exePath)
|
||||
filename := filepath.Base(exePath)
|
||||
|
||||
// 顺手清历史残留(不碰本次的 .new/.rollback)。删不掉的忽略。
|
||||
sweepLeftovers(updateDir, filename, false)
|
||||
|
||||
// 新文件不能带 HIDDEN/READONLY,否则换上去之后主程序是隐藏/只读的。
|
||||
_ = clearFileAttrs(stagedPath)
|
||||
// 源的 READONLY 会让 rename 失败。
|
||||
_ = clearFileAttrs(exePath)
|
||||
|
||||
oldPath := uniqueOldPath(updateDir, filename)
|
||||
|
||||
// 把当前程序挪开。目标唯一 ⇒ 不存在"目标被占用无法覆盖"的问题。
|
||||
if e := retryFileOp("重命名当前程序", renameAttempts, func() error {
|
||||
return os.Rename(exePath, oldPath)
|
||||
}); e != nil {
|
||||
err = fmt.Errorf("%s", diagnoseFileOp("rename", exePath, oldPath, e))
|
||||
return
|
||||
}
|
||||
|
||||
// 新程序就位。
|
||||
if e := retryFileOp("放置新程序", renameAttempts, func() error {
|
||||
return os.Rename(stagedPath, exePath)
|
||||
}); e != nil {
|
||||
err = fmt.Errorf("%s", diagnoseFileOp("rename", stagedPath, exePath, e))
|
||||
// 把原程序挪回来
|
||||
errRecover = os.Rename(oldPath, exePath)
|
||||
return
|
||||
}
|
||||
|
||||
disposeOldBinary(oldPath)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// diagnoseFileOp 组装可诊断的错误说明:errno、源/目标状态、当前角色。
|
||||
// 这条串最终会经 api 层拼成"升级错误:..."推给前端,故截断到 400 字符内保证可读。
|
||||
func diagnoseFileOp(op, src, dst string, err error) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(op)
|
||||
b.WriteString(" ")
|
||||
b.WriteString(src)
|
||||
if dst != "" {
|
||||
b.WriteString(" -> ")
|
||||
b.WriteString(dst)
|
||||
}
|
||||
b.WriteString(" 失败: ")
|
||||
b.WriteString(err.Error())
|
||||
b.WriteString(" [")
|
||||
b.WriteString(errnoName(err))
|
||||
b.WriteString(" role=")
|
||||
b.WriteString(currentRole())
|
||||
b.WriteString(" pid=")
|
||||
b.WriteString(fmt.Sprint(os.Getpid()))
|
||||
b.WriteString(" src=")
|
||||
b.WriteString(platformFileDiag(src))
|
||||
if dst != "" {
|
||||
b.WriteString(" dst=")
|
||||
b.WriteString(platformFileDiag(dst))
|
||||
}
|
||||
b.WriteString("]")
|
||||
|
||||
s := b.String()
|
||||
// 完整信息进日志,前端只拿截断版。
|
||||
zlog.Error("[升级] " + s)
|
||||
// 按 rune 截断——诊断串含中文,按字节切会截出半个字符。
|
||||
if r := []rune(s); len(r) > 400 {
|
||||
s = string(r[:400]) + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// currentRole 返回当前进程角色,仅用于日志与错误诊断。
|
||||
// 与 cmd/samwaf/main.go 的 parseWorkerRole 保持同一判据(扫 os.Args 找 --worker)。
|
||||
func currentRole() string {
|
||||
for _, a := range os.Args[1:] {
|
||||
if a == "--worker" {
|
||||
return "Worker"
|
||||
}
|
||||
}
|
||||
return "Supervisor"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package wafupdate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// 非 Windows 平台不需要属性清理:POSIX 下 unlink 只摘目录项,
|
||||
// rename(2) 对已存在目标是原子替换,都不会因"文件在用"而失败。
|
||||
func clearFileAttrs(path string) error { return nil }
|
||||
|
||||
// markDeleteOnReboot 是 Windows 专有能力(PendingFileRenameOperations)。
|
||||
// 其他平台用不到——旧二进制在这里本来就能立刻删掉。
|
||||
func markDeleteOnReboot(path string) error {
|
||||
return errors.New("当前平台不支持重启时删除")
|
||||
}
|
||||
|
||||
// isTransientFileError 判断是否为"重试有意义"的瞬时错误。
|
||||
func isTransientFileError(err error) bool {
|
||||
var errno syscall.Errno
|
||||
if !errors.As(err, &errno) {
|
||||
return false
|
||||
}
|
||||
switch errno {
|
||||
case syscall.EBUSY, syscall.ETXTBSY, syscall.EAGAIN, syscall.EINTR:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func errnoName(err error) string {
|
||||
var errno syscall.Errno
|
||||
if !errors.As(err, &errno) {
|
||||
return "errno=?"
|
||||
}
|
||||
return fmt.Sprintf("errno=%d(%s)", int(errno), errno.Error())
|
||||
}
|
||||
|
||||
func platformFileDiag(path string) string {
|
||||
fi, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return "不存在"
|
||||
}
|
||||
return fmt.Sprintf("存在/mode=%s/size=%d", fi.Mode().String(), fi.Size())
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package wafupdate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestUniqueOldPathNeverCollides 核心修复的直接断言:
|
||||
// 即便同名目标已存在,uniqueOldPath 也必须给出一个尚不存在的新路径。
|
||||
// 旧实现用固定名 ".<exe>.old",目标一旦残留且被占用,rename 就 ACCESS_DENIED。
|
||||
func TestUniqueOldPathNeverCollides(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exe := "SamWaf64.exe"
|
||||
|
||||
// 预置一个"删不掉的残留"同名文件(内容无所谓,存在即可)
|
||||
if err := os.WriteFile(filepath.Join(dir, oldPrefix(exe)), []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
p := uniqueOldPath(dir, exe)
|
||||
if _, err := os.Lstat(p); !os.IsNotExist(err) {
|
||||
t.Fatalf("uniqueOldPath 返回了已存在的路径: %s", p)
|
||||
}
|
||||
if seen[p] {
|
||||
t.Fatalf("uniqueOldPath 返回了重复路径: %s", p)
|
||||
}
|
||||
seen[p] = true
|
||||
// 占位,模拟这一轮升级已经把它用掉了
|
||||
if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(filepath.Base(p), oldPrefix(exe)) {
|
||||
t.Fatalf("命名未落在 .old 前缀下: %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepLeftoversOnlyTouchesOwnTemps 清扫必须只动自己的临时文件,
|
||||
// 且不碰主程序与目录里的其它文件。
|
||||
func TestSweepLeftoversOnlyTouchesOwnTemps(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exe := "SamWaf64.exe"
|
||||
|
||||
mustWrite := func(name string) string {
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
old1 := mustWrite(oldPrefix(exe) + ".20260101010101.111")
|
||||
old2 := mustWrite(oldPrefix(exe) + ".20260101010102.222")
|
||||
newFile := mustWrite("." + exe + stageNewSuffix)
|
||||
keepExe := mustWrite(exe)
|
||||
keepOther := mustWrite("other.txt")
|
||||
keepOtherExeOld := mustWrite(".OtherApp.exe.old")
|
||||
|
||||
// 把 .old 的时间戳改老,绕开"跳过 60s 内新建文件"的并发保护
|
||||
aged := time.Now().Add(-10 * time.Minute)
|
||||
for _, p := range []string{old1, old2, newFile} {
|
||||
if err := os.Chtimes(p, aged, aged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
removed, kept := sweepLeftovers(dir, exe, true)
|
||||
if removed != 3 || kept != 0 {
|
||||
t.Fatalf("期望删除 3 个/保留 0 个,实际 removed=%d kept=%d", removed, kept)
|
||||
}
|
||||
for _, p := range []string{old1, old2, newFile} {
|
||||
if _, err := os.Lstat(p); !os.IsNotExist(err) {
|
||||
t.Errorf("应被清扫却仍在: %s", p)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{keepExe, keepOther, keepOtherExeOld} {
|
||||
if _, err := os.Lstat(p); err != nil {
|
||||
t.Errorf("不该被动却没了: %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepLeftoversSkipsRecent 刚创建的 .old 可能属于另一个正在进行中的替换
|
||||
// (例如服务运行中同时执行 samwaf rollback),必须跳过。
|
||||
func TestSweepLeftoversSkipsRecent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exe := "SamWaf64.exe"
|
||||
fresh := filepath.Join(dir, oldPrefix(exe)+".20260101010101.333")
|
||||
if err := os.WriteFile(fresh, []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
removed, kept := sweepLeftovers(dir, exe, false)
|
||||
if removed != 0 || kept != 1 {
|
||||
t.Fatalf("期望删除 0 个/保留 1 个,实际 removed=%d kept=%d", removed, kept)
|
||||
}
|
||||
if _, err := os.Lstat(fresh); err != nil {
|
||||
t.Fatalf("刚创建的 .old 不该被删: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepLeftoversKeepsStagingWhenNotIncluded replaceExecutable 途中调用清扫时,
|
||||
// 本次的 .new/.rollback 正在使用,绝不能被扫掉。
|
||||
func TestSweepLeftoversKeepsStagingWhenNotIncluded(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exe := "SamWaf64.exe"
|
||||
staging := filepath.Join(dir, "."+exe+stageNewSuffix)
|
||||
if err := os.WriteFile(staging, []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aged := time.Now().Add(-10 * time.Minute)
|
||||
if err := os.Chtimes(staging, aged, aged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sweepLeftovers(dir, exe, false) // includeStaging=false
|
||||
if _, err := os.Lstat(staging); err != nil {
|
||||
t.Fatalf("includeStaging=false 时 .new 不该被删: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryFileOpStopsOnPermanentError 确定性错误不该白白重试等待。
|
||||
func TestRetryFileOpStopsOnPermanentError(t *testing.T) {
|
||||
calls := 0
|
||||
permanent := errors.New("permanent failure")
|
||||
start := time.Now()
|
||||
err := retryFileOp("test", 5, func() error {
|
||||
calls++
|
||||
return permanent
|
||||
})
|
||||
if !errors.Is(err, permanent) {
|
||||
t.Fatalf("期望原样返回确定性错误,实际 %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("确定性错误应只尝试 1 次,实际 %d 次", calls)
|
||||
}
|
||||
if time.Since(start) > 100*time.Millisecond {
|
||||
t.Fatalf("确定性错误不该退避等待,耗时 %v", time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryFileOpTreatsMissingAsSuccess 删除操作要幂等。
|
||||
func TestRetryFileOpTreatsMissingAsSuccess(t *testing.T) {
|
||||
err := retryFileOp("test", 3, func() error {
|
||||
return os.ErrNotExist
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("文件不存在应视为成功,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceExecutableSwapsAndKeepsUniqueOld 替换的正常路径:
|
||||
// 新内容就位、旧内容被搬走、且 .old 用的是唯一名(不是固定名)。
|
||||
func TestReplaceExecutableSwapsAndKeepsUniqueOld(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "SamWaf64.exe")
|
||||
staged := filepath.Join(dir, ".SamWaf64.exe"+stageNewSuffix)
|
||||
|
||||
if err := os.WriteFile(exe, []byte("OLD"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(staged, []byte("NEW"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err, recoverErr := replaceExecutable(exe, staged)
|
||||
if err != nil || recoverErr != nil {
|
||||
t.Fatalf("替换应成功,实际 err=%v recoverErr=%v", err, recoverErr)
|
||||
}
|
||||
|
||||
got, readErr := os.ReadFile(exe)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if string(got) != "NEW" {
|
||||
t.Fatalf("主程序内容应为 NEW,实际 %q", string(got))
|
||||
}
|
||||
if _, err := os.Lstat(staged); !os.IsNotExist(err) {
|
||||
t.Fatalf("staged 文件应已被搬走: %v", err)
|
||||
}
|
||||
// 固定名 .old 不该出现——那正是旧实现踩坑的地方
|
||||
if _, err := os.Lstat(filepath.Join(dir, oldPrefix("SamWaf64.exe"))); !os.IsNotExist(err) {
|
||||
t.Fatalf("不应生成固定名 .old")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceExecutableWithUndeletableOldLeftover 本 bug 的回归测试。
|
||||
//
|
||||
// 场景:上一次升级留下的 .old 还在(生产上它是常驻 Supervisor 的映像,删不掉)。
|
||||
// 旧实现会把它当作 rename 目标 → Windows 上 ACCESS_DENIED。
|
||||
// 新实现用唯一名,必须照常成功,且不动那个残留。
|
||||
func TestReplaceExecutableWithUndeletableOldLeftover(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "SamWaf64.exe")
|
||||
staged := filepath.Join(dir, ".SamWaf64.exe"+stageNewSuffix)
|
||||
leftover := filepath.Join(dir, oldPrefix("SamWaf64.exe"))
|
||||
|
||||
for p, c := range map[string]string{exe: "OLD", staged: "NEW", leftover: "PINNED"} {
|
||||
if err := os.WriteFile(p, []byte(c), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 保鲜时间戳,让 sweepLeftovers 跳过它,模拟"扫不掉的残留"
|
||||
now := time.Now()
|
||||
if err := os.Chtimes(leftover, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err, recoverErr := replaceExecutable(exe, staged)
|
||||
if err != nil || recoverErr != nil {
|
||||
t.Fatalf("有残留 .old 时替换仍应成功,实际 err=%v recoverErr=%v", err, recoverErr)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(exe)
|
||||
if string(got) != "NEW" {
|
||||
t.Fatalf("主程序内容应为 NEW,实际 %q", string(got))
|
||||
}
|
||||
pinned, readErr := os.ReadFile(leftover)
|
||||
if readErr != nil || string(pinned) != "PINNED" {
|
||||
t.Fatalf("残留 .old 不该被改动,读到 %q err=%v", string(pinned), readErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCurrentRoleFollowsWorkerFlag 诊断信息里的角色判定要与 parseWorkerRole 一致。
|
||||
func TestCurrentRoleFollowsWorkerFlag(t *testing.T) {
|
||||
saved := os.Args
|
||||
defer func() { os.Args = saved }()
|
||||
|
||||
os.Args = []string{"SamWaf64.exe"}
|
||||
if got := currentRole(); got != "Supervisor" {
|
||||
t.Fatalf("无 --worker 时应为 Supervisor,实际 %s", got)
|
||||
}
|
||||
os.Args = []string{"SamWaf64.exe", "--worker", "--ctrl-addr=127.0.0.1:1"}
|
||||
if got := currentRole(); got != "Worker" {
|
||||
t.Fatalf("有 --worker 时应为 Worker,实际 %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package wafupdate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// clearFileAttrs 清除 READONLY / HIDDEN / SYSTEM,保留其余属性。
|
||||
//
|
||||
// 必要性(两条都会以 ACCESS_DENIED 的形式咬人):
|
||||
// 1. os.Remove 对 READONLY 文件返回 ACCESS_DENIED;
|
||||
// 2. os.OpenFile(O_CREATE|O_TRUNC) 底层是 CreateFile(CREATE_ALWAYS),
|
||||
// 目标已存在且带 HIDDEN/SYSTEM 时直接返回 ACCESS_DENIED。
|
||||
// 旧实现给删不掉的 .old 打 HIDDEN,正好踩中这条。
|
||||
func clearFileAttrs(path string) error {
|
||||
p, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attrs, err := windows.GetFileAttributes(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
cleared := attrs &^ (windows.FILE_ATTRIBUTE_READONLY | windows.FILE_ATTRIBUTE_HIDDEN | windows.FILE_ATTRIBUTE_SYSTEM)
|
||||
if cleared == attrs {
|
||||
return nil
|
||||
}
|
||||
if cleared == 0 {
|
||||
cleared = windows.FILE_ATTRIBUTE_NORMAL
|
||||
}
|
||||
return windows.SetFileAttributes(p, cleared)
|
||||
}
|
||||
|
||||
// pendingDeleteOnReboot 按路径去重,防止同一进程反复往
|
||||
// HKLM\...\PendingFileRenameOperations 里塞同一条记录。
|
||||
var pendingDeleteOnReboot sync.Map
|
||||
|
||||
// markDeleteOnReboot 用 MoveFileEx(path, NULL, MOVEFILE_DELAY_UNTIL_REBOOT)
|
||||
// 登记"下次重启时删除"。需要管理员/SYSTEM 权限(服务模式满足),
|
||||
// 交互式非管理员会失败——只记日志,不影响升级结果。
|
||||
func markDeleteOnReboot(path string) error {
|
||||
if _, loaded := pendingDeleteOnReboot.LoadOrStore(path, true); loaded {
|
||||
return nil
|
||||
}
|
||||
p, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
pendingDeleteOnReboot.Delete(path)
|
||||
return err
|
||||
}
|
||||
if err := windows.MoveFileEx(p, nil, windows.MOVEFILE_DELAY_UNTIL_REBOOT); err != nil {
|
||||
pendingDeleteOnReboot.Delete(path)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTransientFileError 判断是否为"重试有意义"的瞬时错误。
|
||||
// ACCESS_DENIED 也纳入:杀软扫描期确实会短暂返回它。
|
||||
// 重试次数很少(rename 3 次 / remove 5 次),确定性失败也不会明显拖慢报错。
|
||||
func isTransientFileError(err error) bool {
|
||||
var errno syscall.Errno
|
||||
if !errors.As(err, &errno) {
|
||||
return false
|
||||
}
|
||||
switch errno {
|
||||
case syscall.Errno(windows.ERROR_ACCESS_DENIED),
|
||||
syscall.Errno(windows.ERROR_SHARING_VIOLATION),
|
||||
syscall.Errno(windows.ERROR_LOCK_VIOLATION),
|
||||
syscall.Errno(windows.ERROR_USER_MAPPED_FILE):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// errnoName 把底层 Windows 错误码翻成可读名,供诊断串使用。
|
||||
func errnoName(err error) string {
|
||||
var errno syscall.Errno
|
||||
if !errors.As(err, &errno) {
|
||||
return "errno=?"
|
||||
}
|
||||
name := ""
|
||||
switch errno {
|
||||
case syscall.Errno(windows.ERROR_ACCESS_DENIED):
|
||||
name = "ERROR_ACCESS_DENIED"
|
||||
case syscall.Errno(windows.ERROR_SHARING_VIOLATION):
|
||||
name = "ERROR_SHARING_VIOLATION"
|
||||
case syscall.Errno(windows.ERROR_LOCK_VIOLATION):
|
||||
name = "ERROR_LOCK_VIOLATION"
|
||||
case syscall.Errno(windows.ERROR_USER_MAPPED_FILE):
|
||||
name = "ERROR_USER_MAPPED_FILE"
|
||||
case syscall.Errno(windows.ERROR_FILE_NOT_FOUND):
|
||||
name = "ERROR_FILE_NOT_FOUND"
|
||||
case syscall.Errno(windows.ERROR_PATH_NOT_FOUND):
|
||||
name = "ERROR_PATH_NOT_FOUND"
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Sprintf("errno=%d", uintptr(errno))
|
||||
}
|
||||
return fmt.Sprintf("errno=%d(%s)", uintptr(errno), name)
|
||||
}
|
||||
|
||||
// platformFileDiag 返回路径的诊断细节:是否存在、属性位、是否疑似被占用。
|
||||
func platformFileDiag(path string) string {
|
||||
p, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return "路径无效"
|
||||
}
|
||||
attrs, err := windows.GetFileAttributes(p)
|
||||
if err != nil {
|
||||
return "不存在"
|
||||
}
|
||||
s := fmt.Sprintf("存在/attrs=0x%x", attrs)
|
||||
// 以"允许删除"的共享模式试开一次:打不开基本可判定被别的进程(或映像段)占着。
|
||||
h, err := windows.CreateFile(p, windows.DELETE,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
|
||||
if err != nil {
|
||||
return s + "/被占用(" + err.Error() + ")"
|
||||
}
|
||||
_ = windows.CloseHandle(h)
|
||||
return s
|
||||
}
|
||||
+55
-58
@@ -90,14 +90,17 @@ func canUpdate() (err error) {
|
||||
fileName := filepath.Base(path)
|
||||
|
||||
// attempt to open a file in the file's directory
|
||||
newPath := filepath.Join(fileDir, fmt.Sprintf(".%s.new", fileName))
|
||||
newPath := stagePath(fileDir, fileName, stageNewSuffix)
|
||||
// 残留的 .new 若带 HIDDEN/SYSTEM 属性,CREATE_ALWAYS 会直接 ACCESS_DENIED,
|
||||
// 预检本身就会误报"目录不可写"。先清属性再删掉。
|
||||
_ = removeFileForce(newPath)
|
||||
fp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fp.Close()
|
||||
|
||||
_ = os.Remove(newPath)
|
||||
_ = removeFileForce(newPath)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -435,66 +438,54 @@ func (u *Updater) UpdateWithChannel(channel string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fromStream 把新二进制落盘为 .new,再委托 replaceExecutable 完成原地替换。
|
||||
// 替换本身(唯一 .old 命名、属性清理、重试、旧二进制处置)统一在 binreplace.go,
|
||||
// 与 RollbackExecutable 共用,不要在这里再写一套 rename 技巧。
|
||||
func fromStream(updateWith io.Reader) (err error, errRecover error) {
|
||||
updatePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var newBytes []byte
|
||||
newBytes, err = ioutil.ReadAll(updateWith)
|
||||
if err != nil {
|
||||
return
|
||||
// 与 Update()/RollbackExecutable() 保持一致:替换软链指向的真实文件,而不是软链本身。
|
||||
if resolved, e := filepath.EvalSymlinks(updatePath); e == nil {
|
||||
updatePath = resolved
|
||||
}
|
||||
|
||||
// get the directory the executable exists in
|
||||
updateDir := filepath.Dir(updatePath)
|
||||
filename := filepath.Base(updatePath)
|
||||
newPath := stagePath(updateDir, filename, stageNewSuffix)
|
||||
|
||||
// 清掉上一轮遗留的 .new(可能带 HIDDEN,会让 CREATE_ALWAYS 直接 ACCESS_DENIED)
|
||||
_ = removeFileForce(newPath)
|
||||
|
||||
// Copy the contents of of newbinary to a the new executable file
|
||||
newPath := filepath.Join(updateDir, fmt.Sprintf(".%s.new", filename))
|
||||
fp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("%s", diagnoseFileOp("create", newPath, "", err))
|
||||
return
|
||||
}
|
||||
defer fp.Close()
|
||||
_, err = io.Copy(fp, bytes.NewReader(newBytes))
|
||||
|
||||
// if we don't call fp.Close(), windows won't let us move the new executable
|
||||
// because the file will still be "in use"
|
||||
fp.Close()
|
||||
|
||||
// this is where we'll move the executable to so that we can swap in the updated replacement
|
||||
oldPath := filepath.Join(updateDir, fmt.Sprintf(".%s.old", filename))
|
||||
|
||||
// delete any existing old exec file - this is necessary on Windows for two reasons:
|
||||
// 1. after a successful update, Windows can't remove the .old file because the process is still running
|
||||
// 2. windows rename operations fail if the destination file already exists
|
||||
_ = os.Remove(oldPath)
|
||||
|
||||
// move the existing executable to a new file in the same directory
|
||||
err = os.Rename(updatePath, oldPath)
|
||||
if err != nil {
|
||||
// 原实现丢弃了 io.Copy 的错误(随后被 rename 的返回值覆盖),
|
||||
// 磁盘满/写盘中断会把半截二进制 rename 成主程序。这里必须逐步判错。
|
||||
if _, cerr := io.Copy(fp, updateWith); cerr != nil {
|
||||
fp.Close()
|
||||
_ = removeFileForce(newPath)
|
||||
err = fmt.Errorf("写入升级临时文件失败: %w", cerr)
|
||||
return
|
||||
}
|
||||
// rename 前确保字节真正落盘
|
||||
if serr := fp.Sync(); serr != nil {
|
||||
fp.Close()
|
||||
_ = removeFileForce(newPath)
|
||||
err = fmt.Errorf("刷盘升级临时文件失败: %w", serr)
|
||||
return
|
||||
}
|
||||
// 必须先 Close,否则 Windows 认为文件仍在使用、无法 rename
|
||||
if cerr := fp.Close(); cerr != nil {
|
||||
_ = removeFileForce(newPath)
|
||||
err = fmt.Errorf("关闭升级临时文件失败: %w", cerr)
|
||||
return
|
||||
}
|
||||
|
||||
// move the new exectuable in to become the new program
|
||||
err = os.Rename(newPath, updatePath)
|
||||
|
||||
if err != nil {
|
||||
// copy unsuccessful
|
||||
errRecover = os.Rename(oldPath, updatePath)
|
||||
} else {
|
||||
// copy successful, remove the old binary
|
||||
errRemove := os.Remove(oldPath)
|
||||
|
||||
// windows has trouble with removing old binaries, so hide it instead
|
||||
if errRemove != nil {
|
||||
_ = hideFile(oldPath)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
return replaceExecutable(updatePath, newPath)
|
||||
}
|
||||
|
||||
// fetchInfo fetches the update JSON manifest at u.ApiURL/appname/platform.json?v=currentVersion
|
||||
@@ -827,7 +818,8 @@ func RollbackExecutable(version string) error {
|
||||
backupFilePath := filepath.Join(backupDir, target.FileName)
|
||||
|
||||
// 将备份复制到临时文件
|
||||
rollbackPath := filepath.Join(updateDir, fmt.Sprintf(".%s.rollback", filename))
|
||||
rollbackPath := stagePath(updateDir, filename, stageRollbackSuffix)
|
||||
_ = removeFileForce(rollbackPath)
|
||||
src, err := os.Open(backupFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开备份文件失败: %w", err)
|
||||
@@ -839,22 +831,27 @@ func RollbackExecutable(version string) error {
|
||||
}
|
||||
_, copyErr := io.Copy(dst, src)
|
||||
src.Close()
|
||||
dst.Close()
|
||||
if syncErr := dst.Sync(); syncErr != nil && copyErr == nil {
|
||||
copyErr = syncErr
|
||||
}
|
||||
if closeErr := dst.Close(); closeErr != nil && copyErr == nil {
|
||||
copyErr = closeErr
|
||||
}
|
||||
if copyErr != nil {
|
||||
_ = removeFileForce(rollbackPath)
|
||||
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)
|
||||
// 与升级共用同一套替换底座(唯一 .old 命名 + 属性清理 + 重试 + 旧二进制处置)。
|
||||
// 旧实现在这里复制了一份固定名 .old 的 rename 技巧,同样会在 Windows 上
|
||||
// 撞到"目标已存在且被常驻 Supervisor 占用"而失败——即 supervisor.rollbackBinary()
|
||||
// 的自动回滚在 Windows 生产上一直是坏的。
|
||||
repErr, recoverErr := replaceExecutable(execPath, rollbackPath)
|
||||
if repErr != nil {
|
||||
if recoverErr != nil {
|
||||
return fmt.Errorf("回退失败且未能恢复原程序(需人工介入): %v ; 恢复错误: %v", repErr, recoverErr)
|
||||
}
|
||||
return fmt.Errorf("回退失败(原程序已恢复): %w", repErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user