Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# 更新日志

## 1.5.9

- 新增:新增终端模块


## 1.5.8

- 其他:修改权限为管理员可用,且禁止修改权限,防止权限越权
Expand Down
11 changes: 8 additions & 3 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**/*"]
}
}
6 changes: 3 additions & 3 deletions backend/src/koa.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
130 changes: 130 additions & 0 deletions backend/src/router/term.js
Original file line number Diff line number Diff line change
@@ -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}` }
}
}
9 changes: 9 additions & 0 deletions backend/src/utils/exec.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
135 changes: 135 additions & 0 deletions backend/src/utils/term/client.js
Original file line number Diff line number Diff line change
@@ -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 }
Loading