Files
clawd/lib/client.js
2026-03-24 14:31:31 +08:00

528 lines
19 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 WebSocket = require('ws');
const { execFileSync } = require('child_process');
const config = require('./config');
const log = require('./logger');
const { getBoxId } = require('./fingerprint');
const { collect } = require('./metrics');
const { getDashboardInfo, startTtyd, FrpcManager } = require('./frpc'); // getDashboardInfo 也用于心跳中定期刷新
const { ProvisionManager } = require('./provisioning');
const { hasInternet, getLocalIps } = require('./network');
const led = require('./led');
const MAX_BACKOFF_MS = 60_000;
const PONG_TIMEOUT_MS = 8_000;
const PING_INTERVAL_MS = 10_000;
const NET_MONITOR_MS = 5_000; // AP 模式网络监视间隔
const HEARTBEAT_INTERVAL_MS = 10_000; // 心跳间隔10 秒,用于快速感知网络状态
const METRICS_EVERY_N = 3; // 每 N 次心跳采集一次指标(= 30 秒)
// systemd watchdog: 如果 WatchdogSec 存在,定期发 WATCHDOG=1
const SD_WATCHDOG_USEC = parseInt(process.env.WATCHDOG_USEC || '0', 10);
const SD_NOTIFY_INTERVAL = SD_WATCHDOG_USEC > 0
? Math.floor(SD_WATCHDOG_USEC / 2 / 1000) // 半周期通知μs → ms
: 0;
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._dashInfo = {};
this._hbCount = 0; // 心跳计数器,用于定期刷新 dashboard 信息
this._externalIp = null; // 外网 IP
this._location = null; // 地理位置(由 ipplus360 返回,如"北京市-北京市西城区"
// WS 层活性检测
this._pingTimer = null;
this._awaitingPong = false;
// AP 模式网络监视WS 连通后每 5s 检查,断网立即 terminate
this._netMonitorTimer = null;
// WS 连续失败计数open 时清零)
this._wsFailCount = 0;
// 是否曾经成功连接过(首次成功前不显示 Err0/AP
this._hasEverConnected = false;
// 最近一次 WS 错误是否是证书时间问题NTP 未同步)
this._certTimeError = false;
// 记录已为哪个 clawId 配置过 openclaw防止 WS 重连时重复执行)
this._openClawConfigured = null;
// systemd watchdog
this._sdTimer = 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}`);
// 启动时全灭WS 连接后由 _applyStatus() 按实际状态设置
led.status.off();
this._startSdNotify();
// 启动 AP 配网管理器(等待已保存 WiFi 自动连接,超时再开 AP
this._provisionMgr = new ProvisionManager(this._cfg.claw_id);
this._connectionStarted = false;
// 网络就绪时连接云端(仅触发一次)
this._provisionMgr.on('network-ready', () => {
if (!this._connectionStarted) {
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', '等待网络就绪WiFi 配网或网线接入)...');
}
}
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._clearHeartbeat();
this._clearPing();
this._clearNetMonitor();
if (this._sdTimer) { clearInterval(this._sdTimer); this._sdTimer = 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 后重新检查网络
if (this._provisionMgr && this._provisionMgr.isApMode() && !hasInternet()) {
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; // 标记已成功连接过
this._sendConnect();
this._startPing();
this._startNetMonitor();
// 显示由 _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;
});
ws.on('close', (code, reason) => {
this._clearHeartbeat();
this._clearPing();
this._clearNetMonitor();
if (!this._stopped) {
this._wsFailCount++;
log.warn('clawd', `连接断开 (${code}),失败次数=${this._wsFailCount}${this._backoff / 1000}s 后重连...`);
if (this._hasEverConnected && this._wsFailCount >= 3) {
const inAp = this._provisionMgr && this._provisionMgr.isApMode();
if (inAp || !hasInternet()) {
led.display.showAP(); // AP 模式 或 无网
} else {
led.display.showErr0(); // STA 模式 + 有网 但 VPS 不可达
}
}
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) {
log.warn('clawd', 'Pong 超时,连接可能已死,主动关闭重连');
this._ws.terminate();
return;
}
this._awaitingPong = true;
try { this._ws.ping(); } catch (_) {}
}, PING_INTERVAL_MS);
}
_clearPing() {
if (this._pingTimer) {
clearInterval(this._pingTimer);
this._pingTimer = null;
}
this._awaitingPong = false;
}
// ── AP 模式网络监视(拔网线后 ≤5s 感知)────────────────────────────────────
_startNetMonitor() {
this._clearNetMonitor();
this._netMonitorTimer = setInterval(() => {
if (!this._provisionMgr || !this._provisionMgr.isApMode()) return;
if (hasInternet()) return;
// AP 模式 + 无网,但 WS 还"活着" → 立即终止,触发 close → _connect() 进入 5s 轮询
log.warn('clawd', 'AP 模式检测到网络断开,主动关闭 WS');
led.display.showAP();
if (this._ws) this._ws.terminate();
}, NET_MONITOR_MS);
}
_clearNetMonitor() {
if (this._netMonitorTimer) {
clearInterval(this._netMonitorTimer);
this._netMonitorTimer = null;
}
}
// ── 发送 connect ─────────────────────────────────────────────────────────────
_sendConnect() {
const msg = {
type: 'connect',
box_id: this._boxId,
claw_id: this._cfg.claw_id ?? null,
token: this._cfg.token ?? null,
local_ip: getLocalIps(),
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 'error':
log.error('clawd', `服务器错误: ${msg.msg}`);
if (msg.msg === 'hardware_mismatch') {
log.warn('clawd', '硬件指纹不符,清除凭证重新注册...');
this._cfg.claw_id = null;
this._cfg.token = null;
config.save(this._cfg);
} else if (msg.msg && msg.msg.includes('invalid')) {
log.warn('clawd', '凭证无效,清除凭证重新注册...');
this._cfg.claw_id = null;
this._cfg.token = null;
config.save(this._cfg);
}
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);
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).catch(e => {
log.error('frpc', '启动失败:', e.message);
});
}
this._startHeartbeat();
}
_applyStatus(msg) {
if (msg.status === 'inactive') {
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', '等待激活,心跳正常运行...');
} else {
led.status.setApps();
led.display.showTime();
log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`);
if (this._cfg.claw_id && this._openClawConfigured !== this._cfg.claw_id) {
this._openClawConfigured = this._cfg.claw_id;
this._configureOpenClaw(this._cfg.claw_id);
}
}
}
// ── OpenClaw 配置 ────────────────────────────────────────────────────────────
_configureOpenClaw(clawId) {
const { existsSync, readFileSync, writeFileSync } = require('fs');
// 1. 直接修改 openclaw 配置文件(避免用 root 执行 openclaw CLI 超时问题)
const configFile = '/home/sts/.openclaw/config/config.yaml';
if (!existsSync(configFile)) {
log.warn('clawd', `openclaw 配置文件不存在: ${configFile}`);
return;
}
try {
let content = readFileSync(configFile, 'utf8');
const newOrigin = `https://${clawId}.claw.cutos.ai`;
// 替换 https://xxxx.claw.cutos.ai 为当前 clawId 对应的域名
const updated = content.replace(
/https:\/\/\d+\.claw\.cutos\.ai/g,
newOrigin
);
if (updated === content) {
log.info('clawd', `openclaw allowedOrigins 已是最新,无需修改`);
} else {
writeFileSync(configFile, updated, 'utf8');
log.info('clawd', `openclaw config 已更新: ${newOrigin}`);
}
} catch (e) {
log.warn('clawd', `openclaw config 修改失败: ${e.message}`);
return;
}
// 2. 用 sts 身份执行 openclaw gateway restart
const openclaw = '/home/sts/.npm-global/bin/openclaw';
if (!existsSync(openclaw)) {
log.warn('clawd', 'openclaw 未找到,跳过 gateway restart');
return;
}
try {
execFileSync('su', ['-', 'sts', '-c', `${openclaw} gateway restart`], {
timeout: 15000,
stdio: 'ignore',
});
log.info('clawd', 'openclaw gateway restart 完成');
} catch (e) {
log.warn('clawd', `openclaw gateway restart 失败: ${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,
...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;
}
}
// ── 工具 ────────────────────────────────────────────────────────────────────
_send(obj) {
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify(obj));
}
}
// ── systemd Watchdog ────────────────────────────────────────────────────────
_startSdNotify() {
if (!SD_NOTIFY_INTERVAL) return;
log.debug('clawd', `systemd watchdog 启用,通知间隔 ${SD_NOTIFY_INTERVAL}ms`);
this._sdNotify('READY=1');
this._sdTimer = setInterval(() => this._sdNotify('WATCHDOG=1'), SD_NOTIFY_INTERVAL);
}
_sdNotify(msg) {
if (!process.env.NOTIFY_SOCKET) return;
try {
execFileSync('systemd-notify', ['--pid=' + process.pid, msg], { timeout: 2000 });
} catch (_) {
// systemd-notify 不可用时静默忽略
}
}
}
module.exports = { ClawClient };