From 3235375581ed418fe12bb13e8bfc4037c939bf14 Mon Sep 17 00:00:00 2001 From: elijahr2411 Date: Tue, 31 Jan 2023 22:00:30 -0500 Subject: [PATCH] everything --- .gitignore | 3 + .npmrc | 1 + config.example.toml | 48 +++++++ package.json | 22 ++++ src/IConfig.ts | 46 +++++++ src/QEMUVM.ts | 70 ++++++++++ src/QMPClient.ts | 45 +++++++ src/RateLimiter.ts | 36 +++++ src/User.ts | 104 +++++++++++++++ src/Utilities.ts | 54 ++++++++ src/WSServer.ts | 315 ++++++++++++++++++++++++++++++++++++++++++++ src/guacutils.ts | 43 ++++++ src/index.ts | 29 ++++ tsconfig.json | 103 +++++++++++++++ 14 files changed, 919 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 config.example.toml create mode 100644 package.json create mode 100644 src/IConfig.ts create mode 100644 src/QEMUVM.ts create mode 100644 src/QMPClient.ts create mode 100644 src/RateLimiter.ts create mode 100644 src/User.ts create mode 100644 src/Utilities.ts create mode 100644 src/WSServer.ts create mode 100644 src/guacutils.ts create mode 100644 src/index.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8924c30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +build/ +config.toml \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..9cf9495 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..b47b920 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,48 @@ +[http] +host = "127.0.0.1" +port = 6004 +# Whether the server is behind a reverse proxy, like NGINX +proxying = true +# IPs allowed to access the server in proxy mode. +# 99% of the time this will only be 127.0.0.1 +proxyAllowedIps = ["127.0.0.1"] + +[vm] +qemuArgs = "qemu-system-x86_64" +vncPort = 5900 +snapshots = true +qmpSockDir = "/tmp/" + +[collabvm] +node = "acoolvm" +displayname = "A Really Cool CollabVM Instance" +motd = "welcome!" +# 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" +moderatorEnabled = true +usernameblacklist = ["jjjj"] +maxChatLength = 100 +# 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 +tempMuteTime = 30 +# How long a turn lasts, in seconds +turnTime = 20 +# SHA256 sum of the admin and mod passwords. This can be generated with the following command: +# printf "" | sha256sum - +# Example hash is hunter2 and hunter3 +adminpass = "f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7" +modpass = "fb8c2e2b85ca81eb4350199faddd983cb26af3064614e737ea9f479621cfa57a" +[collabvm.moderatorPermissions] +# What a moderator can and can't do +restore = true +reboot = true +ban = true +forcevote = true +mute = true +kick = true +bypassturn = true +rename = true +grabip = true +xss = true \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..60d4a6e --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "collabvm1.ts", + "version": "1.0.0", + "description": "replacement for collabvm 1.2.11 because the old one :boom:", + "main": "build/index.js", + "scripts": { + "build": "tsc", + "serve": "node build/index.js" + }, + "author": "Elijah R", + "license": "GPL-3.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/ws": "^8.5.4", + "fs": "^0.0.1-security", + "mnemonist": "^0.39.5", + "rfb2": "^0.2.2", + "toml": "^3.0.0", + "typescript": "^4.9.5", + "ws": "^8.12.0" + } +} diff --git a/src/IConfig.ts b/src/IConfig.ts new file mode 100644 index 0000000..dae0a06 --- /dev/null +++ b/src/IConfig.ts @@ -0,0 +1,46 @@ +export default interface IConfig { + http : { + host : string; + port : number; + proxying : boolean; + proxyAllowedIps : string[]; + }; + vm : { + qemuArgs : string; + vncPort : number; + snapshots : boolean; + qmpSockDir : string; + }; + collabvm : { + node : string; + displayname : string; + motd : string; + bancmd : string; + moderatorEnabled : boolean; + usernameblacklist : string[]; + maxChatLength : number; + automute : { + enabled: boolean; + seconds: number; + messages: number; + }; + tempMuteTime : number; + turnTime : number; + adminpass : string; + modpass : string; + moderatorPermissions : Permissions; + }; +}; + +export interface Permissions { + restore : boolean; + reboot : boolean; + ban : boolean; + forcevote : boolean; + mute : boolean; + kick : boolean; + bypassturn : boolean; + rename : boolean; + grabip : boolean; + xss : boolean; +} \ No newline at end of file diff --git a/src/QEMUVM.ts b/src/QEMUVM.ts new file mode 100644 index 0000000..2f0ca04 --- /dev/null +++ b/src/QEMUVM.ts @@ -0,0 +1,70 @@ +import { EventEmitter } from "events"; +import IConfig from "./IConfig"; +import * as rfb from 'rfb2'; +import * as fs from 'fs'; +import { spawn, ChildProcess } from "child_process"; +import QMPClient from "./QMPClient"; + +export default class QEMUVM extends EventEmitter { + vnc? : rfb.RfbClient; + vncPort : number; + qmpSock : string; + qmpClient : QMPClient; + qemuCmd : string; + qemuProcess? : ChildProcess; + qmpErrorLevel : number; + vncErrorLevel : number; + constructor(Config : IConfig) { + super(); + if (Config.vm.vncPort < 5900) { + 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`; + 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.qmpErrorLevel = 0; + this.vncErrorLevel = 0; + this.qmpClient = new QMPClient(this.qmpSock); + this.qmpClient.on('connected', () => this.qmpConnected()); + } + + Start() { + return new Promise(async (res, rej) => { + if (fs.existsSync(this.qmpSock)) + try { + fs.unlinkSync(this.qmpSock); + } catch (e) { + console.error("[FATAL] Could not remove existing QMP socket: " + e); + process.exit(-1); + } + var qemuArr = this.qemuCmd.split(" "); + this.qemuProcess = spawn(qemuArr[0], qemuArr.slice(1)); + process.on("beforeExit", () => { + this.qemuProcess?.kill(9); + }); + this.qemuProcess.stderr?.on('data', (d) => console.log(d.toString())); + this.qemuProcess.on('spawn', () => { + setTimeout(() => { + this.qmpClient.connect(); + }, 1000) + }); + }); + } + + private qmpConnected() { + console.log("QMP Connected"); + setTimeout(() => this.startVNC(), 1000); + } + + private startVNC() { + this.vnc = rfb.createConnection({ + host: "127.0.0.1", + port: this.vncPort, + }); + } + + private qmpClosed() { + + } +} \ No newline at end of file diff --git a/src/QMPClient.ts b/src/QMPClient.ts new file mode 100644 index 0000000..dd494a7 --- /dev/null +++ b/src/QMPClient.ts @@ -0,0 +1,45 @@ +import EventEmitter from "events"; +import { Socket } from "net"; + +export default class QMPClient extends EventEmitter { + socketfile : string; + socket : Socket; + connected : boolean; + sentConnected : boolean; + constructor(socketfile : string) { + super(); + this.socketfile = socketfile; + this.socket = new Socket(); + this.connected = false; + this.sentConnected = false; + } + connect() { + if (this.connected) return; + try { + this.socket.connect(this.socketfile); + } catch (e) { + this.emit("") + } + this.connected = true; + this.socket.on('data', (data) => this.onData(data)); + this.socket.on('close', () => this.onClose()); + } + + private onData(data : Buffer) { + var msgraw = data.toString(); + var msg = JSON.parse(msgraw); + console.log(msg); + if (msg.QMP) { + if (this.sentConnected) return; + this.socket.write(JSON.stringify({ execute: "qmp_capabilities" })); + this.emit('connected'); + this.sentConnected = true; + } + } + + private onClose() { + this.connected = false; + this.sentConnected = false; + this.emit('close'); + } +} \ No newline at end of file diff --git a/src/RateLimiter.ts b/src/RateLimiter.ts new file mode 100644 index 0000000..2ed4dfe --- /dev/null +++ b/src/RateLimiter.ts @@ -0,0 +1,36 @@ +import { EventEmitter } from "stream"; + +// Class to ratelimit a resource (chatting, logging in, etc) +export default class RateLimiter extends EventEmitter { + private limit : number; + private interval : number; + private requestCount : number; + private limiter? : NodeJS.Timer; + private limiterSet : boolean; + constructor(limit : number, interval : number) { + super(); + this.limit = limit; + this.interval = interval; + this.requestCount = 0; + this.limiterSet = false; + } + // Return value is whether or not the action should be continued + request() : boolean { + this.requestCount++; + if (this.requestCount === this.limit) { + this.emit('limit'); + clearTimeout(this.limiter); + this.limiterSet = false; + this.requestCount = 0; + return false; + } + if (!this.limiterSet) { + this.limiter = setTimeout(() => { + this.limiterSet = false; + this.requestCount = 0; + }, this.interval * 1000); + this.limiterSet = true; + } + return true; + } +} \ No newline at end of file diff --git a/src/User.ts b/src/User.ts new file mode 100644 index 0000000..c6501d9 --- /dev/null +++ b/src/User.ts @@ -0,0 +1,104 @@ +import * as Utilities from './Utilities'; +import * as guacutils from './guacutils'; +import {WebSocket} from 'ws'; +import IConfig from './IConfig'; +import RateLimiter from './RateLimiter'; +export class User { + socket : WebSocket; + nopSendInterval : NodeJS.Timer; + msgRecieveInterval : NodeJS.Timer; + nopRecieveTimeout? : NodeJS.Timer; + username? : string; + connectedToNode : boolean; + rank : Rank; + muted : Boolean; + tempMuteExpireTimeout? : NodeJS.Timer; + msgsSent : number; + Config : IConfig; + IP : string; + // Rate limiters + ChatRateLimit : RateLimiter; + LoginRateLimit : RateLimiter; + RenameRateLimit : RateLimiter; + TurnRateLimit : RateLimiter; + constructor(ws : WebSocket, ip : string, 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.socket.on('close', () => { + clearInterval(this.nopSendInterval); + }); + this.socket.on('message', (e) => { + clearTimeout(this.nopRecieveTimeout); + clearInterval(this.msgRecieveInterval); + this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000); + }) + this.nopSendInterval = setInterval(() => this.sendNop(), 5000); + this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000); + this.sendNop(); + if (username) this.username = username; + this.rank = 0; + this.ChatRateLimit = new RateLimiter(this.Config.collabvm.automute.messages, this.Config.collabvm.automute.seconds); + this.ChatRateLimit.on('limit', () => this.mute(false)); + this.RenameRateLimit = new RateLimiter(4, 3); + this.RenameRateLimit.on('limit', () => this.closeConnection()); + this.LoginRateLimit = new RateLimiter(4, 3); + this.LoginRateLimit.on('limit', () => this.closeConnection()); + this.TurnRateLimit = new RateLimiter(5, 3); + this.TurnRateLimit.on('limit', () => this.closeConnection()); + } + assignGuestName(existingUsers : string[]) : string { + var username; + do { + username = "guest" + Utilities.Randint(10000, 99999); + } while (existingUsers.indexOf(username) !== -1); + this.username = username; + return username; + } + sendNop() { + this.socket.send("3.nop;"); + } + sendMsg(msg : string | Buffer) { + if (this.socket.readyState !== this.socket.OPEN) return; + clearInterval(this.nopSendInterval); + this.nopSendInterval = setInterval(() => this.sendNop(), 5000); + this.socket.send(msg); + } + private onNoMsg() { + this.sendNop(); + this.nopRecieveTimeout = setTimeout(() => { + this.closeConnection(); + }, 3000); + } + closeConnection() { + this.socket.send(guacutils.encode("disconnect")); + this.socket.close(); + } + onMsgSent() { + if (!this.Config.collabvm.automute.enabled) return; + if (this.rank !== 0) return; + this.ChatRateLimit.request(); + } + mute(permanent : boolean) { + this.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); + } + } + unmute() { + clearTimeout(this.tempMuteExpireTimeout); + this.muted = false; + this.sendMsg(guacutils.encode("chat", "", "You are no longer muted.")); + } +} + +export enum Rank { + Unregistered = 0, + Admin = 2, + Moderator = 3, +} \ No newline at end of file diff --git a/src/Utilities.ts b/src/Utilities.ts new file mode 100644 index 0000000..ac67fc2 --- /dev/null +++ b/src/Utilities.ts @@ -0,0 +1,54 @@ +import { Permissions } from "./IConfig"; + +export function Randint(min : number, max : number) { + return Math.floor((Math.random() * (max - min)) + min); +} +export function HTMLSanitize(input : string) : string { + var output = ""; + for (var i = 0; i < input.length; i++) { + switch (input[i]) { + case "<": + output += "<" + break; + case ">": + output += ">" + break; + case "&": + output += "&" + break; + case "\"": + output += """ + break; + case "'": + output += "'"; + break; + case "/": + output += "/"; + break; + case "\n": + output += " "; + break; + default: + var charcode : number = input.charCodeAt(i); + if (charcode >= 32 && charcode <= 126) + output += input[i]; + break; + } + } + return output; +} + +export function MakeModPerms(modperms : Permissions) : number { + var perms = 0; + if (modperms.restore) perms |= 1; + if (modperms.reboot) perms |= 2; + if (modperms.ban) perms |= 4; + if (modperms.forcevote) perms |= 8; + if (modperms.mute) perms |= 16; + if (modperms.kick) perms |= 32; + if (modperms.bypassturn) perms |= 64; + if (modperms.rename) perms |= 128; + if (modperms.grabip) perms |= 256; + if (modperms.xss) perms |= 512; + return perms; +} \ No newline at end of file diff --git a/src/WSServer.ts b/src/WSServer.ts new file mode 100644 index 0000000..ae038d1 --- /dev/null +++ b/src/WSServer.ts @@ -0,0 +1,315 @@ +import {WebSocketServer, WebSocket} from 'ws'; +import * as http from 'http'; +import IConfig from './IConfig'; +import internal from 'stream'; +import * as Utilities from './Utilities'; +import { User, Rank } from './User'; +import * as guacutils from './guacutils'; +import * as fs from 'fs'; +import { CircularBuffer, Queue } from 'mnemonist'; +import { createHash } from 'crypto'; +import { isIP } from 'net'; + +export default class WSServer { + private Config : IConfig; + private server : http.Server; + private socket : WebSocketServer; + private clients : User[]; + private ChatHistory : CircularBuffer<{user:string,msg:string}> + private TurnQueue : Queue; + private TurnTime : number; + private TurnInterval? : NodeJS.Timer; + private TurnIntervalRunning : boolean; + private ModPerms : number; + constructor(config : IConfig) { + this.ChatHistory = new CircularBuffer<{user:string,msg:string}>(Array, 5); + this.TurnQueue = new Queue(); + this.TurnTime = 0; + this.TurnIntervalRunning = false; + this.clients = []; + this.Config = config; + this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions); + this.server = http.createServer(); + this.socket = new WebSocketServer({noServer: true}); + this.server.on('upgrade', (req : http.IncomingMessage, socket : internal.Duplex, head : Buffer) => this.httpOnUpgrade(req, socket, head)); + this.socket.on('connection', (ws : WebSocket, req : http.IncomingMessage) => this.onConnection(ws, req)); + } + + listen() { + this.server.listen(this.Config.http.port, this.Config.http.host); + } + + private 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(); + } + if ( + req.headers['sec-websocket-protocol'] !== "guacamole" + // || req.headers['origin']?.toLocaleLowerCase() !== "https://computernewb.com" + ) { + killConnection(); + return; + } + if (this.Config.http.proxying) { + // If the requesting IP isn't allowed to proxy, kill it + //@ts-ignore + if (this.Config.http.proxyAllowedIps.indexOf(req.socket.remoteAddress) === -1) { + killConnection(); + return; + } + var _ip; + try { + // 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 + killConnection(); + return; + } + // If for some reason the IP isn't defined, kill it + if (!_ip) { + killConnection(); + return; + } + // Make sure the ip is valid. If not, kill the connection. + if (!isIP(_ip)) { + killConnection(); + return; + } + //@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; + if (this.Config.http.proxying) { + //@ts-ignore + if (!req.proxiedIP) return; + //@ts-ignore + ip = req.proxiedIP; + } else { + if (!req.socket.remoteAddress) return; + ip = req.socket.remoteAddress; + } + var user = new User(ws, ip, this.Config); + this.clients.push(user); + 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 + user.closeConnection(); + return; + } + this.onMessage(user, msg); + }); + user.sendMsg(this.getAdduserMsg()); + console.log(`[Connect] From ${user.IP}`); + }; + + private connectionClosed(user : User) { + this.clients.splice(this.clients.indexOf(user), 1); + console.log(`[DISCONNECT] From ${user.IP}${user.username ? ` with username ${user.username}` : ""}`); + if (!user.username) return; + if (this.TurnQueue.toArray().indexOf(user) !== -1) { + var hadturn = (this.TurnQueue.peek() === user); + this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter(u => u !== user)); + if (hadturn) this.nextTurn(); + } + //@ts-ignore + this.clients.forEach((c) => c.sendMsg(guacutils.encode("remuser", "1", user.username))); + } + fuck = fs.readFileSync("/home/elijah/Pictures/thumb.txt").toString(); + private onMessage(client : User, message : string) { + var msgArr = guacutils.decode(message); + if (msgArr.length < 1) return; + switch (msgArr[0]) { + case "list": + client.sendMsg(guacutils.encode("list", this.Config.collabvm.node, this.Config.collabvm.displayname, this.fuck)) + break; + case "connect": + if (!client.username || msgArr.length !== 2 || msgArr[1] !== this.Config.collabvm.node) { + client.sendMsg(guacutils.encode("connect", "0")); + return; + } + client.connectedToNode = true; + client.sendMsg(guacutils.encode("connect", "1", "1", "1", "0")); + if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode("chat", "", this.Config.collabvm.motd)); + if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); + client.sendMsg(guacutils.encode("size", "0", "400", "300")); + client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", this.fuck)); + break; + case "rename": + if (!client.RenameRateLimit.request()) return; + // This shouldn't need a ternary but it does for some reason + var hadName : boolean = client.username ? true : false; + var oldname : any; + if (hadName) oldname = client.username; + if (msgArr.length === 1) { + client.assignGuestName(this.getUsernameList()); + } else { + var newName = msgArr[1]; + if (hadName && newName === oldname) { + //@ts-ignore + client.sendMsg(guacutils.encode("rename", "0", "0", client.username)); + return; + } + if (this.getUsernameList().indexOf(newName) !== -1) { + client.sendMsg(guacutils.encode("rename", "0", "1")); + return; + } + if (!/^[a-zA-Z0-9\ \-\_\.]+$/.test(newName)) { + client.sendMsg(guacutils.encode("rename", "0", "2")); + return; + } + if (this.Config.collabvm.usernameblacklist.indexOf(newName) !== -1) { + client.sendMsg(guacutils.encode("rename", "0", "3")); + return; + } + client.username = newName; + } + //@ts-ignore + client.sendMsg(guacutils.encode("rename", "0", "0", client.username)); + if (hadName) { + console.log(`[RENAME] ${client.IP} 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}`); + this.clients.forEach((c) => + //@ts-ignore + c.sendMsg(guacutils.encode("adduser", "1", client.username, client.rank))); + } + break; + case "chat": + if (!client.username) return; + if (client.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 + if (msg.length > this.Config.collabvm.maxChatLength) msg = msg.substring(0, this.Config.collabvm.maxChatLength); + if (msg.length < 1) return; + //@ts-ignore + this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", client.username, msg))); + this.ChatHistory.push({user: client.username, msg: msg}); + client.onMsgSent(); + break; + case "turn": + if (!client.TurnRateLimit.request()) return; + if (!client.connectedToNode) return; + if (msgArr.length > 2) return; + var takingTurn : boolean; + if (msgArr.length === 1) takingTurn = true; + else switch (msgArr[1]) { + case "0": + takingTurn = false; + break; + case "1": + takingTurn = true; + break; + default: + return; + break; + } + if (takingTurn) { + // If the user is already in the queue, fuck them off + 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; + this.TurnQueue.enqueue(client); + if (this.TurnQueue.size === 1) this.nextTurn(); + } else { + var hadturn = (this.TurnQueue.peek() === client); + this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter(u => u !== client)); + if (hadturn) this.nextTurn(); + } + this.sendTurnUpdate(); + break; + case "admin": + if (msgArr.length < 2) return; + switch (msgArr[1]) { + case "2": + if (!client.LoginRateLimit.request()) return; + if (msgArr.length !== 3) return; + var sha256 = createHash("sha256"); + sha256.update(msgArr[2]); + var pwdHash = sha256.digest('hex'); + sha256.destroy(); + if (pwdHash === this.Config.collabvm.adminpass) { + client.rank = Rank.Admin; + client.sendMsg(guacutils.encode("admin", "0", "1")); + } else if (this.Config.collabvm.moderatorEnabled && pwdHash === this.Config.collabvm.modpass) { + client.rank = Rank.Moderator; + client.sendMsg(guacutils.encode("admin", "0", "3", this.ModPerms.toString())); + } else { + client.sendMsg(guacutils.encode("admin", "0", "0")); + return; + } + //@ts-ignore + this.clients.forEach((c) => c.sendMsg(guacutils.encode("adduser", "1", client.username, client.rank))); + break; + + } + break; + + } + } + + getUsernameList() : string[] { + var arr : string[] = []; + //@ts-ignore + this.clients.filter(c => c.username).forEach((c) => arr.push(c.username)); + return arr; + } + getAdduserMsg() : string { + var arr : string[] = ["adduser", this.clients.length.toString()]; + //@ts-ignore + this.clients.filter(c=>c.username).forEach((c) => arr.push(c.username, c.rank)); + return guacutils.encode(...arr); + } + getChatHistoryMsg() : string { + var arr : string[] = ["chat"]; + this.ChatHistory.forEach(c => arr.push(c.user, c.msg)); + return guacutils.encode(...arr); + } + private sendTurnUpdate() { + var turnQueueArr = this.TurnQueue.toArray(); + var arr = ["turn", (this.TurnTime * 1000).toString(), this.TurnQueue.size.toString()]; + // @ts-ignore + this.TurnQueue.forEach((c) => arr.push(c.username)); + var currentTurningUser = this.TurnQueue.peek(); + this.clients.filter(c => (c !== currentTurningUser && c.connectedToNode)).forEach((c) => { + if (turnQueueArr.indexOf(c) !== -1) { + var time = ((this.TurnTime * 1000) + ((turnQueueArr.indexOf(c) - 1) * this.Config.collabvm.turnTime * 1000)); + c.sendMsg(guacutils.encode(...arr, time.toString())); + } else { + c.sendMsg(guacutils.encode(...arr)); + } + }); + if (currentTurningUser) + currentTurningUser.sendMsg(guacutils.encode(...arr)); + } + 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); + } + this.sendTurnUpdate(); + } + private turnInterval() { + this.TurnTime--; + if (this.TurnTime < 1) { + this.TurnQueue.dequeue(); + this.nextTurn(); + } + } +} \ No newline at end of file diff --git a/src/guacutils.ts b/src/guacutils.ts new file mode 100644 index 0000000..0e0d41c --- /dev/null +++ b/src/guacutils.ts @@ -0,0 +1,43 @@ +export function decode(string : string) : string[] { + let pos = -1; + let sections = []; + + for(;;) { + let len = string.indexOf('.', pos + 1); + + if(len === -1) + break; + + pos = parseInt(string.slice(pos + 1, len)) + len + 1; + + // don't allow funky protocol length + if(pos > string.length) + return []; + + sections.push(string.slice(len + 1, pos)); + + + const sep = string.slice(pos, pos + 1); + + if(sep === ',') + continue; + else if(sep === ';') + break; + else + // Invalid data. + return []; + } + + return sections; +} + +export function encode(...string : string[]) : string { + let command = ''; + + for(var i = 0; i < string.length; i++) { + let current = string[i]; + command += current.toString().length + '.' + current; + command += ( i < string.length - 1 ? ',' : ';'); + } + return command; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8b93730 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,29 @@ +import * as toml from 'toml'; +import IConfig from './IConfig'; +import * as fs from "fs"; +import WSServer from './WSServer'; +import QEMUVM from './QEMUVM'; + +// Parse the config file + +var Config : IConfig; + +if (!fs.existsSync("config.toml")) { + console.error("config.toml not found. Please copy config.example.toml to config.toml and fill out fields."); + process.exit(1); +} +try { + var configRaw = fs.readFileSync("config.toml").toString(); + Config = toml.parse(configRaw); +} catch (e) { + console.error(`Failed to read or parse the config file: ${e}`); + process.exit(1); +} + +// Fire up the VM +var VM = new QEMUVM(Config); +VM.Start(); + +// Start up the websocket server +var WS = new WSServer(Config); +WS.listen(); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ca81fa6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,103 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + "rootDir": "./src", /* Specify the root folder within your source files. */ + // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./build", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}