mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: harden plugin api for separate repos, signed updates and generic external login
This commit is contained in:
1 parent
fb9c168b6b
commit
80944301dd
310 files changed
+6577
-4927
No files matched your search
@@ -7,7 +7,7 @@
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Plugin-RemoteDesktop",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Terminal",
|
||||
"icon": "Monitor",
|
||||
"engine": {
|
||||
@@ -21,7 +21,8 @@
|
||||
"ssh:connect",
|
||||
"network:serve",
|
||||
"desktop:window",
|
||||
"ui:surface"
|
||||
"ui:surface",
|
||||
"users:impersonate"
|
||||
],
|
||||
"optionalDependencies": {
|
||||
"session-recording": "^1.0.0",
|
||||
@@ -442,7 +443,8 @@
|
||||
"labelKey": "settings.host.guacamoleConfig.label",
|
||||
"default": {},
|
||||
"hidden": true,
|
||||
"shareRead": "edit"
|
||||
"shareRead": "edit",
|
||||
"secretKeys": ["gateway-password"]
|
||||
},
|
||||
{
|
||||
"key": "enableToolbar",
|
||||
@@ -454,7 +456,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"uses": ["session.remoteDisplay", "wakeOnLan.macAddress"]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
|
||||
@@ -6,10 +6,35 @@
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
|
||||
"typecheck": "tsc -p .",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"dependencies": {
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"guacamole-lite": "^1.2.0",
|
||||
"lucide-react": "^1.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"react-i18next": "^17.0.14",
|
||||
"sonner": "^2.0.8",
|
||||
"ssh2": "^1.17.0"
|
||||
},
|
||||
"termix": {
|
||||
"patches": [
|
||||
"guacamole-common-js-flush"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
|
||||
// the package resolves the way the plugin's bundle will resolve it.
|
||||
function packageDir(name) {
|
||||
const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
|
||||
// Walked by hand: a package whose exports map hides package.json cannot be
|
||||
// found with require.resolve.
|
||||
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
|
||||
const candidate = path.join(dir, "node_modules", name);
|
||||
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
|
||||
if (path.dirname(dir) === dir) return path.join(root, "node_modules", name);
|
||||
}
|
||||
}
|
||||
|
||||
const guacdClientPath = path.join(
|
||||
packageDir("guacamole-lite"),
|
||||
"lib",
|
||||
"GuacdClient.js",
|
||||
);
|
||||
const cryptPath = path.join(packageDir("guacamole-lite"), "lib", "Crypt.js");
|
||||
const clientConnectionPath = path.join(
|
||||
packageDir("guacamole-lite"),
|
||||
"lib",
|
||||
"ClientConnection.js",
|
||||
);
|
||||
const serverPath = path.join(packageDir("guacamole-lite"), "lib", "Server.js");
|
||||
|
||||
if (
|
||||
!fs.existsSync(guacdClientPath) ||
|
||||
!fs.existsSync(cryptPath) ||
|
||||
!fs.existsSync(clientConnectionPath) ||
|
||||
!fs.existsSync(serverPath)
|
||||
) {
|
||||
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");
|
||||
let serverContent = fs.readFileSync(serverPath, "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 '<len>.<opcode>,...;' 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;
|
||||
}
|
||||
|
||||
// Patch 9: ClientConnection closes malformed-token WebSockets in its
|
||||
// constructor, but Server.newConnection still called connect() afterwards.
|
||||
// That dereferenced the absent connection settings and turned one bad token
|
||||
// into an unhandled rejection that could terminate the backend process.
|
||||
const oldConnectionSetup =
|
||||
" newConnection.on('ready', async (clientConnection) => {";
|
||||
const newConnectionSetup =
|
||||
" if (!newConnection.connectionSettings || !newConnection.connectionSettings.connection) {\n" +
|
||||
" return;\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
oldConnectionSetup;
|
||||
|
||||
if (!serverContent.includes("!newConnection.connectionSettings.connection")) {
|
||||
if (!serverContent.includes(oldConnectionSetup)) {
|
||||
missingAnchor("invalid-token connection guard");
|
||||
}
|
||||
serverContent = serverContent.replace(oldConnectionSetup, newConnectionSetup);
|
||||
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);
|
||||
fs.writeFileSync(serverPath, serverContent);
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Patched protocol negotiation, name handshake, required arguments, UTF-8 token decrypt, read-only joins, and malformed-token handling",
|
||||
);
|
||||
@@ -636,7 +636,8 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
|
||||
return res.status(400).json({ error: "Missing host" });
|
||||
}
|
||||
try {
|
||||
const result = await ctx.desktop.launchNativeRdp({
|
||||
const result = await ctx.desktop.launchExternalClient({
|
||||
protocol: "rdp",
|
||||
host: body.host,
|
||||
port: Number(body.port) || DEFAULT_PORT.rdp,
|
||||
username: typeof body.username === "string" ? body.username : undefined,
|
||||
|
||||
@@ -117,17 +117,8 @@ export class GuacamoleTokenService {
|
||||
}
|
||||
}
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
if (jwtSecret) {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(jwtSecret + "_guacamole")
|
||||
.digest();
|
||||
}
|
||||
|
||||
this.log.warn("No persistent encryption key found, generating random key", {
|
||||
operation: "guac_key_generation",
|
||||
});
|
||||
// A token lives for seconds between issue and connect, so a fresh key
|
||||
// per boot is enough and nothing is derived from core's own secrets.
|
||||
return crypto.randomBytes(KEY_LENGTH);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,13 @@ import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
|
||||
import {
|
||||
buildOriginWsUrl,
|
||||
getBasePath,
|
||||
guacStateToStage,
|
||||
isElectron,
|
||||
resolveConnectionOrigin,
|
||||
type ConnectionOrigin,
|
||||
type ConnectionStage,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { errorMessage } from "./host-remote";
|
||||
import { guacStateToStage } from "./guacamole-state.ts";
|
||||
import { isPasteShortcut, pasteTextToRemote } from "./guacamole-clipboard.ts";
|
||||
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
|
||||
import { bindPointerInput } from "./guacamole-pointer.ts";
|
||||
@@ -88,8 +88,6 @@ interface GuacamoleDisplayProps {
|
||||
onZoomChange?: (zoom: number) => void;
|
||||
}
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
export const GuacamoleDisplay = forwardRef<
|
||||
GuacamoleDisplayHandle,
|
||||
GuacamoleDisplayProps
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
declare module "guacamole-common-js" {
|
||||
namespace Guacamole {
|
||||
class Client {
|
||||
constructor(tunnel: Tunnel);
|
||||
connect(data?: string): void;
|
||||
disconnect(): void;
|
||||
getDisplay(): Display;
|
||||
sendKeyEvent(pressed: number, keysym: number): void;
|
||||
sendMouseState(state: Mouse.State): void;
|
||||
sendSize(width: number, height: number): void;
|
||||
setClipboard(stream: OutputStream, mimetype: string): void;
|
||||
createClipboardStream(mimetype: string): OutputStream;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
onerror: ((error: Status) => void) | null;
|
||||
onsync: ((timestamp: number) => void) | null;
|
||||
onclipboard: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
onaudio: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
onfile:
|
||||
| ((stream: InputStream, mimetype: string, filename: string) => void)
|
||||
| null;
|
||||
onfilesystem: ((filesystem: Object, name: string) => void) | null;
|
||||
}
|
||||
|
||||
// Mirrors Guacamole.Object: a named collection of streams. Within this
|
||||
// namespace `Object` refers to this class, not the global one.
|
||||
class Object {
|
||||
static readonly ROOT_STREAM: string;
|
||||
static readonly STREAM_INDEX_MIMETYPE: string;
|
||||
readonly index: number;
|
||||
requestInputStream(
|
||||
name: string,
|
||||
bodyCallback?: (stream: InputStream, mimetype: string) => void,
|
||||
): void;
|
||||
createOutputStream(mimetype: string, name: string): OutputStream;
|
||||
onbody: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
onundefine: (() => void) | null;
|
||||
}
|
||||
|
||||
class AudioPlayer {
|
||||
static getInstance(
|
||||
stream: InputStream,
|
||||
mimetype: string,
|
||||
): AudioPlayer | null;
|
||||
sync(): void;
|
||||
}
|
||||
|
||||
class Display {
|
||||
getElement(): HTMLElement;
|
||||
getWidth(): number;
|
||||
getHeight(): number;
|
||||
scale(scale: number): void;
|
||||
onresize: (() => void) | null;
|
||||
}
|
||||
|
||||
class Tunnel {
|
||||
onerror: ((status: Status) => void) | null;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
}
|
||||
|
||||
class WebSocketTunnel extends Tunnel {
|
||||
constructor(url: string);
|
||||
}
|
||||
|
||||
class SessionRecording {
|
||||
constructor(source: Blob | Tunnel, refreshInterval?: number);
|
||||
onload: (() => void) | null;
|
||||
onerror: ((message: string) => void) | null;
|
||||
onprogress: ((duration: number, parsedSize: number) => void) | null;
|
||||
onplay: (() => void) | null;
|
||||
onpause: (() => void) | null;
|
||||
onseek:
|
||||
((position: number, current: number, total: number) => void) | null;
|
||||
getDisplay(): Display;
|
||||
getPosition(): number;
|
||||
getDuration(): number;
|
||||
isPlaying(): boolean;
|
||||
play(): void;
|
||||
pause(): void;
|
||||
seek(position: number, callback?: () => void): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
class Mouse {
|
||||
constructor(element: HTMLElement);
|
||||
onmousedown: ((state: Mouse.State) => void) | null;
|
||||
onmouseup: ((state: Mouse.State) => void) | null;
|
||||
onmousemove: ((state: Mouse.State) => void) | null;
|
||||
onmouseout: ((state: Mouse.State) => void) | null;
|
||||
}
|
||||
|
||||
namespace Mouse {
|
||||
class State {
|
||||
constructor(
|
||||
x: number,
|
||||
y: number,
|
||||
left?: boolean,
|
||||
middle?: boolean,
|
||||
right?: boolean,
|
||||
up?: boolean,
|
||||
down?: boolean,
|
||||
);
|
||||
constructor(state: {
|
||||
x: number;
|
||||
y: number;
|
||||
left?: boolean;
|
||||
middle?: boolean;
|
||||
right?: boolean;
|
||||
up?: boolean;
|
||||
down?: boolean;
|
||||
});
|
||||
x: number;
|
||||
y: number;
|
||||
left: boolean;
|
||||
middle: boolean;
|
||||
right: boolean;
|
||||
up: boolean;
|
||||
down: boolean;
|
||||
}
|
||||
|
||||
interface MouseEvent {
|
||||
state: Mouse.State;
|
||||
preventDefault(): void;
|
||||
stopPropagation(): void;
|
||||
}
|
||||
|
||||
class Touchpad {
|
||||
constructor(element: HTMLElement);
|
||||
currentState: Mouse.State;
|
||||
clickTimingThreshold: number;
|
||||
clickMoveThreshold: number;
|
||||
scrollThreshold: number;
|
||||
onEach(
|
||||
types: string[],
|
||||
listener: (event: Mouse.MouseEvent) => void,
|
||||
): void;
|
||||
on(type: string, listener: (event: Mouse.MouseEvent) => void): void;
|
||||
}
|
||||
|
||||
class Touchscreen {
|
||||
constructor(element: HTMLElement);
|
||||
currentState: Mouse.State;
|
||||
clickTimingThreshold: number;
|
||||
clickMoveThreshold: number;
|
||||
scrollThreshold: number;
|
||||
longPressThreshold: number;
|
||||
onEach(
|
||||
types: string[],
|
||||
listener: (event: Mouse.MouseEvent) => void,
|
||||
): void;
|
||||
on(type: string, listener: (event: Mouse.MouseEvent) => void): void;
|
||||
}
|
||||
}
|
||||
|
||||
class Keyboard {
|
||||
constructor(element: Document | HTMLElement);
|
||||
reset(): void;
|
||||
onkeydown: ((keysym: number) => void) | null;
|
||||
onkeyup: ((keysym: number) => void) | null;
|
||||
}
|
||||
|
||||
class Status {
|
||||
code: number;
|
||||
message: string;
|
||||
isError(): boolean;
|
||||
static readonly Code: {
|
||||
SUCCESS: number;
|
||||
UNSUPPORTED: number;
|
||||
SERVER_ERROR: number;
|
||||
SERVER_BUSY: number;
|
||||
UPSTREAM_TIMEOUT: number;
|
||||
UPSTREAM_ERROR: number;
|
||||
RESOURCE_NOT_FOUND: number;
|
||||
RESOURCE_CONFLICT: number;
|
||||
RESOURCE_CLOSED: number;
|
||||
CLIENT_BAD_REQUEST: number;
|
||||
CLIENT_UNAUTHORIZED: number;
|
||||
CLIENT_FORBIDDEN: number;
|
||||
CLIENT_TIMEOUT: number;
|
||||
};
|
||||
}
|
||||
|
||||
class InputStream {
|
||||
onblob: ((data: string) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
sendAck(message: string, code: number): void;
|
||||
}
|
||||
|
||||
class OutputStream {
|
||||
sendBlob(data: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
|
||||
class StringReader {
|
||||
constructor(stream: InputStream);
|
||||
ontext: ((text: string) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
|
||||
class StringWriter {
|
||||
constructor(stream: OutputStream);
|
||||
sendText(text: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
|
||||
class BlobReader {
|
||||
constructor(stream: InputStream, mimetype: string);
|
||||
getBlob(): Blob;
|
||||
getLength(): number;
|
||||
onprogress: ((length: number) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
|
||||
class BlobWriter {
|
||||
constructor(stream: OutputStream);
|
||||
sendBlob(blob: Blob): void;
|
||||
sendEnd(): void;
|
||||
onack: ((status: Status) => void) | null;
|
||||
onerror: ((blob: Blob, offset: number, error: Status) => void) | null;
|
||||
onprogress: ((blob: Blob, offset: number) => void) | null;
|
||||
oncomplete: ((blob: Blob) => void) | null;
|
||||
}
|
||||
}
|
||||
|
||||
export default Guacamole;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ConnectionStage } from "@termix/plugin-sdk/ui";
|
||||
|
||||
// Guacamole client states, per guacamole-common-js Guacamole.Client#STATE_*.
|
||||
const GUAC_STATE_IDLE = 0;
|
||||
const GUAC_STATE_CONNECTING = 1;
|
||||
const GUAC_STATE_WAITING = 2;
|
||||
const GUAC_STATE_CONNECTED = 3;
|
||||
const GUAC_STATE_DISCONNECTING = 4;
|
||||
const GUAC_STATE_DISCONNECTED = 5;
|
||||
|
||||
export function guacStateToStage(state: number): ConnectionStage {
|
||||
switch (state) {
|
||||
case GUAC_STATE_CONNECTING:
|
||||
return "guac_connecting";
|
||||
case GUAC_STATE_WAITING:
|
||||
return "guac_handshake";
|
||||
case GUAC_STATE_CONNECTED:
|
||||
return "guac_ready";
|
||||
case GUAC_STATE_DISCONNECTING:
|
||||
case GUAC_STATE_DISCONNECTED:
|
||||
return "guac_disconnected";
|
||||
case GUAC_STATE_IDLE:
|
||||
default:
|
||||
return "guac_connecting";
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ describe("createMacosVncCompatibilityProxy", () => {
|
||||
socket.write(Buffer.from([5, 30, 33]));
|
||||
socket.write(Buffer.from([36, 2, 35]));
|
||||
socket.once("data", (selection) => {
|
||||
selectedSecurityType = selection[0];
|
||||
selectedSecurityType = Number(selection[0]);
|
||||
socket.write("desktop-data");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,8 @@ import { GuacamoleTokenService } from "../../src/backend/token-service.js";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
// The token key comes from JWT_SECRET; without one each service picks its own.
|
||||
process.env.JWT_SECRET = "remote-desktop-test-secret";
|
||||
// Without a fixed key each token service picks its own.
|
||||
process.env.GUACAMOLE_ENCRYPTION_KEY = "ab".repeat(32);
|
||||
|
||||
function target(
|
||||
overrides: Partial<PluginProtocolTarget["auth"]> = {},
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
isServerOwnedSetting,
|
||||
} from "../../src/backend/token-service.js";
|
||||
|
||||
process.env.JWT_SECRET = "remote-desktop-test-secret";
|
||||
process.env.GUACAMOLE_ENCRYPTION_KEY = "ab".repeat(32);
|
||||
|
||||
function decode(token: string) {
|
||||
return JSON.parse(Buffer.from(token, "base64").toString("utf8")) as {
|
||||
|
||||
@@ -59,8 +59,10 @@ describe("GuacamoleTokenService", () => {
|
||||
const termixMeta = {
|
||||
termixConnectId: "connect-1",
|
||||
hostId: 7,
|
||||
hostName: "windows",
|
||||
ownerUserId: "user-1",
|
||||
protocol: "rdp" as const,
|
||||
tabInstanceId: null,
|
||||
};
|
||||
const token = tokenService.createRdpToken(
|
||||
"windows.example.test",
|
||||
|
||||
Reference in new issue
Block a user