Add internal banning (cvmban) using MySQL

This commit is contained in:
Elijah R
2024-07-31 16:34:42 -04:00
committed by Elijah R
parent d16c045b04
commit b0c23c3cdf
12 changed files with 701 additions and 450 deletions

View File

@@ -52,18 +52,35 @@ vncPort = 5900
# rebootCmd = ""
# restoreCmd = ""
[mysql]
# Configures the MySQL database. This is ONLY required if you use the internal cvmban banning system (configured below)
enabled = false
host = "127.0.0.1"
username = "root"
password = "hunter2"
database = "cvmts"
# This section configures banning users. Note that if you leave this default, banning will NOT function and will be as effective as a kick.
[bans]
# If defined, a command that is run to ban a user from the VM.
# Use $IP to specify an ip and (optionally) use $NAME to specify a username
# bancmd = ""
# If true, enables the internal banning of users using the above MySQL database
cvmban = false
[collabvm]
node = "acoolvm"
displayname = "A <b>Really</b> Cool CollabVM Instance"
motd = "welcome!"
# Maximum amount of active connections allowed from the same IP.
maxConnections = 3
# Command used to ban an IP.
# Use $IP to specify an ip and (optionally) use $NAME to specify a username
bancmd = "iptables -A INPUT -s $IP -j REJECT"
# Moderator rank enabled
moderatorEnabled = true
# List of disallowed usernames
usernameblacklist = []
# Maximum length of a chat message
maxChatLength = 100
# Maximum messages in the chat history buffer before old messages are overwritten
maxChatHistoryLength = 10
# Limit the amount of users allowed in the turn queue at the same time from the same IP
turnlimit = {enabled = true, maximum = 1}

View File

@@ -16,8 +16,10 @@
"@cvmts/cvm-rs": "*",
"@maxmind/geoip2-node": "^5.0.0",
"execa": "^8.0.1",
"ip-address": "^9.0.5",
"mnemonist": "^0.39.5",
"msgpackr": "^1.10.2",
"mysql2": "^3.11.0",
"pino": "^9.3.1",
"sharp": "^0.33.3",
"toml": "^3.0.0",

81
cvmts/src/BanManager.ts Normal file
View File

@@ -0,0 +1,81 @@
import { ExecaSyncError, execa, execaCommand } from "execa";
import { BanConfig } from "./IConfig";
import pino from "pino";
import { Database } from "./Database";
import { Address6 } from "ip-address";
import { isIP } from "net";
export class BanManager {
private cfg: BanConfig;
private logger: pino.Logger;
private db: Database | undefined;
constructor(config: BanConfig, db: Database | undefined) {
this.cfg = config;
this.logger = pino({
name: "CVMTS.BanManager"
});
this.db = db;
}
private formatIP(ip: string) {
switch (isIP(ip)) {
case 4:
// If IPv4, just return as-is
return ip;
case 6: {
// If IPv6, return the /64 equivalent
let addr = new Address6(ip);
addr.subnetMask = 64;
return addr.startAddress().canonicalForm() + '/64';
}
case 0:
default:
// Invalid IP
throw new Error("Invalid IP address (what the hell did you even do???)");
}
}
async BanUser(ip: string, username: string) {
ip = this.formatIP(ip);
// If cvmban enabled, write to DB
if (this.cfg.cvmban) {
if (!this.db) throw new Error("CVMBAN enabled but Database is undefined");
await this.db.banIP(ip, username);
}
// If ban command enabled, run it
try {
if (Array.isArray(this.cfg.bancmd)) {
let args: string[] = this.cfg.bancmd.map((a: string) => this.banCmdArgs(a, ip, username));
if (args.length || args[0].length) {
this.logger.info(`Running "${JSON.stringify(args)}"`);
await execa(args.shift()!, args, { stdout: process.stdout, stderr: process.stderr });
}
} else if (typeof this.cfg.bancmd == 'string') {
let cmd: string = this.banCmdArgs(this.cfg.bancmd, ip, username);
if (cmd.length) {
// Run through JSON.stringify for char escaping
this.logger.info(`Running ${JSON.stringify(cmd)}`);
await execaCommand(cmd, { stdout: process.stdout, stderr: process.stderr });
}
}
} catch (e) {
this.logger.error(`Failed to ban ${ip} (${username}): ${(e as ExecaSyncError).shortMessage}`);
}
}
async isIPBanned(ip: string) {
ip = this.formatIP(ip);
if (!this.db) return false;
if (await this.db.isIPBanned(ip)) {
this.logger.info(`Banned IP ${ip} tried connecting.`);
return true;
}
return false;
}
private banCmdArgs(arg: string, ip: string, username: string): string {
return arg.replace(/\$IP/g, ip).replace(/\$NAME/g, username);
}
}

View File

@@ -19,6 +19,7 @@ import { CollabVMProtocolMessage, CollabVMProtocolMessageType } from '@cvmts/col
import { Size, Rect } from './VMDisplay.js';
import pino from 'pino';
import { BanManager } from './BanManager.js';
// Instead of strange hacks we can just use nodejs provided
// import.meta properties, which have existed since LTS if not before
@@ -90,9 +91,12 @@ export default class CollabVMServer {
// Geoip
private geoipReader: ReaderModel | null;
// Ban manager
private banmgr: BanManager;
private logger = pino({ name: 'CVMTS.Server' });
constructor(config: IConfig, vm: VM, auth: AuthManager | null, geoipReader: ReaderModel | null) {
constructor(config: IConfig, vm: VM, banmgr: BanManager, auth: AuthManager | null, geoipReader: ReaderModel | null) {
this.Config = config;
this.ChatHistory = new CircularBuffer<ChatHistory>(Array, this.Config.collabvm.maxChatHistoryLength);
this.TurnQueue = new Queue<User>();
@@ -147,6 +151,8 @@ export default class CollabVMServer {
this.auth = auth;
this.geoipReader = geoipReader;
this.banmgr = banmgr;
}
public addUser(user: User) {
@@ -530,7 +536,8 @@ export default class CollabVMServer {
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.ban)) return;
var user = this.clients.find((c) => c.username === msgArr[2]);
if (!user) return;
user.ban();
this.logger.info(`Banning ${user.username!} (${user.IP.address}) by request of ${client.username!}`);
user.ban(this.banmgr);
case '13':
// Force Vote
if (msgArr.length !== 3) return;

44
cvmts/src/Database.ts Normal file
View File

@@ -0,0 +1,44 @@
import pino, { Logger } from "pino";
import { MySQLConfig } from "./IConfig";
import * as mysql from 'mysql2/promise';
export class Database {
cfg: MySQLConfig;
logger: Logger;
db: mysql.Pool;
constructor(config: MySQLConfig) {
this.cfg = config;
this.logger = pino({
name: "CVMTS.Database"
});
this.db = mysql.createPool({
host: this.cfg.host,
user: this.cfg.username,
password: this.cfg.password,
database: this.cfg.database,
connectionLimit: 5,
multipleStatements: false,
});
}
async init() {
// Make sure tables exist
let conn = await this.db.getConnection();
await conn.execute("CREATE TABLE IF NOT EXISTS bans (ip VARCHAR(43) PRIMARY KEY NOT NULL, username VARCHAR(20) NOT NULL, reason TEXT DEFAULT NULL, timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP);");
conn.release();
this.logger.info("MySQL successfully initialized");
}
async banIP(ip: string, username: string, reason: string | null = null) {
let conn = await this.db.getConnection();
await conn.execute("INSERT INTO bans (ip, username, reason) VALUES (?, ?, ?);", [ip, username, reason]);
conn.release();
}
async isIPBanned(ip: string): Promise<boolean> {
let conn = await this.db.getConnection();
let res = (await conn.query('SELECT COUNT(ip) AS cnt FROM bans WHERE ip = ?', [ip])) as mysql.RowDataPacket;
conn.release();
return res[0][0]['cnt'] !== 0;
}
}

View File

@@ -40,12 +40,13 @@ export default interface IConfig {
snapshots: boolean;
};
vncvm: VNCVMDef;
mysql: MySQLConfig;
bans: BanConfig;
collabvm: {
node: string;
displayname: string;
motd: string;
maxConnections: number;
bancmd: string | string[];
moderatorEnabled: boolean;
usernameblacklist: string[];
maxChatLength: number;
@@ -71,6 +72,19 @@ export default interface IConfig {
};
}
export interface MySQLConfig {
enabled: boolean;
host: string;
username: string;
password: string;
database: string;
}
export interface BanConfig {
bancmd: string | string[] | undefined;
cvmban: boolean;
}
export interface Permissions {
restore: boolean;
reboot: boolean;

View File

@@ -6,23 +6,31 @@ import TCPClient from './TCPClient.js';
import { IPDataManager } from '../IPData.js';
import { User } from '../User.js';
import pino from 'pino';
import { BanManager } from '../BanManager.js';
export default class TCPServer extends EventEmitter implements NetworkServer {
listener: Server;
Config: IConfig;
logger = pino({name: 'CVMTS.TCPServer'});
clients: TCPClient[];
private banmgr: BanManager;
constructor(config: IConfig) {
constructor(config: IConfig, banmgr: BanManager) {
super();
this.Config = config;
this.listener = new Server();
this.clients = [];
this.listener.on('connection', (socket) => this.onConnection(socket));
this.banmgr = banmgr;
}
private onConnection(socket: Socket) {
private async onConnection(socket: Socket) {
this.logger.info(`New TCP connection from ${socket.remoteAddress}`);
if (await this.banmgr.isIPBanned(socket.remoteAddress!)) {
socket.write("6.banned;");
socket.destroy();
return;
}
var client = new TCPClient(socket);
this.clients.push(client);
this.emit('connect', new User(client, IPDataManager.GetIPData(client.getIP()), this.Config));

View File

@@ -7,6 +7,7 @@ import { execa, execaCommand, ExecaSyncError } from 'execa';
import NetworkClient from './NetworkClient.js';
import { CollabVMCapabilities } from '@cvmts/collab-vm-1.2-binary-protocol';
import pino from 'pino';
import { BanManager } from './BanManager.js';
export class User {
socket: NetworkClient;
@@ -133,35 +134,11 @@ export class User {
this.sendMsg(cvm.guacEncode('chat', '', 'You are no longer muted.'));
}
private banCmdArgs(arg: string): string {
return arg.replace(/\$IP/g, this.IP.address).replace(/\$NAME/g, this.username || '');
}
async ban() {
async ban(banmgr: BanManager) {
// Prevent the user from taking turns or chatting, in case the ban command takes a while
this.IP.muted = true;
try {
if (Array.isArray(this.Config.collabvm.bancmd)) {
let args: string[] = this.Config.collabvm.bancmd.map((a: string) => this.banCmdArgs(a));
if (args.length || args[0].length) {
await execa(args.shift()!, args, { stdout: process.stdout, stderr: process.stderr });
this.kick();
} else {
this.logger.error(`Failed to ban ${this.IP.address} (${this.username}): Empty command`);
}
} else if (typeof this.Config.collabvm.bancmd == 'string') {
let cmd: string = this.banCmdArgs(this.Config.collabvm.bancmd);
if (cmd.length) {
await execaCommand(cmd, { stdout: process.stdout, stderr: process.stderr });
this.kick();
} else {
this.logger.error(`Failed to ban ${this.IP.address} (${this.username}): Empty command`);
}
}
} catch (e) {
this.logger.error(`Failed to ban ${this.IP.address} (${this.username}): ${(e as ExecaSyncError).shortMessage}`);
}
await banmgr.BanUser(this.IP.address, this.username || '');
await this.kick();
}
async kick() {

View File

@@ -9,6 +9,7 @@ import { IPDataManager } from '../IPData.js';
import WSClient from './WSClient.js';
import { User } from '../User.js';
import pino from 'pino';
import { BanManager } from '../BanManager.js';
export default class WSServer extends EventEmitter implements NetworkServer {
private httpServer: http.Server;
@@ -16,8 +17,9 @@ export default class WSServer extends EventEmitter implements NetworkServer {
private clients: WSClient[];
private Config: IConfig;
private logger = pino({ name: 'CVMTS.WSServer' });
private banmgr: BanManager;
constructor(config: IConfig) {
constructor(config: IConfig, banmgr: BanManager) {
super();
this.Config = config;
this.clients = [];
@@ -29,6 +31,7 @@ export default class WSServer extends EventEmitter implements NetworkServer {
res.write('This server only accepts WebSocket connections.');
res.end();
});
this.banmgr = banmgr;
}
start(): void {
@@ -41,7 +44,7 @@ export default class WSServer extends EventEmitter implements NetworkServer {
this.httpServer.close();
}
private httpOnUpgrade(req: http.IncomingMessage, socket: internal.Duplex, head: Buffer) {
private async httpOnUpgrade(req: http.IncomingMessage, socket: internal.Duplex, head: Buffer) {
var killConnection = () => {
socket.write('HTTP/1.1 400 Bad Request\n\n400 Bad Request');
socket.destroy();
@@ -120,6 +123,12 @@ export default class WSServer extends EventEmitter implements NetworkServer {
ip = req.socket.remoteAddress;
}
if (await this.banmgr.isIPBanned(ip)) {
socket.write("HTTP/1.1 403 Forbidden\n\nYou have been banned.");
socket.destroy();
return;
}
this.wsServer.handleUpgrade(req, socket, head, (ws: WebSocket) => {
this.wsServer.emit('connection', ws, req);
this.onConnection(ws, req, ip);

View File

@@ -13,6 +13,8 @@ import VM from './VM.js';
import VNCVM from './VNCVM/VNCVM.js';
import GeoIPDownloader from './GeoIPDownloader.js';
import pino from 'pino';
import { Database } from './Database.js';
import { BanManager } from './BanManager.js';
let logger = pino();
@@ -52,6 +54,20 @@ async function start() {
}
// Init the auth manager if enabled
let auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null;
// Database and ban manager
if (Config.bans.cvmban && !Config.mysql.enabled) {
logger.error("MySQL must be configured to use cvmban.");
process.exit(1);
}
if (!Config.bans.cvmban && !Config.bans.bancmd) {
logger.warn("Neither cvmban nor ban command are configured. Bans will not function.");
}
let db = undefined;
if (Config.mysql.enabled) {
db = new Database(Config.mysql);
await db.init();
}
let banmgr = new BanManager(Config.bans, db);
switch (Config.vm.type) {
case 'qemu': {
// Fire up the VM
@@ -79,14 +95,14 @@ async function start() {
await VM.Start();
// Start up the server
var CVM = new CollabVMServer(Config, VM, auth, geoipReader);
var CVM = new CollabVMServer(Config, VM, banmgr, auth, geoipReader);
var WS = new WSServer(Config);
var WS = new WSServer(Config, banmgr);
WS.on('connect', (client: User) => CVM.addUser(client));
WS.start();
if (Config.tcp.enabled) {
var TCP = new TCPServer(Config);
var TCP = new TCPServer(Config, banmgr);
TCP.on('connect', (client: User) => CVM.addUser(client));
TCP.start();
}

View File

@@ -10,6 +10,7 @@
"@parcel/packager-ts": "2.12.0",
"@parcel/transformer-sass": "2.12.0",
"@parcel/transformer-typescript-types": "2.12.0",
"@types/jsbn": "^1.2.33",
"@types/node": "^20.14.10",
"parcel": "^2.12.0",
"prettier": "^3.2.5",

895
yarn.lock

File diff suppressed because it is too large Load Diff