From 222c38a707dbf0c00e86bbe939a82be5dda72a3d Mon Sep 17 00:00:00 2001 From: stswangzhiping <59632378+stswangzhiping@users.noreply.github.com> Date: Sat, 14 Mar 2026 20:41:26 +0800 Subject: [PATCH] feat: initial clawd implementation - WebSocket daemon for claw box Made-with: Cursor --- .gitignore | 3 + README.md | 85 +++++++++++++++++++++ bin/clawd.js | 11 +++ install.sh | 114 ++++++++++++++++++++++++++++ lib/client.js | 183 +++++++++++++++++++++++++++++++++++++++++++++ lib/config.js | 47 ++++++++++++ lib/fingerprint.js | 43 +++++++++++ lib/metrics.js | 60 +++++++++++++++ package.json | 22 ++++++ 9 files changed, 568 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bin/clawd.js create mode 100644 install.sh create mode 100644 lib/client.js create mode 100644 lib/config.js create mode 100644 lib/fingerprint.js create mode 100644 lib/metrics.js create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53a89d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..b64e01e --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# clawd + +Claw Box 守护进程,将本地 Linux 设备通过 WebSocket 长连接接入 [claw.cutos.ai](https://claw.cutos.ai)。 + +## 功能 + +- 自动生成硬件唯一指纹(`box_id`) +- 首次连接自动注册,获取 `claw_id` + `token` 并持久化 +- 每 30 秒上报系统指标(CPU、内存、磁盘、温度、负载、运行时间) +- 断线自动重连(指数退避,最大 60 秒) +- systemd 管理,开机自启 + +## 快速安装(Linux,需要 root) + +```bash +curl -fsSL https://raw.githubusercontent.com/stswangzhiping/clawd/main/install.sh | sudo bash +``` + +要求: +- Node.js >= 18 +- Linux(systemd) + +## 手动运行(开发调试) + +```bash +git clone https://github.com/stswangzhiping/clawd.git +cd clawd +npm install +node bin/clawd.js +``` + +## 首次启动输出示例 + +``` +[clawd] 启动中... +[clawd] box_id = a1b2c3d4e5f6... +[clawd] 服务器 = wss://claw.cutos.ai/ws +[clawd] WebSocket 已连接 +[clawd] 注册成功!claw_id = 1000 + +╔══════════════════════════════════╗ +║ 激活 PIN 码: 779413 ║ +║ 请在管理后台或前台输入此 PIN 码 ║ +╚══════════════════════════════════╝ + +[clawd] 等待激活中,心跳正常运行... +``` + +## 配置文件 + +路径:`/etc/clawd/config.json`(root 运行)或 `~/.clawd/config.json`(普通用户) + +```json +{ + "server": "wss://claw.cutos.ai/ws", + "claw_id": 1000, + "token": "6e0c182e...", + "heartbeat_interval": 30 +} +``` + +## 服务管理 + +```bash +systemctl status clawd # 查看状态 +journalctl -u clawd -f # 实时日志 +systemctl restart clawd # 重启 +systemctl stop clawd # 停止 +systemctl disable clawd # 取消开机自启 +``` + +## 心跳上报字段 + +| 字段 | 说明 | 单位 | +|------|------|------| +| `cpu` | CPU 使用率 | % | +| `mem_total` / `mem_used` | 内存总量 / 已用 | KB | +| `disk_total` / `disk_used` | 根分区总量 / 已用 | KB | +| `temperature` | CPU 温度 | °C | +| `load_1m` / `load_5m` / `load_15m` | 系统负载 | — | +| `uptime` | 运行时间 | 秒 | + +## License + +MIT diff --git a/bin/clawd.js b/bin/clawd.js new file mode 100644 index 0000000..56829c3 --- /dev/null +++ b/bin/clawd.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node +'use strict'; + +const { ClawClient } = require('../lib/client'); + +const client = new ClawClient(); +client.start(); + +// 优雅退出 +process.on('SIGINT', () => { client.stop(); process.exit(0); }); +process.on('SIGTERM', () => { client.stop(); process.exit(0); }); diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..e8360d2 --- /dev/null +++ b/install.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# clawd 一键安装脚本 +# 用法:curl -fsSL https://raw.githubusercontent.com/stswangzhiping/clawd/main/install.sh | bash +# 需要 root 权限,需要已安装 Node.js >= 18 + +set -e + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' + +info() { echo -e "${GREEN}[clawd]${NC} $*"; } +warn() { echo -e "${YELLOW}[clawd]${NC} $*"; } +error() { echo -e "${RED}[clawd]${NC} $*"; exit 1; } + +# ── 检查 root ──────────────────────────────────────────────────────────────── +if [ "$EUID" -ne 0 ]; then + error "请以 root 身份运行(sudo bash install.sh)" +fi + +# ── 检查 Node.js ───────────────────────────────────────────────────────────── +if ! command -v node &>/dev/null; then + error "未找到 Node.js,请先安装 Node.js >= 18" +fi + +NODE_VER=$(node -e "process.stdout.write(process.versions.node)") +MAJOR=$(echo "$NODE_VER" | cut -d. -f1) +if [ "$MAJOR" -lt 18 ]; then + error "Node.js 版本过低(当前 $NODE_VER),需要 >= 18" +fi +info "Node.js $NODE_VER ✓" + +# ── 安装 clawd ─────────────────────────────────────────────────────────────── +INSTALL_DIR="/opt/clawd" +info "安装到 $INSTALL_DIR ..." + +mkdir -p "$INSTALL_DIR" +cd "$INSTALL_DIR" + +# 下载源码 +if command -v git &>/dev/null; then + if [ -d ".git" ]; then + git pull --quiet + else + git clone --depth=1 https://github.com/stswangzhiping/clawd.git . + fi +else + # 无 git 时用 curl 下载 tarball + TARBALL_URL="https://github.com/stswangzhiping/clawd/archive/refs/heads/main.tar.gz" + curl -fsSL "$TARBALL_URL" | tar -xz --strip-components=1 +fi + +# 安装依赖 +info "安装 npm 依赖..." +npm install --omit=dev --silent + +# 创建可执行链接 +ln -sf "$INSTALL_DIR/bin/clawd.js" /usr/local/bin/clawd +chmod +x "$INSTALL_DIR/bin/clawd.js" + +info "clawd 已安装到 /usr/local/bin/clawd ✓" + +# ── 创建配置目录 ────────────────────────────────────────────────────────────── +mkdir -p /etc/clawd +if [ ! -f /etc/clawd/config.json ]; then + cat > /etc/clawd/config.json < "$SERVICE_FILE" < { + console.log('[clawd] WebSocket 已连接'); + this._backoff = 1_000; + this._sendConnect(); + }); + + ws.on('message', (data) => { + try { + this._handleMessage(JSON.parse(data.toString())); + } catch (e) { + console.error('[clawd] 消息解析失败:', e.message); + } + }); + + ws.on('close', (code, reason) => { + this._clearHeartbeat(); + if (!this._stopped) { + console.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) => { + console.error('[clawd] 连接错误:', err.message); + // close 事件会在 error 之后触发,重连逻辑在 close 里处理 + }); + } + + // ── 发送 connect ────────────────────────────────────────────────────────── + + _sendConnect() { + const msg = { + type: 'connect', + box_id: this._boxId, + claw_id: this._cfg.claw_id ?? null, + token: this._cfg.token ?? null, + }; + this._send(msg); + } + + // ── 消息处理 ────────────────────────────────────────────────────────────── + + _handleMessage(msg) { + switch (msg.type) { + case 'connected': + this._onConnected(msg); + break; + case 'heartbeat_ack': + // 正常回包,静默处理 + break; + case 'error': + console.error(`[clawd] 服务器错误: ${msg.msg}`); + // 若是鉴权失败,清空本地凭证后重连 + if (msg.msg && msg.msg.includes('invalid')) { + console.warn('[clawd] 凭证无效,清除本地凭证并重新注册...'); + this._cfg.claw_id = null; + this._cfg.token = null; + config.save(this._cfg); + } + break; + default: + console.warn('[clawd] 未知消息类型:', msg.type); + } + } + + _onConnected(msg) { + const isNew = !this._cfg.claw_id; + + // 保存 claw_id + token + this._cfg.claw_id = msg.claw_id; + this._cfg.token = msg.token; + config.save(this._cfg); + + if (isNew) { + console.log(`[clawd] 注册成功!claw_id = ${msg.claw_id}`); + } + + if (msg.status === 'inactive') { + console.log(''); + console.log('╔══════════════════════════════════╗'); + console.log(`║ 激活 PIN 码: ${msg.pin} ║`); + console.log('║ 请在管理后台或前台输入此 PIN 码 ║'); + console.log('╚══════════════════════════════════╝'); + console.log(''); + console.log('[clawd] 等待激活中,心跳正常运行...'); + } else { + console.log(`[clawd] 设备已激活,claw_id = ${msg.claw_id}`); + } + + // 开始心跳 + 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) { + console.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)); + } + } +} + +module.exports = { ClawClient }; diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..fed6b78 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,47 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// 生产环境用 /etc/clawd/,开发环境用 ~/.clawd/ +const CONFIG_DIR = process.env.CLAWD_CONFIG_DIR + || (process.getuid && process.getuid() === 0 + ? '/etc/clawd' + : path.join(os.homedir(), '.clawd')); + +const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); + +const DEFAULTS = { + server: 'wss://claw.cutos.ai/ws', + claw_id: null, + token: null, + heartbeat_interval: 30, // 秒 +}; + +function load() { + try { + if (fs.existsSync(CONFIG_FILE)) { + const raw = fs.readFileSync(CONFIG_FILE, 'utf8'); + return Object.assign({}, DEFAULTS, JSON.parse(raw)); + } + } catch (e) { + console.error('[config] 读取配置失败,使用默认值:', e.message); + } + return Object.assign({}, DEFAULTS); +} + +function save(data) { + try { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf8'); + } catch (e) { + console.error('[config] 写入配置失败:', e.message); + } +} + +function getConfigPath() { + return CONFIG_FILE; +} + +module.exports = { load, save, getConfigPath }; diff --git a/lib/fingerprint.js b/lib/fingerprint.js new file mode 100644 index 0000000..7254495 --- /dev/null +++ b/lib/fingerprint.js @@ -0,0 +1,43 @@ +'use strict'; + +const fs = require('fs'); +const crypto = require('crypto'); +const os = require('os'); + +/** + * 生成硬件唯一指纹作为 box_id,优先级: + * 1. /etc/machine-id (systemd 生成,现代 Linux 标配) + * 2. /proc/sys/kernel/random/boot_id (内核 boot UUID,重启会变但稳定) + * 3. 第一块网卡 MAC 地址的 SHA-256 前 16 字节 + * 4. 随机 UUID(最后兜底,存入配置防止每次变化) + */ +function getBoxId() { + // 1. /etc/machine-id + try { + const id = fs.readFileSync('/etc/machine-id', 'utf8').trim(); + if (id && id.length >= 16) return id; + } catch (_) {} + + // 2. boot_id + try { + const id = fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim().replace(/-/g, ''); + if (id && id.length >= 16) return id; + } catch (_) {} + + // 3. MAC 地址 + try { + const interfaces = os.networkInterfaces(); + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name]) { + if (!iface.internal && iface.mac && iface.mac !== '00:00:00:00:00:00') { + return crypto.createHash('sha256').update(iface.mac).digest('hex').slice(0, 32); + } + } + } + } catch (_) {} + + // 4. 随机 UUID 兜底 + return crypto.randomUUID().replace(/-/g, ''); +} + +module.exports = { getBoxId }; diff --git a/lib/metrics.js b/lib/metrics.js new file mode 100644 index 0000000..b714651 --- /dev/null +++ b/lib/metrics.js @@ -0,0 +1,60 @@ +'use strict'; + +const si = require('systeminformation'); +const os = require('os'); + +/** + * 采集当前系统指标,返回符合 claw 协议的 metrics 对象 + * 所有数值均保留 2 位小数,内存/磁盘单位为 KB + */ +async function collect() { + const [load, mem, fsArr, temp] = await Promise.allSettled([ + si.currentLoad(), + si.mem(), + si.fsSize(), + si.cpuTemperature(), + ]); + + const r2 = (v) => (typeof v === 'number' && isFinite(v)) ? Math.round(v * 100) / 100 : null; + const toKB = (bytes) => (typeof bytes === 'number') ? Math.round(bytes / 1024) : null; + + // CPU + const cpu = load.status === 'fulfilled' + ? r2(load.value.currentLoad) + : null; + + // 内存(bytes → KB) + const memVal = mem.status === 'fulfilled' ? mem.value : {}; + const mem_total = toKB(memVal.total); + const mem_used = toKB(memVal.used); + + // 磁盘:取挂载根目录 "/" 或第一个条目 + let disk_total = null, disk_used = null; + if (fsArr.status === 'fulfilled' && fsArr.value.length > 0) { + const root = fsArr.value.find(f => f.mount === '/') || fsArr.value[0]; + disk_total = toKB(root.size); + disk_used = toKB(root.used); + } + + // 温度 + const temperature = temp.status === 'fulfilled' + ? r2(temp.value.main) + : null; + + // 负载(/proc/loadavg,Linux 原生) + const [load_1m, load_5m, load_15m] = os.loadavg().map(r2); + + // 运行时间(秒) + const uptime = Math.floor(os.uptime()); + + return { + cpu, + mem_total, mem_used, + disk_total, disk_used, + temperature, + load_1m, load_5m, load_15m, + uptime, + }; +} + +module.exports = { collect }; diff --git a/package.json b/package.json new file mode 100644 index 0000000..ea7ea82 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "clawd", + "version": "0.1.0", + "description": "Claw Box daemon - connects local Linux box to claw.cutos.ai via WebSocket", + "main": "lib/client.js", + "bin": { + "clawd": "./bin/clawd.js" + }, + "scripts": { + "start": "node bin/clawd.js" + }, + "keywords": ["claw", "iot", "websocket", "daemon"], + "author": "stswangzhiping", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "ws": "^8.18.0", + "systeminformation": "^5.25.0" + } +}