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" qemuArgs = "qemu-system-x86_64"
vncPort = 5900 vncPort = 5900
snapshots = true 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/" qmpSockDir = "/tmp/"
[collabvm] [collabvm]
@@ -31,6 +35,8 @@ tempMuteTime = 30
turnTime = 20 turnTime = 20
# How long a reset vote lasts, in seconds # How long a reset vote lasts, in seconds
voteTime = 100 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: # SHA256 sum of the admin and mod passwords. This can be generated with the following command:
# printf "<password>" | sha256sum - # printf "<password>" | sha256sum -
# Example hash is hunter2 and hunter3 # Example hash is hunter2 and hunter3

View File

@@ -9,7 +9,9 @@ export default interface IConfig {
qemuArgs : string; qemuArgs : string;
vncPort : number; vncPort : number;
snapshots : boolean; snapshots : boolean;
qmpSockDir : string; qmpHost : string | null;
qmpPort : number | null;
qmpSockDir : string | null;
}; };
collabvm : { collabvm : {
node : string; node : string;
@@ -27,6 +29,7 @@ export default interface IConfig {
tempMuteTime : number; tempMuteTime : number;
turnTime : number; turnTime : number;
voteTime : number; voteTime : number;
voteCooldown: number;
adminpass : string; adminpass : string;
modpass : string; modpass : string;
moderatorPermissions : Permissions; 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; framebuffer : Canvas;
framebufferCtx : CanvasRenderingContext2D; framebufferCtx : CanvasRenderingContext2D;
qmpSock : string; qmpSock : string;
qmpType: string;
qmpClient : QMPClient; qmpClient : QMPClient;
qemuCmd : string; qemuCmd : string;
qemuProcess? : ExecaChildProcess; qemuProcess? : ExecaChildProcess;
@@ -36,9 +37,14 @@ export default class QEMUVM extends EventEmitter {
console.error("[FATAL] VNC Port must be 5900 or higher"); console.error("[FATAL] VNC Port must be 5900 or higher");
process.exit(1); 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.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.qmpErrorLevel = 0;
this.vncErrorLevel = 0; this.vncErrorLevel = 0;
this.vncOpen = true; this.vncOpen = true;
@@ -48,7 +54,7 @@ export default class QEMUVM extends EventEmitter {
this.framebufferCtx = this.framebuffer.getContext("2d"); this.framebufferCtx = this.framebuffer.getContext("2d");
this.processRestartErrorLevel = 0; this.processRestartErrorLevel = 0;
this.expectedExit = false; 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('connected', () => this.qmpConnected());
this.qmpClient.on('close', () => this.qmpClosed()); this.qmpClient.on('close', () => this.qmpClosed());
} }

View File

@@ -4,12 +4,14 @@ import { Mutex } from "async-mutex";
export default class QMPClient extends EventEmitter { export default class QMPClient extends EventEmitter {
socketfile : string; socketfile : string;
sockettype: string;
socket : Socket; socket : Socket;
connected : boolean; connected : boolean;
sentConnected : boolean; sentConnected : boolean;
cmdMutex : Mutex; // So command outputs don't get mixed up cmdMutex : Mutex; // So command outputs don't get mixed up
constructor(socketfile : string) { constructor(socketfile : string, sockettype: string) {
super(); super();
this.sockettype = sockettype;
this.socketfile = socketfile; this.socketfile = socketfile;
this.socket = new Socket(); this.socket = new Socket();
this.connected = false; this.connected = false;
@@ -20,7 +22,12 @@ export default class QMPClient extends EventEmitter {
return new Promise((res, rej) => { return new Promise((res, rej) => {
if (this.connected) {res(); return;} if (this.connected) {res(); return;}
try { 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) { } catch (e) {
this.onClose(); this.onClose();
} }

View File

@@ -1,6 +1,7 @@
import * as Utilities from './Utilities.js'; import * as Utilities from './Utilities.js';
import * as guacutils from './guacutils.js'; import * as guacutils from './guacutils.js';
import {WebSocket} from 'ws'; import {WebSocket} from 'ws';
import {IPData} from './IPData.js';
import IConfig from './IConfig.js'; import IConfig from './IConfig.js';
import RateLimiter from './RateLimiter.js'; import RateLimiter from './RateLimiter.js';
import { execaCommand } from 'execa'; import { execaCommand } from 'execa';
@@ -12,25 +13,20 @@ export class User {
username? : string; username? : string;
connectedToNode : boolean; connectedToNode : boolean;
rank : Rank; rank : Rank;
muted : Boolean;
tempMuteExpireTimeout? : NodeJS.Timer;
msgsSent : number; msgsSent : number;
Config : IConfig; Config : IConfig;
IP : string; IP : IPData;
vote : boolean | null;
// Rate limiters // Rate limiters
ChatRateLimit : RateLimiter; ChatRateLimit : RateLimiter;
LoginRateLimit : RateLimiter; LoginRateLimit : RateLimiter;
RenameRateLimit : RateLimiter; RenameRateLimit : RateLimiter;
TurnRateLimit : 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.IP = ip;
this.connectedToNode = false; this.connectedToNode = false;
this.Config = config; this.Config = config;
this.socket = ws; this.socket = ws;
this.muted = false;
this.msgsSent = 0; this.msgsSent = 0;
this.vote = null;
this.socket.on('close', () => { this.socket.on('close', () => {
clearInterval(this.nopSendInterval); clearInterval(this.nopSendInterval);
}); });
@@ -86,22 +82,22 @@ export class User {
this.ChatRateLimit.request(); this.ChatRateLimit.request();
} }
mute(permanent : boolean) { 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`}.`)); this.sendMsg(guacutils.encode("chat", "", `You have been muted${permanent ? "" : ` for ${this.Config.collabvm.tempMuteTime} seconds`}.`));
if (!permanent) { if (!permanent) {
clearTimeout(this.tempMuteExpireTimeout); clearTimeout(this.IP.tempMuteExpireTimeout);
this.tempMuteExpireTimeout = setTimeout(() => this.unmute(), this.Config.collabvm.tempMuteTime * 1000); this.IP.tempMuteExpireTimeout = setTimeout(() => this.unmute(), this.Config.collabvm.tempMuteTime * 1000);
} }
} }
unmute() { unmute() {
clearTimeout(this.tempMuteExpireTimeout); clearTimeout(this.IP.tempMuteExpireTimeout);
this.muted = false; this.IP.muted = false;
this.sendMsg(guacutils.encode("chat", "", "You are no longer muted.")); this.sendMsg(guacutils.encode("chat", "", "You are no longer muted."));
} }
async ban() { async ban() {
// Prevent the user from taking turns or chatting, in case the ban command takes a while // 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 //@ts-ignore
var cmd = this.Config.collabvm.bancmd.replace(/\$IP/g, this.IP).replace(/\$NAME/g, this.username); var cmd = this.Config.collabvm.bancmd.replace(/\$IP/g, this.IP).replace(/\$NAME/g, this.username);
await execaCommand(cmd); await execaCommand(cmd);

View File

@@ -12,12 +12,14 @@ import { createHash } from 'crypto';
import { isIP } from 'net'; import { isIP } from 'net';
import QEMUVM from './QEMUVM.js'; import QEMUVM from './QEMUVM.js';
import { Canvas, createCanvas, CanvasRenderingContext2D } from 'canvas'; import { Canvas, createCanvas, CanvasRenderingContext2D } from 'canvas';
import { IPData } from './IPData.js';
export default class WSServer { export default class WSServer {
private Config : IConfig; private Config : IConfig;
private server : http.Server; private server : http.Server;
private socket : WebSocketServer; private socket : WebSocketServer;
private clients : User[]; private clients : User[];
private ips : IPData[];
private ChatHistory : CircularBuffer<{user:string,msg:string}> private ChatHistory : CircularBuffer<{user:string,msg:string}>
private TurnQueue : Queue<User>; private TurnQueue : Queue<User>;
// Time remaining on the current turn // Time remaining on the current turn
@@ -33,9 +35,9 @@ export default class WSServer {
// How much time is left on the vote // How much time is left on the vote
private voteTime : number; private voteTime : number;
// How much time until another reset vote can be cast // How much time until another reset vote can be cast
private voteTimeout : number; private voteCooldown : number;
// Interval to keep track // Interval to keep track
private voteTimeoutInterval? : NodeJS.Timer; private voteCooldownInterval? : NodeJS.Timer;
// Completely disable turns // Completely disable turns
private turnsAllowed : boolean; private turnsAllowed : boolean;
// Indefinite turn // Indefinite turn
@@ -48,10 +50,11 @@ export default class WSServer {
this.TurnTime = 0; this.TurnTime = 0;
this.TurnIntervalRunning = false; this.TurnIntervalRunning = false;
this.clients = []; this.clients = [];
this.ips = [];
this.Config = config; this.Config = config;
this.voteInProgress = false; this.voteInProgress = false;
this.voteTime = 0; this.voteTime = 0;
this.voteTimeout = 0; this.voteCooldown = 0;
this.turnsAllowed = true; this.turnsAllowed = true;
this.indefiniteTurn = null; this.indefiniteTurn = null;
this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions); this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions);
@@ -120,7 +123,7 @@ export default class WSServer {
} }
private onConnection(ws : WebSocket, req : http.IncomingMessage) { private onConnection(ws : WebSocket, req : http.IncomingMessage) {
var ip; var ip: string;
if (this.Config.http.proxying) { if (this.Config.http.proxying) {
//@ts-ignore //@ts-ignore
if (!req.proxiedIP) return; if (!req.proxiedIP) return;
@@ -130,7 +133,17 @@ export default class WSServer {
if (!req.socket.remoteAddress) return; if (!req.socket.remoteAddress) return;
ip = req.socket.remoteAddress; 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); this.clients.push(user);
ws.on('close', () => this.connectionClosed(user)); ws.on('close', () => this.connectionClosed(user));
ws.on('message', (e) => { ws.on('message', (e) => {
@@ -144,12 +157,13 @@ export default class WSServer {
this.onMessage(user, msg); this.onMessage(user, msg);
}); });
user.sendMsg(this.getAdduserMsg()); user.sendMsg(this.getAdduserMsg());
console.log(`[Connect] From ${user.IP}`); console.log(`[Connect] From ${user.IP.address}`);
}; };
private connectionClosed(user : User) { private connectionClosed(user : User) {
if(user.IP.vote != null) user.IP.vote = null;
this.clients.splice(this.clients.indexOf(user), 1); 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 (!user.username) return;
if (this.TurnQueue.toArray().indexOf(user) !== -1) { if (this.TurnQueue.toArray().indexOf(user) !== -1) {
var hadturn = (this.TurnQueue.peek() === user); var hadturn = (this.TurnQueue.peek() === user);
@@ -189,7 +203,7 @@ export default class WSServer {
break; break;
case "chat": case "chat":
if (!client.username) return; if (!client.username) return;
if (client.muted) return; if (client.IP.muted) return;
if (msgArr.length !== 2) return; if (msgArr.length !== 2) return;
var msg = Utilities.HTMLSanitize(msgArr[1]); 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 // 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 (this.TurnQueue.toArray().indexOf(client) !== -1) return;
// If they're muted, also fuck them off. // If they're muted, also fuck them off.
// Send them the turn queue to prevent client glitches // Send them the turn queue to prevent client glitches
if (client.muted) return; if (client.IP.muted) return;
this.TurnQueue.enqueue(client); this.TurnQueue.enqueue(client);
if (this.TurnQueue.size === 1) this.nextTurn(); if (this.TurnQueue.size === 1) this.nextTurn();
} else { } else {
@@ -261,22 +275,22 @@ export default class WSServer {
switch (msgArr[1]) { switch (msgArr[1]) {
case "1": case "1":
if (!this.voteInProgress) { if (!this.voteInProgress) {
if (this.voteTimeout !== 0) { if (this.voteCooldown !== 0) {
client.sendMsg(guacutils.encode("vote", "3", this.voteTimeout.toString())); client.sendMsg(guacutils.encode("vote", "3", this.voteCooldown.toString()));
return; return;
} }
this.startVote(); this.startVote();
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has started a vote to reset the VM.`))); 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.`))); this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has voted yes.`)));
client.vote = true; client.IP.vote = true;
break; break;
case "0": case "0":
if (!this.voteInProgress) return; 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.`))); this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has voted no.`)));
client.vote = false; client.IP.vote = false;
break; break;
} }
this.sendVoteUpdate(); this.sendVoteUpdate();
@@ -397,7 +411,7 @@ export default class WSServer {
if (msgArr.length !== 3) return; if (msgArr.length !== 3) return;
var user = this.clients.find(c => c.username === msgArr[2]); var user = this.clients.find(c => c.username === msgArr[2]);
if (!user) return; 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; break;
case "20": case "20":
// Steal turn // Steal turn
@@ -473,7 +487,9 @@ export default class WSServer {
} }
if (this.getUsernameList().indexOf(newName) !== -1) { if (this.getUsernameList().indexOf(newName) !== -1) {
client.assignGuestName(this.getUsernameList()); client.assignGuestName(this.getUsernameList());
status = "1"; if(client.connectedToNode) {
status = "1";
}
} else } else
if (!/^[a-zA-Z0-9\ \-\_\.]+$/.test(newName) || newName.length > 20 || newName.length < 3) { if (!/^[a-zA-Z0-9\ \-\_\.]+$/.test(newName) || newName.length > 20 || newName.length < 3) {
client.assignGuestName(this.getUsernameList()); client.assignGuestName(this.getUsernameList());
@@ -487,12 +503,12 @@ export default class WSServer {
//@ts-ignore //@ts-ignore
client.sendMsg(guacutils.encode("rename", "0", status, client.username)); client.sendMsg(guacutils.encode("rename", "0", status, client.username));
if (hadName) { 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) => this.clients.filter(c => c.username !== client.username).forEach((c) =>
//@ts-ignore //@ts-ignore
c.sendMsg(guacutils.encode("rename", "1", oldname, client.username))); c.sendMsg(guacutils.encode("rename", "1", oldname, client.username)));
} else { } else {
console.log(`[RENAME] ${client.IP} to ${client.username}`); console.log(`[RENAME] ${client.IP.address} to ${client.username}`);
this.clients.forEach((c) => this.clients.forEach((c) =>
//@ts-ignore //@ts-ignore
c.sendMsg(guacutils.encode("adduser", "1", client.username, client.rank))); c.sendMsg(guacutils.encode("adduser", "1", client.username, client.rank)));
@@ -624,12 +640,14 @@ export default class WSServer {
} else { } else {
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", "The vote to reset the VM has lost."))); 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.clients.forEach(c => {
this.voteTimeout = 180; c.IP.vote = null;
this.voteTimeoutInterval = setInterval(() => { });
this.voteTimeout--; this.voteCooldown = this.Config.collabvm.voteCooldown;
if (this.voteTimeout < 1) this.voteCooldownInterval = setInterval(() => {
clearInterval(this.voteTimeoutInterval); this.voteCooldown--;
if (this.voteCooldown < 1)
clearInterval(this.voteCooldownInterval);
}, 1000); }, 1000);
} }
@@ -646,7 +664,7 @@ export default class WSServer {
getVoteCounts() : {yes:number,no:number} { getVoteCounts() : {yes:number,no:number} {
var yes = 0; var yes = 0;
var no = 0; var no = 0;
this.clients.forEach((c) => { this.ips.forEach((c) => {
if (c.vote === true) yes++; if (c.vote === true) yes++;
if (c.vote === false) no++; if (c.vote === false) no++;
}); });