From 000301355f70f3c7125d71d2a49799a24c27db93 Mon Sep 17 00:00:00 2001 From: support Date: Mon, 25 May 2026 11:11:00 +0800 Subject: [PATCH] Show VFD time on startup --- lib/client.js | 1587 +++++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 796 insertions(+), 793 deletions(-) diff --git a/lib/client.js b/lib/client.js index 9dc238b..1382de4 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1,792 +1,795 @@ -'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 sysCall = require('./sys-call'); -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 模式下 hasInternet() 可能被热点本地网络 / NetworkManager limited 状态误判。 - // 只有明确探测到有线口可访问公网时,才允许进入 WS 连接流程并显示 Conn。 - if (this._provisionMgr && this._provisionMgr.isApMode() && !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 'sys-call': - sysCall.handle(msg, (reply) => this._send({ type: 'sys-call', ...reply })); - 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/hostname(fs 直接写文件,不需要目录可写) - try { - fs.writeFileSync('/etc/hostname', hostname + '\n', 'utf8'); - } catch (e) { - log.warn('clawd', `write /etc/hostname failed: ${e.message}`); - } - - // 更新 /etc/hosts(fs 读写,绕过 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=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 }; +'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 sysCall = require('./sys-call'); +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(); + } + + // 启动即点亮 VFD,不等 WS / 联网流程 + led.display.showTime(); + + 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 模式下 hasInternet() 可能被热点本地网络 / NetworkManager limited 状态误判。 + // 只有明确探测到有线口可访问公网时,才允许进入 WS 连接流程并显示 Conn。 + if (this._provisionMgr && this._provisionMgr.isApMode() && !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 'sys-call': + sysCall.handle(msg, (reply) => this._send({ type: 'sys-call', ...reply })); + 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/hostname(fs 直接写文件,不需要目录可写) + try { + fs.writeFileSync('/etc/hostname', hostname + '\n', 'utf8'); + } catch (e) { + log.warn('clawd', `write /etc/hostname failed: ${e.message}`); + } + + // 更新 /etc/hosts(fs 读写,绕过 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=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 }; diff --git a/package.json b/package.json index 5cfd143..232efa2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawd", - "version": "1.4.6", + "version": "1.4.7", "description": "Claw Box daemon - connects local Linux box to claw.cutos.ai via WebSocket", "main": "lib/client.js", "bin": {