diff --git a/config.example.toml b/config.example.toml index 65f2fea..1284df5 100644 --- a/config.example.toml +++ b/config.example.toml @@ -10,6 +10,8 @@ proxyAllowedIps = ["127.0.0.1"] origin = false # Origins to accept connections from. originAllowedDomains = ["computernewb.com"] +# Maximum amount of active connections allowed from the same IP. +maxConnections = 3 [vm] qemuArgs = "qemu-system-x86_64" @@ -32,6 +34,8 @@ moderatorEnabled = true usernameblacklist = [] maxChatLength = 100 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} # Temporarily mute a user if they send more than x messages in n seconds automute = {enabled = true, seconds = 5, messages = 5} # How long a temporary mute lasts, in seconds diff --git a/package.json b/package.json index f281c80..1114024 100644 --- a/package.json +++ b/package.json @@ -10,19 +10,19 @@ "author": "Elijah R", "license": "GPL-3.0", "dependencies": { - "@types/node": "^18.11.18", + "@types/node": "^20.6.0", "@types/sharp": "^0.31.1", - "@types/ws": "^8.5.4", + "@types/ws": "^8.5.5", "async-mutex": "^0.4.0", - "canvas": "^2.11.0", - "execa": "^6.1.0", + "canvas": "^2.11.2", + "execa": "^8.0.1", "fs": "^0.0.1-security", - "jimp": "^0.16.2", + "jimp": "^0.22.10", "mnemonist": "^0.39.5", "rfb2": "github:elijahr2411/node-rfb2", "toml": "^3.0.0", - "typescript": "^4.9.5", - "ws": "^8.12.0" + "typescript": "^5.2.2", + "ws": "^8.14.1" }, "type": "module" } diff --git a/src/IConfig.ts b/src/IConfig.ts index 72babd4..9e2323d 100644 --- a/src/IConfig.ts +++ b/src/IConfig.ts @@ -6,6 +6,7 @@ export default interface IConfig { proxyAllowedIps : string[]; origin : boolean; originAllowedDomains : string[]; + maxConnections: number; }; vm : { qemuArgs : string; @@ -24,6 +25,10 @@ export default interface IConfig { usernameblacklist : string[]; maxChatLength : number; maxChatHistoryLength : number; + turnlimit : { + enabled: boolean, + maximum: number; + }; automute : { enabled: boolean; seconds: number; diff --git a/src/IPData.ts b/src/IPData.ts index 0f97b64..9a7c329 100644 --- a/src/IPData.ts +++ b/src/IPData.ts @@ -1,5 +1,5 @@ export class IPData { - tempMuteExpireTimeout? : NodeJS.Timer; + tempMuteExpireTimeout? : NodeJS.Timeout; muted: Boolean; vote: boolean | null; address: string; diff --git a/src/QEMUVM.ts b/src/QEMUVM.ts index e66086a..4da4900 100644 --- a/src/QEMUVM.ts +++ b/src/QEMUVM.ts @@ -1,8 +1,7 @@ -import { EventEmitter } from "events"; import IConfig from "./IConfig.js"; import * as rfb from 'rfb2'; import * as fs from 'fs'; -import { execa, ExecaChildProcess, execaCommand } from "execa"; +import { ExecaChildProcess, execaCommand } from "execa"; import QMPClient from "./QMPClient.js"; import BatchRects from "./RectBatcher.js"; import { createCanvas, Canvas, CanvasRenderingContext2D, createImageData } from "canvas"; @@ -25,13 +24,13 @@ export default class QEMUVM extends VM { processRestartErrorLevel : number; expectedExit : boolean; vncOpen : boolean; - vncUpdateInterval? : NodeJS.Timer; + vncUpdateInterval? : NodeJS.Timeout; rects : {height:number,width:number,x:number,y:number,data:Buffer}[]; rectMutex : Mutex; - vncReconnectTimeout? : NodeJS.Timer; - qmpReconnectTimeout? : NodeJS.Timer; - qemuRestartTimeout? : NodeJS.Timer; + vncReconnectTimeout? : NodeJS.Timeout; + qmpReconnectTimeout? : NodeJS.Timeout; + qemuRestartTimeout? : NodeJS.Timeout; constructor(Config : IConfig) { super(); diff --git a/src/RateLimiter.ts b/src/RateLimiter.ts index 11f219f..32399ea 100644 --- a/src/RateLimiter.ts +++ b/src/RateLimiter.ts @@ -5,7 +5,7 @@ export default class RateLimiter extends EventEmitter { private limit : number; private interval : number; private requestCount : number; - private limiter? : NodeJS.Timer; + private limiter? : NodeJS.Timeout; private limiterSet : boolean; constructor(limit : number, interval : number) { super(); diff --git a/src/User.ts b/src/User.ts index adfb4a4..8be7808 100644 --- a/src/User.ts +++ b/src/User.ts @@ -7,9 +7,9 @@ import RateLimiter from './RateLimiter.js'; import { execaCommand } from 'execa'; export class User { socket : WebSocket; - nopSendInterval : NodeJS.Timer; - msgRecieveInterval : NodeJS.Timer; - nopRecieveTimeout? : NodeJS.Timer; + nopSendInterval : NodeJS.Timeout; + msgRecieveInterval : NodeJS.Timeout; + nopRecieveTimeout? : NodeJS.Timeout; username? : string; connectedToNode : boolean; viewMode : number; diff --git a/src/WSServer.ts b/src/WSServer.ts index dba0fda..be294da 100644 --- a/src/WSServer.ts +++ b/src/WSServer.ts @@ -11,9 +11,9 @@ import Queue from 'mnemonist/queue.js'; import { createHash } from 'crypto'; import { isIP } from 'net'; import QEMUVM from './QEMUVM.js'; -import { Canvas, createCanvas, CanvasRenderingContext2D } from 'canvas'; +import { Canvas, createCanvas } from 'canvas'; import { IPData } from './IPData.js'; -import { read, readFileSync } from 'fs'; +import { readFileSync } from 'fs'; import log from './log.js'; import VM from './VM.js'; import { fileURLToPath } from 'url'; @@ -32,19 +32,17 @@ export default class WSServer { // Time remaining on the current turn private TurnTime : number; // Interval to keep track of the current turn time - private TurnInterval? : NodeJS.Timer; - // Is the turn interval running? - private TurnIntervalRunning : boolean; + private TurnInterval? : NodeJS.Timeout; // If a reset vote is in progress private voteInProgress : boolean; // Interval to keep track of vote resets - private voteInterval? : NodeJS.Timer; + private voteInterval? : NodeJS.Timeout; // How much time is left on the vote private voteTime : number; // How much time until another reset vote can be cast private voteCooldown : number; // Interval to keep track - private voteCooldownInterval? : NodeJS.Timer; + private voteCooldownInterval? : NodeJS.Timeout; // Completely disable turns private turnsAllowed : boolean; // Hide the screen @@ -61,7 +59,6 @@ export default class WSServer { this.ChatHistory = new CircularBuffer<{user:string,msg:string}>(Array, this.Config.collabvm.maxChatHistoryLength); this.TurnQueue = new Queue(); this.TurnTime = 0; - this.TurnIntervalRunning = false; this.clients = []; this.ips = []; this.voteInProgress = false; @@ -140,7 +137,7 @@ export default class WSServer { // Get the first IP from the X-Forwarded-For variable _ip = req.headers["x-forwarded-for"]?.toString().replace(/\ /g, "").split(",")[0]; } catch { - // If we can't get the ip, kill the connection + // If we can't get the IP, kill the connection killConnection(); return; } @@ -149,7 +146,7 @@ export default class WSServer { killConnection(); return; } - // Make sure the ip is valid. If not, kill the connection. + // Make sure the IP is valid. If not, kill the connection. if (!isIP(_ip)) { killConnection(); return; @@ -157,11 +154,8 @@ export default class WSServer { //@ts-ignore req.proxiedIP = _ip; } - this.socket.handleUpgrade(req, socket, head, (ws) => this.socket.emit('connection', ws, req)); - } - private onConnection(ws : WebSocket, req : http.IncomingMessage) { - var ip: string; + let ip: string; if (this.Config.http.proxying) { //@ts-ignore if (!req.proxiedIP) return; @@ -172,23 +166,45 @@ export default class WSServer { ip = req.socket.remoteAddress; } - var _ipdata = this.ips.filter(data => data.address == ip); + //@ts-ignore + req.IP = ip; + + // Get the amount of active connections coming from the requesting IP. + let connections = this.clients.filter(client => client.IP.address == ip); + // If it exceeds the limit set in the config, reject the connection with a 429. + if(connections.length + 1 > this.Config.http.maxConnections) { + socket.write("HTTP/1.1 429 Too Many Requests\n\n429 Too Many Requests"); + socket.destroy(); + } + + this.socket.handleUpgrade(req, socket, head, (ws: WebSocket) => this.socket.emit('connection', ws, req)); + } + + private onConnection(ws : WebSocket, req: http.IncomingMessage) { + //@ts-ignore + var _ipdata = this.ips.filter(data => data.address == req.IP); var ipdata; if(_ipdata.length > 0) { ipdata = _ipdata[0]; }else{ - ipdata = new IPData(ip); + //@ts-ignore + ipdata = new IPData(req.IP); this.ips.push(ipdata); } var user = new User(ws, ipdata, this.Config); this.clients.push(user); + ws.on('error', (e) => { + //@ts-ignore + log("ERROR", `${e} (caused by connection ${req.IP})`); + ws.close(); + }); ws.on('close', () => this.connectionClosed(user)); ws.on('message', (e) => { var msg; try {msg = e.toString()} catch { - // Fuck the user off if they send a non-string message + // Close the user's connection if they send a non-string message user.closeConnection(); return; } @@ -199,7 +215,10 @@ export default class WSServer { }; private connectionClosed(user : User) { - if(user.IP.vote != null) user.IP.vote = null; + if(user.IP.vote != null) { + user.IP.vote = null; + this.sendVoteUpdate(); + }; if (this.indefiniteTurn === user) this.indefiniteTurn = null; this.clients.splice(this.clients.indexOf(user), 1); log("INFO", `Disconnect From ${user.IP.address}${user.username ? ` with username ${user.username}` : ""}`); @@ -322,13 +341,17 @@ export default class WSServer { } if (takingTurn) { var currentQueue = this.TurnQueue.toArray(); - // If the user is already in the queue, fuck them off + // If the user is already in the turn queue, ignore the turn request. if (currentQueue.indexOf(client) !== -1) return; - // If they're muted, also fuck them off. + // If they're muted, also ignore the turn request. // Send them the turn queue to prevent client glitches if (client.IP.muted) return; - // Only allow one active turn per IP address - if(currentQueue.find(user => user.IP.address == client.IP.address)) return; + if(this.Config.collabvm.turnlimit.enabled) { + // Get the amount of users in the turn queue with the same IP as the user requesting a turn. + let turns = currentQueue.filter(user => user.IP.address == client.IP.address); + // If it exceeds the limit set in the config, ignore the turn request. + if(turns.length + 1 > this.Config.collabvm.turnlimit.maximum) return; + } this.TurnQueue.enqueue(client); if (this.TurnQueue.size === 1) this.nextTurn(); } else { @@ -684,7 +707,6 @@ export default class WSServer { private nextTurn() { clearInterval(this.TurnInterval); if (this.TurnQueue.size === 0) { - this.TurnIntervalRunning = false; } else { this.TurnTime = this.Config.collabvm.turnTime; this.TurnInterval = setInterval(() => this.turnInterval(), 1000); @@ -694,7 +716,6 @@ export default class WSServer { clearTurns() { clearInterval(this.TurnInterval); - this.TurnIntervalRunning = false; this.TurnQueue.clear(); this.sendTurnUpdate(); } diff --git a/src/index.ts b/src/index.ts index ef7ce67..d11b272 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,15 @@ try { async function start() { + // Print a warning if qmpSockDir is set + // and the host OS is Windows, as this + // configuration will very likely not work. + if(process.platform === "win32" && Config.vm.qmpSockDir) { + log("WARN", "You appear to have the option 'qmpSockDir' enabled in the config.") + log("WARN", "This is not supported on Windows, and you will likely run into issues."); + log("WARN", "To remove this warning, use the qmpHost and qmpPort options instead."); + } + // Fire up the VM var VM = new QEMUVM(Config); await VM.Start(); diff --git a/src/log.ts b/src/log.ts index 5cbb325..5c32f66 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,3 +1,7 @@ export default function log(loglevel : string, message : string) { - console[(loglevel === "ERROR" || loglevel === "FATAL") ? "error" : "log"](`[${new Date().toLocaleString()}] [${loglevel}] ${message}`); + console[ + (loglevel === "ERROR" || loglevel === "FATAL") ? "error" : + (loglevel === "WARN") ? "warn" : + "log" + ](`[${new Date().toLocaleString()}] [${loglevel}] ${message}`); } \ No newline at end of file