diff --git a/config.example.toml b/config.example.toml index 2506247..2698ec7 100644 --- a/config.example.toml +++ b/config.example.toml @@ -20,13 +20,6 @@ directory = "geoip/" accountID = "" licenseKey = "" -[tcp] -# Enabled the raw TCP socket server -# You usually want to leave this disabled -enabled = false -host = "0.0.0.0" -port = 6014 - [auth] # Enables the CollabVM account authentication system # Requires an authentication server (https://git.computernewb.com/collabvm/CollabVMAuthServer) diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index 876ab3b..15f4ca2 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -1,7 +1,6 @@ import IConfig from './IConfig.js'; import * as Utilities from './Utilities.js'; import { User, Rank } from './User.js'; -import * as cvm from '@cvmts/cvm-rs'; // I hate that you have to do it like this import CircularBuffer from 'mnemonist/circular-buffer.js'; import Queue from 'mnemonist/queue.js'; @@ -14,13 +13,13 @@ import AuthManager from './AuthManager.js'; import { JPEGEncoder } from './JPEGEncoder.js'; import VM from './vm/interface.js'; import { ReaderModel } from '@maxmind/geoip2-node'; -import * as msgpack from 'msgpackr'; -import { CollabVMProtocolMessage, CollabVMProtocolMessageType } from '@cvmts/collab-vm-1.2-binary-protocol'; import { Size, Rect } from './Utilities.js'; import pino from 'pino'; import { BanManager } from './BanManager.js'; import { TheAuditLog } from './AuditLog.js'; +import { IProtocolMessageHandler, ListEntry, ProtocolAddUser, ProtocolFlag, ProtocolRenameStatus, ProtocolUpgradeCapability } from './protocol/Protocol.js'; +import { TheProtocolManager } from './protocol/Manager.js'; // Instead of strange hacks we can just use nodejs provided // import.meta properties, which have existed since LTS if not before @@ -40,7 +39,7 @@ type VoteTally = { no: number; }; -export default class CollabVMServer { +export default class CollabVMServer implements IProtocolMessageHandler { private Config: IConfig; private clients: User[]; @@ -77,8 +76,8 @@ export default class CollabVMServer { private screenHidden: boolean; // base64 image to show when the screen is hidden - private screenHiddenImg: string; - private screenHiddenThumb: string; + private screenHiddenImg: Buffer; + private screenHiddenThumb: Buffer; // Indefinite turn private indefiniteTurn: User | null; @@ -110,8 +109,8 @@ export default class CollabVMServer { this.voteCooldown = 0; this.turnsAllowed = true; this.screenHidden = false; - this.screenHiddenImg = readFileSync(path.join(kCVMTSAssetsRoot, 'screenhidden.jpeg')).toString('base64'); - this.screenHiddenThumb = readFileSync(path.join(kCVMTSAssetsRoot, 'screenhiddenthumb.jpeg')).toString('base64'); + this.screenHiddenImg = readFileSync(path.join(kCVMTSAssetsRoot, 'screenhidden.jpeg')); + this.screenHiddenThumb = readFileSync(path.join(kCVMTSAssetsRoot, 'screenhiddenthumb.jpeg')); this.indefiniteTurn = null; this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions); @@ -159,7 +158,7 @@ export default class CollabVMServer { this.banmgr = banmgr; } - public addUser(user: User) { + public connectionOpened(user: User) { let sameip = this.clients.filter((c) => c.IP.address === user.IP.address); if (sameip.length >= this.Config.collabvm.maxConnections) { // Kick the oldest client @@ -167,6 +166,7 @@ export default class CollabVMServer { sameip[0].kick(); } this.clients.push(user); + if (this.Config.geoip.enabled) { try { user.countryCode = this.geoipReader!.country(user.IP.address).country!.isoCode; @@ -174,13 +174,34 @@ export default class CollabVMServer { this.logger.warn(`Failed to get country code for ${user.IP.address}: ${(error as Error).message}`); } } - user.socket.on('msg', (msg: string) => this.onMessage(user, msg)); + + user.socket.on('msg', (buf: Buffer, binary: boolean) => { + try { + user.protocol.processMessage(buf); + } catch (err) { + this.logger.error({ + ip: user.IP.address, + username: user.username, + error_message: (err as Error).message + }, 'Error in %s#processMessage.', Object.getPrototypeOf(user.protocol).constructor?.name); + user.kick(); + } + }); + user.socket.on('disconnect', () => this.connectionClosed(user)); + + // Set ourselves as the handler + user.protocol.setHandler(this as IProtocolMessageHandler); + if (this.Config.auth.enabled) { - user.sendMsg(cvm.guacEncode('auth', this.Config.auth.apiEndpoint)); + user.protocol.sendAuth(this.Config.auth.apiEndpoint); + } + + user.protocol.sendAddUser(this.getAddUser()); + if (this.Config.geoip.enabled) { + let flags = this.getFlags(); + user.protocol.sendFlag(flags); } - user.sendMsg(this.getAdduserMsg()); - if (this.Config.geoip.enabled) user.sendMsg(this.getFlagMsg()); } private connectionClosed(user: User) { @@ -196,6 +217,8 @@ export default class CollabVMServer { this.clients.splice(clientIndex, 1); + user.protocol.dispose(); + 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) { @@ -204,604 +227,599 @@ export default class CollabVMServer { if (hadturn) this.nextTurn(); } - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('remuser', '1', user.username!))); + this.clients.forEach((c) => c.protocol.sendRemUser([user.username!])); } - private async onMessage(client: User, message: string) { + // Protocol message handlers + + // does auth check + private authCheck(user: User, guestPermission: boolean) { + if (!this.Config.auth.enabled) return true; + + if (user.rank === Rank.Unregistered && !guestPermission) { + user.protocol.sendChatMessage('', 'You need to login to do that.'); + return false; + } + + return true; + } + + onNop(user: User): void { + user.onNop(); + } + + async onLogin(user: User, token: string) { + if (!this.Config.auth.enabled) return; + + if (!user.connectedToNode) { + user.protocol.sendLoginResponse(false, 'You must connect to the VM before logging in.'); + return; + } + try { - var msgArr = cvm.guacDecode(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(cvm.guacEncode('login', '0', 'You must connect to the VM before logging in.')); - return; - } - try { - let res = await this.auth!.Authenticate(msgArr[1], client); - if (res.clientSuccess) { - this.logger.info(`${client.IP.address} logged in as ${res.username}`); - client.sendMsg(cvm.guacEncode('login', '1')); - let 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 - if (client.countryCode !== null && client.noFlag) { - // privacy - for (let cl of this.clients.filter((c) => c !== client)) { - cl.sendMsg(cvm.guacEncode('remuser', '1', client.username!)); - } - this.renameUser(client, res.username, false); - } else this.renameUser(client, res.username, true); - // Set rank - client.rank = res.rank; - if (client.rank === Rank.Admin) { - client.sendMsg(cvm.guacEncode('admin', '0', '1')); - } else if (client.rank === Rank.Moderator) { - client.sendMsg(cvm.guacEncode('admin', '0', '3', this.ModPerms.toString())); - } - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString()))); - } else { - client.sendMsg(cvm.guacEncode('login', '0', res.error!)); - if (res.error === 'You are banned') { - client.kick(); - } - } - } catch (err) { - this.logger.error(`Error authenticating client ${client.IP.address}: ${(err as Error).message}`); - // for now? - client.sendMsg(cvm.guacEncode('login', '0', 'There was an internal error while authenticating. Please let a staff member know as soon as possible')); - } - break; - case 'noflag': { - if (client.connectedToNode) - // too late - return; - client.noFlag = true; + let res = await this.auth!.Authenticate(token, user); + + if (res.clientSuccess) { + this.logger.info(`${user.IP.address} logged in as ${res.username}`); + user.protocol.sendLoginResponse(true, ''); + + let 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(); } - case 'list': - if (this.VM.GetState() == VMState.Started) { - client.sendMsg(cvm.guacEncode('list', this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail())); + // Set username + if (user.countryCode !== null && user.noFlag) { + // privacy + for (let cl of this.clients.filter((c) => c !== user)) { + cl.protocol.sendRemUser([user.username!]); } - break; - case 'connect': - if (!client.username || msgArr.length !== 2 || msgArr[1] !== this.Config.collabvm.node) { - client.sendMsg(cvm.guacEncode('connect', '0')); - return; - } - - client.connectedToNode = true; - client.sendMsg(cvm.guacEncode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); - if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); - if (this.Config.collabvm.motd) client.sendMsg(cvm.guacEncode('chat', '', this.Config.collabvm.motd)); - if (this.screenHidden) { - client.sendMsg(cvm.guacEncode('size', '0', '1024', '768')); - client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); - } else { - await this.SendFullScreenWithSize(client); - } - client.sendMsg(cvm.guacEncode('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(cvm.guacEncode('connect', '0')); - return; - } - - switch (msgArr[2]) { - case '0': - client.viewMode = 0; - break; - case '1': - client.viewMode = 1; - break; - default: - client.sendMsg(cvm.guacEncode('connect', '0')); - return; - } - - client.sendMsg(cvm.guacEncode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); - if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); - if (this.Config.collabvm.motd) client.sendMsg(cvm.guacEncode('chat', '', this.Config.collabvm.motd)); - - if (client.viewMode == 1) { - if (this.screenHidden) { - client.sendMsg(cvm.guacEncode('size', '0', '1024', '768')); - client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); - } else { - await this.SendFullScreenWithSize(client); - } - client.sendMsg(cvm.guacEncode('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(cvm.guacEncode('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(cvm.guacEncode('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(cvm.guacEncode('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(cvm.guacEncode('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.turnWhitelist) return; - if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.turn) { - client.sendMsg(cvm.guacEncode('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.VM.SnapshotsSupported()) return; - if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && !client.turnWhitelist) 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.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.callForReset) { - client.sendMsg(cvm.guacEncode('chat', '', 'You need to login to do that.')); - return; - } - - if (this.voteCooldown !== 0) { - client.sendMsg(cvm.guacEncode('vote', '3', this.voteCooldown.toString())); - return; - } - this.startVote(); - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', `${client.username} has started a vote to reset the VM.`))); - } - if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) { - client.sendMsg(cvm.guacEncode('chat', '', 'You need to login to do that.')); - return; - } else if (client.IP.vote !== true) { - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', `${client.username} has voted yes.`))); - } - client.IP.vote = true; - break; - case '0': - if (!this.voteInProgress) return; - if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) { - client.sendMsg(cvm.guacEncode('chat', '', 'You need to login to do that.')); - return; - } - if (client.IP.vote !== false) { - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', `${client.username} has voted no.`))); - } - client.IP.vote = false; - break; - } - this.sendVoteUpdate(); - break; - case 'cap': { - if (msgArr.length < 2) return; - // Capabilities can only be announced before connecting to the VM - if (client.connectedToNode) return; - var caps = []; - for (const cap of msgArr.slice(1)) - switch (cap) { - case 'bin': { - if (caps.indexOf('bin') !== -1) break; - client.Capabilities.bin = true; - caps.push('bin'); - break; - } - } - client.sendMsg(cvm.guacEncode('cap', ...caps)); + this.renameUser(user, res.username, false); + } else this.renameUser(user, res.username, true); + // Set rank + user.rank = res.rank; + if (user.rank === Rank.Admin) { + user.protocol.sendAdminLoginResponse(true, undefined); + } else if (user.rank === Rank.Moderator) { + user.protocol.sendAdminLoginResponse(true, this.ModPerms); + } + this.clients.forEach((c) => + c.protocol.sendAddUser([ + { + username: user.username!, + rank: user.rank + } + ]) + ); + } else { + user.protocol.sendLoginResponse(false, res.error!); + if (res.error === 'You are banned') { + user.kick(); } - case 'admin': - if (msgArr.length < 2) return; - switch (msgArr[1]) { - case '2': - // Login - - 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 (this.Config.collabvm.turnwhitelist && pwdHash === this.Config.collabvm.turnpass) { - client.turnWhitelist = true; - client.sendMsg(cvm.guacEncode('chat', '', 'You may now take turns.')); - return; - } - - if (this.Config.auth.enabled) { - client.sendMsg(cvm.guacEncode('chat', '', 'This server does not support staff passwords. Please log in to become staff.')); - return; - } - - if (pwdHash === this.Config.collabvm.adminpass) { - client.rank = Rank.Admin; - client.sendMsg(cvm.guacEncode('admin', '0', '1')); - } else if (this.Config.collabvm.moderatorEnabled && pwdHash === this.Config.collabvm.modpass) { - client.rank = Rank.Moderator; - client.sendMsg(cvm.guacEncode('admin', '0', '3', this.ModPerms.toString())); - } else { - client.sendMsg(cvm.guacEncode('admin', '0', '0')); - return; - } - if (this.screenHidden) { - await this.SendFullScreenWithSize(client); - - client.sendMsg(cvm.guacEncode('sync', Date.now().toString())); - } - - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString()))); - break; - case '5': - // QEMU Monitor - if (client.rank !== Rank.Admin) return; - if (msgArr.length !== 4 || msgArr[2] !== this.Config.collabvm.node) return; - TheAuditLog.onMonitorCommand(client, msgArr[3]); - let output = await this.VM.MonitorCommand(msgArr[3]); - client.sendMsg(cvm.guacEncode('admin', '2', String(output))); - break; - case '8': - // Restore - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.restore)) return; - TheAuditLog.onReset(client); - 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; - TheAuditLog.onReboot(client); - await this.VM.Reboot(); - 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; - TheAuditLog.onBan(client, user); - user.ban(this.banmgr); - 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; - } - //TheAdminLogger.onMute(client, user, permamute); - 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; - TheAuditLog.onKick(client, user); - 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(cvm.guacEncode('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(cvm.guacEncode('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(cvm.guacEncode('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(cvm.guacEncode('chat', client.username!, msgArr[2]))); - - this.clients.filter((c) => c.rank === Rank.Admin).forEach((c) => c.sendMsg(cvm.guacEncode('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(cvm.guacEncode('size', '0', '1024', '768')); - client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); - client.sendMsg(cvm.guacEncode('sync', Date.now().toString())); - }); - break; - case '1': - this.screenHidden = false; - let displaySize = this.VM.GetDisplay()?.Size(); - if (displaySize == undefined) return; - - let encoded = await this.MakeRectData({ - x: 0, - y: 0, - width: displaySize.width, - height: displaySize.height - }); - - this.clients.forEach(async (client) => this.SendFullScreenWithSize(client)); - break; - } - break; - case '25': - if (client.rank !== Rank.Admin || msgArr.length !== 3) return; - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', msgArr[2]))); - break; - } - break; } } catch (err) { - this.logger.error({ - ip: client.IP.address, - username: client.username, - error_message: (err as Error).message - }, 'Error in CollabVMServer#onMessage.'); + this.logger.error(`Error authenticating client ${user.IP.address}: ${(err as Error).message}`); - // should probably only do this for protocol errors - client.kick(); + user.protocol.sendLoginResponse(false, 'There was an internal error while authenticating. Please let a staff member know as soon as possible'); } } + onNoFlag(user: User) { + // Too late + if (user.connectedToNode) return; + user.noFlag = true; + } + + onCapabilityUpgrade(user: User, capability: String[]): boolean { + if (user.connectedToNode) return false; + + let enabledCaps = []; + + for (let cap of capability) { + switch (cap) { + // binary 1.0 (msgpack rects) + case ProtocolUpgradeCapability.BinRects: + enabledCaps.push(cap as ProtocolUpgradeCapability); + user.Capabilities.bin = true; + user.protocol.dispose(); + user.protocol = TheProtocolManager.createProtocol('binary1', user); + user.protocol.setHandler(this as IProtocolMessageHandler); + break; + default: + break; + } + } + + user.protocol.sendCapabilities(enabledCaps); + return true; + } + + onTurnRequest(user: User, forfeit: boolean): void { + if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && user.rank !== Rank.Admin && user.rank !== Rank.Moderator && !user.turnWhitelist) return; + + if (!this.authCheck(user, this.Config.auth.guestPermissions.turn)) return; + + if (!user.TurnRateLimit.request()) return; + if (!user.connectedToNode) return; + + if (forfeit == false) { + var currentQueue = this.TurnQueue.toArray(); + // If the user is already in the turn queue, ignore the turn request. + if (currentQueue.indexOf(user) !== -1) return; + // If they're muted, also ignore the turn request. + // Send them the turn queue to prevent client glitches + if (user.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((otheruser) => otheruser.IP.address == user.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(user); + if (this.TurnQueue.size === 1) this.nextTurn(); + } else { + // Not sure why this wasn't using this before + this.endTurn(user); + } + this.sendTurnUpdate(); + } + + onVote(user: User, choice: number): void { + if (!this.VM.SnapshotsSupported()) return; + if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && user.rank !== Rank.Admin && user.rank !== Rank.Moderator && !user.turnWhitelist) return; + if (!user.connectedToNode) return; + if (!user.VoteRateLimit.request()) return; + switch (choice) { + case 1: + if (!this.voteInProgress) { + if (!this.authCheck(user, this.Config.auth.guestPermissions.callForReset)) return; + + if (this.voteCooldown !== 0) { + user.protocol.sendVoteCooldown(this.voteCooldown); + return; + } + + this.startVote(); + this.clients.forEach((c) => c.protocol.sendChatMessage('', `${user.username} has started a vote to reset the VM.`)); + } + + if (!this.authCheck(user, this.Config.auth.guestPermissions.vote)) return; + + if (user.IP.vote !== true) { + this.clients.forEach((c) => c.protocol.sendChatMessage('', `${user.username} has voted yes.`)); + } + user.IP.vote = true; + break; + case 0: + if (!this.voteInProgress) return; + + if (!this.authCheck(user, this.Config.auth.guestPermissions.vote)) return; + + if (user.IP.vote !== false) { + this.clients.forEach((c) => c.protocol.sendChatMessage('', `${user.username} has voted no.`)); + } + user.IP.vote = false; + break; + default: + break; + } + this.sendVoteUpdate(); + } + + async onList(user: User) { + let listEntry: ListEntry = { + id: this.Config.collabvm.node, + name: this.Config.collabvm.displayname, + thumbnail: this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail() + }; + + if (this.VM.GetState() == VMState.Started) { + user.protocol.sendListResponse([listEntry]); + } + } + + private async connectViewShared(user: User, node: string, viewMode: number | undefined) { + if (!user.username || node !== this.Config.collabvm.node) { + user.protocol.sendConnectFailResponse(); + return; + } + + user.connectedToNode = true; + + if (viewMode !== undefined) { + if (viewMode !== 0 && viewMode !== 1) { + user.protocol.sendConnectFailResponse(); + return; + } + + user.viewMode = viewMode; + } + + user.protocol.sendConnectOKResponse(this.VM.SnapshotsSupported()); + + if (this.ChatHistory.size !== 0) { + let history = this.ChatHistory.toArray() as ChatHistory[]; + user.protocol.sendChatHistoryMessage(history); + } + if (this.Config.collabvm.motd) user.protocol.sendChatMessage('', this.Config.collabvm.motd); + if (this.screenHidden) { + user?.protocol.sendScreenResize(1024, 768); + user?.protocol.sendScreenUpdate({ + x: 0, + y: 0, + data: this.screenHiddenImg + }); + } else { + await this.SendFullScreenWithSize(user); + } + + user.protocol.sendSync(Date.now()); + + if (this.voteInProgress) this.sendVoteUpdate(user); + this.sendTurnUpdate(user); + } + + async onConnect(user: User, node: string) { + return this.connectViewShared(user, node, undefined); + } + + async onView(user: User, node: string, viewMode: number) { + return this.connectViewShared(user, node, viewMode); + } + + onRename(user: User, newName: string | undefined): void { + if (!user.RenameRateLimit.request()) return; + if (user.connectedToNode && user.IP.muted) return; + if (this.Config.auth.enabled && user.rank !== Rank.Unregistered) { + user.protocol.sendChatMessage('', 'Go to your account settings to change your username.'); + return; + } + if (this.Config.auth.enabled && newName !== undefined) { + // Don't send system message to a user without a username since it was likely an automated attempt by the webapp + if (user.username) user.protocol.sendChatMessage('', 'You need to log in to do that.'); + if (user.rank !== Rank.Unregistered) return; + this.renameUser(user, undefined); + return; + } + this.renameUser(user, newName); + } + + onChat(user: User, message: string): void { + if (!user.username) return; + if (user.IP.muted) return; + if (!this.authCheck(user, this.Config.auth.guestPermissions.chat)) return; + + var msg = Utilities.HTMLSanitize(message); + // 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.protocol.sendChatMessage(user.username!, msg)); + this.ChatHistory.push({ user: user.username, msg: msg }); + user.onChatMsgSent(); + } + + onKey(user: User, keysym: number, pressed: boolean): void { + if (this.TurnQueue.peek() !== user && user.rank !== Rank.Admin) return; + this.VM.GetDisplay()?.KeyboardEvent(keysym, pressed); + } + + onMouse(user: User, x: number, y: number, buttonMask: number): void { + if (this.TurnQueue.peek() !== user && user.rank !== Rank.Admin) return; + this.VM.GetDisplay()?.MouseEvent(x, y, buttonMask); + } + + async onAdminLogin(user: User, password: string) { + if (!user.LoginRateLimit.request() || !user.username) return; + var sha256 = createHash('sha256'); + sha256.update(password, 'utf-8'); + var pwdHash = sha256.digest('hex'); + sha256.destroy(); + + if (this.Config.collabvm.turnwhitelist && pwdHash === this.Config.collabvm.turnpass) { + user.turnWhitelist = true; + user.protocol.sendChatMessage('', 'You may now take turns.'); + return; + } + + if (this.Config.auth.enabled) { + user.protocol.sendChatMessage('', 'This server does not support staff passwords. Please log in to become staff.'); + return; + } + + if (pwdHash === this.Config.collabvm.adminpass) { + user.rank = Rank.Admin; + user.protocol.sendAdminLoginResponse(true, undefined); + } else if (this.Config.collabvm.moderatorEnabled && pwdHash === this.Config.collabvm.modpass) { + user.rank = Rank.Moderator; + user.protocol.sendAdminLoginResponse(true, this.ModPerms); + } else { + user.protocol.sendAdminLoginResponse(false, undefined); + return; + } + + if (this.screenHidden) { + await this.SendFullScreenWithSize(user); + } + + // Update rank + this.clients.forEach((c) => + c.protocol.sendAddUser([ + { + username: user.username!, + rank: user.rank + } + ]) + ); + } + + async onAdminMonitor(user: User, node: string, command: string) { + if (user.rank !== Rank.Admin) return; + if (node !== this.Config.collabvm.node) return; + TheAuditLog.onMonitorCommand(user, command); + let output = await this.VM.MonitorCommand(command); + user.protocol.sendAdminMonitorResponse(String(output)); + } + + onAdminRestore(user: User, node: string): void { + if (user.rank !== Rank.Admin && (user.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.restore)) return; + TheAuditLog.onReset(user); + this.VM.Reset(); + } + + async onAdminReboot(user: User, node: string) { + if (user.rank !== Rank.Admin && (user.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.reboot)) return; + if (node !== this.Config.collabvm.node) return; + TheAuditLog.onReboot(user); + await this.VM.Reboot(); + } + + async onAdminBanUser(user: User, username: string) { + // Ban + if (user.rank !== Rank.Admin && (user.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.ban)) return; + let target = this.clients.find((c) => c.username === username); + if (!target) return; + TheAuditLog.onBan(user, target); + await target.ban(this.banmgr); + } + + onAdminForceVote(user: User, choice: number): void { + if (user.rank !== Rank.Admin && (user.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.forcevote)) return; + if (!this.voteInProgress) return; + this.endVote(choice == 1); + } + + onAdminMuteUser(user: User, username: string, temporary: boolean): void { + if (user.rank !== Rank.Admin && (user.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.mute)) return; + + let target = this.clients.find((c) => c.username === username); + if (!target) return; + target.mute(!temporary); + } + + onAdminKickUser(user: User, username: string): void { + if (user.rank !== Rank.Admin && (user.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.kick)) return; + var target = this.clients.find((c) => c.username === username); + if (!target) return; + TheAuditLog.onKick(user, target); + target.kick(); + } + + onAdminEndTurn(user: User, username: string): void { + if (user.rank !== Rank.Admin && (user.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; + + var target = this.clients.find((c) => c.username === username); + if (!target) return; + this.endTurn(target); + } + + onAdminClearQueue(user: User, node: string): void { + if (user.rank !== Rank.Admin && (user.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; + if (node !== this.Config.collabvm.node) return; + this.clearTurns(); + } + + onAdminRename(user: User, target: string, newName: string): void { + if (user.rank !== Rank.Admin && (user.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.rename)) return; + if (this.Config.auth.enabled) { + user.protocol.sendChatMessage('', 'Cannot rename users on a server that uses authentication.'); + } + var targetUser = this.clients.find((c) => c.username === target); + if (!targetUser) return; + this.renameUser(targetUser, newName); + } + + onAdminGetIP(user: User, username: string): void { + if (user.rank !== Rank.Admin && (user.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.grabip)) return; + let target = this.clients.find((c) => c.username === username); + if (!target) return; + user.protocol.sendAdminIPResponse(username, target.IP.address); + } + + onAdminBypassTurn(user: User): void { + if (user.rank !== Rank.Admin && (user.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; + this.bypassTurn(user); + } + + onAdminRawMessage(user: User, message: string): void { + if (user.rank !== Rank.Admin && (user.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.xss)) return; + switch (user.rank) { + case Rank.Admin: + this.clients.forEach((c) => c.protocol.sendChatMessage(user.username!, message)); + + this.ChatHistory.push({ user: user.username!, msg: message }); + break; + case Rank.Moderator: + this.clients.filter((c) => c.rank !== Rank.Admin).forEach((c) => c.protocol.sendChatMessage(user.username!, message)); + + this.clients.filter((c) => c.rank === Rank.Admin).forEach((c) => c.protocol.sendChatMessage(user.username!, Utilities.HTMLSanitize(message))); + break; + } + } + + onAdminToggleTurns(user: User, enabled: boolean): void { + if (user.rank !== Rank.Admin) return; + if (enabled) { + this.turnsAllowed = true; + } else { + this.turnsAllowed = false; + this.clearTurns(); + } + } + + onAdminIndefiniteTurn(user: User): void { + if (user.rank !== Rank.Admin) return; + this.indefiniteTurn = user; + this.TurnQueue = Queue.from([user, ...this.TurnQueue.toArray().filter((c) => c !== user)]); + this.sendTurnUpdate(); + } + + async onAdminHideScreen(user: User, show: boolean) { + if (user.rank !== Rank.Admin) return; + if (show) { + // if(!this.screenHidden) return; ? + + this.screenHidden = false; + let displaySize = this.VM.GetDisplay()?.Size(); + + if(displaySize == undefined) + return; + + let encoded = await this.MakeRectData({ + x: 0, + y: 0, + width: displaySize.width, + height: displaySize.height + }); + + this.clients.forEach(async (client) => this.SendFullScreenWithSize(client)); + } else { + this.screenHidden = true; + this.clients + .filter((c) => c.rank == Rank.Unregistered) + .forEach((client) => { + client.protocol.sendScreenResize(1024, 768); + client.protocol.sendScreenUpdate({ + x: 0, + y: 0, + data: this.screenHiddenImg + }); + }); + } + } + + onAdminSystemMessage(user: User, message: string): void { + if (user.rank !== Rank.Admin) return; + this.clients.forEach((c) => c.protocol.sendChatMessage('', message)); + } + + // end protocol message handlers + getUsernameList(): string[] { var arr: string[] = []; - this.clients.filter((c) => c.username).forEach((c) => arr.push(c.username!)); return arr; } renameUser(client: User, newName?: string, announce: boolean = true) { // This shouldn't need a ternary but it does for some reason - var hadName: boolean = client.username ? true : false; - var oldname: any; + let hadName = client.username ? true : false; + let oldname: any; if (hadName) oldname = client.username; - var status = '0'; + + let status = ProtocolRenameStatus.Ok; + if (!newName) { client.assignGuestName(this.getUsernameList()); } else { newName = newName.trim(); if (hadName && newName === oldname) { - client.sendMsg(cvm.guacEncode('rename', '0', '0', client.username!, client.rank.toString())); + client.protocol.sendSelfRename(ProtocolRenameStatus.Ok, client.username!, client.rank); return; } + if (this.getUsernameList().indexOf(newName) !== -1) { client.assignGuestName(this.getUsernameList()); if (client.connectedToNode) { - status = '1'; + status = ProtocolRenameStatus.UsernameTaken; } } else if (!/^[a-zA-Z0-9\ \-\_\.]+$/.test(newName) || newName.length > 20 || newName.length < 3) { client.assignGuestName(this.getUsernameList()); - status = '2'; + status = ProtocolRenameStatus.UsernameInvalid; } else if (this.Config.collabvm.usernameblacklist.indexOf(newName) !== -1) { client.assignGuestName(this.getUsernameList()); - status = '3'; + status = ProtocolRenameStatus.UsernameNotAllowed; } else client.username = newName; } - client.sendMsg(cvm.guacEncode('rename', '0', status, client.username!, client.rank.toString())); + client.protocol.sendSelfRename(status, client.username!, client.rank); + if (hadName) { this.logger.info(`Rename ${client.IP.address} from ${oldname} to ${client.username}`); - if (announce) this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('rename', '1', oldname, client.username!, client.rank.toString()))); + if (announce) this.clients.forEach((c) => c.protocol.sendRename(oldname, client.username!, client.rank)); } else { this.logger.info(`Rename ${client.IP.address} to ${client.username}`); if (announce) this.clients.forEach((c) => { - c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString())); - if (client.countryCode !== null) c.sendMsg(cvm.guacEncode('flag', client.username!, client.countryCode)); + c.protocol.sendAddUser([ + { + username: client.username!, + rank: client.rank + } + ]); + + if (client.countryCode !== null) { + c.protocol.sendFlag([ + { + username: client.username!, + countryCode: client.countryCode + } + ]); + } }); } } - 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 cvm.guacEncode(...arr); + private getAddUser(): ProtocolAddUser[] { + return this.clients + .filter((c) => c.username) + .map((c) => { + return { + username: c.username!, + rank: c.rank + }; + }); } - getFlagMsg(): string { - var arr = ['flag']; + private getFlags(): ProtocolFlag[] { + let arr = []; for (let c of this.clients.filter((cl) => cl.countryCode !== null && cl.username && (!cl.noFlag || cl.rank === Rank.Unregistered))) { - arr.push(c.username!, c.countryCode!); + arr.push({ + username: c.username!, + countryCode: c.countryCode! + }); } - return cvm.guacEncode(...arr); - } - - getChatHistoryMsg(): string { - var arr: string[] = ['chat']; - this.ChatHistory.forEach((c) => arr.push(c.user, c.msg)); - return cvm.guacEncode(...arr); + return arr; } private sendTurnUpdate(client?: User) { var turnQueueArr = this.TurnQueue.toArray(); - var turntime; + var turntime: number; 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 users: string[] = []; + + this.TurnQueue.forEach((c) => users.push(c.username!)); + var currentTurningUser = this.TurnQueue.peek(); + if (client) { - client.sendMsg(cvm.guacEncode(...arr)); + client.protocol.sendTurnQueue(turntime, users); return; } + this.clients .filter((c) => c !== currentTurningUser && c.connectedToNode) .forEach((c) => { @@ -809,12 +827,12 @@ export default class CollabVMServer { var time; if (this.indefiniteTurn === null) time = this.TurnTime * 1000 + (turnQueueArr.indexOf(c) - 1) * this.Config.collabvm.turnTime * 1000; else time = 9999999999; - c.sendMsg(cvm.guacEncode(...arr, time.toString())); + c.protocol.sendTurnQueueWaiting(turntime, users, time); } else { - c.sendMsg(cvm.guacEncode(...arr)); + c.protocol.sendTurnQueue(turntime, users); } }); - if (currentTurningUser) currentTurningUser.sendMsg(cvm.guacEncode(...arr)); + if (currentTurningUser) currentTurningUser.protocol.sendTurnQueue(turntime, users); } private nextTurn() { clearInterval(this.TurnInterval); @@ -839,6 +857,8 @@ export default class CollabVMServer { } endTurn(client: User) { + // I must have somehow accidentally removed this while scalpaling everything out + if (this.indefiniteTurn === client) this.indefiniteTurn = null; var hasTurn = this.TurnQueue.peek() === client; this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter((c) => c !== client)); if (hasTurn) this.nextTurn(); @@ -863,7 +883,7 @@ export default class CollabVMServer { .filter((c) => c.connectedToNode || c.viewMode == 1) .forEach((c) => { if (this.screenHidden && c.rank == Rank.Unregistered) return; - c.sendMsg(cvm.guacEncode('size', '0', size.width.toString(), size.height.toString())); + c.protocol.sendScreenResize(size.width, size.height); }); } @@ -872,28 +892,17 @@ export default class CollabVMServer { let doRect = async (rect: Rect) => { let encoded = await this.MakeRectData(rect); - let encodedb64 = encoded.toString('base64'); - let bmsg: CollabVMProtocolMessage = { - type: CollabVMProtocolMessageType.rect, - rect: { - x: rect.x, - y: rect.y, - data: encoded - } - }; - - var encodedbin = msgpack.encode(bmsg); self.clients .filter((c) => c.connectedToNode || c.viewMode == 1) .forEach((c) => { if (self.screenHidden && c.rank == Rank.Unregistered) return; - if (c.Capabilities.bin) { - c.socket.sendBinary(encodedbin); - } else { - c.sendMsg(cvm.guacEncode('png', '0', '0', rect.x.toString(), rect.y.toString(), encodedb64)); - c.sendMsg(cvm.guacEncode('sync', Date.now().toString())); - } + + c.protocol.sendScreenUpdate({ + x: rect.x, + y: rect.y, + data: encoded + }); }); }; @@ -921,21 +930,13 @@ export default class CollabVMServer { height: displaySize.height }); - client.sendMsg(cvm.guacEncode('size', '0', displaySize.width.toString(), displaySize.height.toString())); + client.protocol.sendScreenResize(displaySize.width, displaySize.height); - if (client.Capabilities.bin) { - let msg: CollabVMProtocolMessage = { - type: CollabVMProtocolMessageType.rect, - rect: { - x: 0, - y: 0, - data: encoded - } - }; - client.socket.sendBinary(msgpack.encode(msg)); - } else { - client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', encoded.toString('base64'))); - } + client.protocol.sendScreenUpdate({ + x: 0, + y: 0, + data: encoded + }); } private async MakeRectData(rect: Rect) { @@ -950,20 +951,19 @@ export default class CollabVMServer { return encoded; } - async getThumbnail(): Promise { + async getThumbnail(): Promise { let display = this.VM.GetDisplay(); // oh well - if (!display?.Connected()) return ''; + if (!display?.Connected()) return Buffer.alloc(4); - let buf = await JPEGEncoder.EncodeThumbnail(display.Buffer(), display.Size()); - return buf.toString('base64'); + return JPEGEncoder.EncodeThumbnail(display.Buffer(), display.Size()); } startVote() { if (this.voteInProgress) return; this.voteInProgress = true; - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('vote', '0'))); + this.clients.forEach((c) => c.protocol.sendVoteStarted()); this.voteTime = this.Config.collabvm.voteTime; this.voteInterval = setInterval(() => { this.voteTime--; @@ -978,12 +978,12 @@ export default class CollabVMServer { this.voteInProgress = false; clearInterval(this.voteInterval); var count = this.getVoteCounts(); - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('vote', '2'))); + this.clients.forEach((c) => c.protocol.sendVoteEnded()); if (result === true || (result === undefined && count.yes >= count.no)) { - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', 'The vote to reset the VM has won.'))); + this.clients.forEach((c) => c.protocol.sendChatMessage('', 'The vote to reset the VM has won.')); this.VM.Reset(); } else { - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', 'The vote to reset the VM has lost.'))); + this.clients.forEach((c) => c.protocol.sendChatMessage('', 'The vote to reset the VM has lost.')); } this.clients.forEach((c) => { c.IP.vote = null; @@ -998,9 +998,9 @@ export default class CollabVMServer { sendVoteUpdate(client?: User) { if (!this.voteInProgress) return; var count = this.getVoteCounts(); - var msg = cvm.guacEncode('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)); + + if (client) client.protocol.sendVoteStats(this.voteTime * 1000, count.yes, count.no); + else this.clients.forEach((c) => c.protocol.sendVoteStats(this.voteTime * 1000, count.yes, count.no)); } getVoteCounts(): VoteTally { diff --git a/cvmts/src/IConfig.ts b/cvmts/src/IConfig.ts index 667c509..5d0f6f5 100644 --- a/cvmts/src/IConfig.ts +++ b/cvmts/src/IConfig.ts @@ -16,11 +16,6 @@ export default interface IConfig { accountID: string; licenseKey: string; }; - tcp: { - enabled: boolean; - host: string; - port: number; - }; auth: { enabled: boolean; apiEndpoint: string; diff --git a/cvmts/src/NetworkClient.ts b/cvmts/src/NetworkClient.ts deleted file mode 100644 index 600356f..0000000 --- a/cvmts/src/NetworkClient.ts +++ /dev/null @@ -1,9 +0,0 @@ -export default interface NetworkClient { - getIP(): string; - send(msg: string): Promise; - sendBinary(msg: Uint8Array): Promise; - close(): void; - on(event: string, listener: (...args: any[]) => void): void; - off(event: string, listener: (...args: any[]) => void): void; - isOpen(): boolean; -} diff --git a/cvmts/src/NetworkServer.ts b/cvmts/src/NetworkServer.ts deleted file mode 100644 index 5fce6f6..0000000 --- a/cvmts/src/NetworkServer.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default interface NetworkServer { - start(): void; - stop(): void; - on(event: string, listener: (...args: any[]) => void): void; - off(event: string, listener: (...args: any[]) => void): void; -} diff --git a/cvmts/src/TCP/TCPClient.ts b/cvmts/src/TCP/TCPClient.ts deleted file mode 100644 index 7d3ff9c..0000000 --- a/cvmts/src/TCP/TCPClient.ts +++ /dev/null @@ -1,72 +0,0 @@ -import EventEmitter from 'events'; -import NetworkClient from '../NetworkClient.js'; -import { Socket } from 'net'; - -const TextHeader = 0; -const BinaryHeader = 1; - -export default class TCPClient extends EventEmitter implements NetworkClient { - private socket: Socket; - private cache: string; - - constructor(socket: Socket) { - super(); - this.socket = socket; - this.cache = ''; - this.socket.on('end', () => { - this.emit('disconnect'); - }); - this.socket.on('data', (data) => { - var msg = data.toString('utf-8'); - if (msg[msg.length - 1] === '\n') msg = msg.slice(0, -1); - this.cache += msg; - this.readCache(); - }); - } - - private readCache() { - for (var index = this.cache.indexOf(';'); index !== -1; index = this.cache.indexOf(';')) { - this.emit('msg', this.cache.slice(0, index + 1)); - this.cache = this.cache.slice(index + 1); - } - } - - getIP(): string { - return this.socket.remoteAddress!; - } - - send(msg: string): Promise { - return new Promise((res, rej) => { - let _msg = new Uint32Array([TextHeader, ...Buffer.from(msg, 'utf-8')]); - this.socket.write(Buffer.from(_msg), (err) => { - if (err) { - rej(err); - return; - } - res(); - }); - }); - } - - sendBinary(msg: Uint8Array): Promise { - return new Promise((res, rej) => { - let _msg = new Uint32Array([BinaryHeader, msg.length, ...msg]); - this.socket.write(Buffer.from(_msg), (err) => { - if (err) { - rej(err); - return; - } - res(); - }); - }); - } - - close(): void { - this.emit('disconnect'); - this.socket.end(); - } - - isOpen(): boolean { - return this.socket.writable; - } -} diff --git a/cvmts/src/TCP/TCPServer.ts b/cvmts/src/TCP/TCPServer.ts deleted file mode 100644 index 8a8158b..0000000 --- a/cvmts/src/TCP/TCPServer.ts +++ /dev/null @@ -1,47 +0,0 @@ -import EventEmitter from 'events'; -import NetworkServer from '../NetworkServer.js'; -import { Server, Socket } from 'net'; -import IConfig from '../IConfig.js'; -import TCPClient from './TCPClient.js'; -import { IPDataManager } from '../IPData.js'; -import { User } from '../User.js'; -import pino from 'pino'; -import { BanManager } from '../BanManager.js'; - -export default class TCPServer extends EventEmitter implements NetworkServer { - listener: Server; - Config: IConfig; - logger = pino({ name: 'CVMTS.TCPServer' }); - clients: TCPClient[]; - private banmgr: BanManager; - - constructor(config: IConfig, banmgr: BanManager) { - super(); - this.Config = config; - this.listener = new Server(); - this.clients = []; - this.listener.on('connection', (socket) => this.onConnection(socket)); - this.banmgr = banmgr; - } - - private async onConnection(socket: Socket) { - this.logger.info(`New TCP connection from ${socket.remoteAddress}`); - if (await this.banmgr.isIPBanned(socket.remoteAddress!)) { - socket.write('6.banned;'); - socket.destroy(); - return; - } - var client = new TCPClient(socket); - this.clients.push(client); - this.emit('connect', new User(client, IPDataManager.GetIPData(client.getIP()), this.Config)); - } - - start(): void { - this.listener.listen(this.Config.tcp.port, this.Config.tcp.host, () => { - this.logger.info(`TCP server listening on ${this.Config.tcp.host}:${this.Config.tcp.port}`); - }); - } - stop(): void { - this.listener.close(); - } -} diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index 264ed1f..e3d2054 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -3,11 +3,12 @@ import * as cvm from '@cvmts/cvm-rs'; import { IPData } from './IPData.js'; import IConfig from './IConfig.js'; import RateLimiter from './RateLimiter.js'; -import { execa, execaCommand, ExecaSyncError } from 'execa'; -import NetworkClient from './NetworkClient.js'; +import { NetworkClient } from './net/NetworkClient.js'; import { CollabVMCapabilities } from '@cvmts/collab-vm-1.2-binary-protocol'; import pino from 'pino'; import { BanManager } from './BanManager.js'; +import { IProtocol } from './protocol/Protocol.js'; +import { TheProtocolManager } from './protocol/Manager.js'; export class User { socket: NetworkClient; @@ -22,6 +23,7 @@ export class User { Config: IConfig; IP: IPData; Capabilities: CollabVMCapabilities; + protocol: IProtocol; turnWhitelist: boolean = false; // Hide flag. Only takes effect if the user is logged in. noFlag: boolean = false; @@ -35,7 +37,7 @@ export class User { private logger = pino({ name: 'CVMTS.User' }); - constructor(socket: NetworkClient, ip: IPData, config: IConfig, username?: string, node?: string) { + constructor(socket: NetworkClient, protocol: string, ip: IPData, config: IConfig, username?: string, node?: string) { this.IP = ip; this.connectedToNode = false; this.viewMode = -1; @@ -44,6 +46,9 @@ export class User { this.msgsSent = 0; this.Capabilities = new CollabVMCapabilities(); + // All clients default to the Guacamole protocol. + this.protocol = TheProtocolManager.createProtocol(protocol, this); + this.socket.on('disconnect', () => { // Unref the ip data for this connection this.IP.Unref(); @@ -52,11 +57,6 @@ export class User { clearInterval(this.msgRecieveInterval); }); - this.socket.on('msg', (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); @@ -84,8 +84,14 @@ export class User { return username; } + onNop() { + clearTimeout(this.nopRecieveTimeout); + clearInterval(this.msgRecieveInterval); + this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000); + } + sendNop() { - this.socket.send('3.nop;'); + this.protocol.sendNop(); } sendMsg(msg: string) { @@ -107,7 +113,7 @@ export class User { this.socket.close(); } - onMsgSent() { + onChatMsgSent() { if (!this.Config.collabvm.automute.enabled) return; // rate limit guest and unregistered chat messages, but not staff ones switch (this.rank) { @@ -153,5 +159,5 @@ export enum Rank { // After all these years Registered = 1, Admin = 2, - Moderator = 3, + Moderator = 3 } diff --git a/cvmts/src/index.ts b/cvmts/src/index.ts index 720ec80..9018c11 100644 --- a/cvmts/src/index.ts +++ b/cvmts/src/index.ts @@ -6,9 +6,8 @@ import CollabVMServer from './CollabVMServer.js'; import { QemuVmDefinition } from '@computernewb/superqemu'; import AuthManager from './AuthManager.js'; -import WSServer from './WebSocket/WSServer.js'; +import WSServer from './net/ws/WSServer.js'; import { User } from './User.js'; -import TCPServer from './TCP/TCPServer.js'; import VM from './vm/interface.js'; import VNCVM from './vm/vnc/VNCVM.js'; import GeoIPDownloader from './GeoIPDownloader.js'; @@ -16,6 +15,9 @@ import pino from 'pino'; import { Database } from './Database.js'; import { BanManager } from './BanManager.js'; import { QemuVMShim } from './vm/qemu.js'; +import { TheProtocolManager } from './protocol/Manager.js'; +import { GuacamoleProtocol } from './protocol/GuacamoleProtocol.js'; +import { BinRectsProtocol } from './protocol/BinRectsProtocol.js'; let logger = pino(); @@ -97,18 +99,16 @@ async function start() { process.on('SIGINT', async () => await stop()); process.on('SIGTERM', async () => await stop()); + // Register protocol(s) that the server supports + TheProtocolManager.registerProtocol("guacamole", () => new GuacamoleProtocol); + TheProtocolManager.registerProtocol("binary1", () => new BinRectsProtocol); + await VM.Start(); // Start up the server var CVM = new CollabVMServer(Config, VM, banmgr, auth, geoipReader); var WS = new WSServer(Config, banmgr); - WS.on('connect', (client: User) => CVM.addUser(client)); + WS.on('connect', (client: User) => CVM.connectionOpened(client)); WS.start(); - - if (Config.tcp.enabled) { - var TCP = new TCPServer(Config, banmgr); - TCP.on('connect', (client: User) => CVM.addUser(client)); - TCP.start(); - } } start(); diff --git a/cvmts/src/net/NetworkClient.ts b/cvmts/src/net/NetworkClient.ts new file mode 100644 index 0000000..5bfd4e4 --- /dev/null +++ b/cvmts/src/net/NetworkClient.ts @@ -0,0 +1,14 @@ +import { EventEmitter } from "stream"; + +interface NetworkClientEvents extends EventEmitter { + on(event: 'msg', listener: (buf: Buffer, binary: boolean) => void): this; + on(event: 'disconnect', listener: () => void): this; +} + +export interface NetworkClient extends NetworkClientEvents { + getIP(): string; + send(msg: string): Promise; + sendBinary(msg: Uint8Array): Promise; + close(): void; + isOpen(): boolean; +} diff --git a/cvmts/src/net/NetworkServer.ts b/cvmts/src/net/NetworkServer.ts new file mode 100644 index 0000000..2c767d3 --- /dev/null +++ b/cvmts/src/net/NetworkServer.ts @@ -0,0 +1,11 @@ +import { EventEmitter } from "stream"; +import { User } from "../User"; + +interface NetworkServerEvents extends EventEmitter { + on(event: 'connect', listener: (user: User) => void): this; +} + +export interface NetworkServer extends NetworkServerEvents { + start(): void; + stop(): void; +} diff --git a/cvmts/src/WebSocket/WSClient.ts b/cvmts/src/net/ws/WSClient.ts similarity index 87% rename from cvmts/src/WebSocket/WSClient.ts rename to cvmts/src/net/ws/WSClient.ts index 3018ace..b79a465 100644 --- a/cvmts/src/WebSocket/WSClient.ts +++ b/cvmts/src/net/ws/WSClient.ts @@ -1,11 +1,12 @@ import { WebSocket } from 'ws'; -import NetworkClient from '../NetworkClient.js'; +import { NetworkClient } from '../NetworkClient.js'; import EventEmitter from 'events'; import pino from 'pino'; export default class WSClient extends EventEmitter implements NetworkClient { socket: WebSocket; ip: string; + enforceTextOnly = true private logger = pino({ name: "CVMTS.WebsocketClient" }); constructor(ws: WebSocket, ip: string) { @@ -13,13 +14,14 @@ export default class WSClient extends EventEmitter implements NetworkClient { this.socket = ws; this.ip = ip; this.socket.on('message', (buf: Buffer, isBinary: boolean) => { - // Close the user's connection if they send a non-string message - if (isBinary) { + // Close the user's connection if they send a binary message + // when we are not expecting them yet. + if (isBinary && this.enforceTextOnly) { this.close(); return; } - this.emit('msg', buf.toString('utf-8')); + this.emit('msg', buf, isBinary); }); this.socket.on('error', (err: Error) => { diff --git a/cvmts/src/WebSocket/WSServer.ts b/cvmts/src/net/ws/WSServer.ts similarity index 87% rename from cvmts/src/WebSocket/WSServer.ts rename to cvmts/src/net/ws/WSServer.ts index 1b1ffe4..3fd7b67 100644 --- a/cvmts/src/WebSocket/WSServer.ts +++ b/cvmts/src/net/ws/WSServer.ts @@ -1,15 +1,19 @@ import * as http from 'http'; -import NetworkServer from '../NetworkServer.js'; +import { NetworkServer } from '../NetworkServer.js'; import EventEmitter from 'events'; import { WebSocketServer, WebSocket } from 'ws'; import internal from 'stream'; -import IConfig from '../IConfig.js'; +import IConfig from '../../IConfig.js'; import { isIP } from 'net'; -import { IPDataManager } from '../IPData.js'; +import { IPDataManager } from '../../IPData.js'; import WSClient from './WSClient.js'; -import { User } from '../User.js'; +import { User } from '../../User.js'; import pino from 'pino'; -import { BanManager } from '../BanManager.js'; +import { BanManager } from '../../BanManager.js'; + +const kAllowedProtocols = [ + "guacamole" // Regular ol' collabvm1 protocol +] export default class WSServer extends EventEmitter implements NetworkServer { private httpServer: http.Server; @@ -50,7 +54,9 @@ export default class WSServer extends EventEmitter implements NetworkServer { socket.destroy(); }; - if (req.headers['sec-websocket-protocol'] !== 'guacamole') { + let protocol = req.headers['sec-websocket-protocol']; + + if (!protocol || kAllowedProtocols.indexOf(protocol) === -1) { killConnection(); return; } @@ -131,14 +137,14 @@ export default class WSServer extends EventEmitter implements NetworkServer { this.wsServer.handleUpgrade(req, socket, head, (ws: WebSocket) => { this.wsServer.emit('connection', ws, req); - this.onConnection(ws, req, ip); + this.onConnection(ws, req, ip, protocol); }); } - private onConnection(ws: WebSocket, req: http.IncomingMessage, ip: string) { + private onConnection(ws: WebSocket, req: http.IncomingMessage, ip: string, protocol: string) { let client = new WSClient(ws, ip); this.clients.push(client); - let user = new User(client, IPDataManager.GetIPData(ip), this.Config); + let user = new User(client, protocol, IPDataManager.GetIPData(ip), this.Config); this.emit('connect', user); diff --git a/cvmts/src/protocol/BinRectsProtocol.ts b/cvmts/src/protocol/BinRectsProtocol.ts new file mode 100644 index 0000000..21bb76a --- /dev/null +++ b/cvmts/src/protocol/BinRectsProtocol.ts @@ -0,0 +1,16 @@ +import * as msgpack from 'msgpackr'; +import { CollabVMProtocolMessage, CollabVMProtocolMessageType } from '@cvmts/collab-vm-1.2-binary-protocol'; +import { GuacamoleProtocol } from './GuacamoleProtocol.js'; + +import { ScreenRect } from './Protocol'; + +export class BinRectsProtocol extends GuacamoleProtocol { + sendScreenUpdate(rect: ScreenRect): void { + let bmsg: CollabVMProtocolMessage = { + type: CollabVMProtocolMessageType.rect, + rect: rect + }; + + this.user?.socket.sendBinary(msgpack.encode(bmsg)); + } +} diff --git a/cvmts/src/protocol/GuacamoleProtocol.ts b/cvmts/src/protocol/GuacamoleProtocol.ts new file mode 100644 index 0000000..4735a37 --- /dev/null +++ b/cvmts/src/protocol/GuacamoleProtocol.ts @@ -0,0 +1,351 @@ +import pino from 'pino'; +import { IProtocol, IProtocolMessageHandler, ListEntry, ProtocolAddUser, ProtocolBase, ProtocolChatHistory, ProtocolFlag, ProtocolRenameStatus, ProtocolUpgradeCapability, ScreenRect } from './Protocol.js'; +import { Rank, User } from '../User.js'; + +import * as cvm from '@cvmts/cvm-rs'; + +// CollabVM protocol implementation for Guacamole. +export class GuacamoleProtocol extends ProtocolBase implements IProtocol { + private logger = pino({ + name: 'CVMTS.GuacamoleProtocol' + }); + + private __processMessage_admin(decodedElements: string[]): boolean { + switch (decodedElements[1]) { + case '2': + if (decodedElements.length !== 3) return false; + this.handlers?.onAdminLogin(this.user!, decodedElements[2]); + break; + case '5': + if (decodedElements.length !== 4) return false; + this.handlers?.onAdminMonitor(this.user!, decodedElements[2], decodedElements[3]); + break; + case '8': + if (decodedElements.length !== 3) return false; + this.handlers?.onAdminRestore(this.user!, decodedElements[2]); + break; + case '10': + if (decodedElements.length !== 3) return false; + this.handlers?.onAdminReboot(this.user!, decodedElements[2]); + break; + case '12': + if (decodedElements.length < 3) return false; + this.handlers?.onAdminBanUser(this.user!, decodedElements[2]); + case '13': + { + if (decodedElements.length !== 3) return false; + let choice = parseInt(decodedElements[2]); + if (choice == undefined) return false; + this.handlers?.onAdminForceVote(this.user!, choice); + } + break; + case '14': + { + if (decodedElements.length !== 4) return false; + let temporary = true; + if (decodedElements[3] == '0') temporary = true; + else if (decodedElements[3] == '1') temporary = false; + else return false; + this.handlers?.onAdminMuteUser(this.user!, decodedElements[2], temporary); + } + break; + case '15': + if (decodedElements.length !== 3) return false; + this.handlers?.onAdminKickUser(this.user!, decodedElements[2]); + break; + case '16': + if (decodedElements.length !== 3) return false; + this.handlers?.onAdminEndTurn(this.user!, decodedElements[2]); + break; + case '17': + if (decodedElements.length !== 3) return false; + this.handlers?.onAdminClearQueue(this.user!, decodedElements[2]); + break; + case '18': + if (decodedElements.length !== 4) return false; + this.handlers?.onAdminRename(this.user!, decodedElements[2], decodedElements[3]); + break; + case '19': + if (decodedElements.length !== 3) return false; + this.handlers?.onAdminGetIP(this.user!, decodedElements[2]); + break; + case '20': + this.handlers?.onAdminBypassTurn(this.user!); + break; + case '21': + if (decodedElements.length !== 3) return false; + this.handlers?.onAdminRawMessage(this.user!, decodedElements[2]); + break; + case '22': + { + // Toggle turns + if (decodedElements.length !== 3) return false; + let enabled = true; + if (decodedElements[2] == '0') enabled = false; + else if (decodedElements[2] == '1') enabled = true; + else return false; + this.handlers?.onAdminToggleTurns(this.user!, enabled); + } + break; + case '23': + this.handlers?.onAdminIndefiniteTurn(this.user!); + break; + case '24': + { + if (decodedElements.length !== 3) return false; + let show = true; + if (decodedElements[2] == '0') show = false; + else if (decodedElements[2] == '1') show = true; + else return false; + this.handlers?.onAdminHideScreen(this.user!, show); + } + break; + case '25': + if (decodedElements.length !== 3) return false; + this.handlers?.onAdminSystemMessage(this.user!, decodedElements[2]); + break; + } + return true; + } + + processMessage(buffer: Buffer): boolean { + let decodedElements = cvm.guacDecode(buffer.toString('utf-8')); + if (decodedElements.length < 1) return false; + + // The first element is the "opcode". + switch (decodedElements[0]) { + case 'nop': + this.handlers?.onNop(this.user!); + break; + case 'cap': + if (decodedElements.length < 2) return false; + this.handlers?.onCapabilityUpgrade(this.user!, decodedElements.slice(1)); + break; + case 'login': + if (decodedElements.length !== 2) return false; + this.handlers?.onLogin(this.user!, decodedElements[1]); + break; + case 'noflag': + this.handlers?.onNoFlag(this.user!); + break; + case 'list': + this.handlers?.onList(this.user!); + break; + case 'connect': + if (decodedElements.length !== 2) return false; + this.handlers?.onConnect(this.user!, decodedElements[1]); + break; + case 'view': + { + if (decodedElements.length !== 3) return false; + let viewMode = parseInt(decodedElements[2]); + if (viewMode == undefined) return false; + + this.handlers?.onView(this.user!, decodedElements[1], viewMode); + } + break; + case 'rename': + this.handlers?.onRename(this.user!, decodedElements[1]); + break; + case 'chat': + if (decodedElements.length !== 2) return false; + this.handlers?.onChat(this.user!, decodedElements[1]); + break; + case 'turn': + let forfeit = false; + if (decodedElements.length > 2) return false; + if (decodedElements.length == 1) { + forfeit = false; + } else { + if (decodedElements[1] == '0') forfeit = true; + else if (decodedElements[1] == '1') forfeit = false; + } + + this.handlers?.onTurnRequest(this.user!, forfeit); + break; + case 'mouse': + if (decodedElements.length !== 4) return false; + + let x = parseInt(decodedElements[1]); + let y = parseInt(decodedElements[2]); + let mask = parseInt(decodedElements[3]); + if (x === undefined || y === undefined || mask === undefined) return false; + + this.handlers?.onMouse(this.user!, x, y, mask); + break; + case 'key': + if (decodedElements.length !== 3) return false; + var keysym = parseInt(decodedElements[1]); + var down = parseInt(decodedElements[2]); + if (keysym === undefined || (down !== 0 && down !== 1)) return false; + this.handlers?.onKey(this.user!, keysym, down === 1); + break; + case 'vote': + if (decodedElements.length !== 2) return false; + let choice = parseInt(decodedElements[1]); + if (choice == undefined) return false; + this.handlers?.onVote(this.user!, choice); + break; + + case 'admin': + if (decodedElements.length < 2) return false; + return this.__processMessage_admin(decodedElements); + } + + return true; + } + + // Senders + + sendAuth(authServer: string): void { + this.user?.sendMsg(cvm.guacEncode('auth', authServer)); + } + + sendNop(): void { + this.user?.sendMsg(cvm.guacEncode('nop')); + } + + sendSync(now: number): void { + this.user?.sendMsg(cvm.guacEncode('sync', now.toString())); + } + + sendCapabilities(caps: ProtocolUpgradeCapability[]): void { + let arr = ['cap', ...caps]; + this?.user?.sendMsg(cvm.guacEncode(...arr)); + } + + sendConnectFailResponse(): void { + this.user?.sendMsg(cvm.guacEncode('connect', '0')); + } + + sendConnectOKResponse(votes: boolean): void { + this.user?.sendMsg(cvm.guacEncode('connect', '1', '1', votes ? '1' : '0', '0')); + } + + sendLoginResponse(ok: boolean, message: string | undefined): void { + if (ok) { + this.user?.sendMsg(cvm.guacEncode('login', '1')); + return; + } else { + this.user?.sendMsg(cvm.guacEncode('login', '0', message!)); + } + } + + sendAdminLoginResponse(ok: boolean, modPerms: number | undefined): void { + if (ok) { + if (modPerms == undefined) { + this.user?.sendMsg(cvm.guacEncode('admin', '0', '1')); + } else { + this.user?.sendMsg(cvm.guacEncode('admin', '0', '3', modPerms.toString())); + } + } else { + this.user?.sendMsg(cvm.guacEncode('admin', '0', '0')); + } + } + + sendAdminMonitorResponse(output: string): void { + this.user?.sendMsg(cvm.guacEncode('admin', '2', output)); + } + + sendAdminIPResponse(username: string, ip: string): void { + this.user?.sendMsg(cvm.guacEncode('admin', '19', username, ip)); + } + + sendChatMessage(username: string, message: string): void { + this.user?.sendMsg(cvm.guacEncode('chat', username, message)); + } + + sendChatHistoryMessage(history: ProtocolChatHistory[]): void { + let arr = ['chat']; + for (let a of history) { + arr.push(a.user, a.msg); + } + + this.user?.sendMsg(cvm.guacEncode(...arr)); + } + + sendAddUser(users: ProtocolAddUser[]): void { + let arr = ['adduser', users.length.toString()]; + for (let user of users) { + arr.push(user.username); + arr.push(user.rank.toString()); + } + + this.user?.sendMsg(cvm.guacEncode(...arr)); + } + + sendRemUser(users: string[]): void { + let arr = ['remuser', users.length.toString()]; + + for (let user of users) { + arr.push(user); + } + + this.user?.sendMsg(cvm.guacEncode(...arr)); + } + + sendFlag(flag: ProtocolFlag[]): void { + // Basically this does the same as the above manual for of things + // but in one line of code + let arr = ['flag', ...flag.flatMap((flag) => [flag.username, flag.countryCode])]; + this.user?.sendMsg(cvm.guacEncode(...arr)); + } + + sendSelfRename(status: ProtocolRenameStatus, newUsername: string, rank: Rank): void { + this.user?.sendMsg(cvm.guacEncode('rename', '0', status.toString(), newUsername)); + } + + sendRename(oldUsername: string, newUsername: string, rank: Rank): void { + this.user?.sendMsg(cvm.guacEncode('rename', '1', oldUsername, newUsername)); + } + + sendListResponse(list: ListEntry[]): void { + let arr = ['list']; + for (let node of list) { + arr.push(node.id); + arr.push(node.name); + arr.push(node.thumbnail.toString('base64')); + } + + this.user?.sendMsg(cvm.guacEncode(...arr)); + } + + sendVoteStarted(): void { + this.user?.sendMsg(cvm.guacEncode('vote', '0')); + } + + sendVoteStats(msLeft: number, nrYes: number, nrNo: number): void { + this.user?.sendMsg(cvm.guacEncode('vote', '1', msLeft.toString(), nrYes.toString(), nrNo.toString())); + } + + sendVoteEnded(): void { + this.user?.sendMsg(cvm.guacEncode('vote', '2')); + } + + sendVoteCooldown(ms: number): void { + this.user?.sendMsg(cvm.guacEncode('vote', '3', ms.toString())); + } + + private getTurnQueueBase(turnTime: number, users: string[]): string[] { + return ['turn', turnTime.toString(), users.length.toString(), ...users]; + } + + sendTurnQueue(turnTime: number, users: string[]): void { + this.user?.sendMsg(cvm.guacEncode(...this.getTurnQueueBase(turnTime, users))); + } + + sendTurnQueueWaiting(turnTime: number, users: string[], waitTime: number): void { + let queue = this.getTurnQueueBase(turnTime, users); + queue.push(waitTime.toString()); + this.user?.sendMsg(cvm.guacEncode(...queue)); + } + + sendScreenResize(width: number, height: number): void { + this.user?.sendMsg(cvm.guacEncode('size', '0', width.toString(), height.toString())); + } + + sendScreenUpdate(rect: ScreenRect): void { + this.user?.sendMsg(cvm.guacEncode('png', '0', '0', rect.x.toString(), rect.y.toString(), rect.data.toString('base64'))); + this.sendSync(Date.now()); + } +} diff --git a/cvmts/src/protocol/Manager.ts b/cvmts/src/protocol/Manager.ts new file mode 100644 index 0000000..d61b003 --- /dev/null +++ b/cvmts/src/protocol/Manager.ts @@ -0,0 +1,27 @@ +import { IProtocol } from "./Protocol"; +import { User } from "../User"; + +// The protocol manager. Holds protocol factories, and provides the ability +// to create a protocol by name. Avoids direct dependency on a given list of protocols, +// and allows (relatively simple) expansion. +export class ProtocolManager { + private protocols = new Map IProtocol>(); + + // Registers a protocol with the given name. + registerProtocol(name: string, protocolFactory: () => IProtocol) { + if (!this.protocols.has(name)) this.protocols.set(name, protocolFactory); + } + + // Creates an instance of a given protocol for a user. + createProtocol(name: string, user: User): IProtocol { + if (!this.protocols.has(name)) throw new Error(`ProtocolManager does not have protocol \"${name}\"`); + + let factory = this.protocols.get(name)!; + let proto = factory(); + proto.init(user); + return proto; + } +} + +/// Global protocol manager +export let TheProtocolManager = new ProtocolManager(); diff --git a/cvmts/src/protocol/Protocol.ts b/cvmts/src/protocol/Protocol.ts new file mode 100644 index 0000000..e6fe4ef --- /dev/null +++ b/cvmts/src/protocol/Protocol.ts @@ -0,0 +1,170 @@ +import { Rank, User } from '../User'; + +// We should probably put this in the binproto repository or something +export enum ProtocolUpgradeCapability { + BinRects = 'bin' +} + +export enum ProtocolRenameStatus { + Ok = 0, + UsernameTaken = 1, + UsernameInvalid = 2, + UsernameNotAllowed = 3 +} + +export interface ScreenRect { + x: number; + y: number; + data: Buffer; +} + +export interface ListEntry { + id: string; + name: string; + thumbnail: Buffer; +} + +export interface ProtocolChatHistory { + user: string; + msg: string; +} + +export interface ProtocolAddUser { + username: string; + rank: Rank; +} + +export interface ProtocolFlag { + username: string; + countryCode: string; +} + +// Protocol handlers. This is implemented by a layer that wants to listen to CollabVM protocol messages. +export interface IProtocolMessageHandler { + onNop(user: User): void; + + onNoFlag(user: User): void; + + // Called when the client requests a capability upgrade + onCapabilityUpgrade(user: User, capability: Array): boolean; + + onLogin(user: User, token: string): void; + + // Called on turn request + onTurnRequest(user: User, forfeit: boolean): void; + + onVote(user: User, choice: number): void; + + onList(user: User): void; + onConnect(user: User, node: string): void; + onView(user: User, node: string, viewMode: number): void; + + // Admin handlers + onAdminLogin(user: User, password: string): void; + onAdminMonitor(user: User, node: string, command: string): void; + onAdminRestore(user: User, node: string): void; + onAdminReboot(user: User, node: string): void; + onAdminBanUser(user: User, username: string): void; + onAdminForceVote(user: User, choice: number): void; + onAdminMuteUser(user: User, username: string, temporary: boolean): void; + onAdminKickUser(user: User, username: string): void; + onAdminEndTurn(user: User, username: string): void; + onAdminClearQueue(user: User, node: string): void; + onAdminRename(user: User, target: string, newName: string): void; + onAdminGetIP(user: User, username: string): void; + onAdminBypassTurn(user: User): void; + onAdminRawMessage(user: User, message: string): void; + onAdminToggleTurns(user: User, enabled: boolean): void; + onAdminIndefiniteTurn(user: User): void; + onAdminHideScreen(user: User, show: boolean): void; + onAdminSystemMessage(user: User, message: string): void; + + onRename(user: User, newName: string | undefined): void; + onChat(user: User, message: string): void; + + onKey(user: User, keysym: number, pressed: boolean): void; + onMouse(user: User, x: number, y: number, buttonMask: number): void; +} + +// Abstracts away all of the protocol details from the CollabVM server, +// allowing it to be protocol-independent (as long as the client and server +// are able to speak the same protocol.) +export interface IProtocol { + // don't implement this yourself, extend from ProtocolBase + init(u: User): void; + dispose(): void; + + // Sets handler object. + setHandler(handlers: IProtocolMessageHandler): void; + + // Protocol implementation stuff + + // Parses a single message and fires the given handler with deserialized arguments. + // This function does not catch any thrown errors; it is the caller's responsibility + // to handle errors. It should, however, catch invalid parameters without failing. + // + // This function will perform conversion to text if it is required. + processMessage(buffer: Buffer): boolean; + + // Senders + + sendNop(): void; + sendSync(now: number): void; + + sendAuth(authServer: string): void; + + sendCapabilities(caps: ProtocolUpgradeCapability[]): void; + + sendConnectFailResponse(): void; + sendConnectOKResponse(votes: boolean): void; + + sendLoginResponse(ok: boolean, message: string | undefined): void; + + sendAdminLoginResponse(ok: boolean, modPerms: number | undefined): void; + sendAdminMonitorResponse(output: string): void; + sendAdminIPResponse(username: string, ip: string): void; + + sendChatMessage(username: '' | string, message: string): void; + sendChatHistoryMessage(history: ProtocolChatHistory[]): void; + + sendAddUser(users: ProtocolAddUser[]): void; + sendRemUser(users: string[]): void; + sendFlag(flag: ProtocolFlag[]): void; + + sendSelfRename(status: ProtocolRenameStatus, newUsername: string, rank: Rank): void; + sendRename(oldUsername: string, newUsername: string, rank: Rank): void; + + sendListResponse(list: ListEntry[]): void; + + sendTurnQueue(turnTime: number, users: string[]): void; + sendTurnQueueWaiting(turnTime: number, users: string[], waitTime: number): void; + + sendVoteStarted(): void; + sendVoteStats(msLeft: number, nrYes: number, nrNo: number): void; + sendVoteEnded(): void; + sendVoteCooldown(ms: number): void; + + sendScreenResize(width: number, height: number): void; + + // Sends a rectangle update to the user. + sendScreenUpdate(rect: ScreenRect): void; +} + +// Base mixin for all concrete protocols to use. Inherit from this! +export class ProtocolBase { + protected handlers: IProtocolMessageHandler | null = null; + protected user: User | null = null; + + init(u: User): void { + this.user = u; + } + + dispose(): void { + this.user = null; + this.handlers = null; + } + + setHandler(handlers: IProtocolMessageHandler): void { + this.handlers = handlers; + } +}