Files
rainblogweb/routes/terminal.js
T

536 lines
23 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.
// Web 终端后端 —— 安全敏感模块:终端 = RCE 级攻击面。
// 安全措施:
// 1. 专用终端密码二次确认:PBKDF2-SHA256 600k(与密码箱 routes/passwords.js 同参数,异步版不阻塞事件循环);
// 首次调用(未设置密码)即完成设置(needs_setup 流程),强度要求 8 位以上
// 2. 短 TTL token5 分钟):jwt 带 terminal:true 声明,普通登录 token 不可直接连终端;
// 认证成功后种 HttpOnly SameSite=Strict 短 TTL cookieterminal_token),WS upgrade 优先取 cookie
// ?token= query 仅为旧前端回退(不推荐,会落 access log);带失败限流 + 请求级限流防 PBKDF2 DoS
// 3. WS handshake Origin 校验(须与站点域名一致;仅 NODE_ENV=development 时放行 localhost)——纵深防御,非认证边界
// 4. 并发会话上限(MAX_SESSIONS=3):handleUpgrade 内同步预占槽位,杜绝升级回调前的竞态突破
// 5. pty.spawn 使用参数数组传参(防命令注入);kill 时连进程组一起终止(forkpty 子进程为 session leader
// 负 PID 即杀整组),杜绝 & 后台任务变孤儿进程
// 6. 审计:会话开始/结束/密码失败等写审计日志(console.log 带时间戳 + audit_logs 表);
// 拒绝类事件(Origin/token/超限)降频落审计防日志洪泛;audit_logs 按 90 天保留策略惰性清理
// 7. 空闲超时(15 分钟无输入)自动断开
// 8. ws close/error → pty.kill() + 进程组清理 + 监听器/计时器全量销毁
// 流控:pty 输出缓冲超限时 pause(仅在 onData 暂停),恢复检查放在 ws 消息处理 + 兜底轮询
//
// 部署注意:
// - node-pty 是原生模块,需要服务器编译环境(build-essential / python3),
// 安装失败时先 rm -rf node_modules 再 npm install(项目已有此重装先例)。
// - sudoers 配置(把 rainweb 换成实际运行用户):
// ⚠️ 实测 NOPASSWD: /usr/bin/sudo -i 写法匹配不到(sudo 匹配规则时 -i 会被规范化
// 为实际执行的 login shell 命令,精确命令规则失效),必须放宽为:
// echo "rainweb ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/rainweb-terminal
// sudo chmod 440 /etc/sudoers.d/rainweb-terminal && sudo visudo -c
// 验证:以运行用户执行 `sudo -n -i id` 应直接输出 root 身份且不询问密码。
// 注意:NOPASSWD: ALL 等同把运行用户提权为免密 root,务必确认运行用户无交互登录面。
// - 后端若置于反向代理之后并希望审计 IP 取真实来源 IP,需设置环境变量 TRUST_PROXY=1
// (否则一律用 socket 直连地址,避免伪造 X-Forwarded-For)。
const express = require('express');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const { WebSocketServer, WebSocket } = require('ws');
const db = require('../db');
const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
const router = express.Router();
// ---------- 常量 ----------
const TOKEN_TTL = '5m'; // 短 TTL token 有效期
const TOKEN_COOKIE = 'terminal_token'; // HttpOnly 短 TTL cookie 名
const TOKEN_COOKIE_MAX_AGE = 300; // cookie 有效期(秒),与 TOKEN_TTL 一致
const MAX_SESSIONS = 3; // 最大并发终端会话数
const IDLE_TIMEOUT = 15 * 60 * 1000; // 空闲超时:15 分钟无输入自动断开
const PIN_MIN_LEN = 8; // 终端=root shellPIN 强度要求 8 位以上
const PBKDF2_ITERATIONS = 600000; // 与 routes/passwords.js 一致(OWASP 2023 建议)
const MAX_PTY_BUFFER = 1024 * 1024; // pty 输出缓冲上限(超过则暂停 pty 做流控)
const WS_MAX_PAYLOAD = 1024 * 1024; // ws 单帧消息上限,超限立即断开
const RESUME_POLL_MS = 2000; // 流控恢复兜底轮询间隔
const WS_CLOSE_BAD_TOKEN = 4001; // token 无效
const WS_CLOSE_OVERLOAD = 1013; // 会话数超限
const WS_CLOSE_IDLE = 4000; // 空闲超时
const WS_CLOSE_BAD_MSG = 4002; // 非法消息
// PIN 失败限流(与 passwords.js unlock 同模式):5 分钟 3 次失败锁定 5 分钟
const authFails = new Map();
const FAIL_LIMIT = 3;
const FAIL_WINDOW = 5 * 60 * 1000;
const LOCK_DURATION = 5 * 60 * 1000;
// /auth 请求级限流:防止异步 PBKDF2 被并发滥用造成 CPU DoS
// (失败锁定只在校验之后生效,无法挡住每请求一次的 600k 迭代计算)
const authRateLimit = rateLimit({
windowMs: 5 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: '尝试次数过多,请稍后再试' },
});
// 拒绝类事件审计降频:同一来源 IP 在窗口内最多记一条(防日志洪泛)
const REJECT_AUDIT_INTERVAL = 10 * 1000;
const rejectAudit = new Map(); // ip -> lastTs
// 并发终端会话集合(计数与统一清理)
const activeSessions = new Set();
// node-pty 懒加载:未安装时 REST 接口(status/auth)仍可用,仅 WS 会话被拒绝
let ptyModule = null;
try { ptyModule = require('node-pty'); } catch (e) { /* 未安装:连接时提示 */ }
// 审计写入计数:每 50 条惰性清理一次过期记录(保留策略,见 db.js)
let auditWriteCount = 0;
// ---------- 审计 ----------
// ctx: { user, ip }user 为 JWT payload
function audit(action, detail, ctx) {
const userId = ctx && ctx.user ? ctx.user.id : 0;
const username = ctx && ctx.user ? ctx.user.username : '';
const ip = ctx && ctx.ip ? ctx.ip : '';
console.log(`[${new Date().toISOString()}] [TERMINAL] ${action} user=${username}(${userId}) ip=${ip} ${detail}`);
// 审计落库(audit_logs 表由 db.js initTables 建表;db.run 内部已容错)
db.run('INSERT INTO audit_logs (action, user_id, username, detail, ip) VALUES (?, ?, ?, ?, ?)',
[action, userId, username, detail, ip]);
// 保留策略:每 50 条写入惰性清理 90 天前的记录(created_at 为 UTC datetime,与建表一致)
if (++auditWriteCount % 50 === 0) {
db.run("DELETE FROM audit_logs WHERE created_at < datetime('now','-90 day')");
}
}
// 客户端真实 IP:仅当 TRUST_PROXY=1 时信任 X-Forwarded-For(否则客户端可任意伪造该头)
function getClientIp(request) {
if (process.env.TRUST_PROXY === '1' && request.headers['x-forwarded-for']) {
return String(request.headers['x-forwarded-for']).split(',')[0].trim() || '';
}
return (request.socket.remoteAddress || '').toString();
}
// 拒绝事件降频审计:同 IP 10 秒内只记一条
function auditRejectOnce(ip, action, detail) {
const now = Date.now();
if ((rejectAudit.get(ip) || 0) > now - REJECT_AUDIT_INTERVAL) return;
rejectAudit.set(ip, now);
audit(action, detail, { ip });
}
// ---------- PIN(终端专用密码)----------
const PIN_SALT_KEY = 'terminal_pin_salt';
const PIN_ITER_KEY = 'terminal_pin_iter';
const PIN_HASH_KEY = 'terminal_pin_hash';
function getPinRecord() {
const salt = db.getSetting(PIN_SALT_KEY);
const hash = db.getSetting(PIN_HASH_KEY);
if (!salt || !hash) return null;
const iter = parseInt(db.getSetting(PIN_ITER_KEY), 10) || PBKDF2_ITERATIONS;
return { salt: Buffer.from(salt, 'hex'), hash: Buffer.from(hash, 'hex'), iter };
}
// 异步 PBKDF2:不阻塞事件循环(600k 迭代约几十毫秒,同步版会卡住所有请求)
function derivePinHash(pin, salt, iter) {
return new Promise((resolve, reject) => {
crypto.pbkdf2(pin, salt, iter, 32, 'sha256', (err, key) => {
if (err) reject(err); else resolve(key);
});
});
}
async function setPin(pin) {
const salt = crypto.randomBytes(16);
const hash = await derivePinHash(pin, salt, PBKDF2_ITERATIONS);
db.setSetting(PIN_SALT_KEY, salt.toString('hex'));
db.setSetting(PIN_ITER_KEY, String(PBKDF2_ITERATIONS));
db.setSetting(PIN_HASH_KEY, hash.toString('hex'));
}
async function verifyPin(pin) {
const rec = getPinRecord();
if (!rec) return false;
const derived = await derivePinHash(pin, rec.salt, rec.iter);
// 常数时间比较,防时序侧信道
return crypto.timingSafeEqual(derived, rec.hash);
}
// ---------- PIN 失败限流 ----------
function isLocked() {
const rec = authFails.get('global');
return !!(rec && rec.lockedUntil && Date.now() < rec.lockedUntil);
}
function recordFail() {
const now = Date.now();
let rec = authFails.get('global');
if (!rec || now - rec.firstTs > FAIL_WINDOW) rec = { count: 0, firstTs: now, lockedUntil: 0 };
rec.count += 1;
if (rec.count >= FAIL_LIMIT) { rec.lockedUntil = now + LOCK_DURATION; rec.count = 0; }
authFails.set('global', rec);
}
// ---------- 短 TTL token(参考 proxy.js----------
function issueToken(user) {
// terminal:true 声明:只有本接口签发的 token 才能连接终端,普通登录 token 无效
return jwt.sign(
{ id: user.id, username: user.username, role: user.role, terminal: true },
SECRET,
{ expiresIn: TOKEN_TTL }
);
}
function verifyToken(token) {
if (!token || typeof token !== 'string') return null;
try {
const payload = jwt.verify(token, SECRET);
if (!payload.terminal) return null; // 必须为终端专用 token
if (payload.role !== 'admin') return null; // 防篡改
const u = db.get('SELECT role FROM users WHERE id = ?', [payload.id]);
if (!u || u.role !== 'admin') return null; // 复查角色(用户被删/降权立即失效)
return payload;
} catch { return null; }
}
// 认证成功 → 种 HttpOnly 短 TTL cookieWS 握手(同源 GET /ws/terminal)自动携带,
// root shell 凭证不再进 URL / access log。Path 精确限定 /ws/terminalHttpOnly 防 XSS 读取,
// SameSite=Strict 防跨站发送。Secure 按连接是否 HTTPS 条件附加。
function setTerminalCookie(res, token) {
// L7HTTPS 判定补 x-forwarded-proto(适配 nginx/Cloudflare 等反代场景——
// 反代 TLS 终结时 req.secure 可能为 false,但 X-Forwarded-Proto: https 表明真实协议为 HTTPS
const isSecure = res.req && (
res.req.secure ||
String(res.req.headers['x-forwarded-proto'] || '').toLowerCase().startsWith('https')
);
res.setHeader('Set-Cookie',
`terminal_token=${token}; Path=/ws/terminal; HttpOnly; SameSite=Strict; Max-Age=${TOKEN_COOKIE_MAX_AGE}` +
(isSecure ? '; Secure' : ''));
}
// 极简 Cookie 解析(upgrade 事件不经 Express,无 cookie-parser
function parseCookies(header) {
const out = {};
if (!header) return out;
for (const part of String(header).split(';')) {
const idx = part.indexOf('=');
if (idx === -1) continue;
const name = part.slice(0, idx).trim();
const value = part.slice(idx + 1).trim();
if (name) { try { out[name] = decodeURIComponent(value); } catch { out[name] = value; } }
}
return out;
}
// ---------- REST API ----------
// GET /api/terminal/status —— 是否已设置密码 + 当前会话数
router.get('/status', authMiddleware, adminOnly, (req, res) => {
res.json({
hasPin: !!getPinRecord(),
sessions: activeSessions.size,
maxSessions: MAX_SESSIONS,
idleTimeoutMs: IDLE_TIMEOUT,
});
});
// POST /api/terminal/auth —— 设置/校验终端专用密码,通过后发放 5 分钟短 TTL token
// 并种 HttpOnly cookieterminal_token)。
// 首次(未设置密码)调用即完成设置(前端按 /status 的 hasPin 决定显示设置表单或密码输入框),
// 响应带 setup:true 标记。响应仍含 token 字段(保持 API 契约),前端无需再从 URL 传递。
router.post('/auth', authRateLimit, authMiddleware, adminOnly, async (req, res) => {
const pin = req.body && req.body.pin;
if (!pin || typeof pin !== 'string') return res.status(400).json({ error: '请输入终端密码' });
if (pin.length > 128) return res.status(400).json({ error: '终端密码过长' });
if (isLocked()) return res.status(429).json({ error: '尝试次数过多,请稍后再试' });
const rec = getPinRecord();
const ctx = { user: req.user, ip: getClientIp(req) };
if (!rec) {
// 首次设置:无密码 → 设置并发放 token
if (pin.length < PIN_MIN_LEN) {
return res.status(400).json({ error: `终端密码至少 ${PIN_MIN_LEN} 位` });
}
try {
await setPin(pin);
} catch (e) {
return res.status(500).json({ error: '密码处理失败' });
}
authFails.delete('global');
audit('PIN_SETUP', '终端专用密码已设置', ctx);
const token = issueToken(req.user);
setTerminalCookie(res, token);
return res.json({
needs_setup: false,
setup: true,
token,
expiresIn: TOKEN_TTL,
message: '终端密码已设置',
});
}
let pinOk = false;
try { pinOk = await verifyPin(pin); } catch { pinOk = false; }
if (!pinOk) {
recordFail();
audit('PIN_FAIL', '终端密码校验失败', ctx);
if (isLocked()) return res.status(429).json({ error: '尝试次数过多,请稍后再试' });
return res.status(401).json({ error: '终端密码错误' });
}
authFails.delete('global');
audit('PIN_OK', '终端密码校验通过,发放短 TTL token', ctx);
const token = issueToken(req.user);
setTerminalCookie(res, token);
res.json({ token, expiresIn: TOKEN_TTL });
});
// ---------- WS /ws/terminal ----------
const isDev = process.env.NODE_ENV === 'development';
// Origin 校验:必须与站点域名一致(site_url 或请求 Host 头)。
// localhost 豁免仅 NODE_ENV=development 时生效(生产环境非浏览器客户端无法借 localhost 绕过)。
// 注意:这是纵深防御,不是认证边界——真正把关的是 token/cookie 校验。
function originAllowed(request) {
const origin = request.headers.origin;
if (!origin) return false;
try {
const o = new URL(origin);
const allowed = [];
const siteUrl = db.getSetting('site_url');
if (siteUrl) { try { allowed.push(new URL(siteUrl).host); } catch {} }
if (request.headers.host) allowed.push(request.headers.host);
if (isDev && ['127.0.0.1', 'localhost', '::1'].includes(o.hostname)) return true;
return allowed.includes(o.host);
} catch { return false; }
}
// pty 安全:参数数组传参(防命令注入),禁止字符串拼接;sudo -i 获得 root shell。
// cwd 必须用运行用户可访问的目录:直接给 '/root' 时 spawn 不抛错,
// 而是 shell 启动后 chdir(2) 失败(Permission denied)——sudo -i 本身会把
// 工作目录与 HOME 切到 /root,所以这里给运行用户当前目录即可。
function spawnPty(cols, rows) {
if (!ptyModule) return null; // node-pty 未安装
const opts = { name: 'xterm-color', cols, rows, cwd: process.cwd() };
return ptyModule.spawn('sudo', ['-i'], opts);
}
// 终止 pty:先杀 shell,再连进程组一起杀(forkpty 子进程是 session leaderpgid == pid
// 负 PID 即杀整组),500ms 后兜底 SIGKILL,杜绝 `&` 后台任务变孤儿进程。
function killPty(pty) {
if (!pty) return;
const pid = pty.pid;
try { pty.kill(); } catch {} // 标准接口:向 shell 发 SIGKILL
if (pid && Number.isInteger(pid)) {
try { process.kill(-pid, 'SIGTERM'); } catch {}
const t = setTimeout(() => {
try { process.kill(-pid, 'SIGKILL'); } catch {}
}, 500);
if (t.unref) t.unref();
}
}
// WS 服务:noServer 模式,由 server.js 的 http server 'upgrade' 事件转交;
// maxPayload 限制单帧大小,超限由 ws 自动关闭(1009)。
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD });
// server.js 调用:返回 true 表示已接管该 upgrade 请求(含拒绝),false 表示非终端路径
function handleUpgrade(request, socket, head) {
let pathname = null;
try { pathname = new URL(request.url, 'http://localhost').pathname; } catch {}
if (pathname !== '/ws/terminal') return false;
const ip = getClientIp(request);
// 1) Origin 校验(纵深防御;拒绝事件降频落审计)
if (!originAllowed(request)) {
auditRejectOnce(ip, 'WS_ORIGIN_REJECT', 'Origin 校验失败');
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
socket.destroy();
return true;
}
// 2) token 校验:优先 HttpOnly cookie(推荐),?token= query 仅作旧前端回退(不推荐,落 access log)
const cookies = parseCookies(request.headers.cookie);
let token = cookies[TOKEN_COOKIE];
if (!token) {
try { token = new URL(request.url, 'http://localhost').searchParams.get('token'); } catch {}
}
const payload = verifyToken(token);
if (!payload) {
auditRejectOnce(ip, 'WS_TOKEN_REJECT', 'token 无效或过期');
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
return true;
}
// 3) 并发会话上限:同步预占槽位再检查(升级回调前执行,杜绝并发竞态突破上限)
const slot = { pending: true };
activeSessions.add(slot);
if (activeSessions.size > MAX_SESSIONS) {
activeSessions.delete(slot);
auditRejectOnce(ip, 'WS_OVERLOAD', '并发会话数超限被拒绝');
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
socket.destroy();
return true;
}
// 升级中途失败(socket 断开等)时释放预占槽位,防止泄漏
const releaseSlot = () => { activeSessions.delete(slot); };
socket.once('close', releaseSlot);
socket.once('error', releaseSlot);
// 4) 升级并触发连接处理(槽位随会话移交)
wss.handleUpgrade(request, socket, head, (ws) => {
socket.removeListener('close', releaseSlot);
socket.removeListener('error', releaseSlot);
if (ws.readyState !== WebSocket.OPEN) { // 升级异常:释放槽位
activeSessions.delete(slot);
return;
}
wss.emit('connection', ws, request, payload, slot);
});
return true;
}
wss.on('connection', (ws, request, payload, slot) => {
// 复用 handleUpgrade 预占的槽位(已在 activeSessions 中,幂等 add),
// 升级失败路径已由 releaseSlot/readyState 检查负责释放。
const session = slot || {};
session.id = session.id || crypto.randomBytes(4).toString('hex');
session.startedAt = session.startedAt || Date.now();
activeSessions.add(session);
let ptyProc = null;
let idleTimer = null;
let resumeTimer = null;
let closed = false;
const ctx = { user: payload, ip: getClientIp(request) };
audit('SESSION_START', `终端会话 ${session.id} 建立`, ctx);
function cleanup() {
if (closed) return;
closed = true;
clearTimeout(idleTimer);
clearInterval(resumeTimer);
// 销毁 pty:kill() + 进程组清理(含后台任务),事件回调为一次性注册随对象失效
killPty(ptyProc);
ptyProc = null;
activeSessions.delete(session);
}
function scheduleIdle() {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (closed) return;
audit('SESSION_IDLE_TIMEOUT', `终端会话 ${session.id} 空闲 ${IDLE_TIMEOUT / 60000} 分钟无输入,自动断开`, ctx);
try { ws.close(WS_CLOSE_IDLE, 'idle timeout'); } catch {}
cleanup();
}, IDLE_TIMEOUT);
if (idleTimer.unref) idleTimer.unref(); // 不阻止进程退出
}
// 流控恢复:onData 暂停后不再触发回调,恢复检查必须在消息路径/轮询中执行
function maybeResume() {
if (closed || !session._paused || !ptyProc) return;
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < MAX_PTY_BUFFER / 2) {
session._paused = false;
try { ptyProc.resume(); } catch {}
}
}
ws.on('message', (data, isBinary) => {
if (closed) return;
maybeResume(); // 任意帧到达即尝试恢复(resize/输入/控制消息均可)
// 二进制帧 = pty 输入(触发空闲计时刷新)
if (isBinary) {
if (!ptyProc) return;
scheduleIdle();
try { ptyProc.write(data); } catch {}
return;
}
// 文本帧 = JSON 控制消息
let msg;
try { msg = JSON.parse(data.toString('utf8')); } catch {
try { ws.close(WS_CLOSE_BAD_MSG, 'invalid json'); } catch {}
cleanup(); return;
}
if (!msg || typeof msg.type !== 'string') {
try { ws.close(WS_CLOSE_BAD_MSG, 'bad message'); } catch {}
cleanup(); return;
}
switch (msg.type) {
case 'auth':
// 连接后可选再发 token 二次认证;无效立即断开(4001)
if (!verifyToken(msg.token)) {
audit('SESSION_AUTH_FAIL', `终端会话 ${session.id} 二次认证失败`, ctx);
try { ws.close(WS_CLOSE_BAD_TOKEN, 'bad token'); } catch {}
cleanup();
}
break;
case 'resize': {
const cols = Math.min(Math.max(parseInt(msg.cols, 10) || 80, 2), 500);
const rows = Math.min(Math.max(parseInt(msg.rows, 10) || 24, 2), 200);
if (ptyProc) { try { ptyProc.resize(cols, rows); } catch {} }
break;
}
case 'ping':
try { ws.send(JSON.stringify({ type: 'pong' })); } catch {}
break;
case 'bye':
audit('SESSION_BYE', `终端会话 ${session.id} 主动退出`, ctx);
try { ws.close(1000, 'bye'); } catch {}
cleanup();
break;
default:
break; // 未知控制消息忽略
}
});
ws.on('error', (err) => {
audit('SESSION_ERROR', `终端会话 ${session.id} WebSocket 错误: ${err.message}`, ctx);
cleanup();
});
ws.on('close', () => {
audit('SESSION_END', `终端会话 ${session.id} 连接关闭`, ctx);
cleanup();
});
// 启动 ptysudo -i → root shell
try {
ptyProc = spawnPty(80, 24);
} catch (e) {
audit('SESSION_SPAWN_FAIL', `终端会话 ${session.id} pty 启动失败: ${e.message}`, ctx);
try { ws.close(1011, 'pty spawn failed'); } catch {}
cleanup(); return;
}
if (!ptyProc) {
audit('SESSION_SPAWN_FAIL', `终端会话 ${session.id} 拒绝:node-pty 未安装或无法加载`, ctx);
try { ws.close(1011, 'node-pty not installed'); } catch {}
cleanup(); return;
}
// 兜底恢复轮询:即使客户端不再发任何帧,也能在缓冲排空后恢复 pty
resumeTimer = setInterval(() => maybeResume(), RESUME_POLL_MS);
if (resumeTimer.unref) resumeTimer.unref();
// pty 输出 → ws 二进制帧(binaryType=arraybuffer
ptyProc.onData((data) => {
if (closed || ws.readyState !== WebSocket.OPEN) return;
// 仅做暂停判断(数据流动时天然触发);恢复由 maybeResume(消息/轮询)负责
if (!session._paused && ws.bufferedAmount > MAX_PTY_BUFFER) {
session._paused = true;
try { ptyProc.pause(); } catch {}
}
try { ws.send(data, { binary: true }); } catch {}
});
ptyProc.onExit(({ exitCode, signal }) => {
audit('SESSION_EXIT', `终端会话 ${session.id} 进程退出 code=${exitCode} signal=${signal}`, ctx);
cleanup();
if (ws.readyState === WebSocket.OPEN) { try { ws.close(1000, 'pty exited'); } catch {} }
});
scheduleIdle();
audit('SESSION_READY', `终端会话 ${session.id} pty 就绪`, ctx);
});
module.exports = router;
module.exports.handleUpgrade = handleUpgrade;