Files
clawd/lib/client.js
stswangzhiping 64f4050014 fix: EROFS dns config, double WS connection, and watchdog timeout
1. EROFS: install.sh pre-writes DNS hijack config to dnsmasq-shared.d
   since /etc may be read-only at runtime. dns-hijack.js gracefully
   falls back to checking if config already exists.

2. Double WS: add _connectionStarted guard to prevent _proceedWithConnection
   from being called twice (via event + hasInternet check).

3. Watchdog: move _startSdNotify() to start() beginning so READY=1
   is sent immediately, not delayed until network is ready.

Made-with: Cursor
2026-03-16 12:54:46 +08:00

319 lines
11 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');
const { ProvisionManager } = require('./provisioning');
const { hasInternet } = require('./network');
const MAX_BACKOFF_MS = 60_000;
const PONG_TIMEOUT_MS = 15_000;
const PING_INTERVAL_MS = 30_000;
// 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 = {};
// WS 层活性检测
this._pingTimer = null;
this._awaitingPong = false;
// 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}`);
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 || {};
this._connect();
}
stop() {
this._stopped = true;
this._clearHeartbeat();
this._clearPing();
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();
this._sdNotify('STOPPING=1');
log.info('clawd', '已停止');
log.close();
}
// ── WebSocket 连接 ──────────────────────────────────────────────────────────
_connect() {
if (this._stopped) return;
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._sendConnect();
this._startPing();
});
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();
if (!this._stopped) {
log.warn('clawd', `连接断开 (${code})${this._backoff / 1000}s 后重连...`);
setTimeout(() => this._connect(), this._backoff);
this._backoff = Math.min(this._backoff * 2, MAX_BACKOFF_MS);
}
});
ws.on('error', (err) => {
log.error('clawd', '连接错误:', err.message);
});
}
// ── 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;
}
// ── 发送 connect ─────────────────────────────────────────────────────────────
_sendConnect() {
const msg = {
type: 'connect',
box_id: this._boxId,
claw_id: this._cfg.claw_id ?? null,
token: this._cfg.token ?? null,
...this._dashInfo,
};
this._send(msg);
}
// ── 消息处理 ─────────────────────────────────────────────────────────────────
_handleMessage(msg) {
switch (msg.type) {
case 'connected':
this._onConnected(msg);
break;
case 'heartbeat_ack':
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}`);
}
if (msg.status === 'inactive') {
const id = String(msg.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 {
log.info('clawd', `已激活 claw_id = ${msg.claw_id}`);
}
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();
}
// ── 心跳 ────────────────────────────────────────────────────────────────────
_startHeartbeat() {
this._clearHeartbeat();
const interval = (this._cfg.heartbeat_interval || 30) * 1000;
this._sendHeartbeat();
this._hbTimer = setInterval(() => this._sendHeartbeat(), interval);
}
async _sendHeartbeat() {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
try {
const metrics = await collect();
this._send({
type: 'heartbeat',
claw_id: this._cfg.claw_id,
token: this._cfg.token,
metrics,
});
} 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 };