码桶

发现社区成员的开源项目

main
NodeNanny/core/terminal.js
terminal.js10.8 KB
'use strict';

// 在线终端模块(交接文档v4方案,骨架阶段)。
//
// 依赖说明(需要 `npm install ws node-pty`,目前还没加进package.json——这两个是新引入的
// 依赖,其中node-pty需要编译原生模块,跟项目原本"零依赖风险"的原则有出入,这一点在
// 交接文档里已经提过,创始人如果部署时编译环境有问题需要单独处理,不是这个文件的bug)。
//
// 安全设计要点(全部对应交接文档v4第二节第4条列出的隐藏风险):
// 1. WS握手阶段就鉴权,鉴权不过直接拒绝upgrade,pty都不创建(不是"连上再踢出去")
// 2. 每个连接独立一个pty进程,空闲超时自动kill,连接断开时同步销毁
// 3. resize事件同步给pty,避免前端换行错位
// 4. 知识库自动写入命令 vs 用户手动打字:检测最近是否有用户输入,有的话拒绝抢写
// 5. 命令执行前的风险分级在这里做最终把关(配合kb-manager.classifyCommandRisk的兜底),
//    high级别要求携带一次性确认token,不是随便一个POST请求就能跑

let WebSocketServer;
let pty;
try {
  // eslint-disable-next-line global-require
  WebSocketServer = require('ws').WebSocketServer;
  // eslint-disable-next-line global-require
  pty = require('node-pty');
} catch (err) {
  // 依赖没装的时候不要让整个面板进程直接崩溃退出——终端功能没了,但节点监控/知识库
  // 展示这些核心功能还应该能正常用。attachTerminal()里会检测这个标记并跳过挂载。
  WebSocketServer = null;
  pty = null;
}

const crypto = require('crypto');
const auth = require('./auth');
const kbManager = require('./kb-manager');

const IDLE_TIMEOUT_MS = 15 * 60 * 1000; // 15分钟无输入自动关闭pty,防止忘记关标签页导致僵尸进程
const USER_TYPING_WINDOW_MS = 800; // 用户最近800ms内有输入,视为"正忙",知识库命令不抢写
const CONFIRM_TOKEN_TTL_MS = 60 * 1000; // high风险命令的一次性确认token,1分钟内有效

// connId -> { ptyProcess, ws, lastUserInputAt, idleTimer, pendingConfirmTokens: Map }
const sessions = new Map();

function parseCookie(cookieHeader, name) {
  if (!cookieHeader) return null;
  const match = cookieHeader
    .split(';')
    .map((s) => s.trim())
    .find((s) => s.startsWith(`${name}=`));
  return match ? decodeURIComponent(match.slice(name.length + 1)) : null;
}

// 握手阶段的双重鉴权:既要有效的面板登录session,也要有效的终端专属session
// (由前端在打开终端面板前先调 /api/terminal/unlock 换来,见panel-server.js里的路由)。
// 任何一个不过,直接拒绝WS upgrade,不进入下一步创建pty。
function verifyUpgradeAuth(req, { sessionSecret, terminalSecret, panelPassword }) {
  if (!panelPassword) return true; // 面板本身就没设密码(本机场景),跟其它/api/行为保持一致
  const cookieHeader = req.headers.cookie;
  if (!auth.verifySessionCookie(cookieHeader, sessionSecret)) return false;
  if (!auth.verifyTerminalSessionCookie(cookieHeader, terminalSecret)) return false;
  return true;
}

// 纯逻辑:给定verified/风险分级/是否正忙,决定writeCommand该做什么动作,
// 不碰pty/session map,方便脱离真实ws/node-pty单测(见test-terminal.js)。
// 行为必须跟原来内联在writeCommand里的判断顺序完全一致:
// 1) unverified直接copy-only,连"是否正忙"都不检查(跟原实现一致)
// 2) 分级
// 3) 正忙拦截
// 4) 按风险出动作
function decideWriteCommand({ command, declaredRiskLevel, verified, lastUserInputAt, now }) {
  if (verified !== true) {
    return { ok: true, risk: 'copy-only', executed: false, note: 'unverified_entry_copy_only', action: 'copy_only' };
  }

  const risk = kbManager.classifyCommandRisk(command, declaredRiskLevel);

  if (now - lastUserInputAt < USER_TYPING_WINDOW_MS) {
    return { ok: false, error: 'terminal_busy_user_typing' };
  }

  if (risk === 'low') {
    return { ok: true, risk, executed: true, action: 'run' };
  }

  if (risk === 'medium') {
    return { ok: true, risk, executed: false, note: 'prefilled_no_autorun', action: 'prefill' };
  }

  // high
  return { ok: true, risk, executed: false, note: 'confirmation_required', action: 'confirm_required' };
}

// 纯逻辑:确认token是否已过期。
function isTokenExpired(pending, now) {
  return now > pending.expiresAt;
}

function resetIdleTimer(session) {
  clearTimeout(session.idleTimer);
  session.idleTimer = setTimeout(() => {
    try {
      session.ws.send(JSON.stringify({ type: 'idle_timeout' }));
    } catch (_) { /* 连接可能已经断了,忽略 */ }
    destroySession(session.id);
  }, IDLE_TIMEOUT_MS);
}

function destroySession(id) {
  const session = sessions.get(id);
  if (!session) return;
  clearTimeout(session.idleTimer);
  try {
    session.ptyProcess.kill();
  } catch (_) { /* 进程可能已经退出 */ }
  try {
    if (session.ws.readyState === session.ws.OPEN) session.ws.close();
  } catch (_) { /* 忽略 */ }
  sessions.delete(id);
}

function attachTerminal(httpServer, { config, sessionSecret, terminalSecret }) {
  if (!WebSocketServer || !pty) {
    console.error(
      '[terminal] 未检测到 ws / node-pty 依赖,在线终端功能已跳过挂载。\n' +
      '[terminal] 如需启用,先执行 `npm install ws node-pty`(node-pty需要本机有编译原生模块的工具链)。'
    );
    return { enabled: false };
  }
  if (!config.terminal || !config.terminal.enabled) {
    return { enabled: false };
  }

  const panelPassword = config.panel.password || '';
  const wss = new WebSocketServer({ noServer: true });

  httpServer.on('upgrade', (req, socket, head) => {
    const url = new URL(req.url, 'http://localhost');
    if (url.pathname !== '/ws/terminal') return; // 不是终端的升级请求,不掺和,让其它逻辑处理

    if (!verifyUpgradeAuth(req, { sessionSecret, terminalSecret, panelPassword })) {
      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
      socket.destroy();
      return;
    }

    wss.handleUpgrade(req, socket, head, (ws) => {
      const id = crypto.randomBytes(8).toString('hex');
      const shell = process.env.SHELL || '/bin/bash';
      const ptyProcess = pty.spawn(shell, [], {
        name: 'xterm-256color',
        cols: 80,
        rows: 24,
        cwd: process.env.HOME || '/root',
        env: process.env
      });

      const session = {
        id,
        ws,
        ptyProcess,
        lastUserInputAt: 0,
        idleTimer: null,
        pendingConfirmTokens: new Map() // token -> { command, expiresAt }
      };
      sessions.set(id, session);
      resetIdleTimer(session);

      ptyProcess.onData((data) => {
        if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'output', data }));
      });
      ptyProcess.onExit(() => destroySession(id));

      ws.send(JSON.stringify({ type: 'ready', sessionId: id }));

      ws.on('message', (raw) => {
        let msg;
        try {
          msg = JSON.parse(raw);
        } catch (_) {
          return; // 忽略解析不了的消息,不让一条坏数据打断连接
        }
        resetIdleTimer(session);

        if (msg.type === 'input') {
          // 用户手动打字:记录时间戳(供知识库写入时判断"是否正忙"),直接转发给pty
          session.lastUserInputAt = Date.now();
          ptyProcess.write(msg.data);
        } else if (msg.type === 'resize') {
          const cols = parseInt(msg.cols, 10);
          const rows = parseInt(msg.rows, 10);
          if (cols > 0 && rows > 0) ptyProcess.resize(cols, rows);
        }
      });

      ws.on('close', () => destroySession(id));
      ws.on('error', () => destroySession(id));
    });
  });

  return {
    enabled: true,
    // 供panel-server.js的/api/kb/execute路由调用:把知识库/AI给出的修复命令写进
    // 指定终端会话,按风险分级决定"自动跑"还是"只填不跑"还是"需要确认token"。
    // verified: 对应知识库条目自己的 verified 字段。这是v4文档明确要求的"权限层面强制"
    // ——AI生成/远程同步来的条目默认 verified:false,即使声明的riskLevel是low,
    // 这里也一律锁定为"仅可复制手动执行",不写入pty、不给一键执行/自动确认的通道。
    // 调用方(panel-server.js的路由)必须显式传true才能解锁一键执行,不传时按false
    // 处理(安全默认值),不能指望调用方"记得传"。
    writeCommand(sessionId, command, declaredRiskLevel, verified = false) {
      const session = sessions.get(sessionId);
      if (!session) return { ok: false, error: 'session_not_found' };

      const decision = decideWriteCommand({
        command,
        declaredRiskLevel,
        verified,
        lastUserInputAt: session.lastUserInputAt,
        now: Date.now()
      });

      if (!decision.ok) return decision; // terminal_busy_user_typing

      if (decision.action === 'copy_only') {
        return { ok: true, risk: decision.risk, executed: false, note: decision.note };
      }

      if (decision.action === 'run') {
        session.ptyProcess.write(`${command}\r`);
        return { ok: true, risk: decision.risk, executed: true };
      }

      if (decision.action === 'prefill') {
        session.ptyProcess.write(command); // 不带\r,用户自己看一眼按回车
        return { ok: true, risk: decision.risk, executed: false, note: decision.note };
      }

      // confirm_required:只生成一次性确认token,真正写入要走 confirmCommand()
      const token = crypto.randomBytes(6).toString('hex');
      session.pendingConfirmTokens.set(token, {
        command,
        expiresAt: Date.now() + CONFIRM_TOKEN_TTL_MS
      });
      return { ok: true, risk: decision.risk, executed: false, note: decision.note, confirmToken: token };
    },

    confirmCommand(sessionId, token) {
      const session = sessions.get(sessionId);
      if (!session) return { ok: false, error: 'session_not_found' };
      const pending = session.pendingConfirmTokens.get(token);
      if (!pending) return { ok: false, error: 'invalid_or_used_token' };
      session.pendingConfirmTokens.delete(token);
      if (isTokenExpired(pending, Date.now())) return { ok: false, error: 'token_expired' };
      // 高危命令即使确认了,也只填入不自动回车——多一道"人手动按Enter"的摩擦,
      // 这是v4文档里明确要求的"高危要有摩擦感",不能确认完就自动执行。
      session.ptyProcess.write(pending.command);
      return { ok: true, executed: false, note: 'prefilled_after_confirm_no_autorun' };
    },

    getActiveSessionCount() {
      return sessions.size;
    }
  };
}

module.exports = {
  attachTerminal,
  // 导出方便脱离ws/node-pty单测,见test-terminal.js
  _internal: { parseCookie, verifyUpgradeAuth, decideWriteCommand, isTokenExpired }
};