control plugin socket and commands
This commit is contained in:
parent
cf43d997d5
commit
7976aec044
9
control/plugin.json
Normal file
9
control/plugin.json
Normal file
@ -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": []
|
||||
}
|
547
control/plugin.ts
Normal file
547
control/plugin.ts
Normal file
@ -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<void> => {
|
||||
if (!plugin) {
|
||||
throw new Error('This function takes 1 argument.');
|
||||
}
|
||||
p.stream.emitTo('core', 'pluginLoad', plugin);
|
||||
},
|
||||
unloadPlugin: async (p: ControlPlugin, plugin: string): Promise<void> => {
|
||||
if (!plugin) {
|
||||
throw new Error('This function takes 1 argument.');
|
||||
}
|
||||
p.stream.emitTo(plugin, 'pluginUnload', plugin);
|
||||
},
|
||||
listActivePlugins: async (p: ControlPlugin): Promise<IPluginManifest[]> => {
|
||||
return p.core!.pluginManager.getLoaded().map((x: IPlugin) => x.manifest);
|
||||
},
|
||||
listInstalledPlugins: async (p: ControlPlugin): Promise<IPluginManifest[]> => {
|
||||
return p.core!.pluginManager.availablePlugins;
|
||||
},
|
||||
installPlugin: async (p: ControlPlugin, plugin: string): Promise<IPluginManifest> => {
|
||||
if (!plugin) {
|
||||
throw new Error('This function takes 1 argument.');
|
||||
}
|
||||
return p.core!.repositoryManager.installPlugin(plugin);
|
||||
},
|
||||
uninstallPlugin: async (p: ControlPlugin, plugin: string): Promise<void> => {
|
||||
if (!plugin) {
|
||||
throw new Error('This function takes 1 argument.');
|
||||
}
|
||||
return p.core!.repositoryManager.uninstallPlugin(plugin);
|
||||
},
|
||||
enablePlugin: 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();
|
||||
},
|
||||
disablePlugin: 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();
|
||||
},
|
||||
installRepository: async (p: ControlPlugin, url: string): Promise<IRepository> => {
|
||||
if (!url) {
|
||||
throw new Error('This function takes 1 argument.');
|
||||
}
|
||||
return p.core!.repositoryManager.installRepository(url);
|
||||
},
|
||||
uninstallRepository: async (p: ControlPlugin, repo: string): Promise<void> => {
|
||||
if (!repo) {
|
||||
throw new Error('This function takes 1 argument.');
|
||||
}
|
||||
return p.core!.repositoryManager.uninstallRepository(repo);
|
||||
},
|
||||
listRepositories: async (p: ControlPlugin): Promise<IRepository[]> => {
|
||||
return p.core!.repositoryManager.getAll();
|
||||
},
|
||||
updateRepository: 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);
|
||||
},
|
||||
newChannel: 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,
|
||||
});
|
||||
},
|
||||
removeChannel: 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);
|
||||
},
|
||||
enableChannel: 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();
|
||||
},
|
||||
disableChannel: 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();
|
||||
},
|
||||
addChannelPlugin: 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
removeChannelPlugin: 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
listChannels: async (p: ControlPlugin): Promise<IChannel[]> => {
|
||||
return p.core!.channelManager.getAll();
|
||||
},
|
||||
getPluginConfig: 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;
|
||||
},
|
||||
getPluginConfigValue: 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);
|
||||
},
|
||||
getPluginConfigSchema: async (p: ControlPlugin, name: string): Promise<any> => {
|
||||
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<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();
|
||||
},
|
||||
setPluginConfigValue: 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: Map<string, any> = new Map<string, any>();
|
||||
private server: Server | null = null;
|
||||
private sockets: Set<Socket> = new Set<Socket>();
|
||||
|
||||
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<any> {
|
||||
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;
|
@ -1,6 +1,10 @@
|
||||
{
|
||||
"name": "plugins-core",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "control",
|
||||
"version": "0.0.0"
|
||||
},
|
||||
{
|
||||
"name": "permissions",
|
||||
"version": "0.0.0"
|
||||
|
Loading…
Reference in New Issue
Block a user