mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-31 10:11:42 +08:00
287 lines
9.8 KiB
Go
287 lines
9.8 KiB
Go
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"
|
||
}
|