feat: initial clawd implementation - WebSocket daemon for claw box
Made-with: Cursor
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.env
|
||||
85
README.md
Normal file
85
README.md
Normal file
@@ -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
|
||||
11
bin/clawd.js
Normal file
11
bin/clawd.js
Normal file
@@ -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); });
|
||||
114
install.sh
Normal file
114
install.sh
Normal file
@@ -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 <<EOF
|
||||
{
|
||||
"server": "wss://claw.cutos.ai/ws",
|
||||
"claw_id": null,
|
||||
"token": null,
|
||||
"heartbeat_interval": 30
|
||||
}
|
||||
EOF
|
||||
info "配置文件已创建:/etc/clawd/config.json ✓"
|
||||
fi
|
||||
|
||||
# ── 创建 systemd service ──────────────────────────────────────────────────────
|
||||
NODE_BIN=$(command -v node)
|
||||
SERVICE_FILE="/etc/systemd/system/clawd.service"
|
||||
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Claw Box Daemon
|
||||
Documentation=https://github.com/stswangzhiping/clawd
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$NODE_BIN $INSTALL_DIR/bin/clawd.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=clawd
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# ── 启用并启动 ─────────────────────────────────────────────────────────────────
|
||||
systemctl daemon-reload
|
||||
systemctl enable clawd
|
||||
systemctl restart clawd
|
||||
|
||||
sleep 2
|
||||
if systemctl is-active --quiet clawd; then
|
||||
info "clawd 服务运行中 ✓"
|
||||
echo ""
|
||||
echo " 查看日志:journalctl -u clawd -f"
|
||||
echo " 查看状态:systemctl status clawd"
|
||||
echo " 停止服务:systemctl stop clawd"
|
||||
echo ""
|
||||
else
|
||||
warn "服务启动失败,请检查日志:journalctl -u clawd -n 30"
|
||||
fi
|
||||
183
lib/client.js
Normal file
183
lib/client.js
Normal file
@@ -0,0 +1,183 @@
|
||||
'use strict';
|
||||
|
||||
const WebSocket = require('ws');
|
||||
const config = require('./config');
|
||||
const { getBoxId } = require('./fingerprint');
|
||||
const { collect } = require('./metrics');
|
||||
|
||||
const MAX_BACKOFF_MS = 60_000;
|
||||
|
||||
class ClawClient {
|
||||
constructor() {
|
||||
this._cfg = config.load();
|
||||
this._boxId = getBoxId();
|
||||
this._ws = null;
|
||||
this._hbTimer = null; // 心跳定时器
|
||||
this._backoff = 1_000; // 重连等待(ms)
|
||||
this._stopped = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
console.log(`[clawd] 启动中...`);
|
||||
console.log(`[clawd] box_id = ${this._boxId}`);
|
||||
console.log(`[clawd] 服务器 = ${this._cfg.server}`);
|
||||
console.log(`[clawd] 配置文件 = ${config.getConfigPath()}`);
|
||||
this._connect();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._stopped = true;
|
||||
this._clearHeartbeat();
|
||||
if (this._ws) this._ws.terminate();
|
||||
console.log('[clawd] 已停止');
|
||||
}
|
||||
|
||||
// ── 连接 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_connect() {
|
||||
if (this._stopped) return;
|
||||
|
||||
console.log(`[clawd] 正在连接 ${this._cfg.server} ...`);
|
||||
const ws = new WebSocket(this._cfg.server, {
|
||||
handshakeTimeout: 10_000,
|
||||
});
|
||||
this._ws = ws;
|
||||
|
||||
ws.on('open', () => {
|
||||
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 };
|
||||
47
lib/config.js
Normal file
47
lib/config.js
Normal file
@@ -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 };
|
||||
43
lib/fingerprint.js
Normal file
43
lib/fingerprint.js
Normal file
@@ -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 };
|
||||
60
lib/metrics.js
Normal file
60
lib/metrics.js
Normal file
@@ -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 };
|
||||
22
package.json
Normal file
22
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user