774 lines
30 KiB
JavaScript
774 lines
30 KiB
JavaScript
'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 watchdog(systemd-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); });
|
||
});
|
||
|
||
// 外网 IP:checkip.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 模式 + 无网:不建立 WS,5s 后重新检查(有线经 -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}`;
|
||
exec(`sudo hostname ${hostname}`, (err) => {
|
||
if (err) {
|
||
log.warn('clawd', `设置 hostname 失败: ${err.message}`);
|
||
} else {
|
||
log.info('clawd', `hostname → ${hostname}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── 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');
|
||
|
||
// 检查脚本是否存在
|
||
if (!fs.existsSync(scriptPath)) {
|
||
const err = `升级脚本不存在: ${scriptPath}`;
|
||
log.error('upgrade', err);
|
||
this._sendUpgradeProgress(0, 'failed', true, err);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
const child = exec(`bash "${scriptPath}" --no-restart`, { timeout: 300_000 });
|
||
|
||
child.stdout.on('data', (data) => {
|
||
const line = data.toString().trim();
|
||
log.info('upgrade', line);
|
||
|
||
// 根据脚本输出关键字上报进度
|
||
if (line.includes('Fetching latest')) this._sendUpgradeProgress(20, '拉取更新中');
|
||
else if (line.includes('Already up to date')) this._sendUpgradeProgress(100, 'already_up_to_date');
|
||
else if (line.includes('Updating working tree')) this._sendUpgradeProgress(50, '更新文件中');
|
||
else if (line.includes('npm install')) this._sendUpgradeProgress(70, '安装依赖中');
|
||
else if (line.includes('No dependency')) this._sendUpgradeProgress(80, '无需安装依赖');
|
||
else if (line.includes('Current commit')) this._sendUpgradeProgress(90, '即将重启');
|
||
});
|
||
|
||
child.stderr.on('data', (data) => {
|
||
log.warn('upgrade', data.toString().trim());
|
||
});
|
||
|
||
child.on('close', (code) => {
|
||
if (code === 0) resolve();
|
||
else reject(new Error(`脚本退出码: ${code}`));
|
||
});
|
||
|
||
child.on('error', reject);
|
||
});
|
||
|
||
// 脚本执行成功,通知服务端完成,然后退出让 systemd 重启
|
||
this._sendUpgradeProgress(100, 'done');
|
||
log.info('upgrade', `升级至 v${targetVersion} 完成,即将重启...`);
|
||
|
||
// 延迟 1.5 秒确保进度消息送达,再退出
|
||
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=all(install.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 };
|