2021-07-03 17:51:36 +00:00
|
|
|
import {
|
|
|
|
Plugin,
|
|
|
|
Configurable,
|
|
|
|
EventListener,
|
2021-10-09 20:39:04 +00:00
|
|
|
DependencyLoad,
|
|
|
|
DependencyUnload
|
2021-07-03 17:51:36 +00:00
|
|
|
} from '@squeebot/core/lib/plugin';
|
|
|
|
|
2021-10-09 20:39:04 +00:00
|
|
|
import express, { RequestHandler } from 'express';
|
2021-07-03 17:51:36 +00:00
|
|
|
import http from 'http';
|
2021-10-09 20:39:04 +00:00
|
|
|
import crypto from 'crypto';
|
|
|
|
import bcrypt from 'bcrypt';
|
2021-07-03 17:51:36 +00:00
|
|
|
import * as WebSocket from 'ws';
|
|
|
|
|
|
|
|
import { logger } from '@squeebot/core/lib/core';
|
|
|
|
import { EventEmitter } from 'events';
|
2021-10-09 20:39:04 +00:00
|
|
|
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;
|
|
|
|
}
|
2021-07-03 17:51:36 +00:00
|
|
|
|
|
|
|
class WebSocketServer extends EventEmitter {
|
|
|
|
public app = express();
|
|
|
|
public server = http.createServer(this.app);
|
2021-10-09 20:39:04 +00:00
|
|
|
public wss = new WebSocket.Server({ noServer: true });
|
2021-07-03 17:51:36 +00:00
|
|
|
public running = false;
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
public port: number,
|
2021-10-09 20:39:04 +00:00
|
|
|
public host: string,
|
|
|
|
trustProxy = false,
|
|
|
|
) {
|
2021-07-03 17:51:36 +00:00
|
|
|
super();
|
2021-10-09 20:39:04 +00:00
|
|
|
this.app.disable('x-powered-by');
|
|
|
|
if (trustProxy) {
|
|
|
|
this.app.set('trust proxy', 1);
|
|
|
|
}
|
2021-07-03 17:51:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public init(): void {
|
2021-10-09 20:39:04 +00:00
|
|
|
this.server.listen(this.port, this.host, () => {
|
|
|
|
logger.log('[webface] WebSocket server listening on %s:%s', this.host, this.port);
|
|
|
|
this.running = true;
|
|
|
|
});
|
2021-07-03 17:51:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public destroy(): void {
|
|
|
|
this.running = false;
|
|
|
|
this.wss.close();
|
|
|
|
this.server.close();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class WebfaceServer extends WebSocketServer {
|
2021-10-09 20:39:04 +00:00
|
|
|
private controlPlugin: any;
|
|
|
|
private connections = new Map<string, WebSocket>();
|
|
|
|
private issuedTokens = new Map<string, string>();
|
|
|
|
|
2021-07-03 17:51:36 +00:00
|
|
|
constructor(
|
|
|
|
public port: number,
|
2021-10-09 20:39:04 +00:00
|
|
|
public host: string,
|
|
|
|
public authorizedURL: string,
|
|
|
|
private users: RegisteredUser[],
|
|
|
|
trustProxy = false,
|
|
|
|
) {
|
|
|
|
super(port, host, trustProxy);
|
|
|
|
this.initializeWebSocket();
|
|
|
|
this.initializeAPI();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
}
|
2021-07-03 17:51:36 +00:00
|
|
|
|
2021-10-09 20:39:04 +00:00
|
|
|
logger.log('[webface] User %s connected via WebSocket', user);
|
2021-07-03 17:51:36 +00:00
|
|
|
|
2021-10-09 20:39:04 +00:00
|
|
|
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,
|
|
|
|
}));
|
2021-07-03 17:51:36 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@Configurable({
|
2021-10-09 20:39:04 +00:00
|
|
|
port: 5511,
|
|
|
|
host: 'localhost',
|
|
|
|
trustedRemote: '*',
|
|
|
|
trustProxy: false,
|
|
|
|
users: [
|
|
|
|
{
|
|
|
|
username: 'squeeadmin',
|
|
|
|
password: ''
|
|
|
|
}
|
|
|
|
]
|
2021-07-03 17:51:36 +00:00
|
|
|
})
|
|
|
|
class WebfacePlugin extends Plugin {
|
|
|
|
private srv?: WebfaceServer;
|
|
|
|
@EventListener('pluginUnload')
|
|
|
|
public unloadEventHandler(plugin: string | Plugin): void {
|
|
|
|
if (plugin === this.name || plugin === this) {
|
|
|
|
this.srv?.destroy();
|
2021-10-09 20:39:04 +00:00
|
|
|
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();
|
2021-07-03 17:51:36 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
initialize(): void {
|
2021-10-09 20:39:04 +00:00
|
|
|
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
|
2021-07-03 17:51:36 +00:00
|
|
|
this.srv = new WebfaceServer(
|
|
|
|
this.config.get('port') as number,
|
2021-10-09 20:39:04 +00:00
|
|
|
this.config.get('host'),
|
|
|
|
this.config.get('trustedRemote', '*') as string,
|
|
|
|
users,
|
|
|
|
this.config.get('trustProxy', false),
|
|
|
|
);
|
|
|
|
|
2021-07-03 17:51:36 +00:00
|
|
|
this.srv.init();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = WebfacePlugin;
|