diff --git a/build_test_update.bat b/build_test_update.bat index 5cc2fff4..42dcc020 100644 --- a/build_test_update.bat +++ b/build_test_update.bat @@ -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. Test backend v1.1.0 is running in %TESTDIR%. echo. -echo All done. Start v1.1.0 to test upgrade. +echo HTTP server started: http://127.0.0.1:8111/ +echo Root: %CURDIR%\release\web +echo Press Ctrl+C to stop. echo. -pause +pushd "%CURDIR%\release\web" +python -m http.server 8111 +popd endlocal diff --git a/build_test_upgrade_twice.bat b/build_test_upgrade_twice.bat new file mode 100644 index 00000000..5b52212f --- /dev/null +++ b/build_test_upgrade_twice.bat @@ -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 diff --git a/check_upgrade_state.ps1 b/check_upgrade_state.ps1 new file mode 100644 index 00000000..c8086936 --- /dev/null +++ b/check_upgrade_state.ps1 @@ -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 '' diff --git a/cmd/samwaf/main.go b/cmd/samwaf/main.go index dc50b64c..8ec4782f 100644 --- a/cmd/samwaf/main.go +++ b/cmd/samwaf/main.go @@ -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} diff --git a/common/zlog/zlog.go b/common/zlog/zlog.go index 753f2cb2..5f781b57 100644 --- a/common/zlog/zlog.go +++ b/common/zlog/zlog.go @@ -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) diff --git a/publish_test_version.bat b/publish_test_version.bat new file mode 100644 index 00000000..690b2a21 --- /dev/null +++ b/publish_test_version.bat @@ -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 diff --git a/supervisor/handoff.go b/supervisor/handoff.go new file mode 100644 index 00000000..d85b73ca --- /dev/null +++ b/supervisor/handoff.go @@ -0,0 +1,7 @@ +package supervisor + +// HandoffRestartArg 是 Windows 下拉起"重启助手"用的隐藏命令行参数。 +// +// 助手是一个脱离服务的短命进程,唯一职责是等本服务停干净后再把它 start 起来, +// 让 Supervisor 换到新二进制。定义在跨平台文件里,供 cmd/samwaf 的命令分发引用。 +const HandoffRestartArg = "handoff-restart" diff --git a/supervisor/handoff_unix.go b/supervisor/handoff_unix.go new file mode 100644 index 00000000..80cfc989 --- /dev/null +++ b/supervisor/handoff_unix.go @@ -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()) +} diff --git a/supervisor/handoff_windows.go b/supervisor/handoff_windows.go new file mode 100644 index 00000000..6b686dd8 --- /dev/null +++ b/supervisor/handoff_windows.go @@ -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 +} diff --git a/supervisor/supervisor.go b/supervisor/supervisor.go index e809c159..29d9e197 100644 --- a/supervisor/supervisor.go +++ b/supervisor/supervisor.go @@ -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() + // 清扫上一轮升级/回退遗留的临时二进制(..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() diff --git a/wafupdate/binreplace.go b/wafupdate/binreplace.go new file mode 100644 index 00000000..298c9ab4 --- /dev/null +++ b/wafupdate/binreplace.go @@ -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" +} diff --git a/wafupdate/binreplace_other.go b/wafupdate/binreplace_other.go new file mode 100644 index 00000000..639e64a3 --- /dev/null +++ b/wafupdate/binreplace_other.go @@ -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()) +} diff --git a/wafupdate/binreplace_test.go b/wafupdate/binreplace_test.go new file mode 100644 index 00000000..1ab8a31d --- /dev/null +++ b/wafupdate/binreplace_test.go @@ -0,0 +1,242 @@ +package wafupdate + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestUniqueOldPathNeverCollides 核心修复的直接断言: +// 即便同名目标已存在,uniqueOldPath 也必须给出一个尚不存在的新路径。 +// 旧实现用固定名 "..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) + } +} diff --git a/wafupdate/binreplace_windows.go b/wafupdate/binreplace_windows.go new file mode 100644 index 00000000..b0d13543 --- /dev/null +++ b/wafupdate/binreplace_windows.go @@ -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 +} diff --git a/wafupdate/selfupdate.go b/wafupdate/selfupdate.go index 32f196ee..9cb53176 100644 --- a/wafupdate/selfupdate.go +++ b/wafupdate/selfupdate.go @@ -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 } - - // 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) - } + // 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 } - 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 }