From cea28ebf8c43287782aebb6c3c2c9d789586a44e Mon Sep 17 00:00:00 2001 From: MDMCK10 <21245760+MDMCK10@users.noreply.github.com> Date: Sat, 11 Feb 2023 15:58:20 +0100 Subject: [PATCH] Add IPData, Windows support, bugfixes (#2) * Add IPData, Windows support, bugfixes Changes: - Added IPData: mutes and votes are now tracked per-IP instead of per-user. - Windows support: QMP over TCP can now be enabled in the config. - Vote cooldown can now be specified in the config. - Fixed bugs: "Username is already taken" message appearing when it shouldn't * Remove vote from closed connections --------- Co-authored-by: Elijah R <62162399+elijahr2411@users.noreply.github.com> --- config.example.toml | 6 ++++ src/IConfig.ts | 5 +++- src/IPData.ts | 12 ++++++++ src/QEMUVM.ts | 12 ++++++-- src/QMPClient.ts | 11 +++++-- src/User.ts | 22 ++++++-------- src/WSServer.ts | 70 ++++++++++++++++++++++++++++----------------- 7 files changed, 93 insertions(+), 45 deletions(-) create mode 100644 src/IPData.ts diff --git a/config.example.toml b/config.example.toml index 0fbfa9a..c4d7ae7 100644 --- a/config.example.toml +++ b/config.example.toml @@ -11,6 +11,10 @@ proxyAllowedIps = ["127.0.0.1"] qemuArgs = "qemu-system-x86_64" vncPort = 5900 snapshots = true +# Uncomment qmpHost and qmpPort if you're using Windows. +#qmpHost = "127.0.0.1" +#qmpPort = "5800" +# Comment out qmpSockDir if you're using Windows. qmpSockDir = "/tmp/" [collabvm] @@ -31,6 +35,8 @@ tempMuteTime = 30 turnTime = 20 # How long a reset vote lasts, in seconds voteTime = 100 +# How long until another vote can be started, in seconds +voteCooldown = 180 # SHA256 sum of the admin and mod passwords. This can be generated with the following command: # printf "" | sha256sum - # Example hash is hunter2 and hunter3 diff --git a/src/IConfig.ts b/src/IConfig.ts index 1a9e3c4..9d0f81e 100644 --- a/src/IConfig.ts +++ b/src/IConfig.ts @@ -9,7 +9,9 @@ export default interface IConfig { qemuArgs : string; vncPort : number; snapshots : boolean; - qmpSockDir : string; + qmpHost : string | null; + qmpPort : number | null; + qmpSockDir : string | null; }; collabvm : { node : string; @@ -27,6 +29,7 @@ export default interface IConfig { tempMuteTime : number; turnTime : number; voteTime : number; + voteCooldown: number; adminpass : string; modpass : string; moderatorPermissions : Permissions; diff --git a/src/IPData.ts b/src/IPData.ts new file mode 100644 index 0000000..0f97b64 --- /dev/null +++ b/src/IPData.ts @@ -0,0 +1,12 @@ +export class IPData { + tempMuteExpireTimeout? : NodeJS.Timer; + muted: Boolean; + vote: boolean | null; + address: string; + + constructor(address: string) { + this.address = address; + this.muted = false; + this.vote = null; + } +} \ No newline at end of file diff --git a/src/QEMUVM.ts b/src/QEMUVM.ts index 0935471..d936485 100644 --- a/src/QEMUVM.ts +++ b/src/QEMUVM.ts @@ -14,6 +14,7 @@ export default class QEMUVM extends EventEmitter { framebuffer : Canvas; framebufferCtx : CanvasRenderingContext2D; qmpSock : string; + qmpType: string; qmpClient : QMPClient; qemuCmd : string; qemuProcess? : ExecaChildProcess; @@ -36,9 +37,14 @@ export default class QEMUVM extends EventEmitter { console.error("[FATAL] VNC Port must be 5900 or higher"); process.exit(1); } - this.qmpSock = `${Config.vm.qmpSockDir}collab-vm-qmp-${Config.collabvm.node}.sock`; + Config.vm.qmpSockDir == null ? this.qmpType = "tcp:" : this.qmpType = "unix:"; + if(this.qmpType == "tcp:") { + this.qmpSock = `${Config.vm.qmpHost}:${Config.vm.qmpPort}`; + }else{ + this.qmpSock = `${Config.vm.qmpSockDir}collab-vm-qmp-${Config.collabvm.node}.sock`; + } this.vncPort = Config.vm.vncPort; - this.qemuCmd = `${Config.vm.qemuArgs} -snapshot -no-shutdown -vnc 127.0.0.1:${this.vncPort - 5900} -qmp unix:${this.qmpSock},server`; + this.qemuCmd = `${Config.vm.qemuArgs} -snapshot -no-shutdown -vnc 127.0.0.1:${this.vncPort - 5900} -qmp ${this.qmpType}${this.qmpSock},server`; this.qmpErrorLevel = 0; this.vncErrorLevel = 0; this.vncOpen = true; @@ -48,7 +54,7 @@ export default class QEMUVM extends EventEmitter { this.framebufferCtx = this.framebuffer.getContext("2d"); this.processRestartErrorLevel = 0; this.expectedExit = false; - this.qmpClient = new QMPClient(this.qmpSock); + this.qmpClient = new QMPClient(this.qmpSock, this.qmpType); this.qmpClient.on('connected', () => this.qmpConnected()); this.qmpClient.on('close', () => this.qmpClosed()); } diff --git a/src/QMPClient.ts b/src/QMPClient.ts index cdd64c0..2a7c4c2 100644 --- a/src/QMPClient.ts +++ b/src/QMPClient.ts @@ -4,12 +4,14 @@ import { Mutex } from "async-mutex"; export default class QMPClient extends EventEmitter { socketfile : string; + sockettype: string; socket : Socket; connected : boolean; sentConnected : boolean; cmdMutex : Mutex; // So command outputs don't get mixed up - constructor(socketfile : string) { + constructor(socketfile : string, sockettype: string) { super(); + this.sockettype = sockettype; this.socketfile = socketfile; this.socket = new Socket(); this.connected = false; @@ -20,7 +22,12 @@ export default class QMPClient extends EventEmitter { return new Promise((res, rej) => { if (this.connected) {res(); return;} try { - this.socket.connect(this.socketfile); + if(this.sockettype == "tcp:") { + let _sock = this.socketfile.split(':'); + this.socket.connect(parseInt(_sock[1]), _sock[0]); + }else{ + this.socket.connect(this.socketfile); + } } catch (e) { this.onClose(); } diff --git a/src/User.ts b/src/User.ts index c6066e6..0341e40 100644 --- a/src/User.ts +++ b/src/User.ts @@ -1,6 +1,7 @@ import * as Utilities from './Utilities.js'; import * as guacutils from './guacutils.js'; import {WebSocket} from 'ws'; +import {IPData} from './IPData.js'; import IConfig from './IConfig.js'; import RateLimiter from './RateLimiter.js'; import { execaCommand } from 'execa'; @@ -12,25 +13,20 @@ export class User { username? : string; connectedToNode : boolean; rank : Rank; - muted : Boolean; - tempMuteExpireTimeout? : NodeJS.Timer; msgsSent : number; Config : IConfig; - IP : string; - vote : boolean | null; + IP : IPData; // Rate limiters ChatRateLimit : RateLimiter; LoginRateLimit : RateLimiter; RenameRateLimit : RateLimiter; TurnRateLimit : RateLimiter; - constructor(ws : WebSocket, ip : string, config : IConfig, username? : string, node? : string) { + constructor(ws : WebSocket, ip : IPData, config : IConfig, username? : string, node? : string) { this.IP = ip; this.connectedToNode = false; this.Config = config; this.socket = ws; - this.muted = false; this.msgsSent = 0; - this.vote = null; this.socket.on('close', () => { clearInterval(this.nopSendInterval); }); @@ -86,22 +82,22 @@ export class User { this.ChatRateLimit.request(); } mute(permanent : boolean) { - this.muted = true; + this.IP.muted = true; this.sendMsg(guacutils.encode("chat", "", `You have been muted${permanent ? "" : ` for ${this.Config.collabvm.tempMuteTime} seconds`}.`)); if (!permanent) { - clearTimeout(this.tempMuteExpireTimeout); - this.tempMuteExpireTimeout = setTimeout(() => this.unmute(), this.Config.collabvm.tempMuteTime * 1000); + clearTimeout(this.IP.tempMuteExpireTimeout); + this.IP.tempMuteExpireTimeout = setTimeout(() => this.unmute(), this.Config.collabvm.tempMuteTime * 1000); } } unmute() { - clearTimeout(this.tempMuteExpireTimeout); - this.muted = false; + clearTimeout(this.IP.tempMuteExpireTimeout); + this.IP.muted = false; this.sendMsg(guacutils.encode("chat", "", "You are no longer muted.")); } async ban() { // Prevent the user from taking turns or chatting, in case the ban command takes a while - this.muted = true; + this.IP.muted = true; //@ts-ignore var cmd = this.Config.collabvm.bancmd.replace(/\$IP/g, this.IP).replace(/\$NAME/g, this.username); await execaCommand(cmd); diff --git a/src/WSServer.ts b/src/WSServer.ts index b6c2d9f..6482ec8 100644 --- a/src/WSServer.ts +++ b/src/WSServer.ts @@ -12,12 +12,14 @@ import { createHash } from 'crypto'; import { isIP } from 'net'; import QEMUVM from './QEMUVM.js'; import { Canvas, createCanvas, CanvasRenderingContext2D } from 'canvas'; +import { IPData } from './IPData.js'; export default class WSServer { private Config : IConfig; private server : http.Server; private socket : WebSocketServer; private clients : User[]; + private ips : IPData[]; private ChatHistory : CircularBuffer<{user:string,msg:string}> private TurnQueue : Queue; // Time remaining on the current turn @@ -33,9 +35,9 @@ export default class WSServer { // How much time is left on the vote private voteTime : number; // How much time until another reset vote can be cast - private voteTimeout : number; + private voteCooldown : number; // Interval to keep track - private voteTimeoutInterval? : NodeJS.Timer; + private voteCooldownInterval? : NodeJS.Timer; // Completely disable turns private turnsAllowed : boolean; // Indefinite turn @@ -48,10 +50,11 @@ export default class WSServer { this.TurnTime = 0; this.TurnIntervalRunning = false; this.clients = []; + this.ips = []; this.Config = config; this.voteInProgress = false; this.voteTime = 0; - this.voteTimeout = 0; + this.voteCooldown = 0; this.turnsAllowed = true; this.indefiniteTurn = null; this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions); @@ -120,7 +123,7 @@ export default class WSServer { } private onConnection(ws : WebSocket, req : http.IncomingMessage) { - var ip; + var ip: string; if (this.Config.http.proxying) { //@ts-ignore if (!req.proxiedIP) return; @@ -130,7 +133,17 @@ export default class WSServer { if (!req.socket.remoteAddress) return; ip = req.socket.remoteAddress; } - var user = new User(ws, ip, this.Config); + + var _ipdata = this.ips.filter(data => data.address == ip); + var ipdata; + if(_ipdata.length > 0) { + ipdata = _ipdata[0]; + }else{ + ipdata = new IPData(ip); + this.ips.push(ipdata); + } + + var user = new User(ws, ipdata, this.Config); this.clients.push(user); ws.on('close', () => this.connectionClosed(user)); ws.on('message', (e) => { @@ -144,12 +157,13 @@ export default class WSServer { this.onMessage(user, msg); }); user.sendMsg(this.getAdduserMsg()); - console.log(`[Connect] From ${user.IP}`); + console.log(`[Connect] From ${user.IP.address}`); }; private connectionClosed(user : User) { + if(user.IP.vote != null) user.IP.vote = null; this.clients.splice(this.clients.indexOf(user), 1); - console.log(`[DISCONNECT] From ${user.IP}${user.username ? ` with username ${user.username}` : ""}`); + console.log(`[DISCONNECT] From ${user.IP.address}${user.username ? ` with username ${user.username}` : ""}`); if (!user.username) return; if (this.TurnQueue.toArray().indexOf(user) !== -1) { var hadturn = (this.TurnQueue.peek() === user); @@ -189,7 +203,7 @@ export default class WSServer { break; case "chat": if (!client.username) return; - if (client.muted) return; + if (client.IP.muted) return; if (msgArr.length !== 2) return; var msg = Utilities.HTMLSanitize(msgArr[1]); // One of the things I hated most about the old server is it completely discarded your message if it was too long @@ -226,7 +240,7 @@ export default class WSServer { if (this.TurnQueue.toArray().indexOf(client) !== -1) return; // If they're muted, also fuck them off. // Send them the turn queue to prevent client glitches - if (client.muted) return; + if (client.IP.muted) return; this.TurnQueue.enqueue(client); if (this.TurnQueue.size === 1) this.nextTurn(); } else { @@ -261,22 +275,22 @@ export default class WSServer { switch (msgArr[1]) { case "1": if (!this.voteInProgress) { - if (this.voteTimeout !== 0) { - client.sendMsg(guacutils.encode("vote", "3", this.voteTimeout.toString())); + if (this.voteCooldown !== 0) { + client.sendMsg(guacutils.encode("vote", "3", this.voteCooldown.toString())); return; } this.startVote(); this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has started a vote to reset the VM.`))); } - else if (client.vote !== true) + else if (client.IP.vote !== true) this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has voted yes.`))); - client.vote = true; + client.IP.vote = true; break; case "0": if (!this.voteInProgress) return; - if (client.vote !== false) + if (client.IP.vote !== false) this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has voted no.`))); - client.vote = false; + client.IP.vote = false; break; } this.sendVoteUpdate(); @@ -397,7 +411,7 @@ export default class WSServer { if (msgArr.length !== 3) return; var user = this.clients.find(c => c.username === msgArr[2]); if (!user) return; - client.sendMsg(guacutils.encode("admin", "19", msgArr[2], user.IP)); + client.sendMsg(guacutils.encode("admin", "19", msgArr[2], user.IP.address)); break; case "20": // Steal turn @@ -473,7 +487,9 @@ export default class WSServer { } if (this.getUsernameList().indexOf(newName) !== -1) { client.assignGuestName(this.getUsernameList()); - status = "1"; + if(client.connectedToNode) { + status = "1"; + } } else if (!/^[a-zA-Z0-9\ \-\_\.]+$/.test(newName) || newName.length > 20 || newName.length < 3) { client.assignGuestName(this.getUsernameList()); @@ -487,12 +503,12 @@ export default class WSServer { //@ts-ignore client.sendMsg(guacutils.encode("rename", "0", status, client.username)); if (hadName) { - console.log(`[RENAME] ${client.IP} from ${oldname} to ${client.username}`); + console.log(`[RENAME] ${client.IP.address} from ${oldname} to ${client.username}`); this.clients.filter(c => c.username !== client.username).forEach((c) => //@ts-ignore c.sendMsg(guacutils.encode("rename", "1", oldname, client.username))); } else { - console.log(`[RENAME] ${client.IP} to ${client.username}`); + console.log(`[RENAME] ${client.IP.address} to ${client.username}`); this.clients.forEach((c) => //@ts-ignore c.sendMsg(guacutils.encode("adduser", "1", client.username, client.rank))); @@ -624,12 +640,14 @@ export default class WSServer { } else { this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", "The vote to reset the VM has lost."))); } - this.clients.forEach(c => c.vote = null); - this.voteTimeout = 180; - this.voteTimeoutInterval = setInterval(() => { - this.voteTimeout--; - if (this.voteTimeout < 1) - clearInterval(this.voteTimeoutInterval); + this.clients.forEach(c => { + c.IP.vote = null; + }); + this.voteCooldown = this.Config.collabvm.voteCooldown; + this.voteCooldownInterval = setInterval(() => { + this.voteCooldown--; + if (this.voteCooldown < 1) + clearInterval(this.voteCooldownInterval); }, 1000); } @@ -646,7 +664,7 @@ export default class WSServer { getVoteCounts() : {yes:number,no:number} { var yes = 0; var no = 0; - this.clients.forEach((c) => { + this.ips.forEach((c) => { if (c.vote === true) yes++; if (c.vote === false) no++; });