Files
clawd/lib/client.js

788 lines
31 KiB
JavaScript
Raw 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.
'use strict';
const fs = require('fs');
const path = require('path');
const { getNotifySocket } = require('./systemd-env');
const WebSocket = require('ws');
const { execFileSync, exec } = require('child_process');
const config = require('./config');
const CLAWD_VERSION = require(path.join(__dirname, '..', 'package.json')).version;
const log = require('./logger');
const { getBoxId } = require('./fingerprint');
const { collect } = require('./metrics');
const { getDashboardInfo, resolveOpenclawConfigFile, startTtyd, FrpcManager } = require('./frpc'); // getDashboardInfo 也用于心跳中定期刷新
const headscale = require('./headscale');
const { ProvisionManager } = require('./provisioning');
const { BtMonitor } = require('./bt-monitor');
const { hasInternet, hasWiredInternetProbe, getLocalIps, getLocalNetworks } = require('./network');
const { applyFullProviderFromVps, removeProviderByName, refreshModelsIfChanged, isFullProvider } = require('./openclaw-provider');
const led = require('./led');
const MAX_BACKOFF_MS = 60_000;
/** 连续若干轮 ping 后仍无 pong 才判定死链(单轮易因调度/弱网误判) */
const PONG_MISS_MAX = 3;
const PING_INTERVAL_MS = 15_000;
const HEARTBEAT_INTERVAL_MS = 10_000; // 心跳间隔10 秒,用于快速感知网络状态
const METRICS_EVERY_N = 3; // 每 N 次心跳采集一次指标(= 30 秒)
/** 尚未连云时轮询外网(如 AP 模式下后接网线);短于 provision 监控,避免只靠 network-ready */
const LATE_NET_POLL_MS = 3_000;
/** 内核是否暴露蓝牙适配器hci* */
function bluetoothAdapterPresent() {
try {
return fs.readdirSync('/sys/class/bluetooth').some((n) => n.startsWith('hci'));
} catch (_) {
return false;
}
}
/**
* 是否启动 BtMonitor会周期性执行 bluetoothctl
* 默认关闭,仅拉代码即可与「不调 bluetoothctl」一致需要蓝牙灯时设 CLAWD_ENABLE_BT=1。
* CLAWD_DISABLE_BT=1 仍可强制关闭(优先于 ENABLE
*/
function btMonitorEnabled() {
const dis = (process.env.CLAWD_DISABLE_BT || '').trim().toLowerCase();
if (dis === '1' || dis === 'true' || dis === 'yes') return false;
const en = (process.env.CLAWD_ENABLE_BT || '').trim().toLowerCase();
if (!(en === '1' || en === 'true' || en === 'yes')) return false;
return bluetoothAdapterPresent();
}
class ClawClient {
constructor() {
this._cfg = config.load();
this._boxId = getBoxId();
this._ws = null;
this._hbTimer = null;
this._backoff = 1_000;
this._stopped = false;
this._frpc = new FrpcManager();
this._btMonitor = null;
this._dashInfo = {};
this._hbCount = 0; // 心跳计数器,用于定期刷新 dashboard 信息
this._externalIp = null; // 外网 IP
this._location = null; // 地理位置(由 ipplus360 返回,如"北京市-北京市西城区"
// WS 层活性检测
this._pingTimer = null;
this._awaitingPong = false;
this._pongMissCount = 0;
// WS 连续失败计数open 时清零)
this._wsFailCount = 0;
// 是否曾经成功连接过(首次成功前不显示 Err0/AP
this._hasEverConnected = false;
// 最近一次 WS 错误是否是证书时间问题NTP 未同步)
this._certTimeError = false;
// systemd watchdogsystemd-notify 子进程 + unit 里 NotifyAccess=all
this._sdTimer = null;
/** setInterval等外网出现后启动 _proceedWithConnection与 network-ready 二选一) */
this._lateNetPollTimer = null;
this._setupGlobalHandlers();
}
// ── 全局异常兜底 ─────────────────────────────────────────────────────────────
_setupGlobalHandlers() {
process.on('uncaughtException', (err) => {
log.error('process', '未捕获异常:', err);
// 给日志写盘的时间,然后退出让 systemd 重启
setTimeout(() => process.exit(1), 1000);
});
process.on('unhandledRejection', (reason) => {
log.error('process', '未处理的 Promise 拒绝:', reason);
});
}
// ── 生命周期 ─────────────────────────────────────────────────────────────────
async start() {
log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`);
if (this._cfg.claw_id) {
this._setHostname(this._cfg.claw_id);
}
// 启动时关 alarm若本地已记为已激活立即亮 pwr不等首包 connected
led.status.off();
if (this._cfg.activated) {
led.status.setApps();
}
this._startSdNotify();
// RJ45 链路轮询OpenVFD play与 WS 无关,进程起来即开始
led.lan.start();
// 蓝牙状态监控bluetoothctl默认不启用见 btMonitorEnabled()
if (btMonitorEnabled()) {
this._btMonitor = new BtMonitor();
this._btMonitor.start();
} else {
const dis = (process.env.CLAWD_DISABLE_BT || '').trim().toLowerCase();
const en = (process.env.CLAWD_ENABLE_BT || '').trim().toLowerCase();
const wantEn = en === '1' || en === 'true' || en === 'yes';
const forcedDis = dis === '1' || dis === 'true' || dis === 'yes';
let msg = '已跳过蓝牙监控(默认关闭;需要时请设 CLAWD_ENABLE_BT=1';
if (forcedDis) msg = '已跳过蓝牙监控CLAWD_DISABLE_BT 已开启)';
else if (wantEn && !bluetoothAdapterPresent()) {
msg = '已跳过蓝牙监控(已设 CLAWD_ENABLE_BT 但未检测到 hci 适配器)';
}
log.info('clawd', msg);
}
// 启动 AP 配网管理器(等待已保存 WiFi 自动连接,超时再开 AP
this._provisionMgr = new ProvisionManager(this._cfg.claw_id);
this._connectionStarted = false;
// 网络就绪时连接云端(仅触发一次)
this._provisionMgr.on('network-ready', () => {
if (!this._connectionStarted) {
this._clearLateNetPoll();
this._connectionStarted = true;
this._proceedWithConnection().catch(e => {
log.error('clawd', '连接启动失败:', e.message);
});
}
});
await this._provisionMgr.start();
// start() 返回后,如果已有网络且尚未启动连接
if (hasInternet() && !this._connectionStarted) {
this._connectionStarted = true;
await this._proceedWithConnection();
} else if (!hasInternet()) {
log.info('clawd', '等待外网就绪(可配网;有线已接时将自动识别网卡,含非 eth0 口)...');
}
if (!this._connectionStarted && !this._stopped) {
this._lateNetPollTimer = setInterval(() => {
if (this._stopped || this._connectionStarted) {
this._clearLateNetPoll();
return;
}
if (hasInternet() || hasWiredInternetProbe()) {
this._clearLateNetPoll();
this._connectionStarted = true;
log.info('clawd', '轮询检测到外网,启动连云');
this._proceedWithConnection().catch(e => {
log.error('clawd', '连接启动失败:', e.message);
});
}
}, LATE_NET_POLL_MS);
}
}
_clearLateNetPoll() {
if (this._lateNetPollTimer) {
clearInterval(this._lateNetPollTimer);
this._lateNetPollTimer = null;
}
}
async _proceedWithConnection() {
const [dashInfo] = await Promise.all([
getDashboardInfo().catch(e => { log.warn('clawd', 'dashboard 信息获取失败:', e.message); return null; }),
startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)),
]);
this._dashInfo = dashInfo || {};
// 查询外网 IP 和地理位置,失败不阻断连接
try {
const https = require('https');
const fetchText = (url) => new Promise((resolve) => {
const req = https.get(url, { timeout: 5000 }, (res) => {
let body = '';
res.on('data', d => { body += d; });
res.on('end', () => resolve(body.trim()));
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
});
const fetchJson = (url) => new Promise((resolve) => {
const req = https.get(url, { timeout: 5000 }, (res) => {
let body = '';
res.on('data', d => { body += d; });
res.on('end', () => {
try { resolve(JSON.parse(body)); } catch { resolve(null); }
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
});
// 外网 IPcheckip.amazonaws.com 返回纯文本 IP
const ip = await fetchText('https://checkip.amazonaws.com');
if (ip && /^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
this._externalIp = ip;
log.info('clawd', `外网 IP: ${this._externalIp}`);
}
// 地理位置ipplus360国内访问返回 {"success":true,"data":"北京市-北京市西城区"}
const geoResp = await fetchJson('https://www.ipplus360.com/getLocation');
if (geoResp && geoResp.success && geoResp.data) {
this._location = geoResp.data;
log.info('clawd', `地理位置: ${this._location}`);
}
} catch (e) {
log.warn('clawd', '网络信息查询失败:', e.message);
}
this._connect();
}
stop() {
this._stopped = true;
this._clearLateNetPoll();
this._clearHeartbeat();
this._clearPing();
if (this._sdTimer) { clearInterval(this._sdTimer); this._sdTimer = null; }
led.lan.stop();
if (this._btMonitor) { this._btMonitor.stop(); this._btMonitor = null; }
if (this._provisionMgr) { this._provisionMgr.stop(); this._provisionMgr = null; }
this._frpc.stop();
if (this._ws) this._ws.terminate();
led.status.off(); // 进程退出,两灯全灭
this._sdNotify('STOPPING=1');
log.info('clawd', '已停止');
log.close();
}
// ── WebSocket 连接 ──────────────────────────────────────────────────────────
_connect() {
if (this._stopped) return;
// AP 模式 + 无网:不建立 WS5s 后重新检查(有线经 -I ping 仍通则建立,避免热点误挡 WS
if (this._provisionMgr && this._provisionMgr.isApMode() && !hasInternet() && !hasWiredInternetProbe()) {
led.display.showAP();
log.info('clawd', 'AP 模式无网络5s 后重新检查...');
this._backoff = 1_000; // 有网时立即快速重连
this._wsFailCount = 0; // 不计入失败
setTimeout(() => this._connect(), 5_000);
return;
}
if (!this._hasEverConnected || this._wsFailCount < 3) led.display.showConn();
log.info('clawd', `正在连接 ${this._cfg.server} ...`);
const ws = new WebSocket(this._cfg.server, {
handshakeTimeout: 10_000,
});
this._ws = ws;
ws.on('open', () => {
log.info('clawd', 'WebSocket 已连接');
this._backoff = 1_000;
this._wsFailCount = 0; // 连接成功,重置失败计数
this._hasEverConnected = true; // 标记已成功连接过
if (this._cfg.activated) {
led.status.setApps();
}
this._sendConnect();
this._startPing();
// 显示由 _onConnected 根据 status 设置,不在此处提前 showTime
});
ws.on('message', (data) => {
try {
this._handleMessage(JSON.parse(data.toString()));
} catch (e) {
log.error('clawd', '消息解析失败:', e.message);
}
});
ws.on('pong', () => {
this._awaitingPong = false;
this._pongMissCount = 0;
});
ws.on('close', (code, reason) => {
this._clearHeartbeat();
this._clearPing();
if (!this._stopped) {
this._wsFailCount++;
log.warn('clawd', `连接断开 (${code}),失败次数=${this._wsFailCount}${this._backoff / 1000}s 后重连...`);
if (this._hasEverConnected && this._wsFailCount >= 3) {
led.display.showAP();
}
if (this._certTimeError) {
// NTP 未同步:固定 5s 重试,等时钟校正
this._certTimeError = false;
this._backoff = 5_000;
log.warn('clawd', '证书时间错误NTP 未同步5s 后重试...');
} else {
this._backoff = Math.min(this._backoff * 2, MAX_BACKOFF_MS);
}
setTimeout(() => this._connect(), this._backoff);
}
});
ws.on('error', (err) => {
log.error('clawd', '连接错误:', err.message);
// 证书时间错误NTP 未同步close 后用固定短间隔重试,不做指数退避
this._certTimeError = !!(
err.code === 'CERT_NOT_YET_VALID' ||
(err.message && err.message.includes('not yet valid'))
);
});
}
// ── WS 层 Ping/Pong 活性检测 ──────────────────────────────────────────────
_startPing() {
this._clearPing();
this._pingTimer = setInterval(() => {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
if (this._awaitingPong) {
this._pongMissCount++;
if (this._pongMissCount >= PONG_MISS_MAX) {
log.warn('clawd', `Pong 连续 ${PONG_MISS_MAX} 次未响应,主动关闭重连`);
this._ws.terminate();
return;
}
log.warn('clawd', `Pong 超时 (${this._pongMissCount}/${PONG_MISS_MAX}),继续探测...`);
}
this._awaitingPong = true;
try { this._ws.ping(); } catch (_) {}
}, PING_INTERVAL_MS);
}
_clearPing() {
if (this._pingTimer) {
clearInterval(this._pingTimer);
this._pingTimer = null;
}
this._awaitingPong = false;
this._pongMissCount = 0;
}
// ── 发送 connect ─────────────────────────────────────────────────────────────
_sendConnect() {
const msg = {
type: 'connect',
box_id: this._boxId,
claw_id: this._cfg.claw_id ?? null,
token: this._cfg.token ?? null,
version: CLAWD_VERSION,
ssh_secret_key: this._cfg.ssh_secret_key ?? null,
share_key: this._cfg.share_key ?? null,
headscale_joined: headscale.isInstalled() && headscale.isJoined(this._cfg.headscale_server || 'https://hs.claw.cutos.ai'),
local_ip: getLocalIps(),
local_networks: getLocalNetworks(),
external_ip: this._externalIp ?? null,
location: this._location ?? null,
...this._dashInfo,
};
this._send(msg);
}
// ── 消息处理 ─────────────────────────────────────────────────────────────────
_handleMessage(msg) {
switch (msg.type) {
case 'connected':
this._onConnected(msg);
break;
case 'heartbeat_ack':
break;
case 'status_update':
this._applyStatus(msg);
break;
case 'upgrade':
this._handleUpgrade(msg);
break;
case 'headscale_logout':
headscale.logout().catch(e => log.error('headscale', 'logout 失败:', e.message));
break;
case 'error':
log.error('clawd', `服务器错误: ${msg.msg}`);
if (msg.msg === 'hardware_mismatch') {
log.warn('clawd', '硬件指纹不符,清除凭证重新注册...');
this._cfg.claw_id = null;
this._cfg.token = null;
this._cfg.activated = false;
config.save(this._cfg);
led.status.setSetup();
} else if (msg.msg && msg.msg.includes('invalid')) {
log.warn('clawd', '凭证无效,清除凭证重新注册...');
this._cfg.claw_id = null;
this._cfg.token = null;
this._cfg.activated = false;
config.save(this._cfg);
led.status.setSetup();
}
break;
default:
log.warn('clawd', '未知消息类型:', msg.type);
}
}
_onConnected(msg) {
const isNew = !this._cfg.claw_id;
this._cfg.claw_id = msg.claw_id;
this._cfg.token = msg.token;
config.save(this._cfg);
this._setHostname(msg.claw_id);
if (isNew) {
log.info('clawd', `注册成功claw_id = ${msg.claw_id}`);
}
this._applyStatus(msg);
if (msg.frp && msg.frp.server && msg.frp.auth_token) {
this._frpc.start(msg.claw_id, msg.frp, this._cfg.ssh_secret_key ?? null).catch(e => {
log.error('frpc', '启动失败:', e.message);
});
}
// headscale收到 authkey 则加入 mesh
if (msg.headscale_authkey && msg.headscale_server) {
const hostname = msg.headscale_hostname || `claw-${msg.claw_id}`;
this._cfg.headscale_server = msg.headscale_server;
config.save(this._cfg);
headscale.join(msg.headscale_server, msg.headscale_authkey, hostname)
.then(ok => {
if (ok) log.info('headscale', `已加入 mesh: ${hostname}`);
})
.catch(e => log.error('headscale', '加入 mesh 失败:', e.message));
}
this._startHeartbeat();
}
_applyStatus(msg) {
if (msg.status === 'inactive') {
if (msg.provider && msg.provider.name) {
removeProviderByName(String(msg.provider.name));
}
this._cfg.activated = false;
config.save(this._cfg);
led.status.setSetup();
led.display.showPin(msg.pin);
const id = String(this._cfg.claw_id || '').padEnd(6);
const pin = String(msg.pin || '');
log.info('clawd', '');
log.info('clawd', '╔════════════════════════════════════╗');
log.info('clawd', `║ Claw ID : ${id}`);
log.info('clawd', `║ PIN 码 : ${pin}`);
log.info('clawd', '║ 请在网页前台「添加设备」中输入 ║');
log.info('clawd', '╚════════════════════════════════════╝');
log.info('clawd', '');
log.info('clawd', '等待激活,心跳正常运行...');
this._updateOpenClawOrigin('0000');
} else {
this._cfg.activated = true;
config.save(this._cfg);
led.status.setApps();
led.display.showTime();
log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`);
const clawIdStr = String(this._cfg.claw_id);
if (isFullProvider(msg.provider)) {
applyFullProviderFromVps(msg.provider, () => {
this._updateOpenClawOrigin(clawIdStr);
});
} else {
// 重连场景:检查模型列表是否有变化,有变化才写盘
refreshModelsIfChanged(() => {
this._updateOpenClawOrigin(clawIdStr);
});
}
}
}
// ── Hostname ─────────────────────────────────────────────────────────────────
_setHostname(clawId) {
const hostname = `claw-${clawId}`;
// 运行时 hostname无需文件权限
exec(`hostname ${hostname}`, { shell: true });
// 写 /etc/hostnamefs 直接写文件,不需要目录可写)
try {
fs.writeFileSync('/etc/hostname', hostname + '\n', 'utf8');
} catch (e) {
log.warn('clawd', `write /etc/hostname failed: ${e.message}`);
}
// 更新 /etc/hostsfs 读写,绕过 sed -i 需要目录可写的限制)
try {
const content = fs.readFileSync('/etc/hosts', 'utf8');
const updated = content.replace(/^127\.0\.1\.1.*/m, `127.0.1.1 ${hostname}`);
fs.writeFileSync('/etc/hosts', updated, 'utf8');
log.info('clawd', `hostname -> ${hostname}`);
} catch (e) {
log.warn('clawd', `write /etc/hosts failed: ${e.message}`);
}
}
// ── OpenClaw 配置 ────────────────────────────────────────────────────────────
_updateOpenClawOrigin(targetId) {
const { readFileSync, writeFileSync } = require('fs');
const configFile = resolveOpenclawConfigFile();
if (!configFile) {
log.warn('clawd', 'openclaw 配置文件不存在(~/.openclaw/openclaw.json 等候选路径均未找到)');
return;
}
try {
const raw = readFileSync(configFile, 'utf8');
const config = JSON.parse(raw);
const newOrigin = `https://${targetId}.claw.cutos.ai`;
const re = /https:\/\/[^"'\s]+\.claw\.cutos\.ai/g;
const hasAnyClawCutosAi = /https:\/\/[^"'\s]+\.claw\.cutos\.ai/.test(JSON.stringify(config));
/** 与原 YAML 全文替换等价:遍历 JSON 内所有字符串并替换匹配的 origin */
const replaceOriginStrings = (node) => {
let changed = false;
if (typeof node === 'string') {
return false;
}
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) {
const v = node[i];
if (typeof v === 'string') {
const next = v.replace(re, newOrigin);
if (next !== v) {
node[i] = next;
changed = true;
}
} else if (v && typeof v === 'object') {
changed = replaceOriginStrings(v) || changed;
}
}
return changed;
}
if (node && typeof node === 'object') {
for (const k of Object.keys(node)) {
const v = node[k];
if (typeof v === 'string') {
const next = v.replace(re, newOrigin);
if (next !== v) {
node[k] = next;
changed = true;
}
} else if (v && typeof v === 'object') {
changed = replaceOriginStrings(v) || changed;
}
}
return changed;
}
return false;
};
let changed;
if (!hasAnyClawCutosAi) {
config.gateway.controlUi = config.gateway.controlUi || {};
const port = config.gateway.port ?? 18789;
config.gateway.controlUi.allowedOrigins = [`http://0.0.0.0:${port}`, newOrigin];
config.gateway.controlUi.allowInsecureAuth = true;
config.gateway.controlUi.dangerouslyDisableDeviceAuth = true;
changed = true;
} else {
changed = replaceOriginStrings(config);
}
if (!changed) {
log.info('clawd', `openclaw origin 已是 ${newOrigin},无需变更`);
return;
}
writeFileSync(configFile, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
log.info('clawd', `openclaw config 已更新: ${newOrigin}`);
} catch (e) {
log.warn('clawd', `openclaw config 更新失败: ${e.message}`);
}
}
// ── 心跳 ────────────────────────────────────────────────────────────────────
_startHeartbeat() {
this._clearHeartbeat();
this._sendHeartbeat();
this._hbTimer = setInterval(() => this._sendHeartbeat(), HEARTBEAT_INTERVAL_MS);
}
async _sendHeartbeat() {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
try {
this._hbCount++;
// 每 30 次心跳(约 5 分钟)刷新一次 dashboard 信息
if (this._hbCount % 30 === 0) {
const freshInfo = await getDashboardInfo().catch(() => null);
if (freshInfo && Object.keys(freshInfo).length > 0) {
this._dashInfo = freshInfo;
}
}
// 每 METRICS_EVERY_N 次心跳30 秒)采集一次指标,其余发轻量心跳
const msg = {
type: 'heartbeat',
claw_id: this._cfg.claw_id,
token: this._cfg.token,
version: CLAWD_VERSION,
local_ip: getLocalIps(),
local_networks: getLocalNetworks(),
...this._dashInfo,
};
if (this._hbCount % METRICS_EVERY_N === 0) {
msg.metrics = await collect();
}
this._send(msg);
} catch (e) {
log.error('clawd', '心跳发送失败:', e.message);
}
}
_clearHeartbeat() {
if (this._hbTimer) {
clearInterval(this._hbTimer);
this._hbTimer = null;
}
}
// ── 升级 ────────────────────────────────────────────────────────────────────
_sendUpgradeProgress(progress, step, failed = false, errorMsg = null) {
const msg = {
type: 'upgrade_progress',
claw_id: this._cfg.claw_id,
token: this._cfg.token,
progress,
step,
failed,
};
if (errorMsg) msg.error = errorMsg;
this._send(msg);
log.info('upgrade', `进度 ${progress}% - ${step}${failed ? ` [失败: ${errorMsg}]` : ''}`);
}
async _handleUpgrade(msg) {
const targetVersion = msg.version;
const installDir = path.dirname(__dirname); // /opt/clawd 或同等安装目录
const scriptPath = path.join(installDir, 'tools', 'update-clawd.sh');
log.info('upgrade', `收到升级命令: ${CLAWD_VERSION}${targetVersion}`);
this._sendUpgradeProgress(5, 'starting');
try {
// Step 1: Node.js 负责 git pull拿到最新代码含最新 update-clawd.sh
this._sendUpgradeProgress(20, '拉取更新中');
await new Promise((resolve, reject) => {
const gitCmd = [
`git config --global --add safe.directory "${installDir}" 2>/dev/null || true`,
`cd "${installDir}"`,
`git remote get-url origin 2>/dev/null | grep -q github.com && git remote set-url origin https://git.cutos.ai/claw-daemon/clawd.git || true`,
`git fetch origin`,
`git reset --hard origin/main`,
`git clean -fd`,
].join(' && ');
const child = exec(gitCmd, { timeout: 120_000 });
child.stdout.on('data', d => log.info('upgrade', d.toString().trim()));
child.stderr.on('data', d => log.warn('upgrade', d.toString().trim()));
child.on('close', code => code === 0 ? resolve() : reject(new Error(`git pull 失败,退出码: ${code}`)));
child.on('error', reject);
});
this._sendUpgradeProgress(50, '更新文件中');
// Step 2: 调用磁盘上已更新的 update-clawd.sh --no-pull跳过 git直接写 service 文件等)
if (!fs.existsSync(scriptPath)) {
throw new Error(`升级脚本不存在: ${scriptPath}`);
}
await new Promise((resolve, reject) => {
const child = exec(`bash "${scriptPath}" --no-pull --no-restart`, { timeout: 180_000 });
child.stdout.on('data', (data) => {
const line = data.toString().trim();
log.info('upgrade', line);
if (line.includes('npm install')) this._sendUpgradeProgress(70, '安装依赖中');
else if (line.includes('No depend')) this._sendUpgradeProgress(80, '无需安装依赖');
else if (line.includes('Writing serv')) this._sendUpgradeProgress(85, '更新服务配置');
else if (line.includes('Current comm')) this._sendUpgradeProgress(90, '即将重启');
});
child.stderr.on('data', d => log.warn('upgrade', d.toString().trim()));
child.on('close', code => code === 0 ? resolve() : reject(new Error(`脚本退出码: ${code}`)));
child.on('error', reject);
});
// 通知服务端完成,延迟 1.5 秒确保消息送达,再退出让 systemd 重启
this._sendUpgradeProgress(100, 'done');
log.info('upgrade', `升级至 v${targetVersion} 完成,即将重启...`);
setTimeout(() => process.exit(0), 1500);
} catch (e) {
log.error('upgrade', `升级失败: ${e.message}`);
this._sendUpgradeProgress(0, 'failed', true, e.message);
}
}
// ── 工具 ────────────────────────────────────────────────────────────────────
_send(obj) {
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify(obj));
}
}
// ── systemd Watchdog ────────────────────────────────────────────────────────
_startSdNotify() {
const raw = getNotifySocket();
if (!raw) return;
// 部分嵌入式 systemd 有 WatchdogSec 但未注入 WATCHDOG_USEC若此时不喂狗约 1min 会 SIGABRT
let usec = parseInt(process.env.WATCHDOG_USEC || '0', 10);
if (usec <= 0) {
usec = 60_000_000;
log.info('clawd', 'WATCHDOG_USEC 未设置,按 60s 周期向 systemd 发送 WATCHDOG=1与 unit WatchdogSec=60 一致)');
}
const intervalMs = Math.max(1000, Math.floor(usec / 2 / 1000));
log.debug('clawd', `systemd watchdog 启用,通知间隔 ${intervalMs}ms`);
this._sdNotify('READY=1');
this._sdTimer = setInterval(() => this._sdNotify('WATCHDOG=1'), intervalMs);
}
/**
* 通过 systemd-notify 写入 NOTIFY_SOCKET。嵌入式上 Node unix_dgram 对抽象套接字常无效;
* 子进程发 notify 需在 unit 中设置 NotifyAccess=allinstall.sh 已写)。
*/
_sdNotify(msg) {
const sock = getNotifySocket();
if (!sock) return;
const arg = String(msg).replace(/\n+$/, '');
if (!arg) return;
try {
execFileSync('systemd-notify', [arg], {
timeout: 3000,
encoding: 'utf8',
env: {
PATH: process.env.PATH || '/usr/bin:/bin',
NOTIFY_SOCKET: sock,
},
});
} catch (e) {
log.warn('clawd', 'systemd-notify 失败:', e.message);
}
}
}
module.exports = { ClawClient };