feat: integrate frpc manager and send dashboard info via WebSocket
Made-with: Cursor
This commit is contained in:
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const { getBoxId } = require('./fingerprint');
|
const { getBoxId } = require('./fingerprint');
|
||||||
const { collect } = require('./metrics');
|
const { collect } = require('./metrics');
|
||||||
|
const { getDashboardInfo, FrpcManager } = require('./frpc');
|
||||||
|
|
||||||
const MAX_BACKOFF_MS = 60_000;
|
const MAX_BACKOFF_MS = 60_000;
|
||||||
|
|
||||||
@@ -15,16 +16,21 @@ class ClawClient {
|
|||||||
this._hbTimer = null; // 心跳定时器
|
this._hbTimer = null; // 心跳定时器
|
||||||
this._backoff = 1_000; // 重连等待(ms)
|
this._backoff = 1_000; // 重连等待(ms)
|
||||||
this._stopped = false;
|
this._stopped = false;
|
||||||
|
this._frpc = new FrpcManager();
|
||||||
|
this._dashInfo = {}; // { dashboard_token, dashboard_port }
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
console.log(`[clawd] 启动中... 服务器 = ${this._cfg.server}`);
|
console.log(`[clawd] 启动中... 服务器 = ${this._cfg.server}`);
|
||||||
|
// 启动前提取 openclaw dashboard 信息(耗时操作放后台,不阻塞连接)
|
||||||
|
this._dashInfo = getDashboardInfo();
|
||||||
this._connect();
|
this._connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this._stopped = true;
|
this._stopped = true;
|
||||||
this._clearHeartbeat();
|
this._clearHeartbeat();
|
||||||
|
this._frpc.stop();
|
||||||
if (this._ws) this._ws.terminate();
|
if (this._ws) this._ws.terminate();
|
||||||
console.log('[clawd] 已停止');
|
console.log('[clawd] 已停止');
|
||||||
}
|
}
|
||||||
@@ -77,6 +83,8 @@ class ClawClient {
|
|||||||
box_id: this._boxId,
|
box_id: this._boxId,
|
||||||
claw_id: this._cfg.claw_id ?? null,
|
claw_id: this._cfg.claw_id ?? null,
|
||||||
token: this._cfg.token ?? null,
|
token: this._cfg.token ?? null,
|
||||||
|
// dashboard 信息(可选,openclaw 未安装时为空)
|
||||||
|
...this._dashInfo,
|
||||||
};
|
};
|
||||||
this._send(msg);
|
this._send(msg);
|
||||||
}
|
}
|
||||||
@@ -139,6 +147,13 @@ class ClawClient {
|
|||||||
console.log(`[clawd] 已激活 claw_id = ${msg.claw_id}`);
|
console.log(`[clawd] 已激活 claw_id = ${msg.claw_id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 启动 frpc(如果 VPS 下发了 frp 配置)
|
||||||
|
if (msg.frp && msg.frp.server && msg.frp.auth_token) {
|
||||||
|
this._frpc.start(msg.claw_id, msg.frp).catch(e => {
|
||||||
|
console.error('[frpc] 启动失败:', e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 开始心跳
|
// 开始心跳
|
||||||
this._startHeartbeat();
|
this._startHeartbeat();
|
||||||
}
|
}
|
||||||
|
|||||||
172
lib/frpc.js
Normal file
172
lib/frpc.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { execSync, spawn, execFileSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
// frpc 配置目录(与 clawd config 同目录)
|
||||||
|
const CONFIG_DIR = process.env.CLAWD_CONFIG_DIR
|
||||||
|
|| (process.getuid && process.getuid() === 0 ? '/etc/clawd' : path.join(os.homedir(), '.clawd'));
|
||||||
|
const FRPC_BIN = path.join(CONFIG_DIR, 'frpc');
|
||||||
|
const FRPC_CONFIG = path.join(CONFIG_DIR, 'frpc.toml');
|
||||||
|
|
||||||
|
// frp 版本
|
||||||
|
const FRP_VERSION = '0.62.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取 openclaw dashboard 的访问 token 和端口。
|
||||||
|
* 执行 `openclaw dashboard`,从输出中解析 Dashboard URL。
|
||||||
|
* 返回 { dashboard_token, dashboard_port } 或 {}(命令不存在/失败时)。
|
||||||
|
*/
|
||||||
|
function getDashboardInfo() {
|
||||||
|
try {
|
||||||
|
const out = execSync(
|
||||||
|
`openclaw dashboard 2>&1 | grep 'Dashboard URL' | sed -E 's|.*:([0-9]+)/.*#token=([a-f0-9]+).*|\\1 \\2|'`,
|
||||||
|
{ timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||||
|
).toString().trim();
|
||||||
|
|
||||||
|
if (!out) return {};
|
||||||
|
const [portStr, token] = out.split(' ');
|
||||||
|
const port = parseInt(portStr, 10);
|
||||||
|
if (!token || isNaN(port)) return {};
|
||||||
|
|
||||||
|
console.log(`[frpc] openclaw dashboard: port=${port}, token=${token.substring(0, 8)}...`);
|
||||||
|
return { dashboard_port: port, dashboard_token: token };
|
||||||
|
} catch (e) {
|
||||||
|
// openclaw 未安装或命令失败,跳过
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据当前系统架构下载对应的 frpc 二进制。
|
||||||
|
*/
|
||||||
|
async function downloadFrpc() {
|
||||||
|
const arch = os.arch(); // 'x64', 'arm64', 'arm', ...
|
||||||
|
const platform = os.platform(); // 'linux'
|
||||||
|
|
||||||
|
const archMap = {
|
||||||
|
x64: 'amd64', arm64: 'arm64',
|
||||||
|
arm: 'arm', ia32: '386',
|
||||||
|
};
|
||||||
|
const frpArch = archMap[arch] || 'amd64';
|
||||||
|
|
||||||
|
const filename = `frp_${FRP_VERSION}_${platform}_${frpArch}.tar.gz`;
|
||||||
|
const url = `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/${filename}`;
|
||||||
|
const tmpFile = `/tmp/${filename}`;
|
||||||
|
|
||||||
|
console.log(`[frpc] 下载 frpc ${FRP_VERSION} (${platform}/${frpArch})...`);
|
||||||
|
|
||||||
|
await downloadFile(url, tmpFile);
|
||||||
|
|
||||||
|
// 解压并复制 frpc
|
||||||
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
|
execSync(`tar -xzf ${tmpFile} -C /tmp && cp /tmp/frp_${FRP_VERSION}_${platform}_${frpArch}/frpc ${FRPC_BIN}`, {
|
||||||
|
stdio: 'inherit'
|
||||||
|
});
|
||||||
|
fs.chmodSync(FRPC_BIN, 0o755);
|
||||||
|
console.log(`[frpc] frpc 已安装到 ${FRPC_BIN}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(url, dest) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const file = fs.createWriteStream(dest);
|
||||||
|
https.get(url, (res) => {
|
||||||
|
if (res.statusCode === 302 || res.statusCode === 301) {
|
||||||
|
file.close();
|
||||||
|
return downloadFile(res.headers.location, dest).then(resolve).catch(reject);
|
||||||
|
}
|
||||||
|
res.pipe(file);
|
||||||
|
file.on('finish', () => { file.close(); resolve(); });
|
||||||
|
}).on('error', (e) => { fs.unlink(dest, () => {}); reject(e); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 frpc.toml 配置文件。
|
||||||
|
*/
|
||||||
|
function writeFrpcConfig(clawId, frpConfig) {
|
||||||
|
const { server, port, auth_token, dashboard_local_port = 18789 } = frpConfig;
|
||||||
|
const toml = `# 由 clawd 自动生成,请勿手动修改
|
||||||
|
serverAddr = "${server}"
|
||||||
|
serverPort = ${port}
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
method = "token"
|
||||||
|
token = "${auth_token}"
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "dashboard-${clawId}"
|
||||||
|
type = "http"
|
||||||
|
localPort = ${dashboard_local_port}
|
||||||
|
subdomain = "${clawId}"
|
||||||
|
`;
|
||||||
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(FRPC_CONFIG, toml, 'utf8');
|
||||||
|
console.log(`[frpc] frpc.toml 已写入: subdomain=${clawId}, localPort=${dashboard_local_port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FrpcManager {
|
||||||
|
constructor() {
|
||||||
|
this._proc = null;
|
||||||
|
this._stopped = false;
|
||||||
|
this._restartTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 frpc:如未安装先下载,写配置,然后 spawn。
|
||||||
|
*/
|
||||||
|
async start(clawId, frpConfig) {
|
||||||
|
this._stopped = false;
|
||||||
|
|
||||||
|
// 下载 frpc(如果不存在)
|
||||||
|
if (!fs.existsSync(FRPC_BIN)) {
|
||||||
|
try {
|
||||||
|
await downloadFrpc();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[frpc] 下载 frpc 失败:', e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFrpcConfig(clawId, frpConfig);
|
||||||
|
this._spawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
_spawn() {
|
||||||
|
if (this._stopped) return;
|
||||||
|
|
||||||
|
console.log('[frpc] 启动 frpc...');
|
||||||
|
this._proc = spawn(FRPC_BIN, ['-c', FRPC_CONFIG], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
this._proc.stdout.on('data', d => {
|
||||||
|
const line = d.toString().trim();
|
||||||
|
if (line) console.log(`[frpc] ${line}`);
|
||||||
|
});
|
||||||
|
this._proc.stderr.on('data', d => {
|
||||||
|
const line = d.toString().trim();
|
||||||
|
if (line) console.warn(`[frpc] ${line}`);
|
||||||
|
});
|
||||||
|
this._proc.on('exit', (code) => {
|
||||||
|
console.warn(`[frpc] 进程退出 (code=${code})`);
|
||||||
|
if (!this._stopped) {
|
||||||
|
this._restartTimer = setTimeout(() => this._spawn(), 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this._stopped = true;
|
||||||
|
if (this._restartTimer) clearTimeout(this._restartTimer);
|
||||||
|
if (this._proc) {
|
||||||
|
this._proc.kill('SIGTERM');
|
||||||
|
this._proc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getDashboardInfo, FrpcManager };
|
||||||
Reference in New Issue
Block a user