From 4e501065856c5296692fea89c73224732d406070 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 19 Jun 2024 01:36:07 -0400 Subject: [PATCH] cvmts: replace guacamole decoder with a node native module written in rust --- .gitignore | 6 +- cvmts/package.json | 1 + cvmts/src/CollabVMServer.ts | 923 ++++++++++++++++++------------------ cvmts/src/User.ts | 8 +- cvmts/src/guacutils.ts | 37 -- guac-rs/Cargo.lock | 209 ++++++++ guac-rs/Cargo.toml | 13 + guac-rs/index.d.ts | 3 + guac-rs/index.js | 6 + guac-rs/package.json | 15 + guac-rs/src/guac.rs | 193 ++++++++ guac-rs/src/lib.rs | 80 ++++ package.json | 1 + yarn.lock | 17 + 14 files changed, 1011 insertions(+), 501 deletions(-) delete mode 100644 cvmts/src/guacutils.ts create mode 100644 guac-rs/Cargo.lock create mode 100644 guac-rs/Cargo.toml create mode 100644 guac-rs/index.d.ts create mode 100644 guac-rs/index.js create mode 100644 guac-rs/package.json create mode 100644 guac-rs/src/guac.rs create mode 100644 guac-rs/src/lib.rs diff --git a/.gitignore b/.gitignore index 3a1de6b..0215abd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,8 @@ config.toml cvmts/attic /dist -**/dist/ \ No newline at end of file +**/dist/ + +# Guac-rs +guac-rs/target +guac-rs/index.node diff --git a/cvmts/package.json b/cvmts/package.json index 909254f..fbf2c87 100644 --- a/cvmts/package.json +++ b/cvmts/package.json @@ -12,6 +12,7 @@ "license": "GPL-3.0", "dependencies": { "@computernewb/jpeg-turbo": "*", + "@cvmts/guac-rs": "*", "@cvmts/qemu": "*", "execa": "^8.0.1", "mnemonist": "^0.39.5", diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index 48b5bb2..be084b2 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -1,7 +1,7 @@ import IConfig from './IConfig.js'; import * as Utilities from './Utilities.js'; import { User, Rank } from './User.js'; -import * as guacutils from './guacutils.js'; +import * as guac from '@cvmts/guac-rs'; // I hate that you have to do it like this import CircularBuffer from 'mnemonist/circular-buffer.js'; import Queue from 'mnemonist/queue.js'; @@ -115,7 +115,7 @@ export default class CollabVMServer { } public addUser(user: User) { - let sameip = this.clients.filter(c => c.IP.address === user.IP.address); + let sameip = this.clients.filter((c) => c.IP.address === user.IP.address); if (sameip.length >= this.Config.collabvm.maxConnections) { // Kick the oldest client // I think this is a better solution than just rejecting the connection @@ -125,7 +125,7 @@ export default class CollabVMServer { user.socket.on('msg', (msg: string) => this.onMessage(user, msg)); user.socket.on('disconnect', () => this.connectionClosed(user)); if (this.Config.auth.enabled) { - user.sendMsg(guacutils.encode('auth', this.Config.auth.apiEndpoint)); + user.sendMsg(guac.guacEncode('auth', this.Config.auth.apiEndpoint)); } user.sendMsg(this.getAdduserMsg()); } @@ -154,473 +154,479 @@ export default class CollabVMServer { if (hadturn) this.nextTurn(); } - this.clients.forEach((c) => c.sendMsg(guacutils.encode('remuser', '1', user.username!))); + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('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; - } - 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(guacutils.encode('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 - 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(); - } - } - } catch(err) { - this.logger.Error(`Error authenticating client ${client.IP.address}: ${(err as Error).message}`); - // for now? - client.sendMsg(guacutils.encode('login', '0', 'There was an internal error while authenticating. Please let a staff member know as soon as possible')) - } - 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.VM.SnapshotsSupported() ? '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')); + try { + var msgArr = guac.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(guac.guacEncode('login', '0', 'You must connect to the VM before logging in.')); return; - } - - client.sendMsg(guacutils.encode('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(guacutils.encode('chat', '', this.Config.collabvm.motd)); - - if (client.viewMode == 1) { + } + 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(guac.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 + this.renameUser(client, res.username); + // Set rank + client.rank = res.rank; + if (client.rank === Rank.Admin) { + client.sendMsg(guac.guacEncode('admin', '0', '1')); + } else if (client.rank === Rank.Moderator) { + client.sendMsg(guac.guacEncode('admin', '0', '3', this.ModPerms.toString())); + } + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('adduser', '1', client.username!, client.rank.toString()))); + } else { + client.sendMsg(guac.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(guac.guacEncode('login', '0', 'There was an internal error while authenticating. Please let a staff member know as soon as possible')); + } + break; + case 'list': + client.sendMsg(guac.guacEncode('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(guac.guacEncode('connect', '0')); + return; + } + client.connectedToNode = true; + client.sendMsg(guac.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(guac.guacEncode('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)); + client.sendMsg(guac.guacEncode('size', '0', '1024', '768')); + client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); } else { await this.SendFullScreenWithSize(client); } - client.sendMsg(guacutils.encode('sync', Date.now().toString())); - } + client.sendMsg(guac.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(guac.guacEncode('connect', '0')); + return; + } - 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]) { + switch (msgArr[2]) { case '0': - if (this.indefiniteTurn === client) { - this.indefiniteTurn = null; - } - takingTurn = false; + client.viewMode = 0; break; case '1': - takingTurn = true; + client.viewMode = 1; break; default: + client.sendMsg(guac.guacEncode('connect', '0')); 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.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.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.callForReset) { - client.sendMsg(guacutils.encode('chat', '', 'You need to login to do that.')); - return; - } - 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.`))); - } - if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) { - client.sendMsg(guacutils.encode('chat', '', 'You need to login to do that.')); - return; - } 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 (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) { - client.sendMsg(guacutils.encode('chat', '', 'You need to login to do that.')); - 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; - } + client.sendMsg(guac.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(guac.guacEncode('chat', '', this.Config.collabvm.motd)); + + if (client.viewMode == 1) { if (this.screenHidden) { + client.sendMsg(guac.guacEncode('size', '0', '1024', '768')); + client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); + } else { await this.SendFullScreenWithSize(client); - - client.sendMsg(guacutils.encode('sync', Date.now().toString())); } + client.sendMsg(guac.guacEncode('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; - 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; - 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; + 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(guac.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(guac.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(guac.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(guac.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.rank !== Rank.Turn) return; + if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.turn) { + client.sendMsg(guac.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': - 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; + if (this.indefiniteTurn === client) { + this.indefiniteTurn = null; + } + takingTurn = false; break; case '1': - permamute = true; + takingTurn = true; break; default: return; + break; } - 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 (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; } - 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.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.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.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.callForReset) { + client.sendMsg(guac.guacEncode('chat', '', 'You need to login to do that.')); + return; + } - 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]))); + if (this.voteCooldown !== 0) { + client.sendMsg(guac.guacEncode('vote', '3', this.voteCooldown.toString())); + return; + } + this.startVote(); + this.clients.forEach((c) => c.sendMsg(guac.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(guac.guacEncode('chat', '', 'You need to login to do that.')); + return; + } else if (client.IP.vote !== true) { + this.clients.forEach((c) => c.sendMsg(guac.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(guac.guacEncode('chat', '', 'You need to login to do that.')); + return; + } + if (client.IP.vote !== false) { + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('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(guac.guacEncode('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(guac.guacEncode('admin', '0', '1')); + } else if (this.Config.collabvm.moderatorEnabled && pwdHash === this.Config.collabvm.modpass) { + client.rank = Rank.Moderator; + client.sendMsg(guac.guacEncode('admin', '0', '3', this.ModPerms.toString())); + } else if (this.Config.collabvm.turnwhitelist && pwdHash === this.Config.collabvm.turnpass) { + client.rank = Rank.Turn; + client.sendMsg(guac.guacEncode('chat', '', 'You may now take turns.')); + } else { + client.sendMsg(guac.guacEncode('admin', '0', '0')); + return; + } + if (this.screenHidden) { + await this.SendFullScreenWithSize(client); - 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())); + client.sendMsg(guac.guacEncode('sync', Date.now().toString())); + } + + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('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(guac.guacEncode("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(guac.guacEncode('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; + 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; + 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(guac.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(guac.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(guac.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(guac.guacEncode('chat', client.username!, msgArr[2]))); + + this.clients.filter((c) => c.rank === Rank.Admin).forEach((c) => c.sendMsg(guac.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(guac.guacEncode('size', '0', '1024', '768')); + client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); + client.sendMsg(guac.guacEncode('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 }); - 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; + this.clients.forEach(async (client) => { + client.sendMsg(guac.guacEncode('size', '0', displaySize.width.toString(), displaySize.height.toString())); + client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', encoded)); + client.sendMsg(guac.guacEncode('sync', Date.now().toString())); + }); + break; + } + break; + case '25': + if (client.rank !== Rank.Admin || msgArr.length !== 3) return; + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', '', msgArr[2]))); + break; + } + break; + } + } catch (err) { + // No + this.logger.Error(`User ${user?.IP.address} ${user?.username ? `with username ${user?.username}` : ''} sent broken Guacamole: ${(err as Error)}`); + user?.kick(); } } @@ -642,7 +648,7 @@ export default class CollabVMServer { } else { newName = newName.trim(); if (hadName && newName === oldname) { - client.sendMsg(guacutils.encode('rename', '0', '0', client.username!, client.rank.toString())); + client.sendMsg(guac.guacEncode('rename', '0', '0', client.username!, client.rank.toString())); return; } if (this.getUsernameList().indexOf(newName) !== -1) { @@ -659,13 +665,13 @@ export default class CollabVMServer { } else client.username = newName; } - client.sendMsg(guacutils.encode('rename', '0', status, client.username!, client.rank.toString())); + client.sendMsg(guac.guacEncode('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()))); + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('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()))); + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('adduser', '1', client.username!, client.rank.toString()))); } } @@ -673,13 +679,13 @@ export default class CollabVMServer { 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); + return guac.guacEncode(...arr); } getChatHistoryMsg(): string { var arr: string[] = ['chat']; this.ChatHistory.forEach((c) => arr.push(c.user, c.msg)); - return guacutils.encode(...arr); + return guac.guacEncode(...arr); } private sendTurnUpdate(client?: User) { @@ -692,7 +698,7 @@ export default class CollabVMServer { this.TurnQueue.forEach((c) => arr.push(c.username)); var currentTurningUser = this.TurnQueue.peek(); if (client) { - client.sendMsg(guacutils.encode(...arr)); + client.sendMsg(guac.guacEncode(...arr)); return; } this.clients @@ -702,12 +708,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(guacutils.encode(...arr, time.toString())); + c.sendMsg(guac.guacEncode(...arr, time.toString())); } else { - c.sendMsg(guacutils.encode(...arr)); + c.sendMsg(guac.guacEncode(...arr)); } }); - if (currentTurningUser) currentTurningUser.sendMsg(guacutils.encode(...arr)); + if (currentTurningUser) currentTurningUser.sendMsg(guac.guacEncode(...arr)); } private nextTurn() { clearInterval(this.TurnInterval); @@ -754,8 +760,8 @@ export default class CollabVMServer { .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())); + c.sendMsg(guac.guacEncode('png', '0', '0', rect.x.toString(), rect.y.toString(), encodedb64)); + c.sendMsg(guac.guacEncode('sync', Date.now().toString())); }); } @@ -764,7 +770,7 @@ export default class CollabVMServer { .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())); + c.sendMsg(guac.guacEncode('size', '0', size.width.toString(), size.height.toString())); }); } @@ -779,8 +785,8 @@ export default class CollabVMServer { 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)); + client.sendMsg(guac.guacEncode('size', '0', displaySize.width.toString(), displaySize.height.toString())); + client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', encoded)); } private async MakeRectData(rect: Rect) { @@ -796,8 +802,7 @@ export default class CollabVMServer { let display = this.VM.GetDisplay(); // oh well - if (!display.Connected()) - return ""; + if (!display.Connected()) return ''; let buf = await JPEGEncoder.EncodeThumbnail(display.Buffer(), display.Size()); return buf.toString('base64'); @@ -806,7 +811,7 @@ export default class CollabVMServer { startVote() { if (this.voteInProgress) return; this.voteInProgress = true; - this.clients.forEach((c) => c.sendMsg(guacutils.encode('vote', '0'))); + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('vote', '0'))); this.voteTime = this.Config.collabvm.voteTime; this.voteInterval = setInterval(() => { this.voteTime--; @@ -821,12 +826,12 @@ export default class CollabVMServer { this.voteInProgress = false; clearInterval(this.voteInterval); var count = this.getVoteCounts(); - this.clients.forEach((c) => c.sendMsg(guacutils.encode('vote', '2'))); + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('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.clients.forEach((c) => c.sendMsg(guac.guacEncode('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.sendMsg(guac.guacEncode('chat', '', 'The vote to reset the VM has lost.'))); } this.clients.forEach((c) => { c.IP.vote = null; @@ -841,7 +846,7 @@ export default class CollabVMServer { 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()); + var msg = guac.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)); } diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index 187efa2..cbc2a68 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -1,5 +1,5 @@ import * as Utilities from './Utilities.js'; -import * as guacutils from './guacutils.js'; +import * as guac from '@cvmts/guac-rs'; import { IPData } from './IPData.js'; import IConfig from './IConfig.js'; import RateLimiter from './RateLimiter.js'; @@ -89,7 +89,7 @@ export class User { } closeConnection() { - this.socket.send(guacutils.encode('disconnect')); + this.socket.send(guac.guacEncode('disconnect')); this.socket.close(); } @@ -109,7 +109,7 @@ export class User { mute(permanent: boolean) { this.IP.muted = true; - this.sendMsg(guacutils.encode('chat', '', `You have been muted${permanent ? '' : ` for ${this.Config.collabvm.tempMuteTime} seconds`}.`)); + this.sendMsg(guac.guacEncode('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); @@ -118,7 +118,7 @@ export class User { unmute() { clearTimeout(this.IP.tempMuteExpireTimeout); this.IP.muted = false; - this.sendMsg(guacutils.encode('chat', '', 'You are no longer muted.')); + this.sendMsg(guac.guacEncode('chat', '', 'You are no longer muted.')); } private banCmdArgs(arg: string): string { diff --git a/cvmts/src/guacutils.ts b/cvmts/src/guacutils.ts deleted file mode 100644 index 0647f70..0000000 --- a/cvmts/src/guacutils.ts +++ /dev/null @@ -1,37 +0,0 @@ -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; - // Invalid data. - else 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; -} diff --git a/guac-rs/Cargo.lock b/guac-rs/Cargo.lock new file mode 100644 index 0000000..8f6bdab --- /dev/null +++ b/guac-rs/Cargo.lock @@ -0,0 +1,209 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "guac-rs" +version = "0.1.0" +dependencies = [ + "neon", +] + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "libloading" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" +dependencies = [ + "cfg-if", + "windows-targets", +] + +[[package]] +name = "neon" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d75440242411c87dc39847b0e33e961ec1f10326a9d8ecf9c1ea64a3b3c13dc" +dependencies = [ + "getrandom", + "libloading", + "neon-macros", + "once_cell", + "semver", + "send_wrapper", + "smallvec", +] + +[[package]] +name = "neon-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6813fde79b646e47e7ad75f480aa80ef76a5d9599e2717407961531169ee38b" +dependencies = [ + "quote", + "syn", + "syn-mid", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "proc-macro2" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "syn" +version = "2.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-mid" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5dc35bb08dd1ca3dfb09dce91fd2d13294d6711c88897d9a9d60acf39bce049" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" diff --git a/guac-rs/Cargo.toml b/guac-rs/Cargo.toml new file mode 100644 index 0000000..917285f --- /dev/null +++ b/guac-rs/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "guac-rs" +description = "Rust guacamole decoding :)" +version = "0.1.0" +license = "MIT" +edition = "2021" +exclude = ["index.node"] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +neon = "1" diff --git a/guac-rs/index.d.ts b/guac-rs/index.d.ts new file mode 100644 index 0000000..8edbc8e --- /dev/null +++ b/guac-rs/index.d.ts @@ -0,0 +1,3 @@ + +export function guacDecode(input: string): string[]; +export function guacEncode(...items: string[]): string; diff --git a/guac-rs/index.js b/guac-rs/index.js new file mode 100644 index 0000000..3d87ceb --- /dev/null +++ b/guac-rs/index.js @@ -0,0 +1,6 @@ +// *sigh* +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +export let {guacDecode, guacEncode} = require('./index.node'); + diff --git a/guac-rs/package.json b/guac-rs/package.json new file mode 100644 index 0000000..493934b --- /dev/null +++ b/guac-rs/package.json @@ -0,0 +1,15 @@ +{ + "name": "@cvmts/guac-rs", + "packageManager": "yarn@4.1.1", + "type": "module", + "main": "index.js", + "types": "index.d.ts", + "scripts": { + "build": "cargo-cp-artifact -nc index.node -- cargo build --release --message-format=json-render-diagnostics", + "install": "yarn build", + "test": "cargo test" + }, + "devDependencies": { + "cargo-cp-artifact": "^0.1" + } +} diff --git a/guac-rs/src/guac.rs b/guac-rs/src/guac.rs new file mode 100644 index 0000000..b92f289 --- /dev/null +++ b/guac-rs/src/guac.rs @@ -0,0 +1,193 @@ +use std::fmt; + +// type of a guac message +pub type Elements = Vec; + +// FIXME: thiserror, please. + +/// Errors during decoding +#[derive(Debug, Clone)] +pub enum DecodeError { + /// Invalid guacamole instruction format + InvalidFormat, + + /// Instruction is too long for the current decode policy. + InstructionTooLong, + + /// Element is too long for the current decode policy. + ElementTooLong, + + /// Invalid element size. + ElementSizeInvalid, +} + +pub type DecodeResult = std::result::Result; + +impl fmt::Display for DecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFormat => write!(f, "Invalid Guacamole instruction while decoding"), + Self::InstructionTooLong => write!(f, "Instruction too long for current decode policy"), + Self::ElementTooLong => write!(f, "Element too long for current decode policy"), + Self::ElementSizeInvalid => write!(f, "Element size is invalid") + } + } +} + +// this decode policy abstraction would in theory be useful, +// but idk how to do this kind of thing in rust very well + +pub struct StaticDecodePolicy(); + +impl StaticDecodePolicy { + fn max_instruction_size(&self) -> usize { + INST_SIZE + } + + fn max_element_size(&self) -> usize { + ELEM_SIZE + } +} + +/// The default decode policy. +pub type DefaultDecodePolicy = StaticDecodePolicy<12288, 4096>; + + +/// Encodes elements into a Guacamole instruction +pub fn encode_instruction(elements: &Elements) -> String { + let mut str = String::new(); + + for elem in elements.iter() { + str.push_str(&format!("{}.{},", elem.len(), elem)); + } + + // hacky, but whatever + str.pop(); + str.push(';'); + + str +} + +/// Decodes a Guacamole instruction to individual elements +pub fn decode_instruction(element_string: &String) -> DecodeResult { + let policy = DefaultDecodePolicy {}; + + let mut vec: Elements = Vec::new(); + let mut current_position: usize = 0; + + // Instruction is too long. Don't even bother + if policy.max_instruction_size() < element_string.len() { + return Err(DecodeError::InstructionTooLong); + } + + let chars = element_string.chars().collect::>(); + + loop { + let mut element_size: usize = 0; + + // Scan the integer value in by hand. This is mostly because + // I'm stupid, and the Rust integer parsing routines (seemingly) + // require a substring (or a slice, but, if you can generate a slice, + // you can also just scan the value in by hand.) + // + // We bound this anyways and do quite the checks, so even though it's not great, + // it should be generally fine (TM). + loop { + let c = chars[current_position]; + + if c >= '0' && c <= '9' { + element_size = element_size * 10 + (c as usize) - ('0' as usize); + } else { + if c == '.' { + break; + } + + return Err(DecodeError::InvalidFormat); + } + current_position += 1; + } + + // Eat the '.' seperating the size and the element data; + // our integer scanning ensures we only get here in the case that this is actually the '.' + // character. + current_position += 1; + + // Make sure the element size doesn't overflow the decode policy + // or the size of the whole instruction. + + if element_size >= policy.max_element_size() { + return Err(DecodeError::ElementTooLong); + } + + if element_size >= element_string.len() { + return Err(DecodeError::ElementSizeInvalid); + } + + // cutoff elements or something + if current_position + element_size > chars.len()-1 { + //println!("? {current_position} a {}", chars.len()); + return Err(DecodeError::InvalidFormat); + } + + let element = chars + .iter() + .skip(current_position) + .take(element_size) + .collect::(); + + current_position += element_size; + + vec.push(element); + + // make sure seperator is proper + match chars[current_position] { + ',' => {} + ';' => break, + _ => return Err(DecodeError::InvalidFormat), + } + + // eat the ',' + current_position += 1; + } + + Ok(vec) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_basic() { + let test = String::from("7.connect,3.vm1;"); + let res = decode_instruction(&test); + + assert!(res.is_ok()); + assert_eq!(res.unwrap(), vec!["connect", "vm1"]); + } + + #[test] + fn decode_errors() { + let test = String::from("700.connect,3.vm1;"); + let res = decode_instruction(&test); + + eprintln!("Error for: {}", res.clone().unwrap_err()); + + assert!(res.is_err()) + } + + // generally just test that the codec even works + // (we can decode a instruction we created) + #[test] + fn general_codec_works() { + let vec = vec![String::from("connect"), String::from("vm1")]; + let test = encode_instruction(&vec); + + assert_eq!(test, "7.connect,3.vm1;"); + + let res = decode_instruction(&test); + + assert!(res.is_ok()); + assert_eq!(res.unwrap(), vec); + } +} diff --git a/guac-rs/src/lib.rs b/guac-rs/src/lib.rs new file mode 100644 index 0000000..dc90d35 --- /dev/null +++ b/guac-rs/src/lib.rs @@ -0,0 +1,80 @@ +mod guac; + +use neon::prelude::*; + +fn guac_decode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsArray> { + let input = cx.argument::(0)?.value(cx); + + match guac::decode_instruction(&input) { + Ok(data) => { + let array = JsArray::new(cx, data.len()); + + let conv = data.iter() + .map(|v| { + cx.string(v) + }) + .collect::>>(); + + for (i, str) in conv.iter().enumerate() { + array.set(cx, i as u32, *str)?; + } + + return Ok(array); + } + + Err(e) => { + let err = cx.string(format!("Error decoding guacamole: {}", e)); + return cx.throw(err); + } + } +} + +fn guac_encode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsString> { + + let mut elements: Vec = Vec::with_capacity(cx.len()); + + // Capture varadic arguments + for i in 0..cx.len() { + let input = cx.argument::(i)?.value(cx); + elements.push(input); + } + + // old array stuff + /* + let input = cx.argument::(0)?; + let raw_elements = input.to_vec(cx)?; + + // bleh + let vecres: Result, _> = raw_elements + .iter() + .map(|item| match item.to_string(cx) { + Ok(s) => { + return Ok(s.value(cx)); + } + + Err(e) => { + return Err(e); + } + }) + .collect(); + + let vec = vecres?; + */ + + Ok(cx.string(guac::encode_instruction(&elements))) +} + +fn guac_decode(mut cx: FunctionContext) -> JsResult { + guac_decode_impl(&mut cx) +} + +fn guac_encode(mut cx: FunctionContext) -> JsResult { + guac_encode_impl(&mut cx) +} + +#[neon::main] +fn main(mut cx: ModuleContext) -> NeonResult<()> { + cx.export_function("guacDecode", guac_decode)?; + cx.export_function("guacEncode", guac_encode)?; + Ok(()) +} diff --git a/package.json b/package.json index 06e4e28..7c9552a 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "cvmts-repo", "workspaces": [ "shared", + "guac-rs", "jpeg-turbo", "nodejs-rfb", "qemu", diff --git a/yarn.lock b/yarn.lock index bf82089..6635007 100644 --- a/yarn.lock +++ b/yarn.lock @@ -75,6 +75,14 @@ __metadata: languageName: unknown linkType: soft +"@cvmts/guac-rs@workspace:guac-rs": + version: 0.0.0-use.local + resolution: "@cvmts/guac-rs@workspace:guac-rs" + dependencies: + cargo-cp-artifact: "npm:^0.1" + languageName: unknown + linkType: soft + "@cvmts/qemu@npm:*, @cvmts/qemu@workspace:qemu": version: 0.0.0-use.local resolution: "@cvmts/qemu@workspace:qemu" @@ -1772,6 +1780,15 @@ __metadata: languageName: node linkType: hard +"cargo-cp-artifact@npm:^0.1": + version: 0.1.9 + resolution: "cargo-cp-artifact@npm:0.1.9" + bin: + cargo-cp-artifact: bin/cargo-cp-artifact.js + checksum: 10c0/60eb1845917cfb021920fcf600a72379890b385396f9c69107face3b16b347960b66cd3d82cc169c6ac8b1212cf0706584125bc36fbc08353b033310c17ca0a6 + languageName: node + linkType: hard + "chalk@npm:^2.4.2": version: 2.4.2 resolution: "chalk@npm:2.4.2"