WIP: protocol abstraction

Work on abstracting the CollabVMServer so it now calls into a interface for sending/recieving protocol messages. This will allow cleaner bringup of a fully binary protocol, and generally is just cleaner code.

Mostly everything is parsd/running through this new layer, although there are some TODO items:

- NetworkClient/... should just spit out a Buffer or something that eventually turns into or has one
- TCP protocol will need to be revamped so we can support an actual binary protocol on top of it. The current thing is line based
- More admin op stuff needs to be handled
- The handlers are a bit jumbled around atm
- There is still a good amount of code which assumes guacamole which needs to be rewritten

dont use this branch fuckers
This commit is contained in:
modeco80
2024-08-21 07:10:58 -04:00
parent 3c4ddb72b8
commit 1c062697b9
7 changed files with 926 additions and 514 deletions

View File

@@ -0,0 +1,6 @@
import * as msgpack from 'msgpackr';
import { CollabVMProtocolMessage, CollabVMProtocolMessageType } from '@cvmts/collab-vm-1.2-binary-protocol';
// TODO: reimplement binrects protocol
// we can just create/proxy a GuacamoleProtocol manually,
// and for the rects do our own thing

View File

@@ -14,12 +14,11 @@ 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 { IProtocolHandlers, ListEntry, ProtocolAddUser, TheProtocolManager } from './Protocol.js';
// Instead of strange hacks we can just use nodejs provided
// import.meta properties, which have existed since LTS if not before
@@ -39,7 +38,7 @@ type VoteTally = {
no: number;
};
export default class CollabVMServer {
export default class CollabVMServer implements IProtocolHandlers {
private Config: IConfig;
private clients: User[];
@@ -76,8 +75,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;
@@ -109,8 +108,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);
@@ -158,7 +157,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
@@ -166,6 +165,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;
@@ -173,12 +173,28 @@ 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('disconnect', () => this.connectionClosed(user));
if (this.Config.auth.enabled) {
user.sendMsg(cvm.guacEncode('auth', this.Config.auth.apiEndpoint));
// TODO: we should probably just make this a buffer arg lol..
user.socket.on('msg', (msg: string) => {
let buf = Buffer.from(msg);
try {
user.protocol.processMessage(buf);
} catch (err) {
user.kick();
}
user.sendMsg(this.getAdduserMsg());
});
user.socket.on('disconnect', () => this.connectionClosed(user));
// Set ourselves as the handler
user.protocol.setHandler(this as IProtocolHandlers);
if (this.Config.auth.enabled) {
user.protocol.sendAuth(this.Config.auth.apiEndpoint);
}
// convert these to proto
user.protocol.sendAddUser(this.getAdduserMsg());
if (this.Config.geoip.enabled) user.sendMsg(this.getFlagMsg());
}
@@ -203,25 +219,42 @@ 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) {
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.'));
// IProtocolHandlers
// 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.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);
let res = await this.auth!.Authenticate(token, user);
if (res.clientSuccess) {
this.logger.info(`${client.IP.address} logged in as ${res.username}`);
client.sendMsg(cvm.guacEncode('login', '1'));
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
@@ -230,327 +263,312 @@ export default class CollabVMServer {
await old.kick();
}
// Set username
if (client.countryCode !== null && client.noFlag) {
if (user.countryCode !== null && user.noFlag) {
// privacy
for (let cl of this.clients.filter((c) => c !== client)) {
cl.sendMsg(cvm.guacEncode('remuser', '1', client.username!));
for (let cl of this.clients.filter((c) => c !== user)) {
cl.sendMsg(cvm.guacEncode('remuser', '1', user.username!));
}
this.renameUser(client, res.username, false);
} else this.renameUser(client, res.username, true);
this.renameUser(user, res.username, false);
} else this.renameUser(user, 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()));
user.rank = res.rank;
if (user.rank === Rank.Admin) {
user.sendMsg(cvm.guacEncode('admin', '0', '1'));
} else if (user.rank === Rank.Moderator) {
user.sendMsg(cvm.guacEncode('admin', '0', '3', this.ModPerms.toString()));
}
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString())));
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('adduser', '1', user.username!, user.rank.toString())));
} else {
client.sendMsg(cvm.guacEncode('login', '0', res.error!));
user.protocol.sendLoginResponse(false, res.error!);
if (res.error === 'You are banned') {
client.kick();
user.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'));
this.logger.error(`Error authenticating client ${user.IP.address}: ${(err as Error).message}`);
user.protocol.sendLoginResponse(false, '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;
}
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()));
}
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;
onNoFlag(user: User) {
// Too late
if (user.connectedToNode) return;
user.noFlag = true;
}
switch (msgArr[2]) {
case '0':
client.viewMode = 0;
break;
case '1':
client.viewMode = 1;
onCapabilityUpgrade(user: User, capability: String[]): boolean {
if (user.connectedToNode) return false;
for (let cap of capability) {
switch (cap) {
// binary 1.0 (msgpack rects)
// TODO: re-enable once binary1.0 is enabled
case 'bin':
this.logger.info('Binary 1.0 protocol is currently disabled for refactoring');
//user.Capabilities.bin = true;
//user.protocol = TheProtocolManager.createProtocol('binary1', user);
//user.protocol.setHandler(this as IProtocolHandlers);
break;
default:
client.sendMsg(cvm.guacEncode('connect', '0'));
return;
break;
}
}
return 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));
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 (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));
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 {
await this.SendFullScreenWithSize(client);
var hadturn = this.TurnQueue.peek() === user;
this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter((u) => u !== user));
if (hadturn) this.nextTurn();
}
client.sendMsg(cvm.guacEncode('sync', Date.now().toString()));
this.sendTurnUpdate();
}
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.'));
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.sendMsg(cvm.guacEncode('vote', '3', this.voteCooldown.toString()));
return;
}
if (this.Config.auth.enabled && msgArr[1] !== undefined) {
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 (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);
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(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;
this.renameUser(user, newName!);
}
var msg = Utilities.HTMLSanitize(msgArr[1]);
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.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;
this.clients.forEach((c) => c.protocol.sendChatMessage(user.username!, msg));
this.ChatHistory.push({ user: user.username, msg: msg });
user.onChatMsgSent();
}
if (this.voteCooldown !== 0) {
client.sendMsg(cvm.guacEncode('vote', '3', this.voteCooldown.toString()));
return;
onKey(user: User, keysym: number, pressed: boolean): void {
if (this.TurnQueue.peek() !== user && user.rank !== Rank.Admin) return;
this.VM.GetDisplay()?.KeyboardEvent(keysym, pressed);
}
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));
}
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;
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(msgArr[2]);
sha256.update(password, 'utf-8');
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.'));
user.turnWhitelist = true;
user.protocol.sendChatMessage('', '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.'));
user.protocol.sendChatMessage('', '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'));
user.rank = Rank.Admin;
user.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()));
user.rank = Rank.Moderator;
user.sendMsg(cvm.guacEncode('admin', '0', '3', this.ModPerms.toString()));
} else {
client.sendMsg(cvm.guacEncode('admin', '0', '0'));
user.sendMsg(cvm.guacEncode('admin', '0', '0'));
return;
}
if (this.screenHidden) {
await this.SendFullScreenWithSize(client);
client.sendMsg(cvm.guacEncode('sync', Date.now().toString()));
if (this.screenHidden) {
await this.SendFullScreenWithSize(user);
}
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString())));
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('adduser', '1', user.username!, user.rank.toString())));
}
async onAdminMonitor(user: User, node: string, command: string) {
if (user.rank !== Rank.Admin) return;
if (node !== this.Config.collabvm.node) return;
let output = await this.VM.MonitorCommand(command);
user.sendMsg(cvm.guacEncode('admin', '2', String(output)));
}
private async onAdmin(user: User, msgArr: string[]) {
/*
switch (msgArr[0]) {
case '2':
// Login
break;
case '5':
// QEMU Monitor
if (client.rank !== Rank.Admin) return;
if (msgArr.length !== 4 || msgArr[2] !== this.Config.collabvm.node) return;
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;
if (user.rank !== Rank.Admin && (user.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 (user.rank !== Rank.Admin && (user.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;
this.logger.info(`Banning ${user.username!} (${user.IP.address}) by request of ${client.username!}`);
if (user.rank !== Rank.Admin && (user.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.ban)) return;
var otherUser = this.clients.find((c) => c.username === msgArr[2]);
if (!otherUser) return;
this.logger.info(`Banning ${otherUser.username!} (${otherUser.IP.address}) by request of ${otherUser.username!}`);
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 (user.rank !== Rank.Admin && (user.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.forcevote)) return;
if (!this.voteInProgress) return;
switch (msgArr[2]) {
case '1':
@@ -605,7 +623,7 @@ export default class CollabVMServer {
// 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.'));
client.protocol.sendChatMessage('', 'Cannot rename users on a server that uses authentication.');
}
if (msgArr.length !== 4) return;
var user = this.clients.find((c) => c.username === msgArr[2]);
@@ -680,8 +698,7 @@ export default class CollabVMServer {
break;
case '1':
this.screenHidden = false;
let displaySize = this.VM.GetDisplay()?.Size();
if (displaySize == undefined) return;
let displaySize = this.VM.GetDisplay().Size();
let encoded = await this.MakeRectData({
x: 0,
@@ -699,15 +716,11 @@ export default class CollabVMServer {
this.clients.forEach((c) => c.sendMsg(cvm.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();
}
*/
}
// end IProtocolHandlers
getUsernameList(): string[] {
var arr: string[] = [];
@@ -757,11 +770,15 @@ export default class CollabVMServer {
}
}
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);
getAdduserMsg(): ProtocolAddUser[] {
return this.clients
.filter((c) => c.username)
.map((c) => {
return {
username: c.username!,
rank: c.rank
};
});
}
getFlagMsg(): string {
@@ -772,12 +789,6 @@ export default class CollabVMServer {
return cvm.guacEncode(...arr);
}
getChatHistoryMsg(): string {
var arr: string[] = ['chat'];
this.ChatHistory.forEach((c) => arr.push(c.user, c.msg));
return cvm.guacEncode(...arr);
}
private sendTurnUpdate(client?: User) {
var turnQueueArr = this.TurnQueue.toArray();
var turntime;
@@ -852,7 +863,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);
});
}
@@ -861,28 +872,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
});
});
};
@@ -908,21 +908,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: {
client.protocol.sendScreenUpdate({
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')));
}
});
}
private async MakeRectData(rect: Rect) {
@@ -937,14 +929,13 @@ export default class CollabVMServer {
return encoded;
}
async getThumbnail(): Promise<string> {
async getThumbnail(): Promise<Buffer> {
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() {
@@ -967,10 +958,10 @@ export default class CollabVMServer {
var count = this.getVoteCounts();
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('vote', '2')));
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;

View File

@@ -0,0 +1,282 @@
import pino from 'pino';
import { IProtocol, IProtocolHandlers, ListEntry, ProtocolAddUser, ProtocolChatHistory, ScreenRect } from './Protocol';
import { User } from './User';
import * as cvm from '@cvmts/cvm-rs';
// CollabVM protocol implementation for Guacamole.
export class GuacamoleProtocol implements IProtocol {
private handlers: IProtocolHandlers | null = null;
private logger = pino({
name: 'CVMTS.GuacamoleProtocol'
});
private user: User | null = null;
init(u: User): void {
this.user = u;
}
setHandler(handlers: IProtocolHandlers): void {
this.handlers = handlers;
}
private __processMessage_admin(decodedElements: string[]): boolean {
switch (decodedElements[1]) {
case '2':
// Login
if (decodedElements.length !== 3) return false;
this.handlers?.onAdminLogin(this.user!, decodedElements[2]);
break;
case '5':
// QEMU Monitor
if (decodedElements.length !== 4) return false;
// [2] node
// [3] cmd
break;
case '8':
// Restore
break;
case '10':
// Reboot
if (decodedElements.length !== 3) return false;
// [2] - node
break;
case '12':
// Ban
case '13':
// Force Vote
if (decodedElements.length !== 3) return false;
break;
case '14':
// Mute
if (decodedElements.length !== 4) return false;
break;
case '15':
// Kick
case '16':
// End turn
if (decodedElements.length !== 3) return false;
break;
case '17':
// Clear turn queue
if (decodedElements.length !== 3) return false;
// [2] - node
break;
case '18':
// Rename user
if (decodedElements.length !== 4) return false;
// [2] - username
// [3] - new username
break;
case '19':
// Get IP
if (decodedElements.length !== 3) return false;
break;
case '20':
// Steal turn
break;
case '21':
// XSS
if (decodedElements.length !== 3) return false;
// [2] message
break;
case '22':
// Toggle turns
if (decodedElements.length !== 3) return false;
// [2] 0 == disable 1 == enable
break;
case '23':
// Indefinite turn
break;
case '24':
// Hide screen
if (decodedElements.length !== 3) return false;
// 0 - hide
// 1 - unhide
break;
case '25':
if (decodedElements.length !== 3) return false;
// [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()));
}
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!));
}
}
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);
arr.push(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));
}
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));
}
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());
}
}

119
cvmts/src/Protocol.ts Normal file
View File

@@ -0,0 +1,119 @@
import { Rank, User } from './User';
// We should probably put this in the binproto repository or something
enum UpgradeCapability {
Binary = 'bin'
}
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;
}
// Protocol handlers. This is implemented by a layer that wants to listen to CollabVM protocol messages.
export interface IProtocolHandlers {
onNop(user: User): void;
onNoFlag(user: User): void;
// Called when the client requests a capability upgrade
onCapabilityUpgrade(user: User, capability: Array<String>): 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;
onAdminLogin(user: User, password: string): void;
onAdminMonitor(user: User, node: string, command: 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 CollabVM protocol details
export interface IProtocol {
init(u: User): void;
// Sets handler object.
setHandler(handlers: IProtocolHandlers): void;
// Parses a single CollabVM protocol message and fires the given handler.
// 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.
processMessage(buffer: Buffer): boolean;
// Senders
sendNop(): void;
sendSync(now: number): void;
sendAuth(authServer: string): void;
sendConnectFailResponse(): void;
sendConnectOKResponse(votes: boolean): void;
sendLoginResponse(ok: boolean, message: string | undefined): void;
sendChatMessage(username: '' | string, message: string): void;
sendChatHistoryMessage(history: ProtocolChatHistory[]): void;
sendAddUser(users: ProtocolAddUser[]): void;
sendRemUser(users: string[]): void;
sendListResponse(list: ListEntry[]): void;
sendScreenResize(width: number, height: number): void;
// Sends a rectangle update to the user.
sendScreenUpdate(rect: ScreenRect): void;
}
// Holds protocol factories.
export class ProtocolManager {
private protocols = new Map<String, () => 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();

View File

@@ -1,3 +1,6 @@
// TODO: replace tcp protocol with smth like
// struct msg { beu32 len; char data[len] }
// (along with a length cap obviously)
import EventEmitter from 'events';
import NetworkServer from '../NetworkServer.js';
import { Server, Socket } from 'net';

View File

@@ -8,6 +8,7 @@ import NetworkClient from './NetworkClient.js';
import { CollabVMCapabilities } from '@cvmts/collab-vm-1.2-binary-protocol';
import pino from 'pino';
import { BanManager } from './BanManager.js';
import { IProtocol, TheProtocolManager } from './Protocol.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;
@@ -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('guacamole', 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
}

View File

@@ -16,6 +16,8 @@ import pino from 'pino';
import { Database } from './Database.js';
import { BanManager } from './BanManager.js';
import { QemuVMShim } from './vm/qemu.js';
import { TheProtocolManager } from './Protocol.js';
import { GuacamoleProtocol } from './GuacamoleProtocol.js';
let logger = pino();
@@ -97,17 +99,20 @@ async function start() {
process.on('SIGINT', async () => await stop());
process.on('SIGTERM', async () => await stop());
// Register protocol(s)
TheProtocolManager.registerProtocol("guacamole", () => new GuacamoleProtocol);
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.on('connect', (client: User) => CVM.connectionOpened(client));
TCP.start();
}
}