mirror of
https://github.com/hotwa/luci-app-openclaw.git
synced 2026-03-30 20:25:44 +00:00
318 lines
13 KiB
JavaScript
318 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
// ============================================================================
|
|
// OpenClaw 配置工具 — Web PTY 服务器
|
|
// 纯 Node.js 实现,零外部依赖
|
|
// 通过 WebSocket 将 oc-config.sh 的 TTY 输出推送给浏览器 xterm.js
|
|
// HTTP 端口 18793, HTTPS 可选端口 18794
|
|
// ============================================================================
|
|
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const crypto = require('crypto');
|
|
const { spawn } = require('child_process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
|
|
// ── 配置 (OpenWrt 适配) ──
|
|
const PORT = parseInt(process.env.OC_CONFIG_PORT || '18793', 10);
|
|
const HOST = process.env.OC_CONFIG_HOST || '0.0.0.0'; // token 认证保护,可安全绑定所有接口
|
|
const NODE_BASE = process.env.NODE_BASE || '/opt/openclaw/node';
|
|
const OC_GLOBAL = process.env.OC_GLOBAL || '/opt/openclaw/global';
|
|
const OC_DATA = process.env.OC_DATA || '/opt/openclaw/data';
|
|
const SCRIPT_PATH = process.env.OC_CONFIG_SCRIPT || '/usr/share/openclaw/oc-config.sh';
|
|
const SSL_CERT = '/etc/uhttpd.crt';
|
|
const SSL_KEY = '/etc/uhttpd.key';
|
|
const MAX_SESSIONS = parseInt(process.env.OC_MAX_SESSIONS || '5', 10);
|
|
|
|
// ── 认证令牌 (从 UCI 或环境变量读取) ──
|
|
function loadAuthToken() {
|
|
try {
|
|
const { execSync } = require('child_process');
|
|
const t = execSync('uci -q get openclaw.main.pty_token 2>/dev/null', { encoding: 'utf8', timeout: 3000 }).trim();
|
|
return t || '';
|
|
} catch { return ''; }
|
|
}
|
|
let AUTH_TOKEN = process.env.OC_PTY_TOKEN || loadAuthToken();
|
|
|
|
// ── 会话计数 ──
|
|
let activeSessions = 0;
|
|
|
|
// ── 静态文件 ──
|
|
const UI_DIR = path.join(__dirname, 'ui');
|
|
|
|
function getMimeType(ext) {
|
|
const types = {
|
|
'.html': 'text/html; charset=utf-8', '.css': 'text/css',
|
|
'.js': 'application/javascript', '.png': 'image/png',
|
|
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json',
|
|
};
|
|
return types[ext] || 'application/octet-stream';
|
|
}
|
|
|
|
const IFRAME_HEADERS = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'X-Frame-Options': 'ALLOWALL',
|
|
'Content-Security-Policy': "default-src * 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss:; frame-ancestors *",
|
|
};
|
|
|
|
// ── WebSocket 帧处理 (RFC 6455) ──
|
|
function decodeWSFrame(buf) {
|
|
if (buf.length < 2) return null;
|
|
const opcode = buf[0] & 0x0f;
|
|
const masked = !!(buf[1] & 0x80);
|
|
let payloadLen = buf[1] & 0x7f;
|
|
let offset = 2;
|
|
if (payloadLen === 126) {
|
|
if (buf.length < 4) return null;
|
|
payloadLen = buf.readUInt16BE(2); offset = 4;
|
|
} else if (payloadLen === 127) {
|
|
if (buf.length < 10) return null;
|
|
payloadLen = Number(buf.readBigUInt64BE(2)); offset = 10;
|
|
}
|
|
let mask = null;
|
|
if (masked) {
|
|
if (buf.length < offset + 4) return null;
|
|
mask = buf.slice(offset, offset + 4); offset += 4;
|
|
}
|
|
if (buf.length < offset + payloadLen) return null;
|
|
const data = buf.slice(offset, offset + payloadLen);
|
|
if (mask) { for (let i = 0; i < data.length; i++) data[i] ^= mask[i & 3]; }
|
|
return { opcode, data, totalLen: offset + payloadLen };
|
|
}
|
|
|
|
function encodeWSFrame(data, opcode = 0x01) {
|
|
const payload = typeof data === 'string' ? Buffer.from(data) : data;
|
|
const len = payload.length;
|
|
let header;
|
|
if (len < 126) {
|
|
header = Buffer.alloc(2); header[0] = 0x80 | opcode; header[1] = len;
|
|
} else if (len < 65536) {
|
|
header = Buffer.alloc(4); header[0] = 0x80 | opcode; header[1] = 126; header.writeUInt16BE(len, 2);
|
|
} else {
|
|
header = Buffer.alloc(10); header[0] = 0x80 | opcode; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2);
|
|
}
|
|
return Buffer.concat([header, payload]);
|
|
}
|
|
|
|
// ── PTY 进程管理 ──
|
|
class PtySession {
|
|
constructor(socket) {
|
|
this.socket = socket;
|
|
this.proc = null;
|
|
this.cols = 80;
|
|
this.rows = 24;
|
|
this.buffer = Buffer.alloc(0);
|
|
this.alive = true;
|
|
this._spawnFailCount = 0;
|
|
this._MAX_SPAWN_RETRIES = 5;
|
|
activeSessions++;
|
|
console.log(`[oc-config] Session created (active: ${activeSessions}/${MAX_SESSIONS})`);
|
|
this._setupWSReader();
|
|
this._spawnPty();
|
|
}
|
|
|
|
_setupWSReader() {
|
|
this.socket.on('data', (chunk) => {
|
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
while (this.buffer.length > 0) {
|
|
const frame = decodeWSFrame(this.buffer);
|
|
if (!frame) break;
|
|
this.buffer = this.buffer.slice(frame.totalLen);
|
|
if (frame.opcode === 0x01) this._handleMessage(frame.data.toString());
|
|
else if (frame.opcode === 0x02 && this.proc && this.proc.stdin.writable) this.proc.stdin.write(frame.data);
|
|
else if (frame.opcode === 0x08) { console.log('[oc-config] WS close frame received'); this._cleanup(); }
|
|
else if (frame.opcode === 0x09) this.socket.write(encodeWSFrame(frame.data, 0x0a));
|
|
}
|
|
});
|
|
this.socket.on('close', (hadError) => { console.log(`[oc-config] Socket closed, hadError=${hadError}`); this._cleanup(); });
|
|
this.socket.on('error', (err) => { console.log(`[oc-config] Socket error: ${err.message}`); this._cleanup(); });
|
|
}
|
|
|
|
_handleMessage(text) {
|
|
try {
|
|
const msg = JSON.parse(text);
|
|
if (msg.type === 'stdin' && this.proc && this.proc.stdin.writable) {
|
|
// 去除 bracketed paste 转义序列,避免污染 shell read 输入
|
|
const cleaned = msg.data.replace(/\x1b\[\?2004[hl]/g, '').replace(/\x1b\[20[01]~/g, '');
|
|
this.proc.stdin.write(cleaned);
|
|
}
|
|
else if (msg.type === 'resize') {
|
|
this.cols = msg.cols || 80; this.rows = msg.rows || 24;
|
|
if (this.proc && this.proc.pid) { try { process.kill(-this.proc.pid, 'SIGWINCH'); } catch(e){} }
|
|
}
|
|
} catch(e) { if (this.proc && this.proc.stdin.writable) this.proc.stdin.write(text); }
|
|
}
|
|
|
|
_spawnPty() {
|
|
const env = {
|
|
...process.env, TERM: 'xterm-256color', COLUMNS: String(this.cols), LINES: String(this.rows),
|
|
COLORTERM: 'truecolor', LANG: 'en_US.UTF-8',
|
|
NODE_BASE, OC_GLOBAL, OC_DATA,
|
|
HOME: OC_DATA,
|
|
OPENCLAW_HOME: OC_DATA,
|
|
OPENCLAW_STATE_DIR: `${OC_DATA}/.openclaw`,
|
|
OPENCLAW_CONFIG_PATH: `${OC_DATA}/.openclaw/openclaw.json`,
|
|
PATH: `${NODE_BASE}/bin:${OC_GLOBAL}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
|
|
};
|
|
// 检测 script 命令是否可用 (OpenWrt 默认不包含 util-linux-script)
|
|
// 如不可用则回退到直接用 sh 执行,牺牲 PTY 但保证功能可用
|
|
const hasScript = (() => {
|
|
try {
|
|
const { execFileSync } = require('child_process');
|
|
execFileSync('which', ['script'], { stdio: 'pipe', timeout: 2000 });
|
|
return true;
|
|
} catch { return false; }
|
|
})();
|
|
if (hasScript) {
|
|
this.proc = spawn('script', ['-qc', `stty rows ${this.rows} cols ${this.cols} 2>/dev/null; printf '\\e[?2004l'; sh "${SCRIPT_PATH}"`, '/dev/null'],
|
|
{ stdio: ['pipe', 'pipe', 'pipe'], env, detached: true });
|
|
} else {
|
|
console.log('[oc-config] "script" command not found, falling back to sh (install util-linux-script for full PTY support)');
|
|
this.proc = spawn('sh', [SCRIPT_PATH],
|
|
{ stdio: ['pipe', 'pipe', 'pipe'], env, detached: true });
|
|
}
|
|
|
|
this.proc.stdout.on('data', (d) => { if (this.alive) { this._spawnFailCount = 0; this.socket.write(encodeWSFrame(d, 0x01)); } });
|
|
this.proc.stderr.on('data', (d) => { if (this.alive) { this._spawnFailCount = 0; this.socket.write(encodeWSFrame(d, 0x01)); } });
|
|
this.proc.on('close', (code) => {
|
|
if (!this.alive) return;
|
|
// PTY 以 root 运行,子脚本可能创建了 root-owned 的目录
|
|
// 修复权限,防止以 openclaw 用户运行的 Gateway 遇到 EACCES
|
|
try { require('child_process').execFileSync('chown', ['-R', 'openclaw:openclaw', OC_DATA], { stdio: 'pipe', timeout: 5000 }); } catch(e) {}
|
|
this._spawnFailCount++;
|
|
if (this._spawnFailCount > this._MAX_SPAWN_RETRIES) {
|
|
console.log(`[oc-config] Script failed ${this._spawnFailCount} times, stopping retries`);
|
|
this.socket.write(encodeWSFrame(`\r\n\x1b[31m配置脚本连续启动失败 ${this._spawnFailCount} 次,已停止重试。\r\n请检查是否已安装 util-linux-script 包: opkg install coreutils-script\x1b[0m\r\n`, 0x01));
|
|
this.proc = null;
|
|
return;
|
|
}
|
|
console.log(`[oc-config] Script exited with code ${code}, auto-restarting (attempt ${this._spawnFailCount}/${this._MAX_SPAWN_RETRIES})...`);
|
|
this.socket.write(encodeWSFrame(`\r\n\x1b[33m配置脚本已退出 (code: ${code}),正在自动重启...\x1b[0m\r\n`, 0x01));
|
|
this.proc = null;
|
|
// 自动重启脚本,保持 WebSocket 连接
|
|
setTimeout(() => {
|
|
if (this.alive) {
|
|
this._spawnPty();
|
|
}
|
|
}, 1500);
|
|
});
|
|
this.proc.on('error', (err) => {
|
|
this._spawnFailCount++;
|
|
if (this.alive) this.socket.write(encodeWSFrame(`\r\n\x1b[31m启动失败: ${err.message}\x1b[0m\r\n`, 0x01));
|
|
});
|
|
}
|
|
|
|
_cleanup() {
|
|
if (!this.alive) return; this.alive = false;
|
|
activeSessions = Math.max(0, activeSessions - 1);
|
|
console.log(`[oc-config] Session ended (active: ${activeSessions}/${MAX_SESSIONS})`);
|
|
if (this.proc) { try { process.kill(-this.proc.pid, 'SIGTERM'); } catch(e){} try { this.proc.kill('SIGTERM'); } catch(e){} }
|
|
try { this.socket.destroy(); } catch(e){}
|
|
}
|
|
}
|
|
|
|
// ── HTTP 请求处理 ──
|
|
function handleRequest(req, res) {
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
let fp = url.pathname;
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': '*' });
|
|
return res.end();
|
|
}
|
|
if (fp === '/health') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
return res.end(JSON.stringify({ status: 'ok', port: PORT, uptime: process.uptime() }));
|
|
}
|
|
if (fp === '/' || fp === '') fp = '/index.html';
|
|
|
|
const fullPath = path.join(UI_DIR, fp);
|
|
if (!fullPath.startsWith(UI_DIR)) { res.writeHead(403); return res.end('Forbidden'); }
|
|
|
|
fs.readFile(fullPath, (err, data) => {
|
|
if (err) {
|
|
if (fp !== '/index.html') {
|
|
fs.readFile(path.join(UI_DIR, 'index.html'), (e2, d2) => {
|
|
if (e2) { res.writeHead(404); res.end('Not Found'); }
|
|
else { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...IFRAME_HEADERS }); res.end(d2); }
|
|
});
|
|
} else { res.writeHead(404); res.end('Not Found'); }
|
|
return;
|
|
}
|
|
const ext = path.extname(fullPath);
|
|
res.writeHead(200, { 'Content-Type': getMimeType(ext), 'Cache-Control': ext === '.html' ? 'no-cache' : 'max-age=3600', ...IFRAME_HEADERS });
|
|
res.end(data);
|
|
});
|
|
}
|
|
|
|
// ── WebSocket Upgrade ──
|
|
function handleUpgrade(req, socket, head) {
|
|
console.log(`[oc-config] WS upgrade: ${req.url} remote=${socket.remoteAddress}:${socket.remotePort}`);
|
|
if (req.url !== '/ws' && !req.url.startsWith('/ws?')) { socket.destroy(); return; }
|
|
|
|
// 认证: 验证查询参数中的 token
|
|
// 每次连接时实时读取 UCI token (安装/升级可能重新生成 token)
|
|
const currentToken = loadAuthToken() || AUTH_TOKEN;
|
|
if (currentToken) {
|
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
const clientToken = url.searchParams.get('token') || '';
|
|
if (clientToken !== currentToken) {
|
|
console.log(`[oc-config] WS auth failed from ${socket.remoteAddress}`);
|
|
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 并发会话限制
|
|
if (activeSessions >= MAX_SESSIONS) {
|
|
console.log(`[oc-config] Max sessions reached (${activeSessions}/${MAX_SESSIONS}), rejecting`);
|
|
socket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n');
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
const key = req.headers['sec-websocket-key'];
|
|
if (!key) { console.log('[oc-config] Missing Sec-WebSocket-Key'); socket.destroy(); return; }
|
|
|
|
const accept = crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
|
|
|
|
socket.setNoDelay(true);
|
|
socket.setTimeout(0);
|
|
|
|
const handshake = 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + '\r\n\r\n';
|
|
|
|
socket.write(handshake, () => {
|
|
if (head && head.length > 0) socket.unshift(head);
|
|
new PtySession(socket);
|
|
console.log('[oc-config] PTY session started');
|
|
});
|
|
}
|
|
|
|
// ── 服务器实例 ──
|
|
const httpServer = http.createServer(handleRequest);
|
|
httpServer.on('upgrade', handleUpgrade);
|
|
let httpsServer = null;
|
|
|
|
httpServer.listen(PORT, HOST, () => {
|
|
console.log(`[oc-config] HTTP listening on ${HOST}:${PORT}`);
|
|
console.log(`[oc-config] Script: ${SCRIPT_PATH}`);
|
|
});
|
|
|
|
// HTTPS 可选端口 PORT+1
|
|
const HTTPS_PORT = PORT + 1;
|
|
try {
|
|
if (fs.existsSync(SSL_CERT) && fs.existsSync(SSL_KEY)) {
|
|
httpsServer = https.createServer({ cert: fs.readFileSync(SSL_CERT), key: fs.readFileSync(SSL_KEY) }, handleRequest);
|
|
httpsServer.on('upgrade', handleUpgrade);
|
|
httpsServer.listen(HTTPS_PORT, HOST, () => console.log(`[oc-config] HTTPS listening on ${HOST}:${HTTPS_PORT}`));
|
|
httpsServer.on('error', (e) => console.log(`[oc-config] HTTPS port ${HTTPS_PORT}: ${e.message}`));
|
|
}
|
|
} catch (e) { console.log(`[oc-config] SSL init: ${e.message}`); }
|
|
|
|
httpServer.on('error', (e) => { console.error(`[oc-config] Fatal: ${e.message}`); process.exit(1); });
|
|
function shutdown() { console.log('[oc-config] Shutdown'); httpServer.close(); if (httpsServer) httpsServer.close(); process.exit(0); }
|
|
process.on('SIGTERM', shutdown);
|
|
process.on('SIGINT', shutdown);
|