import { Plugin, EventListener, Configurable, IPluginManifest, IPlugin, } from '@squeebot/core/lib/plugin'; import { IChannel } from '@squeebot/core/lib/channel'; import { 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'; /* const sc = { instances: { type: 'array', description: 'List of instances', default: [], format: { name: { type: 'string', optional: true, description: 'Instance name', default: 'general', }, restart: { type: 'boolean', optional: true, description: 'Automatic restart on failure', default: false, }, irc: { type: 'object', format: { nick: { type: 'string', default: 'Squeebot', }, host: { type: 'string', default: 'localhost', }, port: { type: 'number', default: 6667, }, password: { type: 'string', optional: true, }, sasl: { type: 'boolean', default: false, }, ssl: { type: 'boolean', default: false, }, channels: { type: 'array', entryType: 'string', default: [], }, nickserv: { type: 'object', format: { enabled: { type: 'boolean', default: false, }, command: { type: 'string', default: 'STATUS', }, } } } } } } }; */ const ControlCommands: { [key: string]: Function } = { loadPlugin: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } p.stream.emitTo('core', 'pluginLoad', plugin); }, unloadPlugin: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } p.stream.emitTo(plugin, 'pluginUnload', plugin); }, listActivePlugins: async (p: ControlPlugin): Promise => { return p.core!.pluginManager.getLoaded().map((x: IPlugin) => x.manifest); }, listInstalledPlugins: async (p: ControlPlugin): Promise => { return p.core!.pluginManager.availablePlugins; }, installPlugin: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } return p.core!.repositoryManager.installPlugin(plugin); }, uninstallPlugin: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } return p.core!.repositoryManager.uninstallPlugin(plugin); }, enablePlugin: 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(); }, disablePlugin: 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(); }, installRepository: async (p: ControlPlugin, url: string): Promise => { if (!url) { throw new Error('This function takes 1 argument.'); } return p.core!.repositoryManager.installRepository(url); }, uninstallRepository: async (p: ControlPlugin, repo: string): Promise => { if (!repo) { throw new Error('This function takes 1 argument.'); } return p.core!.repositoryManager.uninstallRepository(repo); }, listRepositories: async (p: ControlPlugin): Promise => { return p.core!.repositoryManager.getAll(); }, updateRepository: 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); }, newChannel: 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, }); }, removeChannel: 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); }, enableChannel: 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(); }, disableChannel: 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(); }, addChannelPlugin: 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); } } }, removeChannelPlugin: 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); } } }, listChannels: async (p: ControlPlugin): Promise => { return p.core!.channelManager.getAll(); }, getPluginConfig: 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; }, getPluginConfigValue: 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); }, getPluginConfigSchema: async (p: ControlPlugin, name: string): Promise => { if (!name) { throw new Error('This function takes 1 argument.'); } const plugin = p.plugins.get(name); if (!plugin) { throw new Error('This plugin has not registered a schema in control.'); } return plugin; }, setPluginConfig: 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(); }, setPluginConfigValue: 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: Map = new Map(); private server: Server | null = null; private sockets: Set = new Set(); public initialize(): void { this.addEventListener('core', (core: ISqueebotCore) => this.core = core); this.emitTo('core', 'request-core', this.name); this.createSocket(); } 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' }; if (cmdData != null) { if (Array.isArray(cmdData)) { response.list = cmdData; } else { response.data = cmdData; } } socket.write(JSON.stringify(response) + '\r\n'); } catch (e) { 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', command: Object.keys(ControlCommands), }) + '\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) { 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.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.listen(c.bind, () => { logger.log('[%s] Socket listening on %s', this.name, c.bind.toString()); }); } public registerPluginConfigSchema(name: string, confspec?: any): void { this.plugins.set(name, confspec); } public async executeControlCommand(command: string, args: string[]): Promise { if (!this.core) { throw new Error('The control plugin cannot control the bot right now.'); } if (!(command in ControlCommands)) { throw new Error('No such command'); } return ControlCommands[command].call(this, this, ...args); } @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.config.save().then(() => this.emit('pluginUnloaded', this)); } else { if (typeof plugin !== 'string') { plugin = plugin.manifest.name; } this.plugins.delete(plugin); } } } module.exports = ControlPlugin;