feat: add webshell
This commit is contained in:
parent
aa1cee7c9f
commit
9970efccfd
@ -3,7 +3,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AI Apps</title>
|
<title>AI Apps</title>
|
||||||
<link rel="stylesheet" href="./src/assets/index.css">
|
<link rel="stylesheet" href="./src/index.css">
|
||||||
<style>
|
<style>
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
@ -22,10 +22,19 @@
|
|||||||
</style>
|
</style>
|
||||||
<!-- <script src="/system/lib/app.js"></script> -->
|
<!-- <script src="/system/lib/app.js"></script> -->
|
||||||
<script src="https://kevisual.xiongxiao.me/system/lib/app.js"></script>
|
<script src="https://kevisual.xiongxiao.me/system/lib/app.js"></script>
|
||||||
|
<style>
|
||||||
|
#terminal {
|
||||||
|
background-color: #000;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="ai-root"></div>
|
<div id="ai-root">
|
||||||
|
|
||||||
|
<div id="terminal" style="width: 100%; height: 100%;"></div>
|
||||||
|
</div>
|
||||||
<!-- <div id="ai-bot-root"></div> -->
|
<!-- <div id="ai-bot-root"></div> -->
|
||||||
</body>
|
</body>
|
||||||
<script src="./src/main.ts" type="module"></script>
|
<script src="./src/main.ts" type="module"></script>
|
||||||
|
@ -4,10 +4,16 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module"
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"socket.io-client": "^4.8.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
60
packages/webshell/src/main.ts
Normal file
60
packages/webshell/src/main.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
// import { FitAddon } from 'xterm-addon-fit';
|
||||||
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
import io from 'socket.io-client';
|
||||||
|
const term = new Terminal();
|
||||||
|
term.open(document.getElementById('terminal') as HTMLElement);
|
||||||
|
// term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ');
|
||||||
|
let input = '';
|
||||||
|
term.onData((data) => {
|
||||||
|
// socket.emit('input', { name: 'tab', data });
|
||||||
|
if (data === '\b' || data === '\x7F') {
|
||||||
|
console.log('blank data\b');
|
||||||
|
input = input.slice(0, -1);
|
||||||
|
term.write('\b \b');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input += data;
|
||||||
|
if (data === '\r') {
|
||||||
|
console.log('input', input);
|
||||||
|
socket.emit('tab-input', input);
|
||||||
|
input = '';
|
||||||
|
}
|
||||||
|
term.write(data);
|
||||||
|
});
|
||||||
|
// term.on
|
||||||
|
term.onResize(({ cols, rows }) => {
|
||||||
|
term.resize(cols, rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
const socket = io(window.location.origin + '/terminal', { path: '/terminal', reconnection: true });
|
||||||
|
socket.connect();
|
||||||
|
const termialTab0 = { name: 'tab', children: [] };
|
||||||
|
const createTerminal = (tab: any) => {
|
||||||
|
const name = tab.name;
|
||||||
|
const cwd = null;
|
||||||
|
socket.emit('create', { name: name });
|
||||||
|
};
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('connect');
|
||||||
|
createTerminal(termialTab0);
|
||||||
|
});
|
||||||
|
socket.on('message', (data: any) => {
|
||||||
|
console.log('message', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('tab-output', (data: any) => {
|
||||||
|
console.log('tab-output', data);
|
||||||
|
term.write(data);
|
||||||
|
});
|
||||||
|
socket.on('tab-pid', (data: any) => {
|
||||||
|
console.log('tab-pid-data', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputEl = document.querySelector('#input') as HTMLInputElement;
|
||||||
|
const send = document.querySelector('#send');
|
||||||
|
|
||||||
|
send?.addEventListener('click', () => {
|
||||||
|
const data = inputEl?.value;
|
||||||
|
socket.emit('tab-input', data);
|
||||||
|
});
|
@ -50,7 +50,6 @@ export default defineConfig({
|
|||||||
// 'react-dom/client': 'https://cdn.jsdelivr.net/npm/react-dom/client/+esm',
|
// 'react-dom/client': 'https://cdn.jsdelivr.net/npm/react-dom/client/+esm',
|
||||||
// react: 'https://cdn.jsdelivr.net/npm/react@19.0.0/+esm',
|
// react: 'https://cdn.jsdelivr.net/npm/react@19.0.0/+esm',
|
||||||
// 'react-dom': 'https://cdn.jsdelivr.net/npm/react-dom@19.0.0/+esm',
|
// 'react-dom': 'https://cdn.jsdelivr.net/npm/react-dom@19.0.0/+esm',
|
||||||
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
@ -68,6 +67,11 @@ export default defineConfig({
|
|||||||
port: 6022,
|
port: 6022,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
proxy: {
|
proxy: {
|
||||||
|
'/terminal': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
'/system/lib': {
|
'/system/lib': {
|
||||||
target: 'https://kevisual.xiongxiao.me',
|
target: 'https://kevisual.xiongxiao.me',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
45
packages/webshell/webshell-node/bin/webshell
Normal file
45
packages/webshell/webshell-node/bin/webshell
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
const http = require('http');
|
||||||
|
const args = require('args');
|
||||||
|
const app = require('../app');
|
||||||
|
const createSocketServer = require('../socket/index');
|
||||||
|
|
||||||
|
args.option('port', 'The port on which the app will be running', 3000);
|
||||||
|
const flags = args.parse(process.argv);
|
||||||
|
let port = flags.port;
|
||||||
|
|
||||||
|
app.set('port', port);
|
||||||
|
|
||||||
|
let server = http.createServer(app);
|
||||||
|
|
||||||
|
createSocketServer(server);
|
||||||
|
|
||||||
|
server.listen(port);
|
||||||
|
server.on('error', onError);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event listener for HTTP server "error" event.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function onError(error) {
|
||||||
|
if (error.syscall !== 'listen') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
|
||||||
|
|
||||||
|
// handle specific listen errors with friendly messages
|
||||||
|
switch (error.code) {
|
||||||
|
case 'EACCES':
|
||||||
|
console.error(bind + ' requires elevated privileges');
|
||||||
|
process.exit(1);
|
||||||
|
break;
|
||||||
|
case 'EADDRINUSE':
|
||||||
|
console.error(bind + ' is already in use');
|
||||||
|
process.exit(1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
22
packages/webshell/webshell-node/demos/test-install/demo.ts
Normal file
22
packages/webshell/webshell-node/demos/test-install/demo.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import * as pty from 'node-pty';
|
||||||
|
|
||||||
|
export function createPty(cmd: string) {
|
||||||
|
const ptyProcess = pty.spawn(cmd, [], {
|
||||||
|
name: 'xterm-color',
|
||||||
|
cols: 80,
|
||||||
|
rows: 30,
|
||||||
|
cwd: process.env.HOME,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
ptyProcess.onData((data) => {
|
||||||
|
process.stdout.write(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ptyProcess.write('ls\r');
|
||||||
|
ptyProcess.resize(100, 40);
|
||||||
|
ptyProcess.write('ls\r');
|
||||||
|
}
|
||||||
|
|
||||||
|
// createPty('ls');
|
||||||
|
createPty('zsh');
|
1609
packages/webshell/webshell-node/package-lock.json
generated
Normal file
1609
packages/webshell/webshell-node/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -11,9 +11,20 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"node-pty": "^1.0.0"
|
"args": "5.0.3",
|
||||||
|
"body-parser": "1.20.3",
|
||||||
|
"ejs": "3.1.10",
|
||||||
|
"express": "4.21.2",
|
||||||
|
"morgan": "1.10.0",
|
||||||
|
"node-pty": "^1.0.0",
|
||||||
|
"socket.io": "4.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kevisual/types": "^0.0.6"
|
"@kevisual/types": "^0.0.6",
|
||||||
|
"@types/args": "^5.0.3",
|
||||||
|
"@types/body-parser": "^1.19.5",
|
||||||
|
"@types/ejs": "^3.1.5",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/morgan": "^1.9.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
61
packages/webshell/webshell-node/src/app.ts
Normal file
61
packages/webshell/webshell-node/src/app.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import logger from 'morgan';
|
||||||
|
import bodyParser from 'body-parser';
|
||||||
|
import { renderFile } from 'ejs';
|
||||||
|
|
||||||
|
import indexRouter from './routes/index';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// view engine setup
|
||||||
|
app.set('views', path.join(process.cwd(), 'public'));
|
||||||
|
app.set('view engine', 'html');
|
||||||
|
app.engine('html', renderFile);
|
||||||
|
|
||||||
|
app.use(logger('dev'));
|
||||||
|
app.use(bodyParser.json()); // for parsing application/json
|
||||||
|
app.use(bodyParser.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
app.use(express.static(path.join(process.cwd(), 'public')));
|
||||||
|
|
||||||
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
// @ts-ignore
|
||||||
|
res.success = function (data: any) {
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
msg: '操作成功',
|
||||||
|
data: data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
res.fail = function (message: string) {
|
||||||
|
res.json({
|
||||||
|
code: 1,
|
||||||
|
msg: message,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api', indexRouter);
|
||||||
|
|
||||||
|
app.use('*', (req: Request, res: Response) => {
|
||||||
|
res.render('index', { title: 'Express' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// error handler
|
||||||
|
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
|
||||||
|
// set locals, only providing error in development
|
||||||
|
res.locals.message = err.message;
|
||||||
|
res.locals.error = req.app.get('env') === 'development' ? err : {};
|
||||||
|
|
||||||
|
// render the error page
|
||||||
|
res.status(err.status || 500);
|
||||||
|
// res.render('error');
|
||||||
|
res.send(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
@ -1,20 +0,0 @@
|
|||||||
import os from 'node:os';
|
|
||||||
import pty, { IPty } from 'node-pty';
|
|
||||||
|
|
||||||
const shell: string = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
|
||||||
console.log(shell);
|
|
||||||
const ptyProcess: IPty = pty.spawn(shell, [], {
|
|
||||||
name: 'xterm-color',
|
|
||||||
cols: 80,
|
|
||||||
rows: 30,
|
|
||||||
cwd: process.env.HOME || '',
|
|
||||||
env: process.env as NodeJS.ProcessEnv,
|
|
||||||
});
|
|
||||||
|
|
||||||
ptyProcess.onData((data: string) => {
|
|
||||||
process.stdout.write(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
ptyProcess.write('ls\r');
|
|
||||||
ptyProcess.resize(100, 40);
|
|
||||||
ptyProcess.write('ls\r');
|
|
21
packages/webshell/webshell-node/src/routes/index.ts
Normal file
21
packages/webshell/webshell-node/src/routes/index.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const promiseExec = promisify(exec);
|
||||||
|
|
||||||
|
router.get('/cwd', async (req: Request, res: Response) => {
|
||||||
|
const { pid } = req.query;
|
||||||
|
try {
|
||||||
|
const { stdout } = await promiseExec(`lsof -a -p ${pid} -d cwd -Fn | tail -1 | sed 's/.//'`);
|
||||||
|
const cwd = stdout.trim();
|
||||||
|
// @ts-ignore
|
||||||
|
res.success(cwd);
|
||||||
|
} catch (error) {
|
||||||
|
// @ts-ignore
|
||||||
|
res.fail('Failed to retrieve current working directory');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
12
packages/webshell/webshell-node/src/socket/index.ts
Normal file
12
packages/webshell/webshell-node/src/socket/index.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Server as HttpServer } from 'http';
|
||||||
|
import { SocketServer } from './socket';
|
||||||
|
import { terminal } from './terminal';
|
||||||
|
|
||||||
|
export const createSocketServer = (server: HttpServer) => {
|
||||||
|
const socketServer = new SocketServer(server, {
|
||||||
|
path: '/terminal',
|
||||||
|
pingTimeout: 1000 * 60 * 60 * 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
socketServer.use('terminal', terminal);
|
||||||
|
};
|
23
packages/webshell/webshell-node/src/socket/socket.ts
Normal file
23
packages/webshell/webshell-node/src/socket/socket.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Server as HttpServer } from 'http';
|
||||||
|
import { ServerOptions, Server as SocketIoServer, Namespace, Socket } from 'socket.io';
|
||||||
|
|
||||||
|
export class SocketServer {
|
||||||
|
private io: SocketIoServer;
|
||||||
|
|
||||||
|
constructor(server: HttpServer, options?: Partial<ServerOptions>) {
|
||||||
|
this.io = new SocketIoServer(server, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
use(name: string | ((socket: Socket) => void), fn?: (socket: Socket) => void): boolean {
|
||||||
|
if (!name) return false;
|
||||||
|
if (typeof name === 'string') {
|
||||||
|
if (!fn) return false;
|
||||||
|
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
|
||||||
|
|
||||||
|
this.io.of(name).on('connection', fn);
|
||||||
|
} else if (typeof name === 'function') {
|
||||||
|
this.io.on('connection', name);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
49
packages/webshell/webshell-node/src/socket/terminal.ts
Normal file
49
packages/webshell/webshell-node/src/socket/terminal.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import * as pty from 'node-pty';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { Socket } from 'socket.io';
|
||||||
|
|
||||||
|
const shell: string = os.platform() === 'win32' ? 'powershell.exe' : 'zsh';
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
name: string;
|
||||||
|
cols?: number;
|
||||||
|
rows?: number;
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ptyContainers: { [key: string]: pty.IPty } = {};
|
||||||
|
|
||||||
|
export const terminal = (socket: Socket) => {
|
||||||
|
socket.on('create', (option: Option) => {
|
||||||
|
let ptyProcess = pty.spawn(shell, ['--login'], {
|
||||||
|
name: 'xterm-color',
|
||||||
|
cols: option.cols || 80,
|
||||||
|
rows: option.rows || 24,
|
||||||
|
cwd: option.cwd || homedir(),
|
||||||
|
env: process.env,
|
||||||
|
}) as any;
|
||||||
|
console.log('create', option.name);
|
||||||
|
ptyProcess.on('data', (data: string) => socket.emit(option.name + '-output', data));
|
||||||
|
socket.on(option.name + '-input', (data: string) => ptyProcess.write(data));
|
||||||
|
socket.on(option.name + '-resize', (size: [number, number]) => {
|
||||||
|
ptyProcess.resize(size[0], size[1]);
|
||||||
|
});
|
||||||
|
socket.on(option.name + '-exit', () => {
|
||||||
|
console.log('exit', option.name)
|
||||||
|
ptyProcess.destroy();
|
||||||
|
});
|
||||||
|
socket.emit(option.name + '-pid', ptyProcess.pid);
|
||||||
|
ptyContainers[option.name] = ptyProcess;
|
||||||
|
});
|
||||||
|
socket.on('remove', (name: string) => {
|
||||||
|
socket.removeAllListeners(name + '-input');
|
||||||
|
socket.removeAllListeners(name + '-resize');
|
||||||
|
socket.removeAllListeners(name + '-exit');
|
||||||
|
if (name && ptyContainers[name] && ptyContainers[name].pid) {
|
||||||
|
const curentContainer = ptyContainers[name] as any;
|
||||||
|
curentContainer.destroy();
|
||||||
|
delete ptyContainers[name];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
47
packages/webshell/webshell-node/src/www.ts
Normal file
47
packages/webshell/webshell-node/src/www.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
import http from 'http';
|
||||||
|
import args from 'args';
|
||||||
|
import app from './app';
|
||||||
|
import { createSocketServer } from './socket/index';
|
||||||
|
|
||||||
|
args.option('port', 'The port on which the app will be running', 3000);
|
||||||
|
const flags = args.parse(process.argv);
|
||||||
|
let port: number = flags.port;
|
||||||
|
|
||||||
|
app.set('port', port);
|
||||||
|
|
||||||
|
let server = http.createServer(app);
|
||||||
|
|
||||||
|
createSocketServer(server);
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`Server is running on port ${port}`);
|
||||||
|
});
|
||||||
|
server.on('error', onError);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event listener for HTTP server "error" event.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function onError(error: NodeJS.ErrnoException): void {
|
||||||
|
if (error.syscall !== 'listen') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
|
||||||
|
|
||||||
|
// handle specific listen errors with friendly messages
|
||||||
|
switch (error.code) {
|
||||||
|
case 'EACCES':
|
||||||
|
console.error(bind + ' requires elevated privileges');
|
||||||
|
process.exit(1);
|
||||||
|
break;
|
||||||
|
case 'EADDRINUSE':
|
||||||
|
console.error(bind + ' is already in use');
|
||||||
|
process.exit(1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
11
packages/webshell/webshell-node/views/index.ejs
Normal file
11
packages/webshell/webshell-node/views/index.ejs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="referrer" content="no-referrer" />
|
||||||
|
<title>Web Shell</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="terminal"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
986
pnpm-lock.yaml
generated
986
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,2 +1,3 @@
|
|||||||
packages:
|
packages:
|
||||||
- 'packages/*'
|
- 'packages/*'
|
||||||
|
- '!packages/webshell/webshell-node'
|
Loading…
x
Reference in New Issue
Block a user