Files
clawd/lib/frpc.js
stswangzhiping 5f5b976f5b fix: split su command into separate args for ttyd
Made-with: Cursor
2026-03-16 16:21:11 +08:00

219 lines
6.4 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 { execSync, spawn } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const https = require('https');
const log = require('./logger');
const { Watchdog } = require('./watchdog');
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');
const TTYD_BIN = path.join(CONFIG_DIR, 'ttyd');
const FRP_VERSION = '0.62.0';
const TTYD_VERSION = '1.7.7';
const TTYD_PORT = 7681;
/**
* 启动 openclaw dashboard后台运行轮询日志文件等待 Dashboard URL 出现,
* 解析并返回 { dashboard_token, dashboard_port }。
* 超时10s或命令不存在时返回 {}。
*/
function getDashboardInfo() {
return new Promise((resolve) => {
const tmpLog = '/tmp/clawd-dashboard.log';
try {
execSync(`openclaw dashboard > ${tmpLog} 2>&1 &`, { shell: true, timeout: 3000 });
} catch (e) {
// 已在运行或命令不存在,继续轮询
}
let attempts = 0;
const interval = setInterval(() => {
attempts++;
try {
const content = fs.readFileSync(tmpLog, 'utf8');
const match = content.match(/Dashboard URL:.*:(\d+)\/#token=([a-f0-9]+)/);
if (match) {
clearInterval(interval);
const port = parseInt(match[1], 10);
const token = match[2];
log.info('dashboard', `openclaw dashboard: port=${port}, token=${token.substring(0, 8)}...`);
resolve({ dashboard_port: port, dashboard_token: token });
return;
}
} catch (e) { /* 文件暂时不存在 */ }
if (attempts >= 10) {
clearInterval(interval);
log.debug('dashboard', 'openclaw dashboard 未检测到,跳过');
resolve({});
}
}, 1000);
});
}
async function downloadFrpc() {
const arch = os.arch();
const platform = os.platform();
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}`;
log.info('frpc', `下载 frpc ${FRP_VERSION} (${platform}/${frpArch})...`);
await downloadFile(url, tmpFile);
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);
log.info('frpc', `frpc 已安装到 ${FRPC_BIN}`);
}
async function downloadTtyd() {
const arch = os.arch();
const archMap = { arm64: 'aarch64', x64: 'x86_64', arm: 'armv7l', ia32: 'i686' };
const ttydArch = archMap[arch] || 'x86_64';
const url = `https://github.com/tsl0922/ttyd/releases/download/${TTYD_VERSION}/ttyd.${ttydArch}`;
log.info('ttyd', `下载 ttyd ${TTYD_VERSION} (${ttydArch})...`);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
await downloadFile(url, TTYD_BIN);
fs.chmodSync(TTYD_BIN, 0o755);
log.info('ttyd', `ttyd 已安装到 ${TTYD_BIN}`);
}
/**
* 启动 ttyd如未安装先下载
* ttyd 绑定 127.0.0.1:7681供 frpc 代理。
*/
async function startTtyd() {
if (!fs.existsSync(TTYD_BIN)) {
try {
await downloadTtyd();
} catch (e) {
log.warn('ttyd', '下载失败:', e.message);
return false;
}
}
// 终止旧进程
try {
execSync(`pkill -f "${TTYD_BIN}"`, { timeout: 3000 });
await new Promise(r => setTimeout(r, 500));
} catch (_) {}
try {
const shell = fs.existsSync('/bin/bash') ? '/bin/bash' : '/bin/sh';
// 以普通用户身份启动 shell与 SSH 登录一致)
const ttydUser = process.env.CLAWD_TTY_USER || 'sts';
const proc = spawn(TTYD_BIN, ['-p', String(TTYD_PORT), '-i', '127.0.0.1', '-W', '-t', 'cursorBlink=true', '/bin/su', '-', ttydUser], {
stdio: 'ignore',
detached: true,
});
proc.unref();
log.info('ttyd', `已启动,端口 ${TTYD_PORT},用户=${ttydUser}`);
return true;
} catch (e) {
log.warn('ttyd', '启动失败:', e.message);
return false;
}
}
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); });
});
}
function writeFrpcConfig(clawId, frpConfig) {
const { server, port, auth_token, dashboard_local_port = 18789 } = frpConfig;
const ttyRemotePort = 10000 + Number(clawId);
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}"
[[proxies]]
name = "tty-${clawId}"
type = "tcp"
localPort = ${TTYD_PORT}
remotePort = ${ttyRemotePort}
`;
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(FRPC_CONFIG, toml, 'utf8');
log.info('frpc', `frpc.toml 已写入: dashboard subdomain=${clawId}, tty tcp-port=${ttyRemotePort}`);
}
/**
* FrpcManager —— 基于 Watchdog 的 frpc 进程管理器。
* 崩溃自动重启5 分钟内最多重启 10 次。
*/
class FrpcManager {
constructor() {
this._watchdog = null;
}
async start(clawId, frpConfig) {
this.stop();
if (!fs.existsSync(FRPC_BIN)) {
try {
await downloadFrpc();
} catch (e) {
log.error('frpc', '下载 frpc 失败:', e.message);
return;
}
}
writeFrpcConfig(clawId, frpConfig);
this._watchdog = new Watchdog('frpc', FRPC_BIN, ['-c', FRPC_CONFIG], {
maxRestarts: 10,
windowMs: 300_000,
restartDelay: 5_000,
});
this._watchdog.start();
}
stop() {
if (this._watchdog) {
this._watchdog.stop();
this._watchdog = null;
}
}
}
module.exports = { getDashboardInfo, startTtyd, FrpcManager };