first api and websocket server
This commit is contained in:
parent
bd02a496d9
commit
dbb03e6325
2
.gitignore
vendored
2
.gitignore
vendored
@ -4,3 +4,5 @@ deployment.json
|
||||
*.js
|
||||
*.d.ts
|
||||
*.tsbuildinfo
|
||||
!webpack.config.js
|
||||
/webface/public/
|
||||
|
5359
package-lock.json
generated
5359
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@ -11,14 +11,22 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@squeebot/core": "^3.2.0",
|
||||
"@squeebot/core": "^3.3.4",
|
||||
"bcrypt": "^5.0.1",
|
||||
"express": "^4.17.1",
|
||||
"typescript": "^4.1.5",
|
||||
"ws": "^7.4.3"
|
||||
"ws": "^8.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.11",
|
||||
"@types/node": "^14.14.27",
|
||||
"@types/ws": "^7.4.0"
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/node": "^16.10.2",
|
||||
"@types/ws": "^8.2.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.3.2",
|
||||
"sass": "^1.42.1",
|
||||
"sass-loader": "^12.1.0",
|
||||
"ts-loader": "^9.2.6",
|
||||
"typescript": "^4.4.3",
|
||||
"webpack-cli": "^4.8.0"
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
{
|
||||
"main": "plugin.js",
|
||||
"name": "webface",
|
||||
"description": "Web interface plugin",
|
||||
"description": "Web interface WebSocket server plugin",
|
||||
"version": "1.0.0",
|
||||
"tags": [],
|
||||
"dependencies": [],
|
||||
"npmDependencies": ["ws@7.4.3", "express@4.17.1"]
|
||||
"dependencies": ["control"],
|
||||
"npmDependencies": ["ws@7.4.3", "express@4.17.1", "bcrypt@5.0.1"]
|
||||
}
|
||||
|
@ -2,34 +2,63 @@ import {
|
||||
Plugin,
|
||||
Configurable,
|
||||
EventListener,
|
||||
DependencyLoad
|
||||
DependencyLoad,
|
||||
DependencyUnload
|
||||
} from '@squeebot/core/lib/plugin';
|
||||
|
||||
import express from 'express';
|
||||
import express, { RequestHandler } from 'express';
|
||||
import http from 'http';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcrypt';
|
||||
import * as WebSocket from 'ws';
|
||||
|
||||
import { logger } from '@squeebot/core/lib/core';
|
||||
import { EventEmitter } from 'events';
|
||||
import { Socket } from 'net';
|
||||
|
||||
interface RegisteredUser {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface UserRequest {
|
||||
command: string;
|
||||
arguments?: string[];
|
||||
state?: string;
|
||||
}
|
||||
|
||||
interface UserResponse {
|
||||
status: 'OK' | 'ERROR';
|
||||
list?: any[];
|
||||
data?: any;
|
||||
command: string;
|
||||
state?: string;
|
||||
time?: number;
|
||||
}
|
||||
|
||||
class WebSocketServer extends EventEmitter {
|
||||
public app = express();
|
||||
public server = http.createServer(this.app);
|
||||
public wss = new WebSocket.Server({ server: this.server });
|
||||
public wss = new WebSocket.Server({ noServer: true });
|
||||
public running = false;
|
||||
|
||||
constructor(
|
||||
public port: number,
|
||||
public host: string)
|
||||
{
|
||||
public host: string,
|
||||
trustProxy = false,
|
||||
) {
|
||||
super();
|
||||
this.app.disable('x-powered-by');
|
||||
if (trustProxy) {
|
||||
this.app.set('trust proxy', 1);
|
||||
}
|
||||
}
|
||||
|
||||
public init(): void {
|
||||
this.server.listen(this.port, this.host, () => {
|
||||
logger.log('[webface] WebSocket server listening on %s:%s', this.host, this.port);
|
||||
this.running = true;
|
||||
this.server.listen(this.port, this.host,
|
||||
() => logger.log('Web server listening on %s:%s', this.host, this.port));
|
||||
});
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
@ -40,22 +69,392 @@ class WebSocketServer extends EventEmitter {
|
||||
}
|
||||
|
||||
class WebfaceServer extends WebSocketServer {
|
||||
private controlPlugin: any;
|
||||
private connections = new Map<string, WebSocket>();
|
||||
private issuedTokens = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
public port: number,
|
||||
public host: string)
|
||||
{
|
||||
super(port, host);
|
||||
public host: string,
|
||||
public authorizedURL: string,
|
||||
private users: RegisteredUser[],
|
||||
trustProxy = false,
|
||||
) {
|
||||
super(port, host, trustProxy);
|
||||
this.initializeWebSocket();
|
||||
this.initializeAPI();
|
||||
}
|
||||
|
||||
this.app.use(express.static(path.join(__dirname, 'public')));
|
||||
this.wss.on('connection', (ws) => {
|
||||
/**
|
||||
* Set the loaded control plugin and announce that it has been loaded
|
||||
* @param control Control plugin
|
||||
*/
|
||||
public setControlPlugin(control: any): void {
|
||||
this.controlPlugin = control;
|
||||
this.connections.forEach((ws) => {
|
||||
ws.send(JSON.stringify({
|
||||
status: 'OK_CONTROL',
|
||||
message: 'Control plugin has loaded again!'
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify websocket connections that the control plugin disappeared
|
||||
*/
|
||||
public controlPluginUnloaded(): void {
|
||||
this.controlPlugin = null;
|
||||
this.connections.forEach((ws) => {
|
||||
ws.send(JSON.stringify({
|
||||
status: 'MISSING_CONTROL',
|
||||
message: 'Control plugin has unloaded!'
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the username of a token or throw an error if missing or invalid
|
||||
* @param req HTTP connection
|
||||
* @returns Token owner
|
||||
*/
|
||||
public async authorizeAccessToken(req: http.IncomingMessage): Promise<string> {
|
||||
const authHead = req.headers.authorization;
|
||||
if (!authHead || !authHead.startsWith('Bearer')) {
|
||||
throw new Error('Authorization header missing');
|
||||
}
|
||||
|
||||
const owner = this.getTokenOwner(authHead.substring(7));
|
||||
|
||||
if (!owner) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
return owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.connections.forEach((ws, user) => {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
this.connections.clear();
|
||||
this.issuedTokens.clear();
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a token
|
||||
* @returns Middleware
|
||||
*/
|
||||
public tokenOwnerMiddleware(): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
const authHeader = req.get('authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer')) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthorized',
|
||||
error_message: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const tokenOwner = this.getTokenOwner(token);
|
||||
if (!tokenOwner) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthorized',
|
||||
error_message: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
res.locals.token = token;
|
||||
res.locals.user = tokenOwner;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a token's owner or undefined if invalid or missing
|
||||
* @param token Token from headers
|
||||
* @returns Token owner or undefined
|
||||
*/
|
||||
private getTokenOwner(token: string): string | undefined {
|
||||
const representative = [...this.issuedTokens.entries()]
|
||||
.filter(({ 1: v }) => v === token)
|
||||
.map(([k]) => k);
|
||||
|
||||
if (!representative?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return representative[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a status message with available commands
|
||||
* @param ws WebSocket client
|
||||
* @param username Client's username
|
||||
*/
|
||||
private sendStatus(ws: WebSocket, username: string): void {
|
||||
ws.send(JSON.stringify({
|
||||
status: this.controlPlugin ? 'OK' : 'MISSING_CONTROL',
|
||||
commands: this.controlPlugin?.listControlCommands() || [],
|
||||
user: {
|
||||
username,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle input from a WebSocket connection
|
||||
* @param raw WebSocket message
|
||||
* @param ws WebSocket client
|
||||
* @param user Client username
|
||||
*/
|
||||
private handleWSLine(raw: WebSocket.RawData, ws: WebSocket, user: string): void {
|
||||
const line = raw.toString('utf8');
|
||||
let jCmd: UserRequest;
|
||||
try {
|
||||
jCmd = JSON.parse(line);
|
||||
} catch (e: any) {
|
||||
ws.send(JSON.stringify({
|
||||
status: 'ERROR', message: e.message
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const { command, state } = jCmd;
|
||||
if (!command) {
|
||||
ws.send(JSON.stringify({
|
||||
status: 'ERROR',
|
||||
message: 'Unknown or missing command',
|
||||
command: '',
|
||||
state: jCmd.state,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'quit' || command === 'exit') {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'help' || command === 'status') {
|
||||
this.sendStatus(ws, user);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeStart = Date.now();
|
||||
this.controlPlugin.executeControlCommand(command, jCmd.arguments || []).then(
|
||||
(data: any) => {
|
||||
const response: UserResponse = {
|
||||
status: 'OK',
|
||||
command,
|
||||
state,
|
||||
time: Date.now() - timeStart,
|
||||
};
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
response.list = data;
|
||||
} else {
|
||||
response.data = data;
|
||||
}
|
||||
ws.send(JSON.stringify(response));
|
||||
}, (error: Error) => {
|
||||
ws.send(JSON.stringify({
|
||||
status: 'ERROR',
|
||||
message: error.message,
|
||||
command,
|
||||
state,
|
||||
time: Date.now() - timeStart,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for websocket connections
|
||||
*/
|
||||
private initializeWebSocket(): void {
|
||||
this.wss.on('connection', (ws: WebSocket, request: http.IncomingMessage, user: string) => {
|
||||
if (this.connections.has(user)) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('[webface] User %s connected via WebSocket', user);
|
||||
|
||||
this.sendStatus(ws, user);
|
||||
this.connections.set(user, ws);
|
||||
|
||||
ws.on('message', (data) => this.handleWSLine(data, ws, user));
|
||||
ws.on('close', () => {
|
||||
if (this.connections.has(user)) {
|
||||
this.connections.delete(user);
|
||||
}
|
||||
logger.log('[webface] User %s WebSocket disconnected', user);
|
||||
});
|
||||
});
|
||||
|
||||
this.server.on('upgrade', (req, socket, head) => {
|
||||
this.authorizeAccessToken(req).then((user) => {
|
||||
this.wss.handleUpgrade(req, socket as Socket, head, (ws) => {
|
||||
this.wss.emit('connection', ws, req, user);
|
||||
});
|
||||
}, () => {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the HTTP API
|
||||
*/
|
||||
private initializeAPI(): void {
|
||||
this.app.use(express.json() as RequestHandler);
|
||||
this.app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', this.authorizedURL);
|
||||
res.header('Access-Control-Allow-Methods', 'POST, GET, DELETE, OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
|
||||
res.header('Access-Control-Allow-Credentials', 'true');
|
||||
next();
|
||||
});
|
||||
|
||||
const router = express.Router();
|
||||
const authMiddleware = this.tokenOwnerMiddleware();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
status: 'OK',
|
||||
uptime: process.uptime(),
|
||||
connections: this.connections.size,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/squeebot', authMiddleware, (req, res) => {
|
||||
res.json({
|
||||
status: this.controlPlugin ? 'OK' : 'MISSING_CONTROL',
|
||||
uptime: process.uptime(),
|
||||
connections: this.connections.size,
|
||||
user: {
|
||||
username: res.locals.user,
|
||||
},
|
||||
commands: this.controlPlugin?.listControlCommands() || [],
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/authorize', (req, res) => {
|
||||
const { body: { username, password } } = req;
|
||||
const user = this.users.find(
|
||||
(userEntry) => userEntry.username.toLowerCase() === username.toLowerCase()
|
||||
);
|
||||
if (!user || !username || !password) {
|
||||
return res.status(401).json({
|
||||
error: 'invalid_username_password',
|
||||
error_message: 'Invalid username or password',
|
||||
});
|
||||
}
|
||||
|
||||
bcrypt.compare(password, user.password).then((success) => {
|
||||
if (!success) {
|
||||
return res.status(401).json({
|
||||
error: 'invalid_username_password',
|
||||
error_message: 'Invalid username or password',
|
||||
});
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(16).toString('hex');
|
||||
this.issuedTokens.set(username, token);
|
||||
res.json({
|
||||
username, token,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/introspect', authMiddleware, (req, res) => {
|
||||
res.json({ status: 'OK', message: 'Token valid', user: { username: res.locals.user } });
|
||||
});
|
||||
|
||||
router.delete('/token', authMiddleware, (req, res) => {
|
||||
const { user } = res.locals;
|
||||
this.issuedTokens.delete(user);
|
||||
const ws = this.connections.get(user);
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
res.json({ status: 'OK', message: 'Logged out' });
|
||||
});
|
||||
|
||||
router.post('/:command', authMiddleware, (req, res) => {
|
||||
if (!this.controlPlugin) {
|
||||
return res.status(500).json({
|
||||
error: 'control_plugin_unavailable',
|
||||
error_message: 'Control plugin is currently unavailable'
|
||||
});
|
||||
}
|
||||
|
||||
const command = req.params.command;
|
||||
const { body } = req;
|
||||
if (body?.arguments && !Array.isArray(body.arguments)) {
|
||||
return res.status(400).json({
|
||||
error: 'arguments_invalid',
|
||||
error_message: 'Arguments have to be passed as an array',
|
||||
});
|
||||
}
|
||||
|
||||
const timeStart = Date.now();
|
||||
this.controlPlugin.executeControlCommand(command, body?.arguments || []).then(
|
||||
(data: any) => {
|
||||
res.json({
|
||||
status: 'OK',
|
||||
data,
|
||||
time: Date.now() - timeStart,
|
||||
});
|
||||
}, (error: Error) => {
|
||||
res.status(400).json({
|
||||
error: `${command}_fail`,
|
||||
error_message: error.message,
|
||||
time: Date.now() - timeStart,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
this.app.use('/api/v1', router);
|
||||
}
|
||||
|
||||
public announcePluginLoaded(plugin: string): void {
|
||||
this.connections.forEach((ws) => {
|
||||
ws.send(JSON.stringify({
|
||||
status: 'PLUGIN_LOADED',
|
||||
message: `${plugin} has been loaded`,
|
||||
plugin,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
public announcePluginUnloaded(plugin: string): void {
|
||||
this.connections.forEach((ws) => {
|
||||
ws.send(JSON.stringify({
|
||||
status: 'PLUGIN_UNLOADED',
|
||||
message: `${plugin} has been unloaded`,
|
||||
plugin,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Configurable({
|
||||
port: 3000,
|
||||
host: 'localhost'
|
||||
port: 5511,
|
||||
host: 'localhost',
|
||||
trustedRemote: '*',
|
||||
trustProxy: false,
|
||||
users: [
|
||||
{
|
||||
username: 'squeeadmin',
|
||||
password: ''
|
||||
}
|
||||
]
|
||||
})
|
||||
class WebfacePlugin extends Plugin {
|
||||
private srv?: WebfaceServer;
|
||||
@ -63,15 +462,69 @@ class WebfacePlugin extends Plugin {
|
||||
public unloadEventHandler(plugin: string | Plugin): void {
|
||||
if (plugin === this.name || plugin === this) {
|
||||
this.srv?.destroy();
|
||||
this.config.save().then(() =>
|
||||
this.emit('pluginUnloaded', this));
|
||||
this.emit('pluginUnloaded', this);
|
||||
}
|
||||
}
|
||||
|
||||
@EventListener('pluginLoaded')
|
||||
public pluginLoadedEvent(plugin: string | Plugin): void {
|
||||
let pluginName = plugin as string;
|
||||
if (typeof plugin !== 'string') {
|
||||
pluginName = plugin.manifest.name;
|
||||
}
|
||||
this.srv?.announcePluginLoaded(pluginName);
|
||||
}
|
||||
|
||||
@EventListener('pluginUnloaded')
|
||||
public pluginUnloadedEvent(plugin: string | Plugin): void {
|
||||
let pluginName = plugin as string;
|
||||
if (typeof plugin !== 'string') {
|
||||
pluginName = plugin.manifest.name;
|
||||
}
|
||||
this.srv?.announcePluginUnloaded(pluginName);
|
||||
}
|
||||
|
||||
@DependencyLoad('control')
|
||||
public controlLoaded(control: any): void {
|
||||
if (this.srv) {
|
||||
this.srv.setControlPlugin(control);
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyUnload('control')
|
||||
public controlUnloaded(): void {
|
||||
if (this.srv) {
|
||||
this.srv.controlPluginUnloaded();
|
||||
}
|
||||
}
|
||||
|
||||
initialize(): void {
|
||||
const users = this.config.get('users', []) as RegisteredUser[];
|
||||
const defaultUser = users.find(
|
||||
(user) => user.username === 'squeeadmin'
|
||||
);
|
||||
|
||||
// Set default user password
|
||||
if (defaultUser && defaultUser.password === '') {
|
||||
const defaultPassword = 'squeeadmin13';
|
||||
bcrypt.hash(defaultPassword, 10).then((hashed) => {
|
||||
defaultUser.password = hashed;
|
||||
logger.warn('[webface] !!! The default user is "squeeadmin" and the password has been set to "squeeadmin13" !!!');
|
||||
logger.warn('[webface] !!! Change this password ASAP from the front-end of your choice !!!');
|
||||
this.config.set('users', users);
|
||||
this.config.save().catch((e) => logger.error('[webface] Failed to save users:', e.message));
|
||||
});
|
||||
}
|
||||
|
||||
// Create the server
|
||||
this.srv = new WebfaceServer(
|
||||
this.config.get('port') as number,
|
||||
this.config.get('host'));
|
||||
this.config.get('host'),
|
||||
this.config.get('trustedRemote', '*') as string,
|
||||
users,
|
||||
this.config.get('trustProxy', false),
|
||||
);
|
||||
|
||||
this.srv.init();
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Squeebot Webface</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in New Issue
Block a user