Giant refactoring (or at least the start)
In short: - cvmts is now bundled/built via parcel and inside of a npm/yarn workspace with multiple nodejs projects - cvmts now uses the crusttest QEMU management and RFB library (or a fork, if you so prefer). - cvmts does NOT use node-canvas anymore, instead we opt for the same route crusttest took and just encode jpegs ourselves from the RFB provoded framebuffer via jpeg-turbo. this means funnily enough sharp is back for more for thumbnails, but actually seems to WORK this time - IPData is now managed in a very similar way to the original cvm 1.2 implementation where a central manager and reference count exist. tbh it wouldn't be that hard to implement multinode either, but for now, I'm not going to take much time on doing that. this refactor is still incomplete. please do not treat it as generally available while it's not on the default branch. if you want to use it (and report bugs or send fixes) feel free to, but while it may "just work" in certain situations it may be very broken in others. (yes, I know windows support is partially totaled by this; it's something that can and will be fixed)
This commit is contained in:
35
cvmts/package.json
Normal file
35
cvmts/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@cvmts/cvmts",
|
||||
"version": "1.0.0",
|
||||
"description": "replacement for collabvm 1.2.11 because the old one :boom:",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "parcel build src/index.ts --target node",
|
||||
"serve": "node dist/index.js"
|
||||
},
|
||||
"author": "Elijah R, modeco80",
|
||||
"license": "GPL-3.0",
|
||||
"targets": {
|
||||
"node": {
|
||||
"context": "node",
|
||||
"outputFormat": "esmodule"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@computernewb/jpeg-turbo": "*",
|
||||
"@cvmts/qemu": "*",
|
||||
"execa": "^8.0.1",
|
||||
"mnemonist": "^0.39.5",
|
||||
"sharp": "^0.33.3",
|
||||
"toml": "^3.0.0",
|
||||
"ws": "^8.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.5",
|
||||
"@types/ws": "^8.5.5",
|
||||
"parcel": "^2.12.0",
|
||||
"prettier": "^3.2.5",
|
||||
"typescript": "^5.4.4"
|
||||
}
|
||||
}
|
||||
46
cvmts/src/AuthManager.ts
Normal file
46
cvmts/src/AuthManager.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Logger } from '@cvmts/shared';
|
||||
import { Rank, User } from './User.js';
|
||||
|
||||
|
||||
export default class AuthManager {
|
||||
apiEndpoint: string;
|
||||
secretKey: string;
|
||||
|
||||
private logger = new Logger("CVMTS.AuthMan");
|
||||
|
||||
constructor(apiEndpoint: string, secretKey: string) {
|
||||
this.apiEndpoint = apiEndpoint;
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
async Authenticate(token: string, user: User): Promise<JoinResponse> {
|
||||
var response = await fetch(this.apiEndpoint + '/api/v1/join', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
secretKey: this.secretKey,
|
||||
sessionToken: token,
|
||||
ip: user.IP.address
|
||||
})
|
||||
});
|
||||
|
||||
var json = (await response.json()) as JoinResponse;
|
||||
|
||||
if (!json.success) {
|
||||
this.logger.Error(`Failed to query auth server: ${json.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
interface JoinResponse {
|
||||
success: boolean;
|
||||
clientSuccess: boolean;
|
||||
error: string | undefined;
|
||||
username: string | undefined;
|
||||
rank: Rank;
|
||||
}
|
||||
69
cvmts/src/IConfig.ts
Normal file
69
cvmts/src/IConfig.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export default interface IConfig {
|
||||
http: {
|
||||
host: string;
|
||||
port: number;
|
||||
proxying: boolean;
|
||||
proxyAllowedIps: string[];
|
||||
origin: boolean;
|
||||
originAllowedDomains: string[];
|
||||
maxConnections: number;
|
||||
};
|
||||
auth: {
|
||||
enabled: boolean;
|
||||
apiEndpoint: string;
|
||||
secretKey: string;
|
||||
guestPermissions: {
|
||||
chat: boolean;
|
||||
turn: boolean;
|
||||
};
|
||||
};
|
||||
vm: {
|
||||
qemuArgs: string;
|
||||
vncPort: number;
|
||||
snapshots: boolean;
|
||||
qmpHost: string | null;
|
||||
qmpPort: number | null;
|
||||
qmpSockDir: string | null;
|
||||
};
|
||||
collabvm: {
|
||||
node: string;
|
||||
displayname: string;
|
||||
motd: string;
|
||||
bancmd: string | string[];
|
||||
moderatorEnabled: boolean;
|
||||
usernameblacklist: string[];
|
||||
maxChatLength: number;
|
||||
maxChatHistoryLength: number;
|
||||
turnlimit: {
|
||||
enabled: boolean;
|
||||
maximum: number;
|
||||
};
|
||||
automute: {
|
||||
enabled: boolean;
|
||||
seconds: number;
|
||||
messages: number;
|
||||
};
|
||||
tempMuteTime: number;
|
||||
turnTime: number;
|
||||
voteTime: number;
|
||||
voteCooldown: number;
|
||||
adminpass: string;
|
||||
modpass: string;
|
||||
turnwhitelist: boolean;
|
||||
turnpass: 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;
|
||||
}
|
||||
62
cvmts/src/IPData.ts
Normal file
62
cvmts/src/IPData.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Logger } from "@cvmts/shared";
|
||||
|
||||
export class IPData {
|
||||
tempMuteExpireTimeout?: NodeJS.Timeout;
|
||||
muted: Boolean;
|
||||
vote: boolean | null;
|
||||
address: string;
|
||||
refCount: number = 0;
|
||||
|
||||
constructor(address: string) {
|
||||
this.address = address;
|
||||
this.muted = false;
|
||||
this.vote = null;
|
||||
}
|
||||
|
||||
// Call when a connection is closed to "release" the ip data
|
||||
Unref() {
|
||||
if(this.refCount - 1 < 0)
|
||||
this.refCount = 0;
|
||||
this.refCount--;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class IPDataManager {
|
||||
static ipDatas = new Map<string, IPData>();
|
||||
static logger = new Logger("CVMTS.IPDataManager");
|
||||
|
||||
static GetIPData(address: string) {
|
||||
if(IPDataManager.ipDatas.has(address)) {
|
||||
// Note: We already check for if it exists, so we use ! here
|
||||
// because TypeScript can't exactly tell that in this case,
|
||||
// only in explicit null or undefined checks
|
||||
let ref = IPDataManager.ipDatas.get(address)!;
|
||||
ref.refCount++;
|
||||
return ref;
|
||||
}
|
||||
|
||||
let data = new IPData(address);
|
||||
data.refCount++;
|
||||
IPDataManager.ipDatas.set(address, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
static ForEachIPData(callback: (d: IPData) => void) {
|
||||
for(let tuple of IPDataManager.ipDatas)
|
||||
callback(tuple[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Garbage collect unreferenced IPDatas every 15 seconds.
|
||||
// Strictly speaking this will just allow the v8 GC to finally
|
||||
// delete the objects, but same difference.
|
||||
setInterval(() => {
|
||||
for(let tuple of IPDataManager.ipDatas) {
|
||||
if(tuple[1].refCount == 0) {
|
||||
IPDataManager.logger.Info("Deleted ipdata for IP {0}", tuple[0]);
|
||||
IPDataManager.ipDatas.delete(tuple[0]);
|
||||
}
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
36
cvmts/src/RateLimiter.ts
Normal file
36
cvmts/src/RateLimiter.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
// 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.Timeout;
|
||||
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;
|
||||
}
|
||||
}
|
||||
161
cvmts/src/User.ts
Normal file
161
cvmts/src/User.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
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 { execa, execaCommand, ExecaSyncError } from 'execa';
|
||||
import { Logger } from '@cvmts/shared';
|
||||
|
||||
export class User {
|
||||
socket: WebSocket;
|
||||
nopSendInterval: NodeJS.Timeout;
|
||||
msgRecieveInterval: NodeJS.Timeout;
|
||||
nopRecieveTimeout?: NodeJS.Timeout;
|
||||
username?: string;
|
||||
connectedToNode: boolean;
|
||||
viewMode: number;
|
||||
rank: Rank;
|
||||
msgsSent: number;
|
||||
Config: IConfig;
|
||||
IP: IPData;
|
||||
// Rate limiters
|
||||
ChatRateLimit: RateLimiter;
|
||||
LoginRateLimit: RateLimiter;
|
||||
RenameRateLimit: RateLimiter;
|
||||
TurnRateLimit: RateLimiter;
|
||||
VoteRateLimit: RateLimiter;
|
||||
|
||||
private logger = new Logger("CVMTS.User");
|
||||
|
||||
constructor(ws: WebSocket, ip: IPData, config: IConfig, username?: string, node?: string) {
|
||||
this.IP = ip;
|
||||
this.connectedToNode = false;
|
||||
this.viewMode = -1;
|
||||
this.Config = config;
|
||||
this.socket = ws;
|
||||
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(3, 60);
|
||||
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());
|
||||
this.VoteRateLimit = new RateLimiter(3, 3);
|
||||
this.VoteRateLimit.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;
|
||||
// rate limit guest and unregistered chat messages, but not staff ones
|
||||
switch (this.rank) {
|
||||
case Rank.Moderator:
|
||||
case Rank.Admin:
|
||||
break;
|
||||
|
||||
default:
|
||||
this.ChatRateLimit.request();
|
||||
break;
|
||||
}
|
||||
}
|
||||
mute(permanent: boolean) {
|
||||
this.IP.muted = true;
|
||||
this.sendMsg(guacutils.encode('chat', '', `You have been muted${permanent ? '' : ` for ${this.Config.collabvm.tempMuteTime} seconds`}.`));
|
||||
if (!permanent) {
|
||||
clearTimeout(this.IP.tempMuteExpireTimeout);
|
||||
this.IP.tempMuteExpireTimeout = setTimeout(() => this.unmute(), this.Config.collabvm.tempMuteTime * 1000);
|
||||
}
|
||||
}
|
||||
unmute() {
|
||||
clearTimeout(this.IP.tempMuteExpireTimeout);
|
||||
this.IP.muted = false;
|
||||
this.sendMsg(guacutils.encode('chat', '', 'You are no longer muted.'));
|
||||
}
|
||||
|
||||
private banCmdArgs(arg: string): string {
|
||||
return arg.replace(/\$IP/g, this.IP.address).replace(/\$NAME/g, this.username || '');
|
||||
}
|
||||
|
||||
async ban() {
|
||||
// Prevent the user from taking turns or chatting, in case the ban command takes a while
|
||||
this.IP.muted = true;
|
||||
|
||||
try {
|
||||
if (Array.isArray(this.Config.collabvm.bancmd)) {
|
||||
let args: string[] = this.Config.collabvm.bancmd.map((a: string) => this.banCmdArgs(a));
|
||||
if (args.length || args[0].length) {
|
||||
await execa(args.shift()!, args, { stdout: process.stdout, stderr: process.stderr });
|
||||
this.kick();
|
||||
} else {
|
||||
this.logger.Error(`Failed to ban ${this.IP.address} (${this.username}): Empty command`);
|
||||
}
|
||||
} else if (typeof this.Config.collabvm.bancmd == 'string') {
|
||||
let cmd: string = this.banCmdArgs(this.Config.collabvm.bancmd);
|
||||
if (cmd.length) {
|
||||
await execaCommand(cmd, { stdout: process.stdout, stderr: process.stderr });
|
||||
this.kick();
|
||||
} else {
|
||||
this.logger.Error(`Failed to ban ${this.IP.address} (${this.username}): Empty command`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.Error(`Failed to ban ${this.IP.address} (${this.username}): ${(e as ExecaSyncError).shortMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
async kick() {
|
||||
this.sendMsg('10.disconnect;');
|
||||
this.socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
export enum Rank {
|
||||
Unregistered = 0,
|
||||
// After all these years
|
||||
Registered = 1,
|
||||
Admin = 2,
|
||||
Moderator = 3,
|
||||
// Giving a good gap between server only internal ranks just in case
|
||||
Turn = 10
|
||||
}
|
||||
54
cvmts/src/Utilities.ts
Normal file
54
cvmts/src/Utilities.ts
Normal 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 += "<"
|
||||
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;
|
||||
}
|
||||
999
cvmts/src/WSServer.ts
Normal file
999
cvmts/src/WSServer.ts
Normal file
@@ -0,0 +1,999 @@
|
||||
import {WebSocketServer, WebSocket} from 'ws';
|
||||
import * as http from 'http';
|
||||
import IConfig from './IConfig.js';
|
||||
import internal from 'stream';
|
||||
import * as Utilities from './Utilities.js';
|
||||
import { User, Rank } from './User.js';
|
||||
import * as guacutils from './guacutils.js';
|
||||
// I hate that you have to do it like this
|
||||
import CircularBuffer from 'mnemonist/circular-buffer.js';
|
||||
import Queue from 'mnemonist/queue.js';
|
||||
import { createHash } from 'crypto';
|
||||
import { isIP } from 'node:net';
|
||||
import { QemuVM, QemuVmDefinition } from '@cvmts/qemu';
|
||||
import { IPData, IPDataManager } from './IPData.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import AuthManager from './AuthManager.js';
|
||||
import { Size, Rect, Logger } from '@cvmts/shared';
|
||||
|
||||
import jpegTurbo from "@computernewb/jpeg-turbo";
|
||||
import sharp from 'sharp';
|
||||
|
||||
// probably better
|
||||
const __dirname = process.cwd();
|
||||
|
||||
// ejla this exist. Useing it.
|
||||
type ChatHistory = {
|
||||
user: string,
|
||||
msg: string
|
||||
};
|
||||
|
||||
// A good balance. TODO: Configurable?
|
||||
const kJpegQuality = 35;
|
||||
|
||||
// this returns appropiate Sharp options to deal with the framebuffer
|
||||
function GetRawSharpOptions(size: Size): sharp.CreateRaw {
|
||||
return {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
channels: 4
|
||||
}
|
||||
}
|
||||
|
||||
async function EncodeJpeg(canvas: Buffer, displaySize: Size, rect: Rect): Promise<Buffer> {
|
||||
let offset = (rect.y * displaySize.width + rect.x) * 4;
|
||||
|
||||
//console.log('encoding rect', rect, 'with byteoffset', offset, '(size ', displaySize, ')');
|
||||
|
||||
return jpegTurbo.compress(canvas.subarray(offset), {
|
||||
format: jpegTurbo.FORMAT_RGBA,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
subsampling: jpegTurbo.SAMP_422,
|
||||
stride: displaySize.width,
|
||||
quality: kJpegQuality
|
||||
});
|
||||
}
|
||||
|
||||
export default class WSServer {
|
||||
private Config : IConfig;
|
||||
|
||||
private httpServer : http.Server;
|
||||
private wsServer : WebSocketServer;
|
||||
|
||||
private clients : User[];
|
||||
|
||||
private ChatHistory : CircularBuffer<ChatHistory>
|
||||
|
||||
private TurnQueue : Queue<User>;
|
||||
|
||||
// Time remaining on the current turn
|
||||
private TurnTime : number;
|
||||
|
||||
// Interval to keep track of the current turn time
|
||||
private TurnInterval? : NodeJS.Timeout;
|
||||
|
||||
// If a reset vote is in progress
|
||||
private voteInProgress : boolean;
|
||||
|
||||
// Interval to keep track of vote resets
|
||||
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.Timeout;
|
||||
|
||||
// Completely disable turns
|
||||
private turnsAllowed : boolean;
|
||||
|
||||
// Hide the screen
|
||||
private screenHidden : boolean;
|
||||
|
||||
// base64 image to show when the screen is hidden
|
||||
private screenHiddenImg : string;
|
||||
private screenHiddenThumb : string;
|
||||
|
||||
// Indefinite turn
|
||||
private indefiniteTurn : User | null;
|
||||
private ModPerms : number;
|
||||
private VM : QemuVM;
|
||||
|
||||
// Authentication manager
|
||||
private auth : AuthManager | null;
|
||||
|
||||
private logger = new Logger("CVMTS.Server");
|
||||
|
||||
constructor(config : IConfig, vm : QemuVM, auth : AuthManager | null) {
|
||||
this.Config = config;
|
||||
this.ChatHistory = new CircularBuffer<ChatHistory>(Array, this.Config.collabvm.maxChatHistoryLength);
|
||||
this.TurnQueue = new Queue<User>();
|
||||
this.TurnTime = 0;
|
||||
this.clients = [];
|
||||
this.voteInProgress = false;
|
||||
this.voteTime = 0;
|
||||
this.voteCooldown = 0;
|
||||
this.turnsAllowed = true;
|
||||
this.screenHidden = false;
|
||||
this.screenHiddenImg = readFileSync(__dirname + "/../assets/screenhidden.jpeg").toString("base64");
|
||||
this.screenHiddenThumb = readFileSync(__dirname + "/../assets/screenhiddenthumb.jpeg").toString("base64");
|
||||
|
||||
this.indefiniteTurn = null;
|
||||
this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions);
|
||||
this.httpServer = http.createServer();
|
||||
this.wsServer = new WebSocketServer({noServer: true});
|
||||
this.httpServer.on('upgrade', (req : http.IncomingMessage, socket : internal.Duplex, head : Buffer) => this.httpOnUpgrade(req, socket, head));
|
||||
this.httpServer.on('request', (req, res) => {
|
||||
res.writeHead(426);
|
||||
res.write("This server only accepts WebSocket connections.");
|
||||
res.end();
|
||||
});
|
||||
|
||||
let initSize = vm.GetDisplay().Size() || {
|
||||
width: 0,
|
||||
height: 0
|
||||
};
|
||||
|
||||
this.OnDisplayResized(initSize);
|
||||
|
||||
vm.GetDisplay().on('resize', (size: Size) => this.OnDisplayResized(size));
|
||||
vm.GetDisplay().on('rect', (rect: Rect) => this.OnDisplayRectangle(rect));
|
||||
|
||||
this.VM = vm;
|
||||
|
||||
// authentication manager
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
listen() {
|
||||
this.httpServer.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") {
|
||||
killConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.Config.http.origin) {
|
||||
// If the client is not sending an Origin header, kill the connection.
|
||||
if(!req.headers.origin) {
|
||||
killConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to parse the Origin header sent by the client, if it fails, kill the connection.
|
||||
var _uri;
|
||||
var _host;
|
||||
try {
|
||||
_uri = new URL(req.headers.origin.toLowerCase());
|
||||
_host = _uri.host;
|
||||
} catch {
|
||||
killConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
// detect fake origin headers
|
||||
if (_uri.pathname !== "/" || _uri.search !== "") {
|
||||
killConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
// If the domain name is not in the list of allowed origins, kill the connection.
|
||||
if(!this.Config.http.originAllowedDomains.includes(_host)) {
|
||||
killConnection();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let ip: string;
|
||||
if (this.Config.http.proxying) {
|
||||
// If the requesting IP isn't allowed to proxy, kill it
|
||||
if (this.Config.http.proxyAllowedIps.indexOf(req.socket.remoteAddress!) === -1) {
|
||||
killConnection();
|
||||
return;
|
||||
}
|
||||
// Make sure x-forwarded-for is set
|
||||
if (req.headers["x-forwarded-for"] === undefined) {
|
||||
killConnection();
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
if (!req.socket.remoteAddress) return;
|
||||
ip = req.socket.remoteAddress;
|
||||
}
|
||||
|
||||
// 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.wsServer.handleUpgrade(req, socket, head, (ws: WebSocket) => {
|
||||
this.wsServer.emit('connection', ws, req);
|
||||
this.onConnection(ws, req, ip);
|
||||
});
|
||||
}
|
||||
|
||||
private onConnection(ws : WebSocket, req: http.IncomingMessage, ip : string) {
|
||||
let user = new User(ws, IPDataManager.GetIPData(ip), this.Config);
|
||||
this.clients.push(user);
|
||||
|
||||
ws.on('error', (e) => {
|
||||
this.logger.Error(`${e} (caused by connection ${ip})`);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
ws.on('close', () => this.connectionClosed(user));
|
||||
|
||||
ws.on('message', (buf: Buffer, isBinary: boolean) => {
|
||||
var msg;
|
||||
|
||||
// Close the user's connection if they send a non-string message
|
||||
if(isBinary) {
|
||||
user.closeConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.onMessage(user, buf.toString());
|
||||
} catch {
|
||||
}
|
||||
});
|
||||
|
||||
if (this.Config.auth.enabled) {
|
||||
user.sendMsg(guacutils.encode("auth", this.Config.auth.apiEndpoint));
|
||||
}
|
||||
user.sendMsg(this.getAdduserMsg());
|
||||
this.logger.Info(`Connect from ${user.IP.address}`);
|
||||
};
|
||||
|
||||
private connectionClosed(user : User) {
|
||||
let clientIndex = this.clients.indexOf(user)
|
||||
if (clientIndex === -1) return;
|
||||
|
||||
if(user.IP.vote != null) {
|
||||
user.IP.vote = null;
|
||||
this.sendVoteUpdate();
|
||||
}
|
||||
|
||||
// Unreference the IP data.
|
||||
user.IP.Unref();
|
||||
|
||||
if (this.indefiniteTurn === user) this.indefiniteTurn = null;
|
||||
|
||||
this.clients.splice(clientIndex, 1);
|
||||
|
||||
this.logger.Info(`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);
|
||||
this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter(u => u !== user));
|
||||
if (hadturn) this.nextTurn();
|
||||
}
|
||||
|
||||
this.clients.forEach((c) => c.sendMsg(guacutils.encode("remuser", "1", user.username!)));
|
||||
}
|
||||
|
||||
|
||||
private async onMessage(client : User, message : string) {
|
||||
var msgArr = guacutils.decode(message);
|
||||
if (msgArr.length < 1) return;
|
||||
switch (msgArr[0]) {
|
||||
case "login":
|
||||
if (msgArr.length !== 2 || !this.Config.auth.enabled) return;
|
||||
if (!client.connectedToNode) {
|
||||
client.sendMsg(guacutils.encode("login", "0", "You must connect to the VM before logging in."));
|
||||
return;
|
||||
}
|
||||
var res = await this.auth!.Authenticate(msgArr[1], client);
|
||||
if (res.clientSuccess) {
|
||||
this.logger.Info(`${client.IP.address} logged in as ${res.username}`);
|
||||
client.sendMsg(guacutils.encode("login", "1"));
|
||||
var old = this.clients.find(c=>c.username === res.username);
|
||||
if (old) {
|
||||
// kick() doesnt wait until the user is actually removed from the list and itd be anal to make it do that
|
||||
// so we call connectionClosed manually here. When it gets called on kick(), it will return because the user isn't in the list
|
||||
this.connectionClosed(old);
|
||||
await old.kick();
|
||||
}
|
||||
// Set username
|
||||
this.renameUser(client, res.username);
|
||||
// Set rank
|
||||
client.rank = res.rank;
|
||||
if (client.rank === Rank.Admin) {
|
||||
client.sendMsg(guacutils.encode("admin", "0", "1"));
|
||||
} else if (client.rank === Rank.Moderator) {
|
||||
client.sendMsg(guacutils.encode("admin", "0", "3", this.ModPerms.toString()));
|
||||
}
|
||||
this.clients.forEach((c) => c.sendMsg(guacutils.encode("adduser", "1", client.username!, client.rank.toString())));
|
||||
} else {
|
||||
client.sendMsg(guacutils.encode("login", "0", res.error!));
|
||||
if (res.error === "You are banned") {
|
||||
client.kick();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "list":
|
||||
client.sendMsg(guacutils.encode("list", this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail()));
|
||||
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", this.Config.vm.snapshots ? "1" : "0", "0"));
|
||||
if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg());
|
||||
if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode("chat", "", this.Config.collabvm.motd));
|
||||
if (this.screenHidden) {
|
||||
client.sendMsg(guacutils.encode("size", "0", "1024", "768"));
|
||||
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", this.screenHiddenImg));
|
||||
} else {
|
||||
await this.SendFullScreenWithSize(client);
|
||||
}
|
||||
client.sendMsg(guacutils.encode("sync", Date.now().toString()));
|
||||
if (this.voteInProgress) this.sendVoteUpdate(client);
|
||||
this.sendTurnUpdate(client);
|
||||
break;
|
||||
case "view":
|
||||
if(client.connectedToNode) return;
|
||||
if(client.username || msgArr.length !== 3 || msgArr[1] !== this.Config.collabvm.node) {
|
||||
// The use of connect here is intentional.
|
||||
client.sendMsg(guacutils.encode("connect", "0"));
|
||||
return;
|
||||
}
|
||||
|
||||
switch(msgArr[2]) {
|
||||
case "0":
|
||||
client.viewMode = 0;
|
||||
break;
|
||||
case "1":
|
||||
client.viewMode = 1;
|
||||
break;
|
||||
default:
|
||||
client.sendMsg(guacutils.encode("connect", "0"));
|
||||
return;
|
||||
}
|
||||
|
||||
client.sendMsg(guacutils.encode("connect", "1", "1", this.Config.vm.snapshots ? "1" : "0", "0"));
|
||||
if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg());
|
||||
if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode("chat", "", this.Config.collabvm.motd));
|
||||
|
||||
if(client.viewMode == 1) {
|
||||
if (this.screenHidden) {
|
||||
client.sendMsg(guacutils.encode("size", "0", "1024", "768"));
|
||||
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", this.screenHiddenImg));
|
||||
} else {
|
||||
await this.SendFullScreenWithSize(client);
|
||||
}
|
||||
client.sendMsg(guacutils.encode("sync", Date.now().toString()));
|
||||
}
|
||||
|
||||
if (this.voteInProgress) this.sendVoteUpdate(client);
|
||||
this.sendTurnUpdate(client);
|
||||
break;
|
||||
case "rename":
|
||||
if (!client.RenameRateLimit.request()) return;
|
||||
if (client.connectedToNode && client.IP.muted) return;
|
||||
if (this.Config.auth.enabled && client.rank !== Rank.Unregistered) {
|
||||
client.sendMsg(guacutils.encode("chat", "", "Go to your account settings to change your username."));
|
||||
return;
|
||||
}
|
||||
if (this.Config.auth.enabled && msgArr[1] !== undefined) {
|
||||
// Don't send system message to a user without a username since it was likely an automated attempt by the webapp
|
||||
if (client.username) client.sendMsg(guacutils.encode("chat", "", "You need to log in to do that."));
|
||||
if (client.rank !== Rank.Unregistered) return;
|
||||
this.renameUser(client, undefined);
|
||||
return;
|
||||
}
|
||||
this.renameUser(client, msgArr[1]);
|
||||
break;
|
||||
case "chat":
|
||||
if (!client.username) return;
|
||||
if (client.IP.muted) return;
|
||||
if (msgArr.length !== 2) return;
|
||||
if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.chat) {
|
||||
client.sendMsg(guacutils.encode("chat", "", "You need to login to do that."));
|
||||
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.trim().length < 1) return;
|
||||
|
||||
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 ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return;
|
||||
if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.turn) {
|
||||
client.sendMsg(guacutils.encode("chat", "", "You need to login to do that."));
|
||||
return;
|
||||
}
|
||||
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":
|
||||
if (this.indefiniteTurn === client) {
|
||||
this.indefiniteTurn = null;
|
||||
}
|
||||
takingTurn = false;
|
||||
break;
|
||||
case "1":
|
||||
takingTurn = true;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
break;
|
||||
}
|
||||
if (takingTurn) {
|
||||
var currentQueue = this.TurnQueue.toArray();
|
||||
// If the user is already in the turn queue, ignore the turn request.
|
||||
if (currentQueue.indexOf(client) !== -1) return;
|
||||
// If they're muted, also ignore the turn request.
|
||||
// Send them the turn queue to prevent client glitches
|
||||
if (client.IP.muted) 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 {
|
||||
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 "mouse":
|
||||
if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return;
|
||||
var x = parseInt(msgArr[1]);
|
||||
var y = parseInt(msgArr[2]);
|
||||
var mask = parseInt(msgArr[3]);
|
||||
if (x === undefined || y === undefined || mask === undefined) return;
|
||||
this.VM.GetDisplay()!.MouseEvent(x, y, mask);
|
||||
break;
|
||||
case "key":
|
||||
if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return;
|
||||
var keysym = parseInt(msgArr[1]);
|
||||
var down = parseInt(msgArr[2]);
|
||||
if (keysym === undefined || (down !== 0 && down !== 1)) return;
|
||||
this.VM.GetDisplay()!.KeyboardEvent(keysym, down === 1 ? true : false);
|
||||
break;
|
||||
case "vote":
|
||||
if (!this.Config.vm.snapshots) return;
|
||||
if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return;
|
||||
if (!client.connectedToNode) return;
|
||||
if (msgArr.length !== 2) return;
|
||||
if (!client.VoteRateLimit.request()) return;
|
||||
switch (msgArr[1]) {
|
||||
case "1":
|
||||
if (!this.voteInProgress) {
|
||||
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.IP.vote !== true)
|
||||
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has voted yes.`)));
|
||||
client.IP.vote = true;
|
||||
break;
|
||||
case "0":
|
||||
if (!this.voteInProgress) return;
|
||||
if (client.IP.vote !== false)
|
||||
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has voted no.`)));
|
||||
client.IP.vote = false;
|
||||
break;
|
||||
}
|
||||
this.sendVoteUpdate();
|
||||
break;
|
||||
case "admin":
|
||||
if (msgArr.length < 2) return;
|
||||
switch (msgArr[1]) {
|
||||
case "2":
|
||||
// Login
|
||||
if (this.Config.auth.enabled) {
|
||||
client.sendMsg(guacutils.encode("chat", "", "This server does not support staff passwords. Please log in to become staff."));
|
||||
return;
|
||||
}
|
||||
if (!client.LoginRateLimit.request() || !client.username) 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 if (this.Config.collabvm.turnwhitelist && pwdHash === this.Config.collabvm.turnpass) {
|
||||
client.rank = Rank.Turn;
|
||||
client.sendMsg(guacutils.encode("chat", "", "You may now take turns."));
|
||||
} else {
|
||||
client.sendMsg(guacutils.encode("admin", "0", "0"));
|
||||
return;
|
||||
}
|
||||
if (this.screenHidden) {
|
||||
await this.SendFullScreenWithSize(client);
|
||||
|
||||
client.sendMsg(guacutils.encode("sync", Date.now().toString()));
|
||||
}
|
||||
|
||||
this.clients.forEach((c) => c.sendMsg(guacutils.encode("adduser", "1", client.username!, client.rank.toString())));
|
||||
break;
|
||||
case "5":
|
||||
// QEMU Monitor
|
||||
if (client.rank !== Rank.Admin) return;
|
||||
/* Surely there could be rudimentary processing to convert some qemu monitor syntax to [XYZ hypervisor] if possible
|
||||
if (!(this.VM instanceof QEMUVM)) {
|
||||
client.sendMsg(guacutils.encode("admin", "2", "This is not a QEMU VM and therefore QEMU monitor commands cannot be run."));
|
||||
return;
|
||||
}
|
||||
*/
|
||||
if (msgArr.length !== 4 || msgArr[2] !== this.Config.collabvm.node) return;
|
||||
var output = await this.VM.MonitorCommand(msgArr[3]);
|
||||
client.sendMsg(guacutils.encode("admin", "2", String(output)));
|
||||
break;
|
||||
case "8":
|
||||
// Restore
|
||||
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.restore)) return;
|
||||
this.VM.Reset();
|
||||
break;
|
||||
case "10":
|
||||
// Reboot
|
||||
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.reboot)) return;
|
||||
if (msgArr.length !== 3 || msgArr[2] !== this.Config.collabvm.node) return;
|
||||
this.VM.MonitorCommand("system_reset");
|
||||
break;
|
||||
case "12":
|
||||
// Ban
|
||||
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.ban)) return;
|
||||
var user = this.clients.find(c => c.username === msgArr[2]);
|
||||
if (!user) return;
|
||||
user.ban();
|
||||
case "13":
|
||||
// Force Vote
|
||||
if (msgArr.length !== 3) return;
|
||||
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.forcevote)) return;
|
||||
if (!this.voteInProgress) return;
|
||||
switch (msgArr[2]) {
|
||||
case "1":
|
||||
this.endVote(true);
|
||||
break;
|
||||
case "0":
|
||||
this.endVote(false);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "14":
|
||||
// Mute
|
||||
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.mute)) return;
|
||||
if (msgArr.length !== 4) return;
|
||||
var user = this.clients.find(c => c.username === msgArr[2]);
|
||||
if (!user) return;
|
||||
var permamute;
|
||||
switch (msgArr[3]) {
|
||||
case "0":
|
||||
permamute = false;
|
||||
break;
|
||||
case "1":
|
||||
permamute = true;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
user.mute(permamute);
|
||||
break;
|
||||
case "15":
|
||||
// Kick
|
||||
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.kick)) return;
|
||||
var user = this.clients.find(c => c.username === msgArr[2]);
|
||||
if (!user) return;
|
||||
user.kick();
|
||||
break;
|
||||
case "16":
|
||||
// End turn
|
||||
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return;
|
||||
if (msgArr.length !== 3) return;
|
||||
var user = this.clients.find(c => c.username === msgArr[2]);
|
||||
if (!user) return;
|
||||
this.endTurn(user);
|
||||
break;
|
||||
case "17":
|
||||
// Clear turn queue
|
||||
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return;
|
||||
if (msgArr.length !== 3 || msgArr[2] !== this.Config.collabvm.node) return;
|
||||
this.clearTurns();
|
||||
break;
|
||||
case "18":
|
||||
// Rename user
|
||||
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.rename)) return;
|
||||
if (this.Config.auth.enabled) {
|
||||
client.sendMsg(guacutils.encode("chat", "", "Cannot rename users on a server that uses authentication."));
|
||||
}
|
||||
if (msgArr.length !== 4) return;
|
||||
var user = this.clients.find(c => c.username === msgArr[2]);
|
||||
if (!user) return;
|
||||
this.renameUser(user, msgArr[3]);
|
||||
break;
|
||||
case "19":
|
||||
// Get IP
|
||||
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.grabip)) return;
|
||||
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.address));
|
||||
break;
|
||||
case "20":
|
||||
// Steal turn
|
||||
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return;
|
||||
this.bypassTurn(client);
|
||||
break;
|
||||
case "21":
|
||||
// XSS
|
||||
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.xss)) return;
|
||||
if (msgArr.length !== 3) return;
|
||||
switch (client.rank) {
|
||||
case Rank.Admin:
|
||||
|
||||
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", client.username!, msgArr[2])));
|
||||
|
||||
this.ChatHistory.push({user: client.username!, msg: msgArr[2]});
|
||||
break;
|
||||
case Rank.Moderator:
|
||||
|
||||
this.clients.filter(c => c.rank !== Rank.Admin).forEach(c => c.sendMsg(guacutils.encode("chat", client.username!, msgArr[2])));
|
||||
|
||||
this.clients.filter(c => c.rank === Rank.Admin).forEach(c => c.sendMsg(guacutils.encode("chat", client.username!, Utilities.HTMLSanitize(msgArr[2]))));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "22":
|
||||
// Toggle turns
|
||||
if (client.rank !== Rank.Admin) return;
|
||||
if (msgArr.length !== 3) return;
|
||||
switch (msgArr[2]) {
|
||||
case "0":
|
||||
this.clearTurns();
|
||||
this.turnsAllowed = false;
|
||||
break;
|
||||
case "1":
|
||||
this.turnsAllowed = true;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "23":
|
||||
// Indefinite turn
|
||||
if (client.rank !== Rank.Admin) return;
|
||||
this.indefiniteTurn = client;
|
||||
this.TurnQueue = Queue.from([client, ...this.TurnQueue.toArray().filter(c=>c!==client)]);
|
||||
this.sendTurnUpdate();
|
||||
break;
|
||||
case "24":
|
||||
// Hide screen
|
||||
if (client.rank !== Rank.Admin) return;
|
||||
if (msgArr.length !== 3) return;
|
||||
switch (msgArr[2]) {
|
||||
case "0":
|
||||
this.screenHidden = true;
|
||||
this.clients.filter(c => c.rank == Rank.Unregistered).forEach(client => {
|
||||
client.sendMsg(guacutils.encode("size", "0", "1024", "768"));
|
||||
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", this.screenHiddenImg));
|
||||
client.sendMsg(guacutils.encode("sync", Date.now().toString()));
|
||||
});
|
||||
break;
|
||||
case "1":
|
||||
this.screenHidden = false;
|
||||
let displaySize = this.VM.GetDisplay().Size();
|
||||
|
||||
let encoded = await this.MakeRectData({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: displaySize.width,
|
||||
height: displaySize.height
|
||||
});
|
||||
|
||||
|
||||
this.clients.forEach(async client => {
|
||||
client.sendMsg(guacutils.encode("size", "0", displaySize.width.toString(), displaySize.height.toString()));
|
||||
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", encoded));
|
||||
client.sendMsg(guacutils.encode("sync", Date.now().toString()));
|
||||
});
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "25":
|
||||
if (client.rank !== Rank.Admin || msgArr.length !== 3)
|
||||
return;
|
||||
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", msgArr[2])));
|
||||
break;
|
||||
|
||||
}
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
getUsernameList() : string[] {
|
||||
var arr : string[] = [];
|
||||
|
||||
this.clients.filter(c => c.username).forEach((c) => arr.push(c.username!));
|
||||
return arr;
|
||||
}
|
||||
|
||||
renameUser(client : User, newName? : string) {
|
||||
// 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;
|
||||
var status = "0";
|
||||
if (!newName) {
|
||||
client.assignGuestName(this.getUsernameList());
|
||||
} else {
|
||||
newName = newName.trim();
|
||||
if (hadName && newName === oldname) {
|
||||
|
||||
client.sendMsg(guacutils.encode("rename", "0", "0", client.username!, client.rank.toString()));
|
||||
return;
|
||||
}
|
||||
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());
|
||||
status = "2";
|
||||
} else
|
||||
if (this.Config.collabvm.usernameblacklist.indexOf(newName) !== -1) {
|
||||
client.assignGuestName(this.getUsernameList());
|
||||
status = "3";
|
||||
} else client.username = newName;
|
||||
}
|
||||
|
||||
client.sendMsg(guacutils.encode("rename", "0", status, client.username!, client.rank.toString()));
|
||||
if (hadName) {
|
||||
this.logger.Info(`Rename ${client.IP.address} from ${oldname} to ${client.username}`);
|
||||
this.clients.forEach((c) =>
|
||||
|
||||
c.sendMsg(guacutils.encode("rename", "1", oldname, client.username!, client.rank.toString())));
|
||||
} else {
|
||||
this.logger.Info(`Rename ${client.IP.address} to ${client.username}`);
|
||||
this.clients.forEach((c) =>
|
||||
|
||||
c.sendMsg(guacutils.encode("adduser", "1", client.username!, client.rank.toString())));
|
||||
}
|
||||
}
|
||||
|
||||
getAdduserMsg() : string {
|
||||
var arr : string[] = ["adduser", this.clients.filter(c=>c.username).length.toString()];
|
||||
|
||||
this.clients.filter(c=>c.username).forEach((c) => arr.push(c.username!, c.rank.toString()));
|
||||
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(client? : User) {
|
||||
var turnQueueArr = this.TurnQueue.toArray();
|
||||
var turntime;
|
||||
if (this.indefiniteTurn === null) turntime = (this.TurnTime * 1000);
|
||||
else turntime = 9999999999;
|
||||
var arr = ["turn", turntime.toString(), this.TurnQueue.size.toString()];
|
||||
// @ts-ignore
|
||||
this.TurnQueue.forEach((c) => arr.push(c.username));
|
||||
var currentTurningUser = this.TurnQueue.peek();
|
||||
if (client) {
|
||||
client.sendMsg(guacutils.encode(...arr));
|
||||
return;
|
||||
}
|
||||
this.clients.filter(c => (c !== currentTurningUser && c.connectedToNode)).forEach((c) => {
|
||||
if (turnQueueArr.indexOf(c) !== -1) {
|
||||
var time;
|
||||
if (this.indefiniteTurn === null) time = ((this.TurnTime * 1000) + ((turnQueueArr.indexOf(c) - 1) * this.Config.collabvm.turnTime * 1000));
|
||||
else time = 9999999999;
|
||||
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) {
|
||||
} else {
|
||||
this.TurnTime = this.Config.collabvm.turnTime;
|
||||
this.TurnInterval = setInterval(() => this.turnInterval(), 1000);
|
||||
}
|
||||
this.sendTurnUpdate();
|
||||
}
|
||||
|
||||
clearTurns() {
|
||||
clearInterval(this.TurnInterval);
|
||||
this.TurnQueue.clear();
|
||||
this.sendTurnUpdate();
|
||||
}
|
||||
|
||||
bypassTurn(client : User) {
|
||||
var a = this.TurnQueue.toArray().filter(c => c !== client);
|
||||
this.TurnQueue = Queue.from([client, ...a]);
|
||||
this.nextTurn();
|
||||
}
|
||||
|
||||
endTurn(client : User) {
|
||||
var hasTurn = (this.TurnQueue.peek() === client);
|
||||
this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter(c => c !== client));
|
||||
if (hasTurn) this.nextTurn();
|
||||
else this.sendTurnUpdate();
|
||||
}
|
||||
|
||||
private turnInterval() {
|
||||
if (this.indefiniteTurn !== null) return;
|
||||
this.TurnTime--;
|
||||
if (this.TurnTime < 1) {
|
||||
this.TurnQueue.dequeue();
|
||||
this.nextTurn();
|
||||
}
|
||||
}
|
||||
|
||||
private async OnDisplayRectangle(rect: Rect) {
|
||||
let encodedb64 = await this.MakeRectData(rect);
|
||||
|
||||
this.clients.filter(c => c.connectedToNode || c.viewMode == 1).forEach(c => {
|
||||
if (this.screenHidden && c.rank == Rank.Unregistered) return;
|
||||
c.sendMsg(guacutils.encode("png", "0", "0", rect.x.toString(), rect.y.toString(), encodedb64));
|
||||
c.sendMsg(guacutils.encode("sync", Date.now().toString()));
|
||||
});
|
||||
}
|
||||
|
||||
private OnDisplayResized(size : Size) {
|
||||
this.clients.filter(c => c.connectedToNode || c.viewMode == 1).forEach(c => {
|
||||
if (this.screenHidden && c.rank == Rank.Unregistered) return;
|
||||
c.sendMsg(guacutils.encode("size", "0", size.width.toString(), size.height.toString()))
|
||||
});
|
||||
}
|
||||
|
||||
private async SendFullScreenWithSize(client: User) {
|
||||
let display = this.VM.GetDisplay();
|
||||
let displaySize = display.Size();
|
||||
|
||||
let encoded = await this.MakeRectData({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: displaySize.width,
|
||||
height: displaySize.height
|
||||
});
|
||||
|
||||
client.sendMsg(guacutils.encode("size", "0", displaySize.width.toString(), displaySize.height.toString()));
|
||||
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", encoded));
|
||||
}
|
||||
|
||||
private async MakeRectData(rect: Rect) {
|
||||
let display = this.VM.GetDisplay();
|
||||
let displaySize = display.Size();
|
||||
|
||||
let encoded = await EncodeJpeg(display.Buffer(), displaySize, rect);
|
||||
|
||||
return encoded.toString('base64');
|
||||
}
|
||||
|
||||
getThumbnail() : Promise<string> {
|
||||
return new Promise(async (res, rej) => {
|
||||
let display = this.VM.GetDisplay();
|
||||
if(display == null)
|
||||
return;
|
||||
|
||||
// TODO: pass custom options to Sharp.resize() probably
|
||||
let out = await sharp(display.Buffer(), {raw: GetRawSharpOptions(display.Size())})
|
||||
.resize(400, 300)
|
||||
.toFormat('jpeg')
|
||||
.toBuffer();
|
||||
|
||||
res(out.toString('base64'));
|
||||
});
|
||||
}
|
||||
|
||||
startVote() {
|
||||
if (this.voteInProgress) return;
|
||||
this.voteInProgress = true;
|
||||
this.clients.forEach(c => c.sendMsg(guacutils.encode("vote", "0")));
|
||||
this.voteTime = this.Config.collabvm.voteTime;
|
||||
this.voteInterval = setInterval(() => {
|
||||
this.voteTime--;
|
||||
if (this.voteTime < 1) {
|
||||
this.endVote();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
endVote(result? : boolean) {
|
||||
if (!this.voteInProgress) return;
|
||||
this.voteInProgress = false;
|
||||
clearInterval(this.voteInterval);
|
||||
var count = this.getVoteCounts();
|
||||
this.clients.forEach((c) => c.sendMsg(guacutils.encode("vote", "2")));
|
||||
if (result === true || (result === undefined && count.yes >= count.no)) {
|
||||
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", "The vote to reset the VM has won.")));
|
||||
this.VM.Reset();
|
||||
} else {
|
||||
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", "The vote to reset the VM has lost.")));
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
sendVoteUpdate(client? : User) {
|
||||
if (!this.voteInProgress) return;
|
||||
var count = this.getVoteCounts();
|
||||
var msg = guacutils.encode("vote", "1", (this.voteTime * 1000).toString(), count.yes.toString(), count.no.toString());
|
||||
if (client)
|
||||
client.sendMsg(msg);
|
||||
else
|
||||
this.clients.forEach((c) => c.sendMsg(msg));
|
||||
}
|
||||
|
||||
getVoteCounts() : {yes:number,no:number} {
|
||||
var yes = 0;
|
||||
var no = 0;
|
||||
IPDataManager.ForEachIPData((c) => {
|
||||
if (c.vote === true) yes++;
|
||||
if (c.vote === false) no++;
|
||||
});
|
||||
return {yes:yes,no:no};
|
||||
}
|
||||
}
|
||||
43
cvmts/src/guacutils.ts
Normal file
43
cvmts/src/guacutils.ts
Normal 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;
|
||||
}
|
||||
58
cvmts/src/index.ts
Normal file
58
cvmts/src/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as toml from 'toml';
|
||||
import IConfig from './IConfig.js';
|
||||
import * as fs from "fs";
|
||||
import WSServer from './WSServer.js';
|
||||
|
||||
import { QemuVM, QemuVmDefinition } from '@cvmts/qemu';
|
||||
|
||||
import * as Shared from '@cvmts/shared';
|
||||
import AuthManager from './AuthManager.js';
|
||||
|
||||
let logger = new Shared.Logger("CVMTS.Init");
|
||||
|
||||
logger.Info("CollabVM Server starting up");
|
||||
|
||||
// Parse the config file
|
||||
|
||||
var Config : IConfig;
|
||||
|
||||
if (!fs.existsSync("config.toml")) {
|
||||
logger.Error("Fatal error: Config.toml not found. Please copy config.example.toml and fill out fields")
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
var configRaw = fs.readFileSync("config.toml").toString();
|
||||
Config = toml.parse(configRaw);
|
||||
} catch (e) {
|
||||
logger.Error("Fatal error: Failed to read or parse the config file: {0}", (e as Error).message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
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) {
|
||||
logger.Warning("You appear to have the option 'qmpSockDir' enabled in the config.")
|
||||
logger.Warning("This is not supported on Windows, and you will likely run into issues.");
|
||||
logger.Warning("To remove this warning, use the qmpHost and qmpPort options instead.");
|
||||
}
|
||||
|
||||
// Init the auth manager if enabled
|
||||
let auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null;
|
||||
|
||||
// Fire up the VM
|
||||
let def: QemuVmDefinition = {
|
||||
id: Config.collabvm.node,
|
||||
command: Config.vm.qemuArgs
|
||||
}
|
||||
|
||||
var VM = new QemuVM(def);
|
||||
await VM.Start();
|
||||
|
||||
// Start up the websocket server
|
||||
var WS = new WSServer(Config, VM, auth);
|
||||
WS.listen();
|
||||
}
|
||||
start();
|
||||
1
cvmts/tsconfig.json
Symbolic link
1
cvmts/tsconfig.json
Symbolic link
@@ -0,0 +1 @@
|
||||
../tsconfig.json
|
||||
Reference in New Issue
Block a user