From 33e896ba634243844d85e519ecede364fd0f8492 Mon Sep 17 00:00:00 2001 From: leeson <97571961+leesonaa@users.noreply.github.com> Date: Fri, 28 Aug 2026 07:37:18 +0800 Subject: [PATCH] feat: add terminal unit --- CHANGELOG.md | 5 + backend/package.json | 11 +- backend/src/koa.js | 6 +- backend/src/router/term.js | 130 ++++ backend/src/utils/exec.js | 9 + backend/src/utils/term/client.js | 135 ++++ backend/src/utils/term/daemon.js | 722 ++++++++++++++++++ backend/src/utils/term/log.js | 16 + backend/src/utils/term/paths.js | 25 + frontend/package.json | 2 + frontend/src/App.vue | 44 +- frontend/src/components/ClickMove.vue | 3 +- frontend/src/layout/ViewMenu.vue | 10 +- .../src/layout/ViewTerminal/TerminalPanel.vue | 145 ++++ .../src/layout/ViewTerminal/TerminalView.vue | 194 +++++ frontend/src/store/terminal.ts | 124 +++ frontend/src/utils/terminal.ts | 48 ++ package.json | 4 +- 18 files changed, 1620 insertions(+), 13 deletions(-) create mode 100644 backend/src/router/term.js create mode 100644 backend/src/utils/term/client.js create mode 100644 backend/src/utils/term/daemon.js create mode 100644 backend/src/utils/term/log.js create mode 100644 backend/src/utils/term/paths.js create mode 100644 frontend/src/layout/ViewTerminal/TerminalPanel.vue create mode 100644 frontend/src/layout/ViewTerminal/TerminalView.vue create mode 100644 frontend/src/store/terminal.ts create mode 100644 frontend/src/utils/terminal.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c9f174..b73b716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 更新日志 +## 1.5.9 + +- 新增:新增终端模块 + + ## 1.5.8 - 其他:修改权限为管理员可用,且禁止修改权限,防止权限越权 diff --git a/backend/package.json b/backend/package.json index 8d525cc..50298d5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,14 +2,19 @@ "name": "code.editor.server", "scripts": { "dev": "node --watch src/koa.js", - "build": "pkg -t node18-linux-x64 ./src/cgi.js --output ../app/app/server/api", - "build:arm": "pkg -t node18-linux-arm64 ./src/cgi.js --output ../app/app/server/api" + "build": "pkg -t node18-linux-x64 ./src/cgi.js --output ../app/app/server/api && pkg -t node18-linux-x64 ./src/utils/term/daemon.js --output ../app/app/server/term-daemon", + "build:arm": "pkg -t node18-linux-arm64 ./src/cgi.js --output ../app/app/server/api && pkg -t node18-linux-arm64 ./src/utils/term/daemon.js --output ../app/app/server/term-daemon" + }, + "dependencies": { + "node-pty": "^1.0.0" }, - "dependencies": {}, "devDependencies": { "@koa/cors": "^5.0.0", "koa": "^3.1.1", "koa-body": "^7.0.1", "pkg": "^5.8.1" + }, + "pkg": { + "assets": ["node_modules/node-pty/build/Release/**/*"] } } diff --git a/backend/src/koa.js b/backend/src/koa.js index fe27737..75fbabd 100644 --- a/backend/src/koa.js +++ b/backend/src/koa.js @@ -19,9 +19,9 @@ const getData = (ctx) => { body: ctx.request.body || {}, files: ctx.request.files ? Object.keys(ctx.request.files).reduce((obj, key) => { - obj[key] = fs.readFileSync(ctx.request.files[key].filepath) - return obj - }, {}) + obj[key] = fs.readFileSync(ctx.request.files[key].filepath) + return obj + }, {}) : {}, } } else if (path.indexOf('/proxy') === 0) { diff --git a/backend/src/router/term.js b/backend/src/router/term.js new file mode 100644 index 0000000..bc2be14 --- /dev/null +++ b/backend/src/router/term.js @@ -0,0 +1,130 @@ +const client = require('../utils/term/client') + +// 创建一个新的终端会话 +exports.create = async function ({ body }) { + try { + const result = await client.request({ + cmd: 'create', + cols: body.cols, + rows: body.rows, + cwd: body.cwd, + shell: body.shell, + }) + + return { code: 200, msg: '操作成功', data: { id: result.id, mode: result.mode } } + } catch (err) { + return { code: 500, msg: `创建终端失败: ${err.message}` } + } +} + +// 向终端写入用户输入(键盘输入 / 粘贴内容) +exports.input = async function ({ body }) { + if (!body.id) { + return { code: 400, msg: '缺少终端 ID' } + } + + try { + const result = await client.request({ cmd: 'input', id: body.id, data: body.data || '' }) + + if (!result.ok) { + return { code: 404, msg: result.msg || '终端不存在或已关闭' } + } + + return { code: 200, msg: '操作成功', data: {} } + } catch (err) { + return { code: 500, msg: `发送指令失败: ${err.message}` } + } +} + +// 前端窗口大小变化时,同步终端的行列数 +exports.resize = async function ({ body }) { + if (!body.id) { + return { code: 400, msg: '缺少终端 ID' } + } + + try { + const result = await client.request({ cmd: 'resize', id: body.id, cols: body.cols, rows: body.rows }) + + if (!result.ok) { + return { code: 404, msg: result.msg || '终端不存在或已关闭' } + } + + return { code: 200, msg: '操作成功', data: {} } + } catch (err) { + return { code: 500, msg: `调整终端大小失败: ${err.message}` } + } +} + +// 拉取终端自上次拉取以来的新输出(长轮询) +exports.poll = async function ({ query }) { + if (!query.id) { + return { code: 400, msg: '缺少终端 ID' } + } + + try { + const result = await client.request({ cmd: 'poll', id: query.id, seq: query.seq }, { timeout: 12000 }) + + if (!result.ok) { + return { code: 404, msg: result.msg || '终端不存在或已关闭' } + } + + return { code: 200, msg: '操作成功', data: result } + } catch (err) { + return { code: 500, msg: `读取终端输出失败: ${err.message}` } + } +} + +// 一次请求同时提交输入/窗口大小,并取回新输出,减少飞牛 CGI 往返 +exports.sync = async function ({ body }) { + if (!body.id) { + return { code: 400, msg: '缺少终端 ID' } + } + + try { + const result = await client.request( + { + cmd: 'sync', + id: body.id, + seq: body.seq, + data: body.data || '', + cols: body.cols, + rows: body.rows, + }, + { timeout: 12000 }, + ) + + if (!result.ok) { + return { code: 404, msg: result.msg || '终端不存在或已关闭' } + } + + return { code: 200, msg: '操作成功', data: result } + } catch (err) { + return { code: 500, msg: `同步终端失败: ${err.message}` } + } +} + +// 关闭并销毁终端会话 +exports.close = async function ({ body }) { + if (!body.id) { + return { code: 400, msg: '缺少终端 ID' } + } + + try { + await client.request({ cmd: 'close', id: body.id }) + + return { code: 200, msg: '操作成功', data: {} } + } catch (err) { + return { code: 500, msg: `关闭终端失败: ${err.message}` } + } +} + +// 列出当前仍然存活的终端会话(用于刷新页面后恢复终端标签页) +exports.list = async function () { + try { + const result = await client.request({ cmd: 'list' }) + + return { code: 200, msg: '操作成功', data: result.list || [] } + } catch (err) { + return { code: 500, msg: `获取终端列表失败: ${err.message}` } + } +} diff --git a/backend/src/utils/exec.js b/backend/src/utils/exec.js index 133fb7d..b2b63fe 100644 --- a/backend/src/utils/exec.js +++ b/backend/src/utils/exec.js @@ -1,11 +1,20 @@ const getType = require('./type') +const term = require('../router/term') + const router = { '/read': { run: require('../router/read'), type: 'file' }, '/save': { run: require('../router/save') }, '/del': { run: require('../router/del') }, '/dir': { run: require('../router/dir') }, '/type': { run: require('../router/type') }, + '/term/create': { run: term.create }, + '/term/input': { run: term.input }, + '/term/resize': { run: term.resize }, + '/term/poll': { run: term.poll }, + '/term/sync': { run: term.sync }, + '/term/close': { run: term.close }, + '/term/list': { run: term.list }, } module.exports = async function exec(data) { diff --git a/backend/src/utils/term/client.js b/backend/src/utils/term/client.js new file mode 100644 index 0000000..53eae6c --- /dev/null +++ b/backend/src/utils/term/client.js @@ -0,0 +1,135 @@ +const fs = require('fs') +const net = require('net') +const path = require('path') +const { spawn } = require('child_process') + +const { RUN_DIR, SOCK_PATH } = require('./paths') +const log = require('./log') + +const send = (payload, timeout) => + new Promise((resolve, reject) => { + const socket = net.connect({ path: SOCK_PATH }) + + let buf = '' + let settled = false + + const finish = (err, data) => { + if (settled) return + settled = true + clearTimeout(timer) + socket.destroy() + err ? reject(err) : resolve(data) + } + + const timer = setTimeout(() => finish(new Error('终端服务响应超时')), timeout) + + socket.on('connect', () => socket.write(`${JSON.stringify(payload)}\n`)) + + socket.on('data', (chunk) => { + buf += chunk.toString('utf8') + const idx = buf.indexOf('\n') + if (idx === -1) return + + try { + finish(null, JSON.parse(buf.slice(0, idx))) + } catch (err) { + finish(err) + } + }) + + socket.on('error', (err) => finish(err)) + socket.on('close', () => finish(new Error('终端服务连接已断开'))) + }) + +const STDIO_LOG_PATH = path.join(RUN_DIR, 'daemon-stdio.log') + +/** + * 拉起守护进程。这里刻意不再用 stdio:'ignore'——如果子进程在写自己的 + * daemon.log 之前就崩溃或者被系统杀掉,'ignore' 会让我们什么线索都拿不到。 + * 改成把子进程自己的 stdout/stderr(包括 Node 运行时本身的报错、 + * 原生模块加载失败的堆栈等)重定向到一个文件,方便事后排查。 + */ +const spawnDaemon = () => { + fs.mkdirSync(RUN_DIR, { recursive: true }) + + let outFd + try { + outFd = fs.openSync(STDIO_LOG_PATH, 'a') + } catch (err) { + log('client', `打开 daemon-stdio.log 失败: ${err.message}`) + } + + const stdio = outFd !== undefined ? ['ignore', outFd, outFd] : 'ignore' + + // 生产环境(pkg 打包):守护进程是单独打包出来的一个可执行文件(term-daemon), + // 和主程序(api)放在同一目录下,裸调用即可,不需要任何参数或环境变量。 + // + // 之前尝试过“复用主程序自身、靠命令行参数/环境变量区分模式”的方案, + // 结果发现 pkg 打包出来的可执行文件在“被其他进程用特殊参数重新拉起自己” + // 这件事上行为很不稳定:传参数会被它自己的启动引导代码(pkg/prelude/bootstrap.js) + // 当成“要运行的外部脚本路径”去解析,传空参数又会导致它自己内部逻辑因为 + // 拿不到预期的 argv 而抛异常——这两种情况都是在我们自己的代码开始执行之前 + // 就已经崩溃了。为了彻底避开这个问题,改成把 daemon.js 单独编译成一个 + // 独立的可执行文件,运行时就是最基础、最没有歧义的“裸调用一个可执行文件”, + // 不依赖任何 pkg 内部对 argv 的特殊处理逻辑。 + // + // 开发环境(未经 pkg 打包):process.execPath 是系统的 node 可执行文件, + // 直接把 daemon.js 的真实磁盘路径当作脚本参数传进去即可,daemon.js 自身 + // 会在作为主模块被直接运行时自动启动(见文件末尾 require.main === module 判断)。 + const execPath = process.pkg ? path.join(path.dirname(process.execPath), 'term-daemon') : process.execPath + const args = process.pkg ? [] : [path.join(__dirname, 'daemon.js')] + + log('client', `pid=${process.pid} 拉起守护进程: ${execPath} ${args.join(' ')} (pkg=${!!process.pkg})`) + + const child = spawn(execPath, args, { detached: true, stdio }) + + child.on('error', (err) => log('client', `spawn 守护进程失败: ${err.message}`)) + child.on('exit', (code, signal) => log('client', `守护进程子进程退出: code=${code}, signal=${signal}`)) + + child.unref() + + if (outFd !== undefined) { + try { + fs.closeSync(outFd) + } catch { + // 忽略:子进程已经拿到了自己的 fd 副本 + } + } +} + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * 向终端守护进程发送一条请求,如果守护进程尚未启动(或刚被回收), + * 会自动拉起一个新的守护进程并重试。 + */ +const request = async (payload, { timeout = 5000 } = {}) => { + try { + return await send(payload, timeout) + } catch (firstErr) { + log('client', `首次连接守护进程失败(${firstErr.message}),cmd=${payload.cmd},尝试拉起并重试`) + + try { + spawnDaemon() + } catch (spawnErr) { + log('client', `拉起守护进程失败: ${spawnErr.message}`) + } + + // 给新启动的守护进程一点时间完成监听,期间做几次短间隔重试 + for (let i = 0; i < 15; i++) { + await wait(200) + try { + const result = await send(payload, timeout) + log('client', `第 ${i + 1} 次重试后连接成功,cmd=${payload.cmd}`) + return result + } catch { + // 继续重试 + } + } + + log('client', `重试 15 次后仍无法连接守护进程,cmd=${payload.cmd},放弃`) + throw new Error('终端服务启动失败,详情请查看 daemon-stdio.log / daemon.log') + } +} + +module.exports = { request } diff --git a/backend/src/utils/term/daemon.js b/backend/src/utils/term/daemon.js new file mode 100644 index 0000000..f410344 --- /dev/null +++ b/backend/src/utils/term/daemon.js @@ -0,0 +1,722 @@ +/** + * 终端守护进程 + * ------------------------------------------------------------------ + * 生产环境下后端以 CGI 方式运行:每一次 HTTP 请求都会 fork 一个全新的进程, + * 处理完立即退出,进程之间不共享任何内存。而一个可交互的终端(shell)必须 + * 是一个长期存活的进程,因此不能把它直接放在 CGI 进程里。 + * + * 这里的做法是:单独启动一个常驻的“守护进程”,由它来 spawn 真正的 shell。 + * 伪终端(PTY)按下面顺序尝试,保证在飞牛 NAS 这种无法使用 node-pty 原生 + * 模块的环境里,仍然能得到和 VS Code / ttyd 接近的真实终端(job control、 + * vim/top/htop、方向键、Tab 补全都可用): + * 1. node-pty(本地开发或原生模块可用时) + * 2. 系统 Python 的 pty 模块(飞牛 / Linux NAS 上最稳妥) + * 3. util-linux 的 script 命令(同样会分配 PTY) + * 4. 最后才降级为普通管道(无 tty,仅能跑简单命令) + * 每次 CGI 请求只是通过本地 Unix Domain Socket 发一条短消息给守护进程 + * (创建/输入/调整大小/拉取输出/关闭),守护进程处理后写回结果并关闭这条 + * 连接,CGI 进程随即退出——但 shell 本身仍在守护进程里继续跑着,不受影响。 + * + * 前端通过短轮询(poll)不断地问守护进程“从上次的位置往后,有没有新的输出”, + * 守护进程支持在没有新数据时“挂起”这条连接一小段时间(而不是立刻返回空), + * 一旦 shell 有新输出会立刻唤醒并返回,从而在纯 HTTP 轮询模型下也能做到 + * 比较接近实时的回显效果。 + */ + +const fs = require('fs') +const net = require('net') +const os = require('os') +const path = require('path') +const crypto = require('crypto') +const { spawn: spawnChild } = require('child_process') + +const { RUN_DIR, SOCK_PATH } = require('./paths') +const log = require('./log') + +// 单个会话最多缓存的输出字符数,超出后从头部裁剪,避免长期运行的终端无限占用内存 +const MAX_BUFFER_CHARS = 5 * 1024 * 1024 +// /term/poll 在没有新数据时,最多挂起等待的时间 +const POLL_WAIT_MS = 8000 +// 守护进程空闲(没有任何会话)超过这个时间后自动退出,释放资源 +const IDLE_EXIT_MS = 30 * 60 * 1000 + +let nodePty = null +try { + // node-pty 是原生模块,飞牛上用 pkg 打包后通常加载不到对应平台的 .node。 + // 加载失败不致命,后面会改用系统 Python / script 分配真正的 PTY。 + nodePty = require('node-pty') + log('daemon', 'node-pty 加载成功') +} catch (err) { + nodePty = null + log('daemon', `node-pty 不可用: ${err.message}`) +} + +const DEFAULT_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + +const PTY_BRIDGE_PATH = path.join(RUN_DIR, 'pty-bridge.py') + +// 用系统 Python 打开 PTY 再 exec shell。stdin/stdout 是终端数据,fd 3 收 resize。 +const PTY_BRIDGE_SRC = [ + 'import os, sys, pty, select, fcntl, termios, struct, signal, json, errno', + 'cols, rows, cwd, shell = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3], sys.argv[4]', + 'pid = None', + 'def set_winsize(fd, r, c):', + ' try:', + " fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', int(r), int(c), 0, 0))", + ' except Exception:', + ' pass', + ' if pid:', + ' try:', + ' os.kill(pid, signal.SIGWINCH)', + ' except Exception:', + ' pass', + 'master, slave = pty.openpty()', + 'set_winsize(master, rows, cols)', + 'pid = os.fork()', + 'if pid == 0:', + ' os.close(master)', + ' os.setsid()', + ' try:', + ' fcntl.ioctl(slave, termios.TIOCSCTTY, 0)', + ' except Exception:', + ' pass', + ' os.dup2(slave, 0); os.dup2(slave, 1); os.dup2(slave, 2)', + ' if slave > 2: os.close(slave)', + ' try:', + ' os.chdir(cwd)', + ' except Exception:', + ' pass', + ' env = os.environ.copy()', + " env['TERM'] = 'xterm-256color'", + " env.setdefault('COLORTERM', 'truecolor')", + ' os.execvpe(shell, [os.path.basename(shell), "-l"], env)', + ' os._exit(127)', + 'os.close(slave)', + 'def nb(fd):', + ' try:', + ' fl = fcntl.fcntl(fd, fcntl.F_GETFL)', + ' fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)', + ' except Exception:', + ' pass', + 'nb(master); nb(0)', + 'ctrl = 3', + 'try:', + ' nb(ctrl)', + 'except Exception:', + ' ctrl = None', + 'ctrl_buf = b""', + 'def handle_ctrl():', + ' global ctrl_buf', + ' try:', + ' data = os.read(ctrl, 4096)', + ' except OSError:', + ' return', + ' if not data: return', + ' ctrl_buf += data', + ' while b"\\n" in ctrl_buf:', + ' line, ctrl_buf = ctrl_buf.split(b"\\n", 1)', + ' try:', + ' msg = json.loads(line.decode())', + ' set_winsize(master, msg.get("rows", 24), msg.get("cols", 80))', + ' except Exception:', + ' pass', + 'while True:', + ' rlist = [master, 0] + ([ctrl] if ctrl is not None else [])', + ' try:', + ' r, _, _ = select.select(rlist, [], [], 1.0)', + ' except Exception:', + ' continue', + ' if ctrl is not None and ctrl in r: handle_ctrl()', + ' if master in r:', + ' try:', + ' data = os.read(master, 8192)', + ' except OSError as e:', + ' if e.errno == errno.EIO: break', + ' data = b""', + ' if data:', + ' os.write(1, data)', + ' else:', + ' try:', + ' if os.waitpid(pid, os.WNOHANG)[0] != 0: break', + ' except ChildProcessError:', + ' break', + ' if 0 in r:', + ' try:', + ' data = os.read(0, 8192)', + ' except OSError:', + ' data = b""', + ' if not data:', + ' try: os.kill(pid, signal.SIGHUP)', + ' except Exception: pass', + ' break', + ' os.write(master, data)', + ' try:', + ' wpid, _ = os.waitpid(pid, os.WNOHANG)', + ' if wpid != 0:', + ' try:', + ' while True:', + ' data = os.read(master, 8192)', + ' if not data: break', + ' os.write(1, data)', + ' except OSError:', + ' pass', + ' break', + ' except ChildProcessError:', + ' break', + 'try: os.close(master)', + 'except Exception: pass', + 'try: os.kill(pid, signal.SIGHUP)', + 'except Exception: pass', +].join('\n') + +const findExecutable = (names) => { + const dirs = `${process.env.PATH || ''}:${DEFAULT_PATH}`.split(':').filter(Boolean) + + for (const name of names) { + if (name.startsWith('/') && fs.existsSync(name)) { + return name + } + } + + for (const name of names) { + if (name.startsWith('/')) continue + for (const dir of dirs) { + const full = path.join(dir, name) + if (fs.existsSync(full)) { + return full + } + } + } + + return null +} + +const enrichEnv = () => { + const env = { ...process.env } + env.TERM = 'xterm-256color' + env.COLORTERM = env.COLORTERM || 'truecolor' + env.HOME = env.HOME || os.homedir() || '/root' + env.LANG = env.LANG || env.LC_ALL || 'C.UTF-8' + env.PATH = env.PATH && env.PATH.includes('/usr/bin') ? env.PATH : [env.PATH, DEFAULT_PATH].filter(Boolean).join(':') + return env +} + +const ensurePtyBridge = () => { + fs.mkdirSync(RUN_DIR, { recursive: true }) + fs.writeFileSync(PTY_BRIDGE_PATH, PTY_BRIDGE_SRC) +} + +const attachChildIO = (session, child) => { + session.child = child + + child.stdout.on('data', (buf) => appendData(session, buf.toString('utf8'))) + child.stderr.on('data', (buf) => { + const text = buf.toString('utf8') + log('daemon', `会话 ${session.id} stderr: ${text.trim()}`) + // python/script 自己的报错不要污染终端画面;管道模式下 stderr 就是命令输出 + if (session.mode === 'pipe') { + appendData(session, text) + } + }) + child.on('exit', (exitCode) => { + session.exited = true + session.exitCode = exitCode + log('daemon', `会话 ${session.id} (${session.mode}/${session.ptyKind}) 退出, exitCode=${exitCode}`) + wake(session) + }) + child.on('error', (err) => { + appendData(session, `\r\n[终端启动失败] ${err.message}\r\n`) + session.exited = true + wake(session) + }) +} + +/** @type {Map} */ +const sessions = new Map() + +let lastActivity = Date.now() + +const getDefaultShell = () => { + if (process.env.SHELL && fs.existsSync(process.env.SHELL)) { + return process.env.SHELL + } + + return ['/bin/bash', '/bin/sh'].find((i) => fs.existsSync(i)) || '/bin/sh' +} + +const getDefaultCwd = (cwd) => { + if (cwd && fs.existsSync(cwd) && fs.statSync(cwd).isDirectory()) { + return cwd + } + + return os.homedir() || '/root' +} + +const appendData = (session, data) => { + if (!data) { + return + } + + const seq = session.length + session.chunks.push({ seq, data }) + session.length += data.length + + while (session.chunks.length > 1 && session.length - session.chunks[0].seq > MAX_BUFFER_CHARS) { + session.chunks.shift() + } + + wake(session) +} + +const wake = (session) => { + session.lastActive = Date.now() + lastActivity = Date.now() + + if (session.waiters.size) { + session.waiters.forEach((fn) => fn()) + session.waiters.clear() + } +} + +const collectData = (session, seq) => { + const from = Math.max(seq, session.chunks[0]?.seq || 0) + + let out = '' + + for (const chunk of session.chunks) { + const chunkEnd = chunk.seq + chunk.data.length + if (chunkEnd <= from) { + continue + } + + const start = Math.max(0, from - chunk.seq) + out += chunk.data.slice(start) + } + + return { out, truncated: from > seq } +} + +const closeSession = (id, { immediate } = {}) => { + const session = sessions.get(id) + if (!session) { + return + } + + try { + if (session.pty) { + session.pty.kill() + } else if (session.child) { + session.child.kill('SIGHUP') + } + } catch { + // 进程可能已经退出,忽略 + } + + session.exited = true + wake(session) + + log('daemon', `会话 ${id} 已关闭`) + + // 保留一小段时间,方便最后一次 poll 还能取到关闭前的输出,随后再彻底移除 + setTimeout( + () => { + sessions.delete(id) + }, + immediate ? 0 : 5000, + ).unref() +} + +const spawnNodePty = (session, shellPath, useCwd, useCols, useRows, env) => { + if (!nodePty) return false + + try { + session.pty = nodePty.spawn(shellPath, ['-l'], { + name: 'xterm-256color', + cols: useCols, + rows: useRows, + cwd: useCwd, + env, + }) + session.mode = 'pty' + session.ptyKind = 'node-pty' + session.pty.onData((data) => appendData(session, data)) + session.pty.onExit(({ exitCode }) => { + session.exited = true + session.exitCode = exitCode + log('daemon', `会话 ${session.id} (pty/node-pty) 退出, exitCode=${exitCode}`) + wake(session) + }) + return true + } catch (err) { + log('daemon', `node-pty spawn 失败: ${err.message}`) + session.pty = null + return false + } +} + +const spawnPythonPty = (session, shellPath, useCwd, useCols, useRows, env) => { + const python = findExecutable(['python3', 'python', '/usr/bin/python3', '/usr/bin/python']) + if (!python) { + log('daemon', '未找到 python3,跳过系统 PTY') + return false + } + + try { + ensurePtyBridge() + const child = spawnChild(python, ['-u', PTY_BRIDGE_PATH, String(useCols), String(useRows), useCwd, shellPath], { + cwd: useCwd, + env, + stdio: ['pipe', 'pipe', 'pipe', 'pipe'], + }) + session.mode = 'pty' + session.ptyKind = 'python' + attachChildIO(session, child) + return true + } catch (err) { + log('daemon', `python PTY 启动失败: ${err.message}`) + return false + } +} + +const spawnScriptPty = (session, shellPath, useCwd, useCols, useRows, env) => { + const scriptBin = findExecutable(['script', '/usr/bin/script']) + if (!scriptBin) { + log('daemon', '未找到 script 命令,跳过 script PTY') + return false + } + + try { + // util-linux 的 script -qc 会给命令分配一个 PTY + const child = spawnChild(scriptBin, ['-qefc', `${shellPath} -l`, '/dev/null'], { + cwd: useCwd, + env: { ...env, COLUMNS: String(useCols), LINES: String(useRows) }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + session.mode = 'pty' + session.ptyKind = 'script' + attachChildIO(session, child) + return true + } catch (err) { + log('daemon', `script PTY 启动失败: ${err.message}`) + return false + } +} + +const spawnPipe = (session, shellPath, useCwd, env) => { + const child = spawnChild(shellPath, ['-i'], { + cwd: useCwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }) + session.mode = 'pipe' + session.ptyKind = 'pipe' + attachChildIO(session, child) +} + +const createSession = ({ cols, rows, cwd, shell }) => { + const id = crypto.randomUUID() + const shellPath = shell && fs.existsSync(shell) ? shell : getDefaultShell() + const useCwd = getDefaultCwd(cwd) + const useCols = Number(cols) > 0 ? Number(cols) : 120 + const useRows = Number(rows) > 0 ? Number(rows) : 30 + const env = enrichEnv() + + /** @type {Session} */ + const session = { + id, + mode: 'pipe', + ptyKind: 'pipe', + shell: shellPath, + cwd: useCwd, + cols: useCols, + rows: useRows, + chunks: [], + length: 0, + exited: false, + exitCode: null, + waiters: new Set(), + createdAt: Date.now(), + lastActive: Date.now(), + } + + const ok = + spawnNodePty(session, shellPath, useCwd, useCols, useRows, env) || + spawnPythonPty(session, shellPath, useCwd, useCols, useRows, env) || + spawnScriptPty(session, shellPath, useCwd, useCols, useRows, env) + + if (!ok) { + log('daemon', '所有 PTY 方案均失败,降级为管道模式(无 job control)') + spawnPipe(session, shellPath, useCwd, env) + } + + sessions.set(id, session) + lastActivity = Date.now() + + log('daemon', `新建会话 ${id}, mode=${session.mode}, kind=${session.ptyKind}, shell=${shellPath}, cwd=${useCwd}`) + + return { id, mode: session.mode } +} + +const writeInput = (session, data) => { + if (session.pty) { + session.pty.write(data) + return + } + + if (!session.child) return + + // 管道模式没有真正的 tty 行规程,Ctrl+C 不会被内核转换成 SIGINT, + // 这里做一个近似处理:单独的 Ctrl+C 按键直接转成给子进程发信号。 + if (session.mode === 'pipe' && data === '\x03') { + try { + session.child.kill('SIGINT') + } catch { + // 忽略 + } + return + } + + try { + session.child.stdin.write(data) + } catch { + // 子进程可能已经退出 + } +} + +const resizeSession = (session, cols, rows) => { + session.cols = cols + session.rows = rows + + if (session.pty) { + try { + session.pty.resize(cols, rows) + } catch { + // 会话可能刚好已经退出 + } + return + } + + if (session.ptyKind === 'python' && session.child && session.child.stdio[3]) { + try { + session.child.stdio[3].write(`${JSON.stringify({ cols, rows })}\n`) + } catch { + // 忽略 + } + } +} + +const handleRequest = async (req) => { + const session = req.id ? sessions.get(req.id) : undefined + + switch (req.cmd) { + case 'create': { + const result = createSession(req) + return { ok: true, ...result } + } + + case 'input': { + if (!session) return { ok: false, msg: '终端不存在或已关闭' } + writeInput(session, req.data || '') + return { ok: true } + } + + case 'resize': { + if (!session) return { ok: false, msg: '终端不存在或已关闭' } + const cols = Number(req.cols) + const rows = Number(req.rows) + if (cols > 0 && rows > 0) { + resizeSession(session, cols, rows) + } + return { ok: true } + } + + case 'poll': + case 'sync': { + if (!session) return { ok: false, msg: '终端不存在或已关闭' } + + if (req.cmd === 'sync') { + if (req.data) writeInput(session, req.data) + const cols = Number(req.cols) + const rows = Number(req.rows) + if (cols > 0 && rows > 0) resizeSession(session, cols, rows) + } + + const seq = Number(req.seq) || 0 + + const build = () => { + const { out, truncated } = collectData(session, seq) + return { + ok: true, + data: out, + seq: session.length, + exited: session.exited, + exitCode: session.exitCode, + truncated, + mode: session.mode, + } + } + + let result = build() + if (result.data || result.exited) { + return result + } + + await new Promise((resolve) => { + const timer = setTimeout(() => { + session.waiters.delete(waiter) + resolve() + }, POLL_WAIT_MS) + + const waiter = () => { + clearTimeout(timer) + resolve() + } + + session.waiters.add(waiter) + }) + + return build() + } + + case 'close': { + if (session) closeSession(req.id) + return { ok: true } + } + + case 'list': { + return { + ok: true, + list: Array.from(sessions.values()).map((i) => ({ + id: i.id, + mode: i.mode, + shell: i.shell, + cwd: i.cwd, + cols: i.cols, + rows: i.rows, + exited: i.exited, + exitCode: i.exitCode, + createdAt: i.createdAt, + })), + } + } + + case 'ping': { + return { ok: true } + } + + default: + return { ok: false, msg: `未知指令: ${req.cmd}` } + } +} + +const startIdleWatcher = () => { + const timer = setInterval(() => { + if (sessions.size === 0 && Date.now() - lastActivity > IDLE_EXIT_MS) { + cleanupAndExit() + } + }, 60 * 1000) + + timer.unref() +} + +const cleanupAndExit = () => { + log('daemon', `准备退出,清理 ${sessions.size} 个终端会话`) + sessions.forEach((_, id) => closeSession(id, { immediate: true })) + + try { + fs.rmSync(SOCK_PATH, { force: true }) + } catch { + // 忽略 + } + + process.exit(0) +} + +const start = () => { + log('daemon', `启动中,pid=${process.pid}, RUN_DIR=${RUN_DIR}, SOCK_PATH=${SOCK_PATH}`) + + fs.mkdirSync(RUN_DIR, { recursive: true }) + + // 避免多个 CGI 请求同时抢跑导致重复启动:如果 socket 文件已经能连上, + // 说明已经有一个守护进程在跑了,直接退出即可。 + const probe = net.connect({ path: SOCK_PATH }) + + probe.on('connect', () => { + log('daemon', '已存在正在运行的守护进程,本次启动退出') + probe.end() + process.exit(0) + }) + + probe.on('error', (probeErr) => { + log('daemon', `未检测到已运行的守护进程(${probeErr.code || probeErr.message}),开始正式监听`) + + try { + fs.rmSync(SOCK_PATH, { force: true }) + } catch { + // 忽略 + } + + const server = net.createServer((socket) => { + let buf = '' + + socket.on('data', (chunk) => { + buf += chunk.toString('utf8') + + const idx = buf.indexOf('\n') + if (idx === -1) { + return + } + + const line = buf.slice(0, idx) + + let req + try { + req = JSON.parse(line) + } catch { + socket.end(`${JSON.stringify({ ok: false, msg: '非法请求' })}\n`) + return + } + + handleRequest(req) + .then((res) => socket.end(`${JSON.stringify(res)}\n`)) + .catch((err) => { + log('daemon', `处理请求出错 cmd=${req && req.cmd}: ${err.stack || err.message}`) + socket.end(`${JSON.stringify({ ok: false, msg: err.message })}\n`) + }) + }) + + socket.on('error', () => { + // 客户端异常断开,忽略 + }) + }) + + server.on('error', (serverErr) => { + // 极端竞态:几乎同时有另一个守护进程抢先监听成功,直接退出即可 + log('daemon', `监听失败,退出: ${serverErr.code || serverErr.message}`) + process.exit(0) + }) + + server.listen(SOCK_PATH, () => { + log('daemon', `监听成功: ${SOCK_PATH}`) + startIdleWatcher() + }) + }) +} + +process.on('SIGTERM', cleanupAndExit) +process.on('SIGINT', cleanupAndExit) + +process.on('uncaughtException', (err) => { + log('daemon', `未捕获异常,即将退出: ${err.stack || err.message}`) + cleanupAndExit() +}) + +process.on('unhandledRejection', (err) => { + log('daemon', `未处理的 Promise 拒绝: ${err && err.stack ? err.stack : err}`) +}) + +if (require.main === module) { + start() +} + +module.exports = { start } diff --git a/backend/src/utils/term/log.js b/backend/src/utils/term/log.js new file mode 100644 index 0000000..d446c4e --- /dev/null +++ b/backend/src/utils/term/log.js @@ -0,0 +1,16 @@ +const fs = require('fs') + +const { RUN_DIR, LOG_PATH } = require('./paths') + +/** + * 追加一行日志到 daemon.log。特意做成“打不开就算了”的静默失败, + * 绝不能因为日志写不了而影响终端功能本身。 + */ +module.exports = function log(tag, msg) { + try { + fs.mkdirSync(RUN_DIR, { recursive: true }) + fs.appendFileSync(LOG_PATH, `${new Date().toISOString()} [${tag}] ${msg}\n`) + } catch { + // 忽略:日志只是排查手段,不应该影响主流程 + } +} diff --git a/backend/src/utils/term/paths.js b/backend/src/utils/term/paths.js new file mode 100644 index 0000000..e296fe8 --- /dev/null +++ b/backend/src/utils/term/paths.js @@ -0,0 +1,25 @@ +const os = require('os') +const path = require('path') + +// 由于生产环境以 CGI 方式运行(每次请求都是一个全新进程,进程退出后内存状态即丢失), +// 终端会话必须由一个独立的常驻后台进程(daemon)持有,各次 CGI 请求仅作为“客户端” +// 通过本地 Unix Domain Socket 与其通信。这里统一存放该 socket 的路径,避免多处硬编码。 +// +// 注意:这里不能简单用 os.tmpdir()——部分 NAS/CGI 运行环境会给每一次请求分配独立的 +// 临时目录(每次调用 TMPDIR 都不一样),如果这样,daemon 监听的 socket 路径和后续 +// 请求去连接的路径就会对不上,导致“连不上就一直重新拉起、但永远连不上”。 +// 生产环境下这里改用可执行文件自身所在目录(pkg 打包后是一个固定的安装路径, +// 例如 /var/apps/code.editor/target/server/api),把运行时数据放在它旁边, +// 这个路径在整个应用生命周期内是稳定不变的。开发环境(未经 pkg 打包)则仍使用 +// os.tmpdir(),因为本地开发时不存在“每次请求独立沙箱目录”的问题。 +const RUN_DIR = process.pkg + ? path.join(path.dirname(process.execPath), 'runtime', 'term') + : path.join(os.tmpdir(), 'code.editor-term') + +module.exports = { + RUN_DIR, + SOCK_PATH: path.join(RUN_DIR, 'daemon.sock'), + // 排查用的日志文件:daemon 和各次 CGI 客户端调用都会往这里追加记录, + // 方便直接用代码编辑器本身打开查看,不需要登录 NAS 控制台。 + LOG_PATH: path.join(RUN_DIR, 'daemon.log'), +} diff --git a/frontend/package.json b/frontend/package.json index a7cc7cf..a97c76c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "@element-plus/icons-vue": "^2.3.2", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "axios": "^1.13.2", "buffer": "^6.0.3", "element-plus": "^2.11.9", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d284572..9dbc3bd 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,8 +1,15 @@