generic socket plugin, control uses it, simplecommands rate limit implementation

This commit is contained in:
Evert Prants 2023-04-08 13:45:59 +03:00
parent ffee7103e0
commit ecb8c2a69c
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
7 changed files with 790 additions and 200 deletions

View File

@ -3,7 +3,7 @@
"name": "control", "name": "control",
"description": "Squeebot Plugin Management API and sockets", "description": "Squeebot Plugin Management API and sockets",
"tags": ["api", "control", "management"], "tags": ["api", "control", "management"],
"version": "0.1.3", "version": "0.2.0",
"dependencies": [], "dependencies": ["socket"],
"npmDependencies": [] "npmDependencies": []
} }

View File

@ -5,16 +5,18 @@ import {
Configurable, Configurable,
IPluginManifest, IPluginManifest,
IPlugin, IPlugin,
DependencyLoad,
} from '@squeebot/core/lib/plugin'; } from '@squeebot/core/lib/plugin';
import { IChannel } from '@squeebot/core/lib/channel'; import { IChannel } from '@squeebot/core/lib/channel';
import { IRepoPluginDef, IRepository } from '@squeebot/core/lib/plugin/repository'; import {
IRepoPluginDef,
IRepository,
} from '@squeebot/core/lib/plugin/repository';
import { ISqueebotCore, logger } from '@squeebot/core/lib/core'; import { ISqueebotCore, logger } from '@squeebot/core/lib/core';
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
import tls, { TLSSocket } from 'tls';
import net, { Server, Socket } from 'net';
interface ControlCommand { interface ControlCommand {
execute: (p: ControlPlugin, ...args: any[]) => Promise<any>; execute: (p: ControlPlugin, ...args: any[]) => Promise<any>;
@ -22,6 +24,18 @@ interface ControlCommand {
plugin: string; plugin: string;
} }
interface SocketMessage {
[x: string]: unknown;
command?: string;
id?: string;
status?: string;
arguments?: unknown[];
list?: unknown[];
data?: unknown;
}
type ReplyFn = (message: SocketMessage) => void;
let controlCommands: ControlCommand[] = [ let controlCommands: ControlCommand[] = [
{ {
name: 'loadPlugin', name: 'loadPlugin',
@ -60,7 +74,10 @@ let controlCommands: ControlCommand[] = [
{ {
name: 'installPlugin', name: 'installPlugin',
plugin: 'control', plugin: 'control',
execute: async (p: ControlPlugin, plugin: string): Promise<IPluginManifest> => { execute: async (
p: ControlPlugin,
plugin: string
): Promise<IPluginManifest> => {
if (!plugin) { if (!plugin) {
throw new Error('This function takes 1 argument.'); throw new Error('This function takes 1 argument.');
} }
@ -142,7 +159,10 @@ let controlCommands: ControlCommand[] = [
{ {
name: 'listRepositoryPlugins', name: 'listRepositoryPlugins',
plugin: 'control', plugin: 'control',
execute: async (p: ControlPlugin, repo: string): Promise<IRepoPluginDef[]> => { execute: async (
p: ControlPlugin,
repo: string
): Promise<IRepoPluginDef[]> => {
if (!repo) { if (!repo) {
throw new Error('This function takes 1 argument.'); throw new Error('This function takes 1 argument.');
} }
@ -163,7 +183,10 @@ let controlCommands: ControlCommand[] = [
{ {
name: 'updateRepository', name: 'updateRepository',
plugin: 'control', plugin: 'control',
execute: async (p: ControlPlugin, repo: string): Promise<IPluginManifest[]> => { execute: async (
p: ControlPlugin,
repo: string
): Promise<IPluginManifest[]> => {
if (!repo) { if (!repo) {
throw new Error('This function takes 1 argument.'); throw new Error('This function takes 1 argument.');
} }
@ -177,7 +200,11 @@ let controlCommands: ControlCommand[] = [
{ {
name: 'newChannel', name: 'newChannel',
plugin: 'control', plugin: 'control',
execute: async (p: ControlPlugin, name: string, plugins?: string[]): Promise<void> => { execute: async (
p: ControlPlugin,
name: string,
plugins?: string[]
): Promise<void> => {
if (!name) { if (!name) {
throw new Error('This function takes 1 argument.'); throw new Error('This function takes 1 argument.');
} }
@ -244,7 +271,11 @@ let controlCommands: ControlCommand[] = [
{ {
name: 'addChannelPlugin', name: 'addChannelPlugin',
plugin: 'control', plugin: 'control',
execute: async (p: ControlPlugin, name: string, plugins: string | string[]): Promise<void> => { execute: async (
p: ControlPlugin,
name: string,
plugins: string | string[]
): Promise<void> => {
if (!name || !plugins) { if (!name || !plugins) {
throw new Error('This function takes 2 arguments.'); throw new Error('This function takes 2 arguments.');
} }
@ -265,7 +296,11 @@ let controlCommands: ControlCommand[] = [
{ {
name: 'removeChannelPlugin', name: 'removeChannelPlugin',
plugin: 'control', plugin: 'control',
execute: async (p: ControlPlugin, name: string, plugins: string | string[]): Promise<void> => { execute: async (
p: ControlPlugin,
name: string,
plugins: string | string[]
): Promise<void> => {
if (!name || !plugins) { if (!name || !plugins) {
throw new Error('This function takes 2 arguments.'); throw new Error('This function takes 2 arguments.');
} }
@ -308,7 +343,11 @@ let controlCommands: ControlCommand[] = [
{ {
name: 'getPluginConfigValue', name: 'getPluginConfigValue',
plugin: 'control', plugin: 'control',
execute: async (p: ControlPlugin, name: string, key: string): Promise<any> => { execute: async (
p: ControlPlugin,
name: string,
key: string
): Promise<any> => {
if (!name || !key) { if (!name || !key) {
throw new Error('This function takes 2 arguments.'); throw new Error('This function takes 2 arguments.');
} }
@ -332,7 +371,11 @@ let controlCommands: ControlCommand[] = [
{ {
name: 'setPluginConfig', name: 'setPluginConfig',
plugin: 'control', plugin: 'control',
execute: async (p: ControlPlugin, name: string, config: any): Promise<any> => { execute: async (
p: ControlPlugin,
name: string,
config: any
): Promise<any> => {
if (!name || !config) { if (!name || !config) {
throw new Error('This function takes 2 arguments.'); throw new Error('This function takes 2 arguments.');
} }
@ -347,7 +390,12 @@ let controlCommands: ControlCommand[] = [
{ {
name: 'setPluginConfigValue', name: 'setPluginConfigValue',
plugin: 'control', plugin: 'control',
execute: async (p: ControlPlugin, name: string, key: string, value: string): Promise<any> => { execute: async (
p: ControlPlugin,
name: string,
key: string,
value: string
): Promise<any> => {
if (!name || !key) { if (!name || !key) {
throw new Error('This function takes 3 arguments.'); throw new Error('This function takes 3 arguments.');
} }
@ -361,26 +409,6 @@ let controlCommands: ControlCommand[] = [
}, },
]; ];
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({ @Configurable({
authorizedIPs: [], authorizedIPs: [],
tls: { tls: {
@ -394,16 +422,15 @@ async function parseTLSConfig(tlsconfig: StringAny): Promise<StringAny> {
}) })
class ControlPlugin extends Plugin { class ControlPlugin extends Plugin {
public core: ISqueebotCore | null = null; public core: ISqueebotCore | null = null;
public plugins = new Map<string, any>(); public plugins = new Map<string, unknown>();
private server: Server | null = null;
private sockets = new Set<Socket>();
public initialize(): void { public initialize(): void {
this.addEventListener('core', (core: ISqueebotCore) => this.core = core); this.addEventListener('core', (core: ISqueebotCore) => (this.core = core));
this.emitTo('core', 'request-core', this.name); this.emitTo('core', 'request-core', this.name);
this.createSocket(); this.getPluginSchema(this.name).catch(() =>
this.getPluginSchema(this.name).catch( logger.error(
() => logger.error('[control] How embarrasing! control could not load it\'s own schema!') '[control] How embarrasing! control could not load it\'s own schema!'
)
); );
} }
@ -412,7 +439,7 @@ class ControlPlugin extends Plugin {
* @param name Plugin name * @param name Plugin name
* @returns Plugin schema * @returns Plugin schema
*/ */
public async loadPluginSchema(name: string): Promise<any> { public async loadPluginSchema(name: string): Promise<unknown> {
if (!this.core) { if (!this.core) {
throw new Error('control could not access the core.'); throw new Error('control could not access the core.');
} }
@ -425,11 +452,15 @@ class ControlPlugin extends Plugin {
const fileRead = await fs.readFile(schemaPath, { encoding: 'utf8' }); const fileRead = await fs.readFile(schemaPath, { encoding: 'utf8' });
schema = JSON.parse(fileRead); schema = JSON.parse(fileRead);
} catch (e: any) { } catch (e: any) {
throw new Error('No schema file found, it is not accessible or is not valid JSON.'); throw new Error(
'No schema file found, it is not accessible or is not valid JSON.'
);
} }
if (!schema.type) { if (!schema.type) {
throw new Error('Schema does not specify what type of object it is referencing.'); throw new Error(
'Schema does not specify what type of object it is referencing.'
);
} }
this.plugins.set(name, schema); this.plugins.set(name, schema);
@ -441,7 +472,7 @@ class ControlPlugin extends Plugin {
* @param name Plugin name * @param name Plugin name
* @param confspec Static schema * @param confspec Static schema
*/ */
public registerPluginConfigSchema(name: string, confspec?: any): void { public registerPluginConfigSchema(name: string, confspec?: unknown): void {
this.plugins.set(name, confspec); this.plugins.set(name, confspec);
} }
@ -451,7 +482,7 @@ class ControlPlugin extends Plugin {
* @returns Schema * @returns Schema
* @throws Error if schema is not found or is invalid * @throws Error if schema is not found or is invalid
*/ */
public async getPluginSchema(name: string): Promise<any> { public async getPluginSchema(name: string): Promise<unknown> {
if (this.plugins.has(name)) { if (this.plugins.has(name)) {
return this.plugins.get(name); return this.plugins.get(name);
} }
@ -465,11 +496,14 @@ class ControlPlugin extends Plugin {
* @param args Control command arguments * @param args Control command arguments
* @returns Control command response * @returns Control command response
*/ */
public async executeControlCommand(command: string, args: string[]): Promise<any> { public async executeControlCommand(
command: string,
args: string[]
): Promise<unknown> {
if (!this.core) { if (!this.core) {
throw new Error('The control plugin cannot control the bot right now.'); throw new Error('The control plugin cannot control the bot right now.');
} }
const cmdobj = controlCommands.find(k => k.name === command); const cmdobj = controlCommands.find((k) => k.name === command);
if (!cmdobj || !cmdobj.execute) { if (!cmdobj || !cmdobj.execute) {
throw new Error('No such command'); throw new Error('No such command');
} }
@ -484,11 +518,14 @@ class ControlPlugin extends Plugin {
if (!obj.execute || !obj.name || !obj.plugin) { if (!obj.execute || !obj.name || !obj.plugin) {
throw new Error('Invalid command object.'); throw new Error('Invalid command object.');
} }
const exists = controlCommands.find(k => k.name === obj.name);
const exists = controlCommands.find((k) => k.name === obj.name);
if (exists) { if (exists) {
throw new Error('Control commands should not be overwritten.'); throw new Error('Control commands should not be overwritten.');
} }
controlCommands.push(obj); controlCommands.push(obj);
logger.log('[%s] registered control command', this.name, obj.name); logger.log('[%s] registered control command', this.name, obj.name);
} }
@ -501,15 +538,6 @@ class ControlPlugin extends Plugin {
if (plugin === this.name || plugin === this) { if (plugin === this.name || plugin === this) {
logger.debug('[%s]', this.name, 'shutting down..'); 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.plugins.clear();
this.emit('pluginUnloaded', this); this.emit('pluginUnloaded', this);
} }
@ -521,152 +549,70 @@ class ControlPlugin extends Plugin {
plugin = plugin.manifest.name; plugin = plugin.manifest.name;
} }
this.plugins.delete(plugin); this.plugins.delete(plugin);
controlCommands = controlCommands.filter(k => k.plugin !== plugin); controlCommands = controlCommands.filter((k) => k.plugin !== plugin);
} }
private errorToClient(socket: TLSSocket | Socket, error: Error): void { @DependencyLoad('socket')
socket.write(JSON.stringify({ public socketDepLoaded(socketAPI: any) {
status: 'ERROR', const config = this.config.config;
message: error.message, socketAPI
}) + '\r\n'); .createServer({
name: 'control',
plugin: this.name,
...config,
})
.then((server: any) => {
server.on('message', (...args: [SocketMessage, never, ReplyFn]) =>
this.handleClientLine(...args)
);
})
.catch((err: Error) =>
logger.error('[%s] Failed to initialize: ', this.name, err.message)
);
} }
private handleClientLine(socket: TLSSocket | Socket, req: any): void { private handleClientLine(
if (!req.command || req.command === 'status') { message: SocketMessage,
socket.write(JSON.stringify({ sender: never,
status: 'OK' reply: ReplyFn
}) + '\r\n'); ): void {
return; if (!message.command) return;
} this.executeControlCommand(
message.command,
(message.arguments as string[]) || []
).then(
(cmdData) => {
try {
const response: SocketMessage = {
status: 'OK',
command: message.command,
id: message.id
};
if (req.command === 'quit') { if (cmdData != null) {
socket.end(); if (Array.isArray(cmdData)) {
return; response.list = cmdData;
} } else {
response.data = cmdData;
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); reply(response);
this.handleClientLine(socket, req); } catch (error) {
reply({
status: 'ERROR',
arguments: [(error as Error).message],
id: message.id
});
} }
} catch (e: any) { },
this.errorToClient(socket, e); (error) =>
} reply({
}); status: 'ERROR',
} arguments: [(error as Error).message],
id: message.id
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());
});
} }
} }

View File

@ -38,7 +38,7 @@ interface CommandSpec {
interface RateLimit { interface RateLimit {
rooms: string[]; rooms: string[];
perSecond: number; rate: number;
cooldown: number; cooldown: number;
} }
@ -61,7 +61,7 @@ class SqueebotCommandsAPIPlugin extends Plugin {
private permissions: any = null; private permissions: any = null;
public getRateLimit(room: string): RateLimit | null { public getRateLimit(room: string): RateLimit | null {
for (const rm of this.config.get('rateLimits', [])) { for (const rm of this.config.get<RateLimit[]>('rateLimits', [])) {
if (rm.rooms && (rm.rooms.indexOf(room) !== -1 || rm.rooms.indexOf('*') !== -1)) { if (rm.rooms && (rm.rooms.indexOf(room) !== -1 || rm.rooms.indexOf('*') !== -1)) {
return rm; return rm;
} }
@ -86,7 +86,7 @@ class SqueebotCommandsAPIPlugin extends Plugin {
const r = rates[sender]; const r = rates[sender];
if (r.lastMessage > Date.now() - rl.cooldown) { if (r.lastMessage > Date.now() - rl.cooldown) {
if (r.messages >= rl.perSecond) { if (r.messages >= rl.rate) {
// Rate limited // Rate limited
return true; return true;
} }
@ -101,6 +101,56 @@ class SqueebotCommandsAPIPlugin extends Plugin {
return false; return false;
} }
public setRateLimit(room: string, rate: number, cooldown: number): void {
const basis = this.config.get('rateLimits', []) as RateLimit[];
const found = basis.find((item) => item.rooms.includes(room));
const resave = () => {
this.config.set('rateLimits', basis);
this.config.save();
};
const addNew = () => {
const newRate: RateLimit = {
rate, cooldown, rooms: [room]
};
basis.push(newRate);
resave();
};
if (found) {
if (rate === 0 || (rate !== found.rate || cooldown !== found.cooldown)) {
if (rate === 0 || found.rooms.length > 1) {
found.rooms.splice(found.rooms.indexOf(room), 1);
}
if (rate === 0) {
if (found.rooms.length === 0) {
basis.splice(basis.indexOf(found), 1);
}
resave();
return;
}
addNew();
return;
}
if (found.rooms.length > 1) {
addNew();
return;
}
found.rate = rate;
found.cooldown = cooldown;
resave();
return;
}
addNew();
}
private roomMatcher(msg: IMessage, specList: CommandSpec[]): CommandSpec[] { private roomMatcher(msg: IMessage, specList: CommandSpec[]): CommandSpec[] {
const roomMatches = []; const roomMatches = [];
@ -248,7 +298,7 @@ class SqueebotCommandsAPIPlugin extends Plugin {
} }
let allowedPlugins: string[] = []; let allowedPlugins: string[] = [];
if (chan && this.config.get('channelMatching', false) === true) { if (chan && this.config.get<boolean>('channelMatching', false) === true) {
allowedPlugins = chan.plugins; allowedPlugins = chan.plugins;
} }
@ -572,6 +622,48 @@ class SqueebotCommandsAPIPlugin extends Plugin {
return true; return true;
} }
}); });
this.registerCommand({
plugin: this.name,
name: 'ratelimit',
usage: '<commands per cooldown> <cooldown in seconds>',
description: 'Set command rate limits in the current room',
hidden: true,
permissions: ['room.rate-limit'],
execute: async (
msg: IMessage,
msr: MessageResolver,
spec: CommandSpec,
prefix: string,
...args: any[]
): Promise<boolean> => {
if (!args.length) {
const rl = this.getRateLimit(msg.target?.id as string);
if (!rl) {
msg.resolve(`There are no rate limits enabled for ${msg.target?.name}.`);
return true;
}
const isGlobal = rl.rooms.includes('*');
msg.resolve(
`Active rate limit in ${msg.target?.name}${isGlobal ? ' (global)' : ''}: ${rl.rate} messages in ${rl.cooldown}`
);
return true;
}
const [count, cooldown] = args;
try {
this.setRateLimit(msg.target?.id as string, parseInt(count, 10), parseInt(cooldown, 10));
} catch (e: any) {
msg.resolve('Setting rate limit failed:', e.message);
return true;
}
msg.resolve('Rate limit has been configured.');
return true;
}
});
} }
@DependencyLoad('permissions') @DependencyLoad('permissions')

9
socket/plugin.json Normal file
View File

@ -0,0 +1,9 @@
{
"main": "plugin.js",
"name": "socket",
"description": "General-purpose socket server API and service",
"tags": ["api"],
"version": "0.0.1",
"dependencies": [],
"npmDependencies": []
}

438
socket/plugin.ts Normal file
View File

@ -0,0 +1,438 @@
import {
Plugin,
EventListener,
} from '@squeebot/core/lib/plugin';
import { 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';
import { randomBytes } from 'crypto';
import EventEmitter from 'events';
interface TLSConfiguration {
[x: string]: string | boolean | undefined;
enabled: boolean;
key?: string;
cert?: string;
ca?: string;
dhparam?: string;
crl?: string;
pfx?: string;
rejectUnauthorized: boolean;
requestCert: boolean;
}
interface SocketMessage {
[x: string]: unknown;
command?: string;
id?: string;
status?: string;
arguments?: unknown[];
}
interface SocketConfiguration {
name: string;
plugin?: string;
authorizedIPs: string[];
tls?: TLSConfiguration;
bind: number | string | null | false;
}
interface PluginConfiguration {
sockets: SocketConfiguration[];
}
type SocketWithID = (Socket | TLSSocket) & { id: string };
const makeID = () => randomBytes(8).toString('hex');
class SocketServer extends EventEmitter {
private server: Server | null = null;
private sockets = new Set<SocketWithID>();
private intents = new Map<string, string>();
public alive = false;
constructor(public config: SocketConfiguration) {
super();
}
get logName() {
return `socket:${this.config.name}`;
}
public stop() {
if (this.server) {
logger.log('[%s] Stopping socket server..', this.logName);
this.server.close();
for (const sock of this.sockets) {
sock.destroy();
}
this.sockets.clear();
this.intents.clear();
}
this.emit('close');
this.alive = false;
}
public async start() {
if (this.alive) {
this.stop();
}
await this.createSocket();
}
public send(message: SocketMessage): void {
this.sockets.forEach((socket) => this.sendToClient(socket, message));
}
public sendToID(id: string, message: SocketMessage): void {
const findClient = Array.from(this.sockets.values()).find(
(client) => client.id === id
);
if (!findClient) return;
this.sendToClient(findClient, message);
}
public sendIntent(intent: string, message: SocketMessage): void {
for (const [id, clientintent] of this.intents) {
if (clientintent !== intent) continue;
this.sendToID(id, message);
}
}
private sendToClient(socket: SocketWithID, message: SocketMessage): void {
socket.write(JSON.stringify(message) + '\r\n');
}
private handleClientLine(socket: SocketWithID, req: SocketMessage): void {
if (!req.command || req.command === 'status') {
socket.write(
JSON.stringify({
id: socket.id,
status: 'OK',
}) + '\r\n'
);
return;
}
// Client requests graceful close
if (req.command === 'quit') {
socket.end();
this.intents.delete(socket.id);
this.emit('disconnect', socket.id);
return;
}
// Parse argument list
let args = [];
const argField = req.args || req.argument || req.arguments;
if (argField) {
if (!Array.isArray(argField)) {
args = [argField];
} else {
args = argField;
}
}
// Set or return the intent
if (req.command === 'intent') {
if (args[0]) {
this.intents.set(socket.id, args[0]);
this.emit('intent', socket.id, args[0]);
}
this.sendToClient(socket, {
id: socket.id,
command: 'intent',
arguments: [this.intents.get(socket.id)],
});
return;
}
try {
const intent = this.intents.get(socket.id);
this.emit(
'message',
{ ...req, arguments: args },
{ id: socket.id, intent },
(response: SocketMessage) => {
socket.write(JSON.stringify(response) + '\r\n');
}
);
} catch (err) {
logger.error(
`[%s] Error in message handler: ${(err as Error).stack}`,
this.logName
);
}
}
private handleIncoming(incoming: Socket | TLSSocket): void {
const socket = incoming as SocketWithID;
socket.id = makeID();
const c = this.config;
let addr = socket.remoteAddress;
if (addr?.indexOf('::ffff:') === 0) {
addr = addr.substring(7);
}
if (
addr &&
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.logName,
addr
);
socket.destroy();
return;
}
}
this.sockets.add(socket);
socket.once('end', () => {
logger.log('[%s] Client from %s (%s) disconnected.', this.logName, addr, socket.id);
this.sockets.delete(socket);
this.intents.delete(socket.id);
});
logger.log('[%s] Client from %s (%s) connected.', this.logName, addr, socket.id);
socket.setEncoding('utf8');
socket.write(
JSON.stringify({
id: socket.id,
status: 'OK',
}) + '\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 (error) {
this.sendToClient(socket, {
status: 'ERROR',
arguments: [(error as Error).message],
});
}
});
this.emit('connect', socket.id);
}
private createSocket(): Promise<void> {
const c = this.config;
if (c.bind == null || c.bind === false) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
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.logName
);
logger.warn(
'[%s] [SECURITY WARNING] You have enabled TLS, ' +
'but you do not have any form of access control configured.',
this.logName
);
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.logName
);
logger.warn(
'[%s] [SECURITY WARNING] !!! YOUR CONTROL SOCKET IS (STILL) INSECURE !!!',
this.logName
);
}
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);
if (!this.alive) reject(e);
});
this.server.listen(c.bind, () => {
logger.log(
'[%s] Secure socket listening on %s',
this.logName,
c.bind?.toString()
);
this.alive = true;
resolve();
});
},
(err) => {
logger.error(
'[%s] Secure socket listen failed: %s',
this.logName,
err.message
);
reject(err);
}
);
return;
}
if (!c.authorizedIPs || !c.authorizedIPs.length) {
logger.warn(
'[%s] [SECURITY WARNING] !!! YOUR SOCKET IS INSECURE !!!',
this.logName
);
logger.warn(
'[%s] [SECURITY WARNING] You do not have any form of access control configured.',
this.logName
);
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.logName
);
logger.warn(
'[%s] [SECURITY WARNING] !!! YOUR SOCKET IS INSECURE !!!',
this.logName
);
}
this.server = net.createServer((socket) => this.handleIncoming(socket));
this.server.on('error', (e) => {
logger.error('[%s] Socket error:', e.message);
if (!this.alive) reject(e);
});
this.server.listen(c.bind, () => {
logger.log(
'[%s] Socket listening on %s',
this.logName,
c.bind?.toString()
);
this.alive = true;
resolve();
});
});
}
}
const match = ['key', 'cert', 'ca', 'dhparam', 'crl', 'pfx'];
async function parseTLSConfig(
tlsconfig: TLSConfiguration
): Promise<Record<string, unknown>> {
const result: Record<string, unknown> = {};
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] as string);
const bf = await fs.readFile(value);
result[key] = bf;
}
return result;
}
class SocketPlugin extends Plugin {
private plugins = new Map<string, any>();
private servers = new Map<string, SocketServer>();
/** Create an unique 8-bit ID */
public makeID = makeID;
/**
* Kill a socket server by name
* @param name Socket server name
*/
public killServer(name: string) {
if (!this.servers.has(name)) return;
this.servers.get(name)?.stop();
this.servers.delete(name);
}
/**
* Create a socket server
* @param config Socket Server configuration
* @param start Start the socket automatically on creation
* @returns Socket Server
*/
public async createServer(config: SocketConfiguration, start = true) {
if (this.servers.has(config.name)) return;
const server = new SocketServer(config);
this.servers.set(config.name, server);
if (start) await server.start();
return server;
}
/**
* Get an existing socket server by name
* @param name Socket Server name
* @returns Existing Socket Server or undefined
*/
public getServer(name: string) {
return this.servers.get(name);
}
@EventListener('pluginUnload')
public unloadEventHandler(plugin: string | Plugin): void {
if (plugin === this.name || plugin === this) {
logger.debug('[%s]', this.name, 'shutting down..');
for (const [key] of this.servers) {
this.killServer(key);
}
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);
this.killPluginServers(plugin);
}
private killPluginServers(plugin: string) {
for (const [name, server] of this.servers) {
if (!server.config.plugin || server.config.plugin !== plugin) continue;
this.killServer(name);
}
}
}
module.exports = SocketPlugin;

101
socket/proto.ts.bak Normal file
View File

@ -0,0 +1,101 @@
import { EMessageType, Formatter, IMessage, Protocol } from '@squeebot/core';
class VirtualChatProtocol extends Protocol {
public format = new Formatter(false, false);
public type = 'VirtualChatProtocol';
public me = {
name: 'Squeebot',
id: `socket/${this.serverName}/Squeebot`
};
get serverName() {
return this.config.name;
}
get server() {
const plugin = this.plugin;
return plugin.getServer(this.serverName);
}
public start(...args: any[]): void {
this.server?.addListener('message', this.boundMessageHandler);
this.server?.addListener('close', this.stopHandler);
}
public stop(force?: boolean | undefined): void {
this.server?.removeListener('message', this.boundMessageHandler);
this.server?.removeListener('close', this.stopHandler);
}
private messageHandler(
message: SocketMessage,
sender: { id: string; intent?: string },
reply: replyFn
) {
if (message.command !== 'event') return;
const [eventName, ...argv] = message.arguments as string[];
const newMessage = {
id: message.id || makeID(),
type: eventName as EMessageType,
data: message,
text: argv.join(' '),
source: this,
time: new Date(),
target: { id: sender.id, name: sender.intent || sender.id },
sender: { id: sender.id, name: sender.intent || sender.id },
fullSenderID: `socket/${this.serverName}/${sender.id}`,
fullRoomID: `socket/${this.serverName}/${sender.id}`,
resolved: false,
direct: true,
resolve: (...data: any[]) => {
newMessage.resolved = true;
const response = this.parseMessage(...data);
if (!response || !this.server?.alive) return;
reply({
id: newMessage.id,
command: 'message',
arguments: ['message', response],
});
},
reject: (error) =>
reply({
id: newMessage.id,
status: 'ERROR',
arguments: [error],
}),
mention: (target) => `${target.name}, `,
} as IMessage;
this.plugin.stream.emitTo(
'channel',
eventName !== 'message' ? 'event' : 'message',
newMessage
);
}
private parseMessage(...data: any[]): string | null {
let response = util.format(data[0], ...data.slice(1));
if (!response) {
return null;
}
if (Array.isArray(data[0])) {
try {
response = this.format.compose(data[0]);
} catch (e: any) {
logger.error(
'[%s] Failed to compose message:',
this.fullName,
e.message
);
return null;
}
}
return response;
}
private boundMessageHandler = this.messageHandler.bind(this);
private stopHandler = this.stop.bind(this, true);
}

View File

@ -17,6 +17,10 @@
"name": "simplecommands", "name": "simplecommands",
"version": "1.1.4" "version": "1.1.4"
}, },
{
"name": "socket",
"version": "0.0.1"
},
{ {
"name": "xprotocol", "name": "xprotocol",
"version": "1.0.1" "version": "1.0.1"