diff --git a/control/plugin.json b/control/plugin.json new file mode 100644 index 0000000..ba48811 --- /dev/null +++ b/control/plugin.json @@ -0,0 +1,9 @@ +{ + "main": "plugin.js", + "name": "control", + "description": "Squeebot Plugin Management API and sockets", + "tags": ["api", "control", "management"], + "version": "0.0.0", + "dependencies": [], + "npmDependencies": [] +} diff --git a/control/plugin.ts b/control/plugin.ts new file mode 100644 index 0000000..384944a --- /dev/null +++ b/control/plugin.ts @@ -0,0 +1,547 @@ +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; diff --git a/squeebot.repo.json b/squeebot.repo.json index 435478f..03ede66 100644 --- a/squeebot.repo.json +++ b/squeebot.repo.json @@ -1,6 +1,10 @@ { "name": "plugins-core", "plugins": [ + { + "name": "control", + "version": "0.0.0" + }, { "name": "permissions", "version": "0.0.0"