plugins-core/control/plugin.ts

674 lines
20 KiB
TypeScript

/* 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<any>;
name: string;
plugin: string;
}
let controlCommands: ControlCommand[] = [
{
name: 'loadPlugin',
plugin: 'control',
execute: async (p: ControlPlugin, plugin: string): Promise<void> => {
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<void> => {
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<IPluginManifest[]> => {
return p.core!.pluginManager.getLoaded().map((x: IPlugin) => x.manifest);
},
},
{
name: 'listInstalledPlugins',
plugin: 'control',
execute: async (p: ControlPlugin): Promise<IPluginManifest[]> => {
return p.core!.pluginManager.availablePlugins;
},
},
{
name: 'installPlugin',
plugin: 'control',
execute: async (p: ControlPlugin, plugin: string): Promise<IPluginManifest> => {
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<void> => {
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<void> => {
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<void> => {
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<IRepository> => {
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<void> => {
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<IRepoPluginDef[]> => {
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<IRepository[]> => {
return p.core!.repositoryManager.getAll();
},
},
{
name: 'updateRepository',
plugin: 'control',
execute: async (p: ControlPlugin, repo: string): Promise<IPluginManifest[]> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<IChannel[]> => {
return p.core!.channelManager.getAll();
},
},
{
name: 'getPluginConfig',
plugin: 'control',
execute: async (p: ControlPlugin, name: string): Promise<any> => {
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<any> => {
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<any> => {
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<any> => {
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<any> => {
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<StringAny> {
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<string, any>();
private server: Server | null = null;
private sockets = new Set<Socket>();
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<any> {
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<any> {
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<any> {
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;