everything

This commit is contained in:
elijahr2411
2023-01-31 22:00:30 -05:00
commit 3235375581
14 changed files with 919 additions and 0 deletions

46
src/IConfig.ts Normal file
View File

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

70
src/QEMUVM.ts Normal file
View File

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

45
src/QMPClient.ts Normal file
View File

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

36
src/RateLimiter.ts Normal file
View File

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

104
src/User.ts Normal file
View File

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

54
src/Utilities.ts Normal file
View File

@@ -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 += "&lt;"
break;
case ">":
output += "&gt;"
break;
case "&":
output += "&amp;"
break;
case "\"":
output += "&quot;"
break;
case "'":
output += "&#x27;";
break;
case "/":
output += "&#x2F;";
break;
case "\n":
output += "&#13;&#10;";
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;
}

315
src/WSServer.ts Normal file
View File

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

43
src/guacutils.ts Normal file
View File

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

29
src/index.ts Normal file
View File

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