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>
This commit is contained in:
MDMCK10
2023-02-11 15:58:20 +01:00
committed by GitHub
parent 006edd4453
commit cea28ebf8c
7 changed files with 93 additions and 45 deletions

View File

@@ -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 "<password>" | sha256sum -
# Example hash is hunter2 and hunter3

View File

@@ -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;

12
src/IPData.ts Normal file
View File

@@ -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;
}
}

View File

@@ -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);
}
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());
}

View File

@@ -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 {
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();
}

View File

@@ -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);

View File

@@ -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<User>;
// 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());
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++;
});