/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { Plugin, EventListener, Configurable, IPluginManifest, IPlugin, } from '@squeebot/core/lib/plugin'; import { IChannel } from '@squeebot/core/lib/channel'; 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; name: string; plugin: string; } let controlCommands: ControlCommand[] = [ { name: 'loadPlugin', plugin: 'control', execute: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } p.stream.emitTo('core', 'pluginLoad', plugin); }, }, { name: 'unloadPlugin', plugin: 'control', execute: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } p.stream.emitTo(plugin, 'pluginUnload', plugin); }, }, { name: 'listActivePlugins', plugin: 'control', execute: async (p: ControlPlugin): Promise => { return p.core!.pluginManager.getLoaded().map((x: IPlugin) => x.manifest); }, }, { name: 'listInstalledPlugins', plugin: 'control', execute: async (p: ControlPlugin): Promise => { return p.core!.pluginManager.availablePlugins; }, }, { name: 'installPlugin', plugin: 'control', execute: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } return p.core!.repositoryManager.installPlugin(plugin); }, }, { name: 'uninstallPlugin', plugin: 'control', execute: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } return p.core!.repositoryManager.uninstallPlugin(plugin); }, }, { name: 'enablePlugin', plugin: 'control', execute: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } if (!p.core!.pluginManager.getAvailableByName(plugin)) { throw new Error('No such plugin is available.'); } if (!p.core!.config.config.enabled) { p.core!.config.config.enabled = [plugin]; } else if (p.core!.config.config.enabled.indexOf(plugin) === -1) { p.core!.config.config.enabled.push(plugin); } return p.core!.config.save(); }, }, { name: 'disablePlugin', plugin: 'control', execute: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } if (!p.core!.pluginManager.getAvailableByName(plugin)) { throw new Error('No such plugin is available.'); } if (!p.core!.config.config.enabled) { return; } const indx = p.core!.config.config.enabled.indexOf(plugin); if (indx > -1) { p.core!.config.config.enabled.splice(indx, 1); } return p.core!.config.save(); }, }, { name: 'installRepository', plugin: 'control', execute: async (p: ControlPlugin, url: string): Promise => { if (!url) { throw new Error('This function takes 1 argument.'); } return p.core!.repositoryManager.installRepository(url); }, }, { name: 'uninstallRepository', plugin: 'control', execute: async (p: ControlPlugin, repo: string): Promise => { if (!repo) { throw new Error('This function takes 1 argument.'); } return p.core!.repositoryManager.uninstallRepository(repo); }, }, { name: 'listRepositoryPlugins', plugin: 'control', execute: async (p: ControlPlugin, repo: string): Promise => { if (!repo) { throw new Error('This function takes 1 argument.'); } const repoData = p.core!.repositoryManager.getRepoByName(repo); if (!repoData) { throw new Error('No such repository found.'); } return repoData.plugins; }, }, { name: 'listRepositories', plugin: 'control', execute: async (p: ControlPlugin): Promise => { return p.core!.repositoryManager.getAll(); }, }, { name: 'updateRepository', plugin: 'control', execute: async (p: ControlPlugin, repo: string): Promise => { if (!repo) { throw new Error('This function takes 1 argument.'); } const repoData = p.core!.repositoryManager.getRepoByName(repo); if (!repoData) { throw new Error('No such repository found.'); } return p.core!.repositoryManager.checkForUpdates(repoData); }, }, { name: 'newChannel', plugin: 'control', execute: async (p: ControlPlugin, name: string, plugins?: string[]): Promise => { if (!name) { throw new Error('This function takes 1 argument.'); } if (p.core!.channelManager.getChannelByName(name)) { throw new Error('A channel by that name already exists!'); } p.core!.channelManager.addChannel({ name, plugins: plugins || [], enabled: true, }); }, }, { name: 'removeChannel', plugin: 'control', execute: async (p: ControlPlugin, name: string): Promise => { if (!name) { throw new Error('This function takes 1 argument.'); } const chan = p.core!.channelManager.getChannelByName(name); if (!chan) { throw new Error('A channel by that name does not exists!'); } p.core!.channelManager.removeChannel(chan); }, }, { name: 'enableChannel', plugin: 'control', execute: async (p: ControlPlugin, name: string): Promise => { if (!name) { throw new Error('This function takes 1 argument.'); } const chan = p.core!.channelManager.getChannelByName(name); if (!chan) { throw new Error('A channel by that name does not exists!'); } chan.enabled = true; p.core!.config.config.channels = p.core!.channelManager.getAll(); await p.core!.config.save(); }, }, { name: 'disableChannel', plugin: 'control', execute: async (p: ControlPlugin, name: string): Promise => { if (!name) { throw new Error('This function takes 1 argument.'); } const chan = p.core!.channelManager.getChannelByName(name); if (!chan) { throw new Error('A channel by that name does not exists!'); } chan.enabled = false; p.core!.config.config.channels = p.core!.channelManager.getAll(); await p.core!.config.save(); }, }, { name: 'addChannelPlugin', plugin: 'control', execute: async (p: ControlPlugin, name: string, plugins: string | string[]): Promise => { if (!name || !plugins) { throw new Error('This function takes 2 arguments.'); } const chan = p.core!.channelManager.getChannelByName(name); if (!chan) { throw new Error('A channel by that name does not exists!'); } if (!Array.isArray(plugins)) { plugins = [plugins]; } for (const plugin of plugins) { if (chan.plugins.indexOf(plugin) === -1) { chan.plugins.push(plugin); } } }, }, { name: 'removeChannelPlugin', plugin: 'control', execute: async (p: ControlPlugin, name: string, plugins: string | string[]): Promise => { if (!name || !plugins) { throw new Error('This function takes 2 arguments.'); } const chan = p.core!.channelManager.getChannelByName(name); if (!chan) { throw new Error('A channel by that name does not exists!'); } if (!Array.isArray(plugins)) { plugins = [plugins]; } for (const plugin of plugins) { const idx = chan.plugins.indexOf(plugin); if (idx !== -1) { chan.plugins.splice(idx, 1); } } }, }, { name: 'listChannels', plugin: 'control', execute: async (p: ControlPlugin): Promise => { return p.core!.channelManager.getAll(); }, }, { name: 'getPluginConfig', plugin: 'control', execute: async (p: ControlPlugin, name: string): Promise => { if (!name) { throw new Error('This function takes 1 argument.'); } const plugin = p.core!.pluginManager.getLoadedByName(name); if (!plugin) { throw new Error('No such plugin is currently running!'); } return plugin.config.config; }, }, { name: 'getPluginConfigValue', plugin: 'control', execute: async (p: ControlPlugin, name: string, key: string): Promise => { if (!name || !key) { throw new Error('This function takes 2 arguments.'); } const plugin = p.core!.pluginManager.getLoadedByName(name); if (!plugin) { throw new Error('No such plugin is currently running!'); } return plugin.config.get(key); }, }, { name: 'getPluginConfigSchema', plugin: 'control', execute: async (p: ControlPlugin, name: string): Promise => { if (!name) { throw new Error('This function takes 1 argument.'); } return p.getPluginSchema(name); }, }, { name: 'setPluginConfig', plugin: 'control', execute: async (p: ControlPlugin, name: string, config: any): Promise => { if (!name || !config) { throw new Error('This function takes 2 arguments.'); } const plugin = p.core!.pluginManager.getLoadedByName(name); if (!plugin) { throw new Error('No such plugin is currently running!'); } plugin.config.config = config; return plugin.config.save(); }, }, { name: 'setPluginConfigValue', plugin: 'control', execute: async (p: ControlPlugin, name: string, key: string, value: string): Promise => { if (!name || !key) { throw new Error('This function takes 3 arguments.'); } const plugin = p.core!.pluginManager.getLoadedByName(name); if (!plugin) { throw new Error('No such plugin is currently running!'); } plugin.config.set(key, value); return plugin.config.save(); }, }, ]; 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: { enabled: false, key: '', cert: '', rejectUnauthorized: false, requestCert: false, }, bind: null, }) class ControlPlugin extends Plugin { public core: ISqueebotCore | null = null; public plugins = new Map(); private server: Server | null = null; private sockets = new Set(); public initialize(): void { 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!') ); } /** * Load a plugin schema from it's schema file, if it exists. * @param name Plugin name * @returns Plugin schema */ public async loadPluginSchema(name: string): Promise { if (!this.core) { throw new Error('control could not access the core.'); } const { pluginsPath } = this.core.environment; const schemaPath = path.join(pluginsPath, name, 'schema.json'); let schema: any; try { 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.'); } if (!schema.type) { throw new Error('Schema does not specify what type of object it is referencing.'); } this.plugins.set(name, schema); return schema; } /** * Register a plugin's configuration schema directly instead of reading from file. * @param name Plugin name * @param confspec Static schema */ public registerPluginConfigSchema(name: string, confspec?: any): void { this.plugins.set(name, confspec); } /** * Load a plugin's configuration schema from memory or from file. * @param name Plugin name * @returns Schema * @throws Error if schema is not found or is invalid */ public async getPluginSchema(name: string): Promise { if (this.plugins.has(name)) { return this.plugins.get(name); } return this.loadPluginSchema(name); } /** * Execute a registered control command. * @param command Control command * @param args Control command arguments * @returns Control command response */ 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); if (!cmdobj || !cmdobj.execute) { throw new Error('No such command'); } return cmdobj.execute.call(this, this, ...args); } /** * Register a new custom control command. * @param obj ControlCommand object */ public registerControlCommand(obj: ControlCommand): void { if (!obj.execute || !obj.name || !obj.plugin) { throw new Error('Invalid command object.'); } 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); } public listControlCommands(): string[] { return controlCommands.map((command) => command.name); } @EventListener('pluginUnload') public unloadEventHandler(plugin: string | Plugin): void { 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); } } @EventListener('pluginUnloaded') public unloadedEventHandler(plugin: string | Plugin): void { if (typeof plugin !== 'string') { plugin = plugin.manifest.name; } this.plugins.delete(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'); } private handleClientLine(socket: TLSSocket | Socket, req: any): void { if (!req.command || req.command === 'status') { socket.write(JSON.stringify({ status: 'OK' }) + '\r\n'); return; } 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; } const req = JSON.parse(chunk); this.handleClientLine(socket, req); } } 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()); }); } } module.exports = ControlPlugin;