536 lines
23 KiB
JavaScript
536 lines
23 KiB
JavaScript
// Web 终端后端 —— 安全敏感模块:终端 = RCE 级攻击面。
|
||
// 安全措施:
|
||
// 1. 专用终端密码二次确认:PBKDF2-SHA256 600k(与密码箱 routes/passwords.js 同参数,异步版不阻塞事件循环);
|
||
// 首次调用(未设置密码)即完成设置(needs_setup 流程),强度要求 8 位以上
|
||
// 2. 短 TTL token(5 分钟):jwt 带 terminal:true 声明,普通登录 token 不可直接连终端;
|
||
// 认证成功后种 HttpOnly SameSite=Strict 短 TTL cookie(terminal_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 shell,PIN 强度要求 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 cookie:WS 握手(同源 GET /ws/terminal)自动携带,
|
||
// root shell 凭证不再进 URL / access log。Path 精确限定 /ws/terminal,HttpOnly 防 XSS 读取,
|
||
// SameSite=Strict 防跨站发送。Secure 按连接是否 HTTPS 条件附加。
|
||
function setTerminalCookie(res, token) {
|
||
// L7:HTTPS 判定补 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 cookie(terminal_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 leader,pgid == 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();
|
||
});
|
||
|
||
// 启动 pty(sudo -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;
|