From ecb8c2a69c6e7b87814b2c901f51281a1641ce19 Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Sat, 8 Apr 2023 13:45:59 +0300 Subject: [PATCH] generic socket plugin, control uses it, simplecommands rate limit implementation --- control/plugin.json | 4 +- control/plugin.ts | 334 +++++++++++++---------------- simplecommands/plugin.ts | 100 ++++++++- socket/plugin.json | 9 + socket/plugin.ts | 438 +++++++++++++++++++++++++++++++++++++++ socket/proto.ts.bak | 101 +++++++++ squeebot.repo.json | 4 + 7 files changed, 790 insertions(+), 200 deletions(-) create mode 100644 socket/plugin.json create mode 100644 socket/plugin.ts create mode 100644 socket/proto.ts.bak diff --git a/control/plugin.json b/control/plugin.json index ffcbeea..e38e72f 100644 --- a/control/plugin.json +++ b/control/plugin.json @@ -3,7 +3,7 @@ "name": "control", "description": "Squeebot Plugin Management API and sockets", "tags": ["api", "control", "management"], - "version": "0.1.3", - "dependencies": [], + "version": "0.2.0", + "dependencies": ["socket"], "npmDependencies": [] } diff --git a/control/plugin.ts b/control/plugin.ts index 6ea00c9..a97bb51 100644 --- a/control/plugin.ts +++ b/control/plugin.ts @@ -5,16 +5,18 @@ import { Configurable, IPluginManifest, IPlugin, + DependencyLoad, } from '@squeebot/core/lib/plugin'; import { IChannel } from '@squeebot/core/lib/channel'; -import { IRepoPluginDef, IRepository } from '@squeebot/core/lib/plugin/repository'; +import { + IRepoPluginDef, + IRepository, +} from '@squeebot/core/lib/plugin/repository'; import { ISqueebotCore, logger } from '@squeebot/core/lib/core'; import path from 'path'; import fs from 'fs/promises'; -import tls, { TLSSocket } from 'tls'; -import net, { Server, Socket } from 'net'; interface ControlCommand { execute: (p: ControlPlugin, ...args: any[]) => Promise; @@ -22,6 +24,18 @@ interface ControlCommand { plugin: string; } +interface SocketMessage { + [x: string]: unknown; + command?: string; + id?: string; + status?: string; + arguments?: unknown[]; + list?: unknown[]; + data?: unknown; +} + +type ReplyFn = (message: SocketMessage) => void; + let controlCommands: ControlCommand[] = [ { name: 'loadPlugin', @@ -60,7 +74,10 @@ let controlCommands: ControlCommand[] = [ { name: 'installPlugin', plugin: 'control', - execute: async (p: ControlPlugin, plugin: string): Promise => { + execute: async ( + p: ControlPlugin, + plugin: string + ): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } @@ -142,7 +159,10 @@ let controlCommands: ControlCommand[] = [ { name: 'listRepositoryPlugins', plugin: 'control', - execute: async (p: ControlPlugin, repo: string): Promise => { + execute: async ( + p: ControlPlugin, + repo: string + ): Promise => { if (!repo) { throw new Error('This function takes 1 argument.'); } @@ -163,7 +183,10 @@ let controlCommands: ControlCommand[] = [ { name: 'updateRepository', plugin: 'control', - execute: async (p: ControlPlugin, repo: string): Promise => { + execute: async ( + p: ControlPlugin, + repo: string + ): Promise => { if (!repo) { throw new Error('This function takes 1 argument.'); } @@ -177,7 +200,11 @@ let controlCommands: ControlCommand[] = [ { name: 'newChannel', plugin: 'control', - execute: async (p: ControlPlugin, name: string, plugins?: string[]): Promise => { + execute: async ( + p: ControlPlugin, + name: string, + plugins?: string[] + ): Promise => { if (!name) { throw new Error('This function takes 1 argument.'); } @@ -244,7 +271,11 @@ let controlCommands: ControlCommand[] = [ { name: 'addChannelPlugin', plugin: 'control', - execute: async (p: ControlPlugin, name: string, plugins: string | string[]): Promise => { + execute: async ( + p: ControlPlugin, + name: string, + plugins: string | string[] + ): Promise => { if (!name || !plugins) { throw new Error('This function takes 2 arguments.'); } @@ -265,7 +296,11 @@ let controlCommands: ControlCommand[] = [ { name: 'removeChannelPlugin', plugin: 'control', - execute: async (p: ControlPlugin, name: string, plugins: string | string[]): Promise => { + execute: async ( + p: ControlPlugin, + name: string, + plugins: string | string[] + ): Promise => { if (!name || !plugins) { throw new Error('This function takes 2 arguments.'); } @@ -308,7 +343,11 @@ let controlCommands: ControlCommand[] = [ { name: 'getPluginConfigValue', plugin: 'control', - execute: async (p: ControlPlugin, name: string, key: string): Promise => { + execute: async ( + p: ControlPlugin, + name: string, + key: string + ): Promise => { if (!name || !key) { throw new Error('This function takes 2 arguments.'); } @@ -332,7 +371,11 @@ let controlCommands: ControlCommand[] = [ { name: 'setPluginConfig', plugin: 'control', - execute: async (p: ControlPlugin, name: string, config: any): Promise => { + execute: async ( + p: ControlPlugin, + name: string, + config: any + ): Promise => { if (!name || !config) { throw new Error('This function takes 2 arguments.'); } @@ -347,7 +390,12 @@ let controlCommands: ControlCommand[] = [ { name: 'setPluginConfigValue', plugin: 'control', - execute: async (p: ControlPlugin, name: string, key: string, value: string): Promise => { + execute: async ( + p: ControlPlugin, + name: string, + key: string, + value: string + ): Promise => { if (!name || !key) { throw new Error('This function takes 3 arguments.'); } @@ -361,26 +409,6 @@ let controlCommands: ControlCommand[] = [ }, ]; -declare type StringAny = {[key: string]: any}; -const match = ['key', 'cert', 'ca', 'dhparam', 'crl', 'pfx']; -async function parseTLSConfig(tlsconfig: StringAny): Promise { - const result: StringAny = {}; - for (const key in tlsconfig) { - if (key === 'enabled') { - continue; - } - if (match.indexOf(key) === -1) { - result[key] = tlsconfig[key]; - continue; - } - - const value = path.resolve(tlsconfig[key]); - const bf = await fs.readFile(value); - result[key] = bf; - } - return result; -} - @Configurable({ authorizedIPs: [], tls: { @@ -394,16 +422,15 @@ async function parseTLSConfig(tlsconfig: StringAny): Promise { }) class ControlPlugin extends Plugin { public core: ISqueebotCore | null = null; - public plugins = new Map(); - private server: Server | null = null; - private sockets = new Set(); + public plugins = new Map(); public initialize(): void { - this.addEventListener('core', (core: ISqueebotCore) => this.core = core); + this.addEventListener('core', (core: ISqueebotCore) => (this.core = core)); this.emitTo('core', 'request-core', this.name); - this.createSocket(); - this.getPluginSchema(this.name).catch( - () => logger.error('[control] How embarrasing! control could not load it\'s own schema!') + this.getPluginSchema(this.name).catch(() => + logger.error( + '[control] How embarrasing! control could not load it\'s own schema!' + ) ); } @@ -412,7 +439,7 @@ class ControlPlugin extends Plugin { * @param name Plugin name * @returns Plugin schema */ - public async loadPluginSchema(name: string): Promise { + public async loadPluginSchema(name: string): Promise { if (!this.core) { throw new Error('control could not access the core.'); } @@ -425,11 +452,15 @@ class ControlPlugin extends Plugin { const fileRead = await fs.readFile(schemaPath, { encoding: 'utf8' }); schema = JSON.parse(fileRead); } catch (e: any) { - throw new Error('No schema file found, it is not accessible or is not valid JSON.'); + throw new Error( + 'No schema file found, it is not accessible or is not valid JSON.' + ); } if (!schema.type) { - throw new Error('Schema does not specify what type of object it is referencing.'); + throw new Error( + 'Schema does not specify what type of object it is referencing.' + ); } this.plugins.set(name, schema); @@ -441,7 +472,7 @@ class ControlPlugin extends Plugin { * @param name Plugin name * @param confspec Static schema */ - public registerPluginConfigSchema(name: string, confspec?: any): void { + public registerPluginConfigSchema(name: string, confspec?: unknown): void { this.plugins.set(name, confspec); } @@ -451,7 +482,7 @@ class ControlPlugin extends Plugin { * @returns Schema * @throws Error if schema is not found or is invalid */ - public async getPluginSchema(name: string): Promise { + public async getPluginSchema(name: string): Promise { if (this.plugins.has(name)) { return this.plugins.get(name); } @@ -465,11 +496,14 @@ class ControlPlugin extends Plugin { * @param args Control command arguments * @returns Control command response */ - public async executeControlCommand(command: string, args: string[]): Promise { + public async executeControlCommand( + command: string, + args: string[] + ): Promise { if (!this.core) { throw new Error('The control plugin cannot control the bot right now.'); } - const cmdobj = controlCommands.find(k => k.name === command); + const cmdobj = controlCommands.find((k) => k.name === command); if (!cmdobj || !cmdobj.execute) { throw new Error('No such command'); } @@ -484,11 +518,14 @@ class ControlPlugin extends Plugin { if (!obj.execute || !obj.name || !obj.plugin) { throw new Error('Invalid command object.'); } - const exists = controlCommands.find(k => k.name === obj.name); + + const exists = controlCommands.find((k) => k.name === obj.name); if (exists) { throw new Error('Control commands should not be overwritten.'); } + controlCommands.push(obj); + logger.log('[%s] registered control command', this.name, obj.name); } @@ -501,15 +538,6 @@ class ControlPlugin extends Plugin { if (plugin === this.name || plugin === this) { logger.debug('[%s]', this.name, 'shutting down..'); - if (this.server) { - logger.log('[%s] Stopping socket server..', this.name); - this.server.close(); - for (const sock of this.sockets) { - sock.destroy(); - } - this.sockets.clear(); - } - this.plugins.clear(); this.emit('pluginUnloaded', this); } @@ -521,152 +549,70 @@ class ControlPlugin extends Plugin { plugin = plugin.manifest.name; } this.plugins.delete(plugin); - controlCommands = controlCommands.filter(k => k.plugin !== plugin); + controlCommands = controlCommands.filter((k) => k.plugin !== plugin); } - private errorToClient(socket: TLSSocket | Socket, error: Error): void { - socket.write(JSON.stringify({ - status: 'ERROR', - message: error.message, - }) + '\r\n'); + @DependencyLoad('socket') + public socketDepLoaded(socketAPI: any) { + const config = this.config.config; + socketAPI + .createServer({ + name: 'control', + plugin: this.name, + ...config, + }) + .then((server: any) => { + server.on('message', (...args: [SocketMessage, never, ReplyFn]) => + this.handleClientLine(...args) + ); + }) + .catch((err: Error) => + logger.error('[%s] Failed to initialize: ', this.name, err.message) + ); } - private handleClientLine(socket: TLSSocket | Socket, req: any): void { - if (!req.command || req.command === 'status') { - socket.write(JSON.stringify({ - status: 'OK' - }) + '\r\n'); - return; - } + private handleClientLine( + message: SocketMessage, + sender: never, + reply: ReplyFn + ): void { + if (!message.command) return; + this.executeControlCommand( + message.command, + (message.arguments as string[]) || [] + ).then( + (cmdData) => { + try { + const response: SocketMessage = { + status: 'OK', + command: message.command, + id: message.id + }; - if (req.command === 'quit') { - socket.end(); - return; - } - - let args = []; - const argField = req.args || req.argument || req.arguments; - if (argField) { - if (!Array.isArray(argField)) { - args = [argField]; - } else { - args = argField; - } - } - - this.executeControlCommand(req.command, args).then((cmdData) => { - try { - const response: any = { status: 'OK', command: req.command }; - if (cmdData != null) { - if (Array.isArray(cmdData)) { - response.list = cmdData; - } else { - response.data = cmdData; - } - } - socket.write(JSON.stringify(response) + '\r\n'); - } catch (e: any) { - this.errorToClient(socket, e); - } - }, (e) => this.errorToClient(socket, e)); - } - - private handleIncoming(socket: TLSSocket | Socket): void { - const c = this.config.config; - let addr = socket.remoteAddress; - if (addr?.indexOf('::ffff:') === 0) { - addr = addr.substr(7); - } - if (c.authorizedIPs && - c.authorizedIPs.length && - c.authorizedIPs.indexOf(addr) === -1) { - if (!(c.authorizedIPs.indexOf('localhost') !== -1 && - (addr === '::1' || addr === '127.0.0.1'))) { - logger.warn('[%s] Unauthorized connection made from %s', - this.name, addr); - socket.destroy(); - return; - } - } - - this.sockets.add(socket); - - socket.once('end', () => { - logger.log('[%s] Client from %s disconnected.', this.name, addr); - this.sockets.delete(socket); - }); - - logger.log('[%s] Client from %s connected.', this.name, addr); - socket.setEncoding('utf8'); - socket.write(JSON.stringify({ - status: 'OK', - commands: controlCommands.map(k => k.name), - }) + '\r\n'); - - socket.on('data', (data) => { - try { - const split = data.split('\r\n'); - for (const chunk of split) { - if (chunk === '') { - continue; + if (cmdData != null) { + if (Array.isArray(cmdData)) { + response.list = cmdData; + } else { + response.data = cmdData; + } } - const req = JSON.parse(chunk); - this.handleClientLine(socket, req); + reply(response); + } catch (error) { + reply({ + status: 'ERROR', + arguments: [(error as Error).message], + id: message.id + }); } - } catch (e: any) { - this.errorToClient(socket, e); - } - }); - } - - private createSocket(): void { - const c = this.config.config; - if (c.bind == null || - c.bind === false) { - return; - } - - if (c.tls && c.tls.enabled) { - if (!c.tls.rejectUnauthorized && (!c.authorizedIPs || !c.authorizedIPs.length)) { - logger.warn('[%s] [SECURITY WARNING] !!! YOUR CONTROL SOCKET IS (STILL) INSECURE !!!', this.name); - logger.warn('[%s] [SECURITY WARNING] You have enabled TLS, ' + - 'but you do not have any form of access control configured.', this.name); - logger.warn('[%s] [SECURITY WARNING] In order to secure your control socket, ' + - 'either enable invalid certificate rejection (rejectUnauthorized) or set a ' + - 'list of authorized IPs.', this.name); - logger.warn('[%s] [SECURITY WARNING] !!! YOUR CONTROL SOCKET IS (STILL) INSECURE !!!', this.name); - } - parseTLSConfig(c.tls).then((options) => { - this.server = tls.createServer(options, - (socket) => this.handleIncoming(socket)); - this.server.on('error', - (e) => logger.error('[%s] Secure socket error:', e.message)); - this.server.listen(c.bind, () => { - logger.log('[%s] Secure socket listening on %s', - this.name, c.bind.toString()); - }); - }, (err) => { - logger.error('[%s] Secure socket listen failed: %s', - this.name, err.message); - }); - return; - } - - if (!c.authorizedIPs || !c.authorizedIPs.length) { - logger.warn('[%s] [SECURITY WARNING] !!! YOUR CONTROL SOCKET IS INSECURE !!!', this.name); - logger.warn('[%s] [SECURITY WARNING] You do not have any form of access control configured.', this.name); - logger.warn('[%s] [SECURITY WARNING] In order to secure your control socket, ' + - 'either enable TLS with certificate verification or set a list of authorized IPs.', this.name); - logger.warn('[%s] [SECURITY WARNING] !!! YOUR CONTROL SOCKET IS INSECURE !!!', this.name); - } - - this.server = net.createServer((socket) => this.handleIncoming(socket)); - this.server.on('error', (e) => logger.error('[%s] Socket error:', e.message)); - this.server.listen(c.bind, () => { - logger.log('[%s] Socket listening on %s', - this.name, c.bind.toString()); - }); + }, + (error) => + reply({ + status: 'ERROR', + arguments: [(error as Error).message], + id: message.id + }) + ); } } diff --git a/simplecommands/plugin.ts b/simplecommands/plugin.ts index 152e5ae..8114057 100644 --- a/simplecommands/plugin.ts +++ b/simplecommands/plugin.ts @@ -38,7 +38,7 @@ interface CommandSpec { interface RateLimit { rooms: string[]; - perSecond: number; + rate: number; cooldown: number; } @@ -61,7 +61,7 @@ class SqueebotCommandsAPIPlugin extends Plugin { private permissions: any = null; public getRateLimit(room: string): RateLimit | null { - for (const rm of this.config.get('rateLimits', [])) { + for (const rm of this.config.get('rateLimits', [])) { if (rm.rooms && (rm.rooms.indexOf(room) !== -1 || rm.rooms.indexOf('*') !== -1)) { return rm; } @@ -86,7 +86,7 @@ class SqueebotCommandsAPIPlugin extends Plugin { const r = rates[sender]; if (r.lastMessage > Date.now() - rl.cooldown) { - if (r.messages >= rl.perSecond) { + if (r.messages >= rl.rate) { // Rate limited return true; } @@ -101,6 +101,56 @@ class SqueebotCommandsAPIPlugin extends Plugin { return false; } + public setRateLimit(room: string, rate: number, cooldown: number): void { + const basis = this.config.get('rateLimits', []) as RateLimit[]; + const found = basis.find((item) => item.rooms.includes(room)); + const resave = () => { + this.config.set('rateLimits', basis); + this.config.save(); + }; + + const addNew = () => { + const newRate: RateLimit = { + rate, cooldown, rooms: [room] + }; + + basis.push(newRate); + resave(); + }; + + if (found) { + if (rate === 0 || (rate !== found.rate || cooldown !== found.cooldown)) { + if (rate === 0 || found.rooms.length > 1) { + found.rooms.splice(found.rooms.indexOf(room), 1); + } + + if (rate === 0) { + if (found.rooms.length === 0) { + basis.splice(basis.indexOf(found), 1); + } + + resave(); + return; + } + + addNew(); + return; + } + + if (found.rooms.length > 1) { + addNew(); + return; + } + + found.rate = rate; + found.cooldown = cooldown; + resave(); + return; + } + + addNew(); + } + private roomMatcher(msg: IMessage, specList: CommandSpec[]): CommandSpec[] { const roomMatches = []; @@ -248,7 +298,7 @@ class SqueebotCommandsAPIPlugin extends Plugin { } let allowedPlugins: string[] = []; - if (chan && this.config.get('channelMatching', false) === true) { + if (chan && this.config.get('channelMatching', false) === true) { allowedPlugins = chan.plugins; } @@ -572,6 +622,48 @@ class SqueebotCommandsAPIPlugin extends Plugin { return true; } }); + + this.registerCommand({ + plugin: this.name, + name: 'ratelimit', + usage: ' ', + description: 'Set command rate limits in the current room', + hidden: true, + permissions: ['room.rate-limit'], + execute: async ( + msg: IMessage, + msr: MessageResolver, + spec: CommandSpec, + prefix: string, + ...args: any[] + ): Promise => { + if (!args.length) { + const rl = this.getRateLimit(msg.target?.id as string); + if (!rl) { + msg.resolve(`There are no rate limits enabled for ${msg.target?.name}.`); + return true; + } + + const isGlobal = rl.rooms.includes('*'); + msg.resolve( + `Active rate limit in ${msg.target?.name}${isGlobal ? ' (global)' : ''}: ${rl.rate} messages in ${rl.cooldown}` + ); + + return true; + } + + const [count, cooldown] = args; + try { + this.setRateLimit(msg.target?.id as string, parseInt(count, 10), parseInt(cooldown, 10)); + } catch (e: any) { + msg.resolve('Setting rate limit failed:', e.message); + return true; + } + + msg.resolve('Rate limit has been configured.'); + return true; + } + }); } @DependencyLoad('permissions') diff --git a/socket/plugin.json b/socket/plugin.json new file mode 100644 index 0000000..3729b96 --- /dev/null +++ b/socket/plugin.json @@ -0,0 +1,9 @@ +{ + "main": "plugin.js", + "name": "socket", + "description": "General-purpose socket server API and service", + "tags": ["api"], + "version": "0.0.1", + "dependencies": [], + "npmDependencies": [] +} diff --git a/socket/plugin.ts b/socket/plugin.ts new file mode 100644 index 0000000..82c1997 --- /dev/null +++ b/socket/plugin.ts @@ -0,0 +1,438 @@ +import { + Plugin, + EventListener, +} from '@squeebot/core/lib/plugin'; + +import { logger } from '@squeebot/core/lib/core'; + +import path from 'path'; +import fs from 'fs/promises'; +import tls, { TLSSocket } from 'tls'; +import net, { Server, Socket } from 'net'; +import { randomBytes } from 'crypto'; +import EventEmitter from 'events'; + +interface TLSConfiguration { + [x: string]: string | boolean | undefined; + enabled: boolean; + key?: string; + cert?: string; + ca?: string; + dhparam?: string; + crl?: string; + pfx?: string; + rejectUnauthorized: boolean; + requestCert: boolean; +} + +interface SocketMessage { + [x: string]: unknown; + command?: string; + id?: string; + status?: string; + arguments?: unknown[]; +} + +interface SocketConfiguration { + name: string; + plugin?: string; + authorizedIPs: string[]; + tls?: TLSConfiguration; + bind: number | string | null | false; +} + +interface PluginConfiguration { + sockets: SocketConfiguration[]; +} + +type SocketWithID = (Socket | TLSSocket) & { id: string }; + +const makeID = () => randomBytes(8).toString('hex'); + +class SocketServer extends EventEmitter { + private server: Server | null = null; + private sockets = new Set(); + private intents = new Map(); + public alive = false; + + constructor(public config: SocketConfiguration) { + super(); + } + + get logName() { + return `socket:${this.config.name}`; + } + + public stop() { + if (this.server) { + logger.log('[%s] Stopping socket server..', this.logName); + this.server.close(); + for (const sock of this.sockets) { + sock.destroy(); + } + this.sockets.clear(); + this.intents.clear(); + } + this.emit('close'); + this.alive = false; + } + + public async start() { + if (this.alive) { + this.stop(); + } + await this.createSocket(); + } + + public send(message: SocketMessage): void { + this.sockets.forEach((socket) => this.sendToClient(socket, message)); + } + + public sendToID(id: string, message: SocketMessage): void { + const findClient = Array.from(this.sockets.values()).find( + (client) => client.id === id + ); + if (!findClient) return; + this.sendToClient(findClient, message); + } + + public sendIntent(intent: string, message: SocketMessage): void { + for (const [id, clientintent] of this.intents) { + if (clientintent !== intent) continue; + this.sendToID(id, message); + } + } + + private sendToClient(socket: SocketWithID, message: SocketMessage): void { + socket.write(JSON.stringify(message) + '\r\n'); + } + + private handleClientLine(socket: SocketWithID, req: SocketMessage): void { + if (!req.command || req.command === 'status') { + socket.write( + JSON.stringify({ + id: socket.id, + status: 'OK', + }) + '\r\n' + ); + return; + } + + // Client requests graceful close + if (req.command === 'quit') { + socket.end(); + this.intents.delete(socket.id); + this.emit('disconnect', socket.id); + return; + } + + // Parse argument list + let args = []; + const argField = req.args || req.argument || req.arguments; + if (argField) { + if (!Array.isArray(argField)) { + args = [argField]; + } else { + args = argField; + } + } + + // Set or return the intent + if (req.command === 'intent') { + if (args[0]) { + this.intents.set(socket.id, args[0]); + this.emit('intent', socket.id, args[0]); + } + + this.sendToClient(socket, { + id: socket.id, + command: 'intent', + arguments: [this.intents.get(socket.id)], + }); + + return; + } + + try { + const intent = this.intents.get(socket.id); + this.emit( + 'message', + { ...req, arguments: args }, + { id: socket.id, intent }, + (response: SocketMessage) => { + socket.write(JSON.stringify(response) + '\r\n'); + } + ); + } catch (err) { + logger.error( + `[%s] Error in message handler: ${(err as Error).stack}`, + this.logName + ); + } + } + + private handleIncoming(incoming: Socket | TLSSocket): void { + const socket = incoming as SocketWithID; + socket.id = makeID(); + const c = this.config; + let addr = socket.remoteAddress; + if (addr?.indexOf('::ffff:') === 0) { + addr = addr.substring(7); + } + + if ( + addr && + c.authorizedIPs?.length && + c.authorizedIPs.indexOf(addr) === -1 + ) { + if ( + !( + c.authorizedIPs.indexOf('localhost') !== -1 && + (addr === '::1' || addr === '127.0.0.1') + ) + ) { + logger.warn( + '[%s] Unauthorized connection made from %s', + this.logName, + addr + ); + socket.destroy(); + return; + } + } + + this.sockets.add(socket); + + socket.once('end', () => { + logger.log('[%s] Client from %s (%s) disconnected.', this.logName, addr, socket.id); + this.sockets.delete(socket); + this.intents.delete(socket.id); + }); + + logger.log('[%s] Client from %s (%s) connected.', this.logName, addr, socket.id); + socket.setEncoding('utf8'); + socket.write( + JSON.stringify({ + id: socket.id, + status: 'OK', + }) + '\r\n' + ); + + socket.on('data', (data) => { + try { + const split = data.split('\r\n'); + for (const chunk of split) { + if (chunk === '') { + continue; + } + + const req = JSON.parse(chunk); + this.handleClientLine(socket, req); + } + } catch (error) { + this.sendToClient(socket, { + status: 'ERROR', + arguments: [(error as Error).message], + }); + } + }); + + this.emit('connect', socket.id); + } + + private createSocket(): Promise { + const c = this.config; + if (c.bind == null || c.bind === false) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + if (c.tls && c.tls.enabled) { + if ( + !c.tls.rejectUnauthorized && + (!c.authorizedIPs || !c.authorizedIPs.length) + ) { + logger.warn( + '[%s] [SECURITY WARNING] !!! YOUR CONTROL SOCKET IS (STILL) INSECURE !!!', + this.logName + ); + logger.warn( + '[%s] [SECURITY WARNING] You have enabled TLS, ' + + 'but you do not have any form of access control configured.', + this.logName + ); + logger.warn( + '[%s] [SECURITY WARNING] In order to secure your control socket, ' + + 'either enable invalid certificate rejection (rejectUnauthorized) or set a ' + + 'list of authorized IPs.', + this.logName + ); + logger.warn( + '[%s] [SECURITY WARNING] !!! YOUR CONTROL SOCKET IS (STILL) INSECURE !!!', + this.logName + ); + } + parseTLSConfig(c.tls).then( + (options) => { + this.server = tls.createServer(options, (socket) => + this.handleIncoming(socket) + ); + this.server.on('error', (e) => { + logger.error('[%s] Secure socket error:', e.message); + if (!this.alive) reject(e); + }); + this.server.listen(c.bind, () => { + logger.log( + '[%s] Secure socket listening on %s', + this.logName, + c.bind?.toString() + ); + this.alive = true; + resolve(); + }); + }, + (err) => { + logger.error( + '[%s] Secure socket listen failed: %s', + this.logName, + err.message + ); + reject(err); + } + ); + return; + } + + if (!c.authorizedIPs || !c.authorizedIPs.length) { + logger.warn( + '[%s] [SECURITY WARNING] !!! YOUR SOCKET IS INSECURE !!!', + this.logName + ); + logger.warn( + '[%s] [SECURITY WARNING] You do not have any form of access control configured.', + this.logName + ); + logger.warn( + '[%s] [SECURITY WARNING] In order to secure your control socket, ' + + 'either enable TLS with certificate verification or set a list of authorized IPs.', + this.logName + ); + logger.warn( + '[%s] [SECURITY WARNING] !!! YOUR SOCKET IS INSECURE !!!', + this.logName + ); + } + + this.server = net.createServer((socket) => this.handleIncoming(socket)); + this.server.on('error', (e) => { + logger.error('[%s] Socket error:', e.message); + if (!this.alive) reject(e); + }); + this.server.listen(c.bind, () => { + logger.log( + '[%s] Socket listening on %s', + this.logName, + c.bind?.toString() + ); + this.alive = true; + resolve(); + }); + }); + } +} + +const match = ['key', 'cert', 'ca', 'dhparam', 'crl', 'pfx']; +async function parseTLSConfig( + tlsconfig: TLSConfiguration +): Promise> { + const result: Record = {}; + for (const key in tlsconfig) { + if (key === 'enabled') { + continue; + } + + if (match.indexOf(key) === -1) { + result[key] = tlsconfig[key]; + continue; + } + + const value = path.resolve(tlsconfig[key] as string); + const bf = await fs.readFile(value); + result[key] = bf; + } + + return result; +} + +class SocketPlugin extends Plugin { + private plugins = new Map(); + private servers = new Map(); + + /** Create an unique 8-bit ID */ + public makeID = makeID; + + /** + * Kill a socket server by name + * @param name Socket server name + */ + public killServer(name: string) { + if (!this.servers.has(name)) return; + this.servers.get(name)?.stop(); + this.servers.delete(name); + } + + /** + * Create a socket server + * @param config Socket Server configuration + * @param start Start the socket automatically on creation + * @returns Socket Server + */ + public async createServer(config: SocketConfiguration, start = true) { + if (this.servers.has(config.name)) return; + const server = new SocketServer(config); + this.servers.set(config.name, server); + if (start) await server.start(); + return server; + } + + /** + * Get an existing socket server by name + * @param name Socket Server name + * @returns Existing Socket Server or undefined + */ + public getServer(name: string) { + return this.servers.get(name); + } + + @EventListener('pluginUnload') + public unloadEventHandler(plugin: string | Plugin): void { + if (plugin === this.name || plugin === this) { + logger.debug('[%s]', this.name, 'shutting down..'); + + for (const [key] of this.servers) { + this.killServer(key); + } + + this.plugins.clear(); + this.emit('pluginUnloaded', this); + } + } + + @EventListener('pluginUnloaded') + public unloadedEventHandler(plugin: string | Plugin): void { + if (typeof plugin !== 'string') { + plugin = plugin.manifest.name; + } + this.plugins.delete(plugin); + this.killPluginServers(plugin); + } + + private killPluginServers(plugin: string) { + for (const [name, server] of this.servers) { + if (!server.config.plugin || server.config.plugin !== plugin) continue; + this.killServer(name); + } + } +} + +module.exports = SocketPlugin; diff --git a/socket/proto.ts.bak b/socket/proto.ts.bak new file mode 100644 index 0000000..23b7d31 --- /dev/null +++ b/socket/proto.ts.bak @@ -0,0 +1,101 @@ +import { EMessageType, Formatter, IMessage, Protocol } from '@squeebot/core'; + +class VirtualChatProtocol extends Protocol { + public format = new Formatter(false, false); + public type = 'VirtualChatProtocol'; + + public me = { + name: 'Squeebot', + id: `socket/${this.serverName}/Squeebot` + }; + + get serverName() { + return this.config.name; + } + + get server() { + const plugin = this.plugin; + return plugin.getServer(this.serverName); + } + + public start(...args: any[]): void { + this.server?.addListener('message', this.boundMessageHandler); + this.server?.addListener('close', this.stopHandler); + } + + public stop(force?: boolean | undefined): void { + this.server?.removeListener('message', this.boundMessageHandler); + this.server?.removeListener('close', this.stopHandler); + } + + private messageHandler( + message: SocketMessage, + sender: { id: string; intent?: string }, + reply: replyFn + ) { + if (message.command !== 'event') return; + const [eventName, ...argv] = message.arguments as string[]; + const newMessage = { + id: message.id || makeID(), + type: eventName as EMessageType, + data: message, + text: argv.join(' '), + source: this, + time: new Date(), + target: { id: sender.id, name: sender.intent || sender.id }, + sender: { id: sender.id, name: sender.intent || sender.id }, + fullSenderID: `socket/${this.serverName}/${sender.id}`, + fullRoomID: `socket/${this.serverName}/${sender.id}`, + resolved: false, + direct: true, + resolve: (...data: any[]) => { + newMessage.resolved = true; + const response = this.parseMessage(...data); + if (!response || !this.server?.alive) return; + reply({ + id: newMessage.id, + command: 'message', + arguments: ['message', response], + }); + }, + reject: (error) => + reply({ + id: newMessage.id, + status: 'ERROR', + arguments: [error], + }), + mention: (target) => `${target.name}, `, + } as IMessage; + + this.plugin.stream.emitTo( + 'channel', + eventName !== 'message' ? 'event' : 'message', + newMessage + ); + } + + private parseMessage(...data: any[]): string | null { + let response = util.format(data[0], ...data.slice(1)); + if (!response) { + return null; + } + + if (Array.isArray(data[0])) { + try { + response = this.format.compose(data[0]); + } catch (e: any) { + logger.error( + '[%s] Failed to compose message:', + this.fullName, + e.message + ); + return null; + } + } + + return response; + } + + private boundMessageHandler = this.messageHandler.bind(this); + private stopHandler = this.stop.bind(this, true); +} diff --git a/squeebot.repo.json b/squeebot.repo.json index 443944f..d48a577 100644 --- a/squeebot.repo.json +++ b/squeebot.repo.json @@ -17,6 +17,10 @@ "name": "simplecommands", "version": "1.1.4" }, + { + "name": "socket", + "version": "0.0.1" + }, { "name": "xprotocol", "version": "1.0.1"