const fs = require("fs"); const path = require("path"); const guacdClientPath = path.join( __dirname, "..", "node_modules", "guacamole-lite", "lib", "GuacdClient.js", ); const cryptPath = path.join( __dirname, "..", "node_modules", "guacamole-lite", "lib", "Crypt.js", ); const clientConnectionPath = path.join( __dirname, "..", "node_modules", "guacamole-lite", "lib", "ClientConnection.js", ); if ( !fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath) || !fs.existsSync(clientConnectionPath) ) { console.log("[patch-guacamole-lite] File not found, skipping"); process.exit(0); } // Every patch below is required for correctness: protocol negotiation, the // guacd 1.6.0 name handshake, dynamic argument answering, UTF-8 tokens and // read-only joins. If an upstream release moves an anchor string, silently // skipping would ship a Termix that looks fine and then drops VNC/RDP sessions // at runtime, so a missing anchor has to stop the install instead. function missingAnchor(patch) { console.error( `[patch-guacamole-lite] ${patch} anchor not found in guacamole-lite. ` + "The upstream file has changed and this patch no longer applies — " + "update scripts/patch-guacamole-lite.cjs to match the new source.", ); process.exit(1); } let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8"); let cryptContent = fs.readFileSync(cryptPath, "utf8"); let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8"); // Patch 1: protocol version negotiation. // guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol // versions Termix can handle, and conservatively answer future 1.x versions as // VERSION_1_5_0 so guacd still sees support for `require`/`name` without us // claiming support for unknown instructions. const oldVersionBlock = " if (version === '1_0_0' || version === '1_1_0') {\n" + " protocolVersion = version;\n" + " } else {\n" + " protocolVersion = '1_1_0';\n" + " }"; const oldPatchedVersionBlock = " if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" + " protocolVersion = version;\n" + " } else {\n" + " protocolVersion = '1_1_0';\n" + " }"; const newVersionBlock = " if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" + " protocolVersion = version;\n" + " } else if (/^1_\\d+_0$/.test(version)) {\n" + " protocolVersion = '1_5_0';\n" + " } else {\n" + " protocolVersion = '1_1_0';\n" + " }"; // Patch 2: timezone instruction must be sent for all protocols >= 1.1.0, not just 1.1.0 const oldTimezone = "if (protocolVersion === '1_1_0') {"; const newTimezone = "if (protocolVersion !== '1_0_0') {"; // Patch 3: send the `name` handshake instruction for all protocol versions >= 1.1.0. // The Guacamole protocol added `name` in 1.3.0, but guacd 1.6.0 began requiring it // during the VNC handshake even when negotiating VERSION_1_1_0, causing connections to // silently drop right after "User joined". Sending it for all non-1.0.0 sessions is // harmless (guacd ignores unknown handshake instructions for older versions). See // Termix-SSH/Support#567 and #734. const oldConnect = " this.sendInstruction(['connect'].concat(connectArgs));"; const oldNameConnect = " if (protocolVersion === '1_3_0' || protocolVersion === '1_5_0') {\n" + " this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" + " }\n" + "\n" + " this.sendInstruction(['connect'].concat(connectArgs));"; const newConnect = " if (protocolVersion !== '1_0_0') {\n" + " this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" + " }\n" + "\n" + " this.sendInstruction(['connect'].concat(connectArgs));"; // Patch 4: answer guacd's dynamic argument requests locally. // macOS Screen Sharing can request VNC username/password through the // post-handshake `required`/`require` flow. guacamole-lite forwards those // instructions to the browser, but Termix already keeps the credentials in the // server-side token and the browser does not provide an onrequired handler. const oldSendBuffer = " this.lastActivity = Date.now();\n" + " this.sendBuffer = '';"; const newSendBuffer = " this.lastActivity = Date.now();\n" + " this.sendBuffer = '';\n" + " this.nextArgumentStreamIndex = 0;"; const oldSendInstructionBlock = " sendInstruction(instruction) {\n" + " // convert every element in the instruction array to a string. convert null to an empty string\n" + " instruction = instruction.map((element) => {\n" + " if (element === null || element === undefined) {\n" + " return '';\n" + " }\n" + " return String(element);\n" + " });\n" + "\n" + " const instructionString = GuacamoleParser.toInstruction(instruction);\n" + " this.send(instructionString);\n" + " }\n"; const newSendInstructionBlock = oldSendInstructionBlock + "\n" + " sendArgumentValue(name, value) {\n" + " const stream = this.nextArgumentStreamIndex++;\n" + " this.sendInstruction(['argv', stream, 'text/plain', name]);\n" + " this.sendInstruction(['blob', stream, Buffer.from(String(value ?? ''), 'utf8').toString('base64')]);\n" + " this.sendInstruction(['end', stream]);\n" + " }\n" + "\n" + " sendRequiredArguments(params) {\n" + " params.forEach((name) => {\n" + " this.sendArgumentValue(name, this.connectionSettings[name]);\n" + " });\n" + " }\n"; const oldReadyHandler = ' // Handle "ready" instruction\n' + " if (opcode === 'ready') {"; const newReadyHandler = " // Handle dynamic argument requests from guacd\n" + " if (opcode === 'required' || opcode === 'require') {\n" + " this.sendRequiredArguments(params);\n" + " return;\n" + " }\n" + "\n" + oldReadyHandler; let patched = false; if (!guacdClientContent.includes("} else if (/^1_\\d+_0$/.test(version)) {")) { if (guacdClientContent.includes(oldPatchedVersionBlock)) { guacdClientContent = guacdClientContent.replace( oldPatchedVersionBlock, newVersionBlock, ); } else if (guacdClientContent.includes(oldVersionBlock)) { guacdClientContent = guacdClientContent.replace( oldVersionBlock, newVersionBlock, ); } else { missingAnchor("Version check"); } patched = true; } if (!guacdClientContent.includes(newTimezone)) { if (!guacdClientContent.includes(oldTimezone)) { missingAnchor("Timezone"); } guacdClientContent = guacdClientContent.replace(oldTimezone, newTimezone); patched = true; } if (!guacdClientContent.includes(newConnect)) { if (guacdClientContent.includes(oldNameConnect)) { guacdClientContent = guacdClientContent.replace(oldNameConnect, newConnect); } else if (guacdClientContent.includes(oldConnect)) { guacdClientContent = guacdClientContent.replace(oldConnect, newConnect); } else { missingAnchor("Connect"); } patched = true; } if (!guacdClientContent.includes("this.nextArgumentStreamIndex = 0;")) { if (!guacdClientContent.includes(oldSendBuffer)) { missingAnchor("Argument stream index"); } guacdClientContent = guacdClientContent.replace(oldSendBuffer, newSendBuffer); patched = true; } if (!guacdClientContent.includes("sendRequiredArguments(params) {")) { if (!guacdClientContent.includes(oldSendInstructionBlock)) { missingAnchor("Required argument helper"); } guacdClientContent = guacdClientContent.replace( oldSendInstructionBlock, newSendInstructionBlock, ); patched = true; } if ( !guacdClientContent.includes("opcode === 'required' || opcode === 'require'") ) { if (!guacdClientContent.includes(oldReadyHandler)) { missingAnchor("Required opcode"); } guacdClientContent = guacdClientContent.replace( oldReadyHandler, newReadyHandler, ); patched = true; } // Patch 5: guacamole-lite decrypts token JSON through ASCII/binary strings, // which corrupts IV/ciphertext bytes and non-ASCII connection settings such as // RDP/VNC passwords with umlauts. Keep the encrypted fields as Buffers and // decode the plaintext JSON as UTF-8. const oldDecryptBlock = " let encoded = JSON.parse(this.constructor.base64decode(encodedString));\n" + "\n" + " encoded.iv = this.constructor.base64decode(encoded.iv);\n" + " encoded.value = this.constructor.base64decode(encoded.value, 'binary');\n" + "\n" + " const decipher = Crypto.createDecipheriv(this.cypher, this.key, encoded.iv);\n" + "\n" + " let decrypted = decipher.update(encoded.value, 'binary', 'ascii');\n" + " decrypted += decipher.final('ascii');"; const oldPartiallyPatchedDecryptBlock = " let encoded = JSON.parse(this.constructor.base64decode(encodedString));\n" + "\n" + " encoded.iv = this.constructor.base64decode(encoded.iv);\n" + " encoded.value = this.constructor.base64decode(encoded.value, 'binary');\n" + "\n" + " const decipher = Crypto.createDecipheriv(this.cypher, this.key, encoded.iv);\n" + "\n" + " let decrypted = decipher.update(encoded.value, 'binary', 'utf8');\n" + " decrypted += decipher.final('utf8');"; const newDecryptBlock = " const encoded = JSON.parse(Buffer.from(encodedString, 'base64').toString('utf8'));\n" + "\n" + " const iv = Buffer.from(encoded.iv, 'base64');\n" + " const value = Buffer.from(encoded.value, 'base64');\n" + "\n" + " const decipher = Crypto.createDecipheriv(this.cypher, this.key, iv);\n" + "\n" + " let decrypted = decipher.update(value, undefined, 'utf8');\n" + " decrypted += decipher.final('utf8');"; if (!cryptContent.includes(newDecryptBlock)) { if (cryptContent.includes(oldDecryptBlock)) { cryptContent = cryptContent.replace(oldDecryptBlock, newDecryptBlock); } else if (cryptContent.includes(oldPartiallyPatchedDecryptBlock)) { cryptContent = cryptContent.replace( oldPartiallyPatchedDecryptBlock, newDecryptBlock, ); } else { missingAnchor("UTF-8 token decrypt"); } patched = true; } // Patch 7: drop client-to-guacd input instructions from read-only session-share // joins. guacd has no native read-only enforcement in the versions this project // targets, so Termix must gate here. Denylist (not allowlist) on purpose: an // unrecognized opcode is far more likely to be protocol plumbing (sync, blob, // clipboard streams) than a new input vector, so failing open is the safer // default for a client we already control. const oldSendMessageToGuacd = " sendMessageToGuacd(message) {\n" + " this.lastActivity = Date.now();\n" + " this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" + "\n" + " if (this.guacdClient) {\n" + " this.guacdClient.send(message, true);\n" + " }\n" + " }"; const newSendMessageToGuacd = " sendMessageToGuacd(message) {\n" + " this.lastActivity = Date.now();\n" + " this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" + "\n" + " if (this.isReadOnlyJoin() && this.isInputInstruction(message)) {\n" + " return;\n" + " }\n" + "\n" + " if (this.guacdClient) {\n" + " this.guacdClient.send(message, true);\n" + " }\n" + " }\n" + "\n" + " isReadOnlyJoin() {\n" + " const connection = this.connectionSettings && this.connectionSettings.connection;\n" + " return !!(connection && connection.join && connection.readOnly === true);\n" + " }\n" + "\n" + " // Termix-only read-only gate, not part of the vendored library: extracts just\n" + " // the leading opcode from a raw '.,...;' instruction without the\n" + " // overhead of a full stateful parse.\n" + " isInputInstruction(message) {\n" + " const dot = message.indexOf('.');\n" + " if (dot === -1) return false;\n" + " const len = parseInt(message.substring(0, dot), 10);\n" + " if (isNaN(len)) return false;\n" + " const opcode = message.substring(dot + 1, dot + 1 + len);\n" + " return ['mouse', 'key', 'touch', 'size'].includes(opcode);\n" + " }"; if (!clientConnectionContent.includes("isReadOnlyJoin()")) { if (!clientConnectionContent.includes(oldSendMessageToGuacd)) { missingAnchor("sendMessageToGuacd"); } clientConnectionContent = clientConnectionContent.replace( oldSendMessageToGuacd, newSendMessageToGuacd, ); patched = true; } // Patch 8: mergeConnectionOptions only preserves `join` across the settings // merge, dropping Termix's `readOnly` flag before sendMessageToGuacd can see it. const oldPreserveJoin = " // For join connections, preserve the join property\n" + " if (this.connectionSettings.connection.join) {\n" + " compiledSettings.join = this.connectionSettings.connection.join;\n" + " }"; const newPreserveJoin = " // For join connections, preserve the join property\n" + " if (this.connectionSettings.connection.join) {\n" + " compiledSettings.join = this.connectionSettings.connection.join;\n" + " compiledSettings.readOnly = this.connectionSettings.connection.readOnly === true;\n" + " }"; if (!clientConnectionContent.includes("compiledSettings.readOnly")) { if (!clientConnectionContent.includes(oldPreserveJoin)) { missingAnchor("join-preserve"); } clientConnectionContent = clientConnectionContent.replace( oldPreserveJoin, newPreserveJoin, ); patched = true; } if (!patched) { console.log("[patch-guacamole-lite] Already patched"); process.exit(0); } fs.writeFileSync(guacdClientPath, guacdClientContent); fs.writeFileSync(cryptPath, cryptContent); fs.writeFileSync(clientConnectionPath, clientConnectionContent); console.log( "[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, UTF-8 token decrypt, and read-only join input filtering", );