Files
2026-07-28 16:42:34 +08:00

287 lines
9.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
}