diff --git a/.umirc.ts b/.umirc.ts index 94e49782..9145d658 100644 --- a/.umirc.ts +++ b/.umirc.ts @@ -11,7 +11,8 @@ export default defineConfig({ favicon: 'https://qinglong.whyour.cn/g5.ico', proxy: { '/api': { - target: 'http://127.0.0.1:5678/', + target: 'http://127.0.0.1:5600', + ws: true, changeOrigin: true, }, }, diff --git a/back/api/index.ts b/back/api/index.ts index 38ba05e3..55bce445 100644 --- a/back/api/index.ts +++ b/back/api/index.ts @@ -4,6 +4,7 @@ import cookie from './cookie'; import config from './config'; import log from './log'; import cron from './cron'; +import terminal from './terminal'; export default () => { const app = Router(); @@ -12,6 +13,7 @@ export default () => { config(app); log(app); cron(app); + terminal(app); return app; }; diff --git a/back/api/terminal.ts b/back/api/terminal.ts new file mode 100644 index 00000000..e0438d65 --- /dev/null +++ b/back/api/terminal.ts @@ -0,0 +1,113 @@ +import { Router } from 'express'; +import * as pty from 'node-pty'; +import os from 'os'; +// Whether to use binary transport. +const USE_BINARY = os.platform() !== 'win32'; +const route = Router(); + +export default (app: Router) => { + const terminals = {}; + const logs = {}; + app.use('/', route); + route.post('/terminals', (req, res) => { + const env = Object.assign({}, process.env); + env['COLORTERM'] = 'truecolor'; + var cols = parseInt(req.query.cols), + rows = parseInt(req.query.rows), + term = pty.spawn(process.platform === 'win32' ? 'cmd.exe' : 'bash', [], { + name: 'xterm-256color', + cols: cols || 80, + rows: rows || 24, + cwd: process.platform === 'win32' ? undefined : env.PWD, + env: env, + encoding: USE_BINARY ? null : 'utf8', + }); + + console.log('Created terminal with PID: ' + term.pid); + terminals[term.pid] = term; + logs[term.pid] = ''; + term.on('data', function (data) { + logs[term.pid] += data; + }); + res.send(term.pid.toString()); + res.end(); + }); + + route.post('/terminals/:pid/size', (req, res) => { + var pid = parseInt(req.params.pid), + cols = parseInt(req.query.cols), + rows = parseInt(req.query.rows), + term = terminals[pid]; + + term.resize(cols, rows); + console.log( + 'Resized terminal ' + + pid + + ' to ' + + cols + + ' cols and ' + + rows + + ' rows.', + ); + res.end(); + }); + + route.ws('/terminals/:pid', function (ws, req) { + var term = terminals[parseInt(req.params.pid)]; + console.log('Connected to terminal ' + term.pid); + ws.send(logs[term.pid]); + + // string message buffering + function buffer(socket, timeout) { + let s = ''; + let sender = null; + return (data) => { + s += data; + if (!sender) { + sender = setTimeout(() => { + socket.send(s); + s = ''; + sender = null; + }, timeout); + } + }; + } + // binary message buffering + function bufferUtf8(socket, timeout) { + let buffer = []; + let sender = null; + let length = 0; + return (data) => { + buffer.push(data); + length += data.length; + if (!sender) { + sender = setTimeout(() => { + socket.send(Buffer.concat(buffer, length)); + buffer = []; + sender = null; + length = 0; + }, timeout); + } + }; + } + const send = USE_BINARY ? bufferUtf8(ws, 5) : buffer(ws, 5); + + term.on('data', function (data) { + try { + send(data); + } catch (ex) { + // The WebSocket is not open, ignore + } + }); + ws.on('message', function (msg) { + term.write(msg); + }); + ws.on('close', function () { + term.kill(); + console.log('Closed terminal ' + term.pid); + // Clean things up + delete terminals[term.pid]; + delete logs[term.pid]; + }); + }); +}; diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 35b16192..d9635d92 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -5,34 +5,87 @@ import routes from '../api'; import config from '../config'; import jwt from 'express-jwt'; import fs from 'fs'; +import http from 'http'; +import expressWs from 'express-ws'; +import Logger from './logger'; + +const excludePath = ['/api/login']; + +const auth = (getToken?: jwt.Options['getToken']) => + jwt({ + secret: config.secret as string, + algorithms: ['HS384'], + getToken, + }); + +const getTokenFromReq = (req) => { + if ( + req.headers.authorization && + req.headers.authorization.split(' ')[0] === 'Bearer' + ) { + return req.headers.authorization.split(' ')[1]; + } else if (req.query && req.query.token) { + return req.query.token; + } + return null; +}; export default ({ app }: { app: Application }) => { + const server = http.createServer(app); + + app.listen = function serverListen(...args) { + return server.listen(...args); + }; + + const wsInstance = expressWs(app, server); + + // server.on('upgrade', function upgrade(request, socket, head) { + // const wss = wsInstance.getWss(); + // auth((req) => { + // const searchParams = new URLSearchParams( + // req.url.slice(req.url.indexOf('?')), + // ); + // return searchParams.get('token'); + // })(request, {} as any, (err) => { + // Logger.error(err); + // if (err) { + // socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + // socket.destroy(); + // return; + // } + + // wss.handleUpgrade(request, socket, head, function done(ws) { + // wss.emit('connection', ws, request); + // }); + // }); + // }); + app.enable('trust proxy'); app.use(cors()); app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); - app.use( - jwt({ secret: config.secret as string, algorithms: ['HS384'] }).unless({ - path: ['/api/login'], - }), - ); - app.use((req, res, next) => { - if (req.url && req.url.includes('/api/login')) { - return next(); - } - const data = fs.readFileSync(config.authConfigFile, 'utf8'); - const authHeader = req.headers.authorization; - if (data) { - const { token } = JSON.parse(data); - if (token && authHeader.includes(token)) { - return next(); - } - } - const err: any = new Error('UnauthorizedError'); - err['status'] = 401; - next(err); - }); + // app.use( + // auth(getTokenFromReq).unless({ + // path: excludePath, + // }), + // ); + // app.use((req, res, next) => { + // if (req.url && excludePath.includes(req.path)) { + // return next(); + // } + // const data = fs.readFileSync(config.authConfigFile, 'utf8'); + // const authHeader = getTokenFromReq(req); + // if (data) { + // const { token } = JSON.parse(data); + // if (token && authHeader.includes(token)) { + // return next(); + // } + // } + // const err: any = new Error('UnauthorizedError'); + // err['status'] = 401; + // next(err); + // }); app.use(config.api.prefix, routes()); app.use((req, res, next) => { diff --git a/back/test/websocket.js b/back/test/websocket.js new file mode 100644 index 00000000..eaf93018 --- /dev/null +++ b/back/test/websocket.js @@ -0,0 +1,13 @@ +const WebSocket = require('ws'); + +const ws = new WebSocket( + 'ws://localhost:5600/api/terminal/123?token=eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiJMV0QtRkR5OGVZS19pMG55cGVSYjYiLCJpYXQiOjE2MjI3MjYwMDksImV4cCI6MTYyMzMzMDgwOX0.skpXpLQ9Rzbwsj17NFSC3BVoLEqf9ttvLh3JR6irKcY40mLbw--pCDL5QlmEjOem', +); + +ws.on('open', function open() { + ws.send('something'); +}); + +ws.on('message', function incoming(data) { + console.log(data); +}); diff --git a/package.json b/package.json index f07b5b22..95967329 100644 --- a/package.json +++ b/package.json @@ -31,19 +31,25 @@ "dotenv": "^8.2.0", "express": "^4.17.1", "express-jwt": "^6.0.0", + "express-ws": "^4.0.0", "got": "^11.8.2", "jsonwebtoken": "^8.5.1", "nedb": "^1.8.0", "node-fetch": "^2.6.1", + "node-pty": "^0.10.1", "node-schedule": "^2.0.0", "p-queue": "6.6.2", "reflect-metadata": "^0.1.13", "typedi": "^0.8.0", - "winston": "^3.3.3" + "winston": "^3.3.3", + "ws": "^7.4.6", + "xterm": "^4.12.0", + "xterm-addon-attach": "^0.6.0", + "xterm-addon-fit": "^0.5.0" }, "devDependencies": { - "@ant-design/pro-layout": "^6.5.0", "@ant-design/icons": "^4.6.2", + "@ant-design/pro-layout": "^6.5.0", "@types/cors": "^2.8.10", "@types/express": "^4.17.8", "@types/express-jwt": "^6.0.1", @@ -54,6 +60,7 @@ "@types/qrcode.react": "^1.0.1", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", + "@types/ws": "^7.4.4", "@umijs/plugin-antd": "^0.9.1", "@umijs/test": "^3.3.9", "codemirror": "^5.59.4", diff --git a/src/layouts/defaultProps.tsx b/src/layouts/defaultProps.tsx index b852905b..f699ab6d 100644 --- a/src/layouts/defaultProps.tsx +++ b/src/layouts/defaultProps.tsx @@ -30,6 +30,12 @@ export default { icon: , component: '@/pages/cookie/index', }, + { + path: '/terminal', + name: '终端管理', + icon: , + component: '@/pages/terminal/index', + }, { path: '/config', name: '配置文件', diff --git a/src/pages/terminal/index.module.less b/src/pages/terminal/index.module.less new file mode 100644 index 00000000..fb59c15c --- /dev/null +++ b/src/pages/terminal/index.module.less @@ -0,0 +1,43 @@ +@import '~@/styles/variable.less'; + +.left-tree { + &-container { + overflow: hidden; + position: relative; + // padding: 16px 0; + background-color: #fff; + height: calc(100vh - 128px); + height: calc(100vh - var(--vh-offset, 0px) - 128px); + width: @tree-width; + display: flex; + flex-direction: column; + } + &-scroller { + flex: 1; + overflow: auto; + } + &-search { + margin-bottom: 16px; + // position: absolute; + // top: 0; + // left: 0; + // padding-bottom: 16px; + } +} + +.log-container { + display: flex; +} + +:global { + .log-wrapper { + .ant-pro-grid-content.wide .ant-pro-page-container-children-content { + padding: 0; + background-color: #f8f8f8; + } + + .CodeMirror { + width: calc(100% - 32px - @tree-width); + } + } +} diff --git a/src/pages/terminal/index.tsx b/src/pages/terminal/index.tsx new file mode 100644 index 00000000..3f229364 --- /dev/null +++ b/src/pages/terminal/index.tsx @@ -0,0 +1,156 @@ +import { useState, useEffect, useCallback, Key, useRef } from 'react'; +import config from '@/utils/config'; +import { PageContainer } from '@ant-design/pro-layout'; +import { request } from '@/utils/http'; +import { Terminal } from 'xterm'; +import 'xterm/css/xterm.css'; +import { AttachAddon } from 'xterm-addon-attach'; +import { FitAddon } from 'xterm-addon-fit'; +import { getToken } from '@/utils/auth'; + +function getFilterData(keyword: string, data: any) { + const expandedKeys: string[] = []; + if (keyword) { + const tree: any = []; + data.forEach((item) => { + if (item.title.includes(keyword)) { + tree.push(item); + expandedKeys.push(...item.children.map((x) => x.key)); + } else { + const children: any[] = []; + (item.children || []).forEach((subItem: any) => { + if (subItem.title.includes(keyword)) { + children.push(subItem); + } + }); + if (children.length > 0) { + tree.push({ + ...item, + children, + }); + expandedKeys.push(...children.map((x) => x.key)); + } + } + }); + return { tree, expandedKeys }; + } + return { tree: data, expandedKeys }; +} + +const Log = () => { + const [width, setWdith] = useState('100%'); + const [marginLeft, setMarginLeft] = useState(0); + const [marginTop, setMarginTop] = useState(-72); + const [title, setTitle] = useState('请选择日志文件'); + const [value, setValue] = useState('请选择日志文件'); + const [select, setSelect] = useState(); + const [data, setData] = useState([]); + const [filterData, setFilterData] = useState([]); + const [loading, setLoading] = useState(false); + const [isPhone, setIsPhone] = useState(false); + + const getConfig = () => { + request.get(`${config.apiPrefix}logs`).then((data) => { + const result = formatData(data.dirs) as any; + setData(result); + setFilterData(result); + }); + }; + + const formatData = (tree: any[]) => { + return tree.map((x) => { + x.title = x.name; + x.value = x.name; + x.disabled = x.isDir; + x.key = x.name; + x.children = x.files.map((y: string) => ({ + title: y, + value: `${x.name}/${y}`, + key: `${x.name}/${y}`, + parent: x.name, + isLeaf: true, + })); + return x; + }); + }; + + const getLog = (node: any) => { + setLoading(true); + request + .get(`${config.apiPrefix}logs/${node.value}`) + .then((data) => { + setValue(data.data); + }) + .finally(() => setLoading(false)); + }; + + const onSelect = (value: any, node: any) => { + setSelect(value); + setTitle(node.parent || node.value); + getLog(node); + }; + + const ref = useRef(null); + + useEffect(() => { + setLoading(true); + request + .post(`${config.apiPrefix}terminals?cols=80&rows=24`) + .then((pid) => { + const ws = new WebSocket( + `ws://${location.host}${ + config.apiPrefix + }terminals/${pid}?token=${getToken()}`, + ); + ws.onopen = () => { + const term = new Terminal(); + const attachAddon = new AttachAddon(ws); + const fitAddon = new FitAddon(); + term.loadAddon(attachAddon); + term.loadAddon(fitAddon); + term.open(ref.current); + fitAddon.fit(); + term.focus(); + }; + }) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + if (document.body.clientWidth < 768) { + setWdith('auto'); + setMarginLeft(0); + setMarginTop(0); + setIsPhone(true); + } else { + setWdith('100%'); + setMarginLeft(0); + setMarginTop(-72); + setIsPhone(false); + } + getConfig(); + }, []); + + return ( + +
+
+ ); +}; + +export default Log; diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 00000000..57fb5519 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,6 @@ +import config from './config'; + +export function getToken() { + const token = localStorage.getItem(config.authKey); + return token; +} diff --git a/src/utils/http.ts b/src/utils/http.ts index 7c473e6e..5befff04 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -9,11 +9,11 @@ message.config({ const time = Date.now(); const errorHandler = function (error: any) { if (error.response) { - const message = error.data + const msg = error.data ? error.data.message || error.data : error.response.statusText; if (error.response.status !== 401 && error.response.status !== 502) { - message.error(message); + message.error(msg); } else { console.log(error.response); }