Merge branch 'develop'

This commit is contained in:
周腾 2021-06-04 19:58:03 +08:00
commit 1c7c20dc76
11 changed files with 339 additions and 9 deletions

View File

@ -11,7 +11,8 @@ export default defineConfig({
favicon: 'https://qinglong.whyour.cn/g5.ico', favicon: 'https://qinglong.whyour.cn/g5.ico',
proxy: { proxy: {
'/api': { '/api': {
target: 'http://127.0.0.1:5678/', target: 'http://127.0.0.1:5600',
ws: true,
changeOrigin: true, changeOrigin: true,
}, },
}, },

View File

@ -4,6 +4,7 @@ import cookie from './cookie';
import config from './config'; import config from './config';
import log from './log'; import log from './log';
import cron from './cron'; import cron from './cron';
import terminal from './terminal';
export default () => { export default () => {
const app = Router(); const app = Router();
@ -12,6 +13,7 @@ export default () => {
config(app); config(app);
log(app); log(app);
cron(app); cron(app);
terminal(app);
return app; return app;
}; };

113
back/api/terminal.ts Normal file
View File

@ -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];
});
});
};

View File

@ -5,24 +5,77 @@ import routes from '../api';
import config from '../config'; import config from '../config';
import jwt from 'express-jwt'; import jwt from 'express-jwt';
import fs from 'fs'; 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 }) => { 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.enable('trust proxy');
app.use(cors()); app.use(cors());
app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
app.use( app.use(
jwt({ secret: config.secret as string, algorithms: ['HS384'] }).unless({ auth(getTokenFromReq).unless({
path: ['/api/login'], path: excludePath,
}), }),
); );
app.use((req, res, next) => { app.use((req, res, next) => {
if (req.url && req.url.includes('/api/login')) { if (req.url && excludePath.includes(req.path)) {
return next(); return next();
} }
const data = fs.readFileSync(config.authConfigFile, 'utf8'); const data = fs.readFileSync(config.authConfigFile, 'utf8');
const authHeader = req.headers.authorization; const authHeader = getTokenFromReq(req);
if (data) { if (data) {
const { token } = JSON.parse(data); const { token } = JSON.parse(data);
if (token && authHeader.includes(token)) { if (token && authHeader.includes(token)) {

13
back/test/websocket.js Normal file
View File

@ -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);
});

View File

@ -31,19 +31,25 @@
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"express": "^4.17.1", "express": "^4.17.1",
"express-jwt": "^6.0.0", "express-jwt": "^6.0.0",
"express-ws": "^4.0.0",
"got": "^11.8.2", "got": "^11.8.2",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"nedb": "^1.8.0", "nedb": "^1.8.0",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"node-pty": "^0.10.1",
"node-schedule": "^2.0.0", "node-schedule": "^2.0.0",
"p-queue": "6.6.2", "p-queue": "6.6.2",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"typedi": "^0.8.0", "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": { "devDependencies": {
"@ant-design/pro-layout": "^6.5.0",
"@ant-design/icons": "^4.6.2", "@ant-design/icons": "^4.6.2",
"@ant-design/pro-layout": "^6.5.0",
"@types/cors": "^2.8.10", "@types/cors": "^2.8.10",
"@types/express": "^4.17.8", "@types/express": "^4.17.8",
"@types/express-jwt": "^6.0.1", "@types/express-jwt": "^6.0.1",
@ -54,6 +60,7 @@
"@types/qrcode.react": "^1.0.1", "@types/qrcode.react": "^1.0.1",
"@types/react": "^17.0.0", "@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0", "@types/react-dom": "^17.0.0",
"@types/ws": "^7.4.4",
"@umijs/plugin-antd": "^0.9.1", "@umijs/plugin-antd": "^0.9.1",
"@umijs/test": "^3.3.9", "@umijs/test": "^3.3.9",
"codemirror": "^5.59.4", "codemirror": "^5.59.4",

View File

@ -7,7 +7,9 @@ import {
FolderOutlined, FolderOutlined,
RadiusSettingOutlined, RadiusSettingOutlined,
ControlOutlined, ControlOutlined,
DesktopOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import React from 'react';
export default { export default {
route: { route: {
@ -30,6 +32,12 @@ export default {
icon: <RadiusSettingOutlined />, icon: <RadiusSettingOutlined />,
component: '@/pages/cookie/index', component: '@/pages/cookie/index',
}, },
{
path: '/terminal',
name: '终端管理',
icon: <DesktopOutlined />,
component: '@/pages/terminal/index',
},
{ {
path: '/config', path: '/config',
name: '配置文件', name: '配置文件',

View File

@ -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);
}
}
}

View File

@ -0,0 +1,84 @@
import {
useState,
useEffect,
useCallback,
Key,
useRef,
CSSProperties,
} 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';
const TerminalApp = () => {
const [width, setWdith] = useState('100%');
const [marginLeft, setMarginLeft] = useState(0);
const [marginTop, setMarginTop] = useState(-72);
const [style, setStyle] = useState<CSSProperties | undefined>(undefined);
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const term = new Terminal();
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
const cols = 80;
const rows = 24;
fitAddon.fit();
term.open(ref.current);
term.focus();
request
.post(`${config.apiPrefix}terminals?cols=${cols}&rows=${rows}`)
.then((pid) => {
const ws = new WebSocket(
`ws://${location.host}${
config.apiPrefix
}terminals/${pid}?token=${getToken()}`,
);
ws.onopen = () => {
const attachAddon = new AttachAddon(ws);
term.loadAddon(attachAddon);
};
});
}, []);
useEffect(() => {
if (document.body.clientWidth < 768) {
setWdith('auto');
setMarginLeft(0);
setMarginTop(0);
} else {
setWdith('100%');
setMarginLeft(0);
setMarginTop(-72);
}
}, []);
return (
<PageContainer
className="ql-container-wrapper terminal-wrapper"
title={'终端'}
header={{
style: {
padding: '4px 16px 4px 15px',
position: 'sticky',
top: 0,
left: 0,
zIndex: 20,
marginTop,
width,
marginLeft,
},
}}
>
<div ref={ref} style={style}></div>
</PageContainer>
);
};
export default TerminalApp;

6
src/utils/auth.ts Normal file
View File

@ -0,0 +1,6 @@
import config from './config';
export function getToken() {
const token = localStorage.getItem(config.authKey);
return token;
}

View File

@ -9,11 +9,11 @@ message.config({
const time = Date.now(); const time = Date.now();
const errorHandler = function (error: any) { const errorHandler = function (error: any) {
if (error.response) { if (error.response) {
const message = error.data const msg = error.data
? error.data.message || error.data ? error.data.message || error.data
: error.response.statusText; : error.response.statusText;
if (error.response.status !== 401 && error.response.status !== 502) { if (error.response.status !== 401 && error.response.status !== 502) {
message.error(message); message.error(msg);
} else { } else {
console.log(error.response); console.log(error.response);
} }