mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Remote Desktop (RDP/VNC/Telnet) - first-party, in-process plugin.
|
||||
*
|
||||
* Runs on the main thread rather than in a worker for the same class of
|
||||
* reason as ssh-terminal/docker (see src/backend/plugins/first-party.ts): it
|
||||
* owns a WebSocket server (guacamole-lite) on port 30008, and its
|
||||
* /connect-host/:hostId route does multi-step credential/tunnel resolution
|
||||
* (jump-host SSH tunnels, a macOS VNC compatibility proxy) involving raw
|
||||
* net.Socket plumbing the worker's structured-clone postMessage boundary
|
||||
* cannot carry.
|
||||
*
|
||||
* guacd itself is not a process this plugin spawns: it is a separate
|
||||
* external service (a sibling Docker container by default, or any
|
||||
* network-reachable guacd) reached over plain TCP. See guacd-config.ts's
|
||||
* resolveGuacdOptions(). There is nothing to supervise here - guacd's
|
||||
* availability is handled as an external dependency with graceful
|
||||
* degradation, same as before this plugin existed.
|
||||
*
|
||||
* Its backend logic is real TypeScript physically relocated here (routes.ts,
|
||||
* guacamole-server.ts, token-service.ts, etc.), compiled by
|
||||
* scripts/copy-bundled-plugins.cjs like docker's plugin backend, rather than
|
||||
* kept as a thin wrapper over code left in core -- see that script's own
|
||||
* comment for why. The one piece that stays in core is
|
||||
* src/backend/database/routes/guacamole-dispatch.ts, since it is what
|
||||
* database.ts mounts permanently at /guacamole regardless of whether this
|
||||
* plugin is installed.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
async function loadRelative(relativePath) {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const target = path.join(here, `${relativePath}.js`);
|
||||
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
|
||||
}
|
||||
|
||||
let guacServerModule = null;
|
||||
let dispatchModule = null;
|
||||
|
||||
export async function activate(ctx) {
|
||||
const [server, routes, dispatch] = await Promise.all([
|
||||
loadRelative("guacamole-server"),
|
||||
loadRelative("routes"),
|
||||
loadRelative("../../../backend/backend/database/routes/guacamole-dispatch"),
|
||||
]);
|
||||
|
||||
guacServerModule = server;
|
||||
dispatchModule = dispatch;
|
||||
|
||||
await server.startGuacamoleService();
|
||||
dispatch.registerGuacamoleBridge({
|
||||
router: routes.router,
|
||||
restart: server.restartGuacServer,
|
||||
createJoinToken: (guacamoleConnectionId, readOnly) =>
|
||||
server.tokenService.createJoinToken(guacamoleConnectionId, readOnly),
|
||||
getSessionInfo: server.getGuacSessionInfo,
|
||||
});
|
||||
|
||||
ctx.log.info(
|
||||
`Remote Desktop listening on ${server.GUAC_WS_PORT ?? 30008}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
if (dispatchModule) {
|
||||
dispatchModule.unregisterGuacamoleBridge();
|
||||
dispatchModule = null;
|
||||
}
|
||||
if (guacServerModule) {
|
||||
await guacServerModule.stopGuacamoleService();
|
||||
guacServerModule = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -61,8 +61,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"backend": "backend/index.mjs",
|
||||
"frontend": "frontend/index.mjs",
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@termix-plugin/remote-desktop",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
}
|
||||
File renamed without changes.
+3
-3
@@ -1,5 +1,5 @@
|
||||
import GuacamoleLite from "guacamole-lite";
|
||||
import { guacLogger } from "../../../src/backend/utils/logger.js";
|
||||
import { guacLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
GuacamoleTokenService,
|
||||
type GuacamoleRecordingMetadata,
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
import {
|
||||
createCurrentSessionRecordingRepository,
|
||||
getCurrentSettingValue,
|
||||
} from "../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js";
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveGuacdOptions } from "../../../../src/backend/utils/guacd-config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
GUAC_WS_PORT,
|
||||
getGuacSessionInfo,
|
||||
restartGuacServer,
|
||||
startGuacamoleService,
|
||||
stopGuacamoleService,
|
||||
tokenService,
|
||||
} from "./guacamole-server.js";
|
||||
import { router } from "./routes.js";
|
||||
import {
|
||||
registerGuacamoleBridge,
|
||||
unregisterGuacamoleBridge,
|
||||
} from "../../../../src/backend/database/routes/guacamole-dispatch.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
await startGuacamoleService();
|
||||
|
||||
registerGuacamoleBridge({
|
||||
router,
|
||||
restart: restartGuacServer,
|
||||
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
|
||||
tokenService.createJoinToken(guacamoleConnectionId, readOnly),
|
||||
getSessionInfo: getGuacSessionInfo,
|
||||
});
|
||||
|
||||
ctx.log.info(`Remote Desktop listening on ${GUAC_WS_PORT ?? 30008}`);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
unregisterGuacamoleBridge();
|
||||
await stopGuacamoleService();
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+11
-11
@@ -1,32 +1,32 @@
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import express from "express";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { withRecordingSettings } from "./recording-settings.js";
|
||||
import { withDriveSettings } from "./drive-settings.js";
|
||||
import { guacLogger } from "../../../src/backend/utils/logger.js";
|
||||
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
|
||||
import { guacLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
||||
import {
|
||||
resolveRecipientSharedHostAuthentication,
|
||||
type RecipientSharedHostAuthResolution,
|
||||
} from "../../../src/backend/utils/shared-host-auth-resolver.js";
|
||||
import type { AuthOverrideProtocol } from "../../../src/types/auth-protocols.js";
|
||||
} from "../../../../src/backend/utils/shared-host-auth-resolver.js";
|
||||
import type { AuthOverrideProtocol } from "../../../../src/types/auth-protocols.js";
|
||||
import net from "net";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js";
|
||||
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveGuacdOptions } from "../../../../src/backend/utils/guacd-config.js";
|
||||
import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js";
|
||||
import { getGuacSessionByConnectId } from "./guacamole-server.js";
|
||||
import {
|
||||
logAudit,
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
} from "../../../src/backend/utils/audit-logger.js";
|
||||
} from "../../../../src/backend/utils/audit-logger.js";
|
||||
import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js";
|
||||
import {
|
||||
buildRdpSettings,
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import crypto from "crypto";
|
||||
import { guacLogger } from "../../../src/backend/utils/logger.js";
|
||||
import { guacLogger } from "../../../../src/backend/utils/logger.js";
|
||||
|
||||
export interface GuacamoleConnectionSettings {
|
||||
type?: "rdp" | "vnc" | "telnet";
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withDriveSettings } from "../../src/backend/drive-settings.js";
|
||||
|
||||
describe("withDriveSettings", () => {
|
||||
it("gives each user a folder under GUACD_DRIVE_PATH and creates it", () => {
|
||||
expect(
|
||||
withDriveSettings({ "enable-drive": true }, "user-1", {
|
||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive/",
|
||||
}),
|
||||
).toEqual({
|
||||
"enable-drive": true,
|
||||
"drive-path": "/termix-data/rdp-drive/user-1",
|
||||
"create-drive-path": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to /drive when the environment says nothing", () => {
|
||||
expect(
|
||||
withDriveSettings({ "enable-drive": true }, "user-1", {}),
|
||||
).toMatchObject({ "drive-path": "/drive/user-1" });
|
||||
});
|
||||
|
||||
it("leaves a host-chosen drive-path alone", () => {
|
||||
const config = { "enable-drive": true, "drive-path": "/mnt/share" };
|
||||
expect(
|
||||
withDriveSettings(config, "user-1", { GUACD_DRIVE_PATH: "/x" }),
|
||||
).toBe(config);
|
||||
});
|
||||
|
||||
it("does nothing when the drive is not enabled", () => {
|
||||
const config = { "enable-drive": false };
|
||||
expect(withDriveSettings(config, "user-1")).toBe(config);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveJumpTunnelEndpoint } from "../../src/backend/jump-tunnel-endpoint.js";
|
||||
|
||||
describe("resolveJumpTunnelEndpoint", () => {
|
||||
it("keeps an in-process guacd tunnel on loopback", () => {
|
||||
expect(resolveJumpTunnelEndpoint("localhost")).toEqual({
|
||||
bindHost: "127.0.0.1",
|
||||
advertisedHost: "127.0.0.1",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes the tunnel to a separate guacd container", () => {
|
||||
expect(resolveJumpTunnelEndpoint("guacd")).toEqual({
|
||||
bindHost: "0.0.0.0",
|
||||
advertisedHost: "termix",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports a custom backend hostname for external guacd", () => {
|
||||
expect(
|
||||
resolveJumpTunnelEndpoint("guacd.example", "termix-backend"),
|
||||
).toEqual({
|
||||
bindHost: "0.0.0.0",
|
||||
advertisedHost: "termix-backend",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import net from "net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createMacosVncCompatibilityProxy } from "../../src/backend/macos-vnc-proxy.js";
|
||||
|
||||
const closers: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const close of closers.splice(0)) close();
|
||||
});
|
||||
|
||||
async function listen(server: net.Server): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
resolve((server.address() as net.AddressInfo).port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function read(socket: net.Socket, length: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let received = Buffer.alloc(0);
|
||||
const onData = (chunk: Buffer) => {
|
||||
received = Buffer.concat([received, chunk]);
|
||||
if (received.length < length) return;
|
||||
socket.off("data", onData);
|
||||
resolve(received);
|
||||
};
|
||||
socket.on("data", onData);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("createMacosVncCompatibilityProxy", () => {
|
||||
it("normalizes Apple's private RFB banner and forces classic VNC auth", async () => {
|
||||
let clientBanner = "";
|
||||
let selectedSecurityType = 0;
|
||||
const target = net.createServer((socket) => {
|
||||
socket.write("RFB 003.");
|
||||
socket.write("889\n");
|
||||
socket.once("data", (data) => {
|
||||
clientBanner = data.toString("ascii");
|
||||
socket.write(Buffer.from([5, 30, 33]));
|
||||
socket.write(Buffer.from([36, 2, 35]));
|
||||
socket.once("data", (selection) => {
|
||||
selectedSecurityType = selection[0];
|
||||
socket.write("desktop-data");
|
||||
});
|
||||
});
|
||||
});
|
||||
const targetPort = await listen(target);
|
||||
closers.push(() => target.close());
|
||||
|
||||
const proxy = await createMacosVncCompatibilityProxy({
|
||||
targetHost: "127.0.0.1",
|
||||
targetPort,
|
||||
bindHost: "127.0.0.1",
|
||||
});
|
||||
closers.push(proxy.close);
|
||||
|
||||
const client = net.createConnection(proxy.port, "127.0.0.1");
|
||||
closers.push(() => client.destroy());
|
||||
expect((await read(client, 12)).subarray(0, 12).toString("ascii")).toBe(
|
||||
"RFB 003.008\n",
|
||||
);
|
||||
client.write("RFB 003.008\n");
|
||||
expect([...(await read(client, 2))]).toEqual([1, 2]);
|
||||
client.write(Buffer.from([2]));
|
||||
expect((await read(client, 12)).toString("ascii")).toBe("desktop-data");
|
||||
expect(clientBanner).toBe("RFB 003.008\n");
|
||||
expect(selectedSecurityType).toBe(2);
|
||||
});
|
||||
|
||||
it("passes standard RFB negotiation through unchanged", async () => {
|
||||
const target = net.createServer((socket) => {
|
||||
socket.write("RFB 003.008\n");
|
||||
socket.once("data", () => socket.write(Buffer.from([2, 30, 2])));
|
||||
});
|
||||
const targetPort = await listen(target);
|
||||
closers.push(() => target.close());
|
||||
const proxy = await createMacosVncCompatibilityProxy({
|
||||
targetHost: "127.0.0.1",
|
||||
targetPort,
|
||||
bindHost: "127.0.0.1",
|
||||
});
|
||||
closers.push(proxy.close);
|
||||
|
||||
const client = net.createConnection(proxy.port, "127.0.0.1");
|
||||
closers.push(() => client.destroy());
|
||||
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
|
||||
client.write("RFB 003.008\n");
|
||||
expect([...(await read(client, 3))]).toEqual([2, 30, 2]);
|
||||
});
|
||||
|
||||
it("passes Apple's security types through when VNC auth is unavailable", async () => {
|
||||
const target = net.createServer((socket) => {
|
||||
socket.write("RFB 003.889\n");
|
||||
socket.once("data", () => socket.write(Buffer.from([2, 30, 33])));
|
||||
});
|
||||
const targetPort = await listen(target);
|
||||
closers.push(() => target.close());
|
||||
const proxy = await createMacosVncCompatibilityProxy({
|
||||
targetHost: "127.0.0.1",
|
||||
targetPort,
|
||||
bindHost: "127.0.0.1",
|
||||
});
|
||||
closers.push(proxy.close);
|
||||
|
||||
const client = net.createConnection(proxy.port, "127.0.0.1");
|
||||
closers.push(() => client.destroy());
|
||||
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
|
||||
client.write("RFB 003.008\n");
|
||||
expect([...(await read(client, 3))]).toEqual([2, 30, 33]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRdpSettings,
|
||||
resolveRdpAuthTypeForConnect,
|
||||
resolveRdpDomain,
|
||||
} from "../../src/backend/rdp-settings.js";
|
||||
|
||||
describe("buildRdpSettings", () => {
|
||||
it("keeps saved RDP settings authoritative over stale advanced config", () => {
|
||||
expect(
|
||||
buildRdpSettings({
|
||||
port: 3390,
|
||||
domain: "EXAMPLE",
|
||||
security: "nla",
|
||||
ignoreCert: true,
|
||||
guacConfig: {
|
||||
port: 3389,
|
||||
domain: "OLD",
|
||||
security: "rdp",
|
||||
"ignore-cert": false,
|
||||
"color-depth": 24,
|
||||
},
|
||||
guacdOverrides: { guacdHost: "guacd.internal" },
|
||||
}),
|
||||
).toEqual({
|
||||
port: 3390,
|
||||
domain: "EXAMPLE",
|
||||
security: "nla",
|
||||
"ignore-cert": true,
|
||||
"color-depth": 24,
|
||||
guacdHost: "guacd.internal",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an advanced security value when no saved value exists", () => {
|
||||
expect(
|
||||
buildRdpSettings({
|
||||
port: 3389,
|
||||
ignoreCert: false,
|
||||
guacConfig: { security: "tls" },
|
||||
guacdOverrides: {},
|
||||
}).security,
|
||||
).toBe("tls");
|
||||
});
|
||||
|
||||
it("uses the prompted domain for prompt-on-connect authentication", () => {
|
||||
expect(resolveRdpDomain("none", "EXAMPLE", "OLD")).toBe("EXAMPLE");
|
||||
expect(resolveRdpDomain("none", "", "OLD")).toBe("");
|
||||
});
|
||||
|
||||
it("keeps the stored domain for saved authentication", () => {
|
||||
expect(resolveRdpDomain("direct", "EXAMPLE", "SAVED")).toBe("SAVED");
|
||||
expect(resolveRdpDomain("none", undefined, "SAVED")).toBe("SAVED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRdpAuthTypeForConnect", () => {
|
||||
it("keeps prompt-on-connect authentication for a secretless recipient", () => {
|
||||
expect(
|
||||
resolveRdpAuthTypeForConnect({
|
||||
storedAuthType: "none",
|
||||
sharedResolution: { source: "secretless" },
|
||||
}),
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
it("uses a recipient override instead of prompting", () => {
|
||||
expect(
|
||||
resolveRdpAuthTypeForConnect({
|
||||
storedAuthType: "none",
|
||||
sharedResolution: { source: "personal-override" },
|
||||
}),
|
||||
).toBe("credential");
|
||||
});
|
||||
|
||||
it("uses authentication shared by the owner", () => {
|
||||
expect(
|
||||
resolveRdpAuthTypeForConnect({
|
||||
storedAuthType: "credential",
|
||||
sharedResolution: { source: "owner-shared", authType: "credential" },
|
||||
}),
|
||||
).toBe("credential");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withRecordingSettings } from "../../src/backend/recording-settings.js";
|
||||
|
||||
const PATH = "/app/data/session_recordings/guacamole";
|
||||
const NAME = "b7e6c0f2-0000-4000-8000-000000000000.guac";
|
||||
|
||||
describe("withRecordingSettings", () => {
|
||||
it("takes ownership of the location and filename", () => {
|
||||
const merged = withRecordingSettings(
|
||||
{
|
||||
"recording-path": "/var/lib/termix/recordings",
|
||||
"recording-name": "${GUAC_USERNAME}-${GUAC_DATE}",
|
||||
"create-recording-path": false,
|
||||
},
|
||||
PATH,
|
||||
NAME,
|
||||
);
|
||||
|
||||
expect(merged).toMatchObject({
|
||||
"recording-path": PATH,
|
||||
"recording-name": NAME,
|
||||
"create-recording-path": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults the content flags when the host has no opinion", () => {
|
||||
expect(withRecordingSettings({}, PATH, NAME)).toMatchObject({
|
||||
"recording-exclude-output": false,
|
||||
"recording-include-keys": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the host's content flags, including the falsy ones", () => {
|
||||
const merged = withRecordingSettings(
|
||||
{
|
||||
"recording-exclude-output": true,
|
||||
"recording-include-keys": false,
|
||||
},
|
||||
PATH,
|
||||
NAME,
|
||||
);
|
||||
|
||||
expect(merged).toMatchObject({
|
||||
"recording-exclude-output": true,
|
||||
"recording-include-keys": false,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unrelated settings alone", () => {
|
||||
const merged = withRecordingSettings(
|
||||
{ "recording-exclude-mouse": true, width: "1920" },
|
||||
PATH,
|
||||
NAME,
|
||||
);
|
||||
|
||||
expect(merged).toMatchObject({
|
||||
"recording-exclude-mouse": true,
|
||||
width: "1920",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the settings it was given", () => {
|
||||
const original = { "recording-path": "/tmp/mine" };
|
||||
withRecordingSettings(original, PATH, NAME);
|
||||
|
||||
expect(original).toEqual({ "recording-path": "/tmp/mine" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
it("returns the minted token without waiting for a connection that needs that token", async () => {
|
||||
const source = readFileSync(
|
||||
new URL("../../src/backend/routes.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
"routes.ts",
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const route = ast.statements.find(
|
||||
(s) =>
|
||||
ts.isExpressionStatement(s) &&
|
||||
ts.isCallExpression(s.expression) &&
|
||||
s.expression.arguments[0]?.getText(ast) === '"/connect-host/:hostId"',
|
||||
) as ts.ExpressionStatement;
|
||||
const callback = (route.expression as ts.CallExpression)
|
||||
.arguments[1] as ts.ArrowFunction;
|
||||
const block = (callback.body as ts.Block).statements[0] as ts.TryStatement;
|
||||
const index = block.tryBlock.statements
|
||||
.map((s, i) => (ts.isSwitchStatement(s) ? i : -1))
|
||||
.filter((i) => i >= 0)
|
||||
.at(-1)!;
|
||||
expect(index).toBeGreaterThan(-1);
|
||||
const tail = block.tryBlock.statements
|
||||
.slice(index + 1)
|
||||
.map((s) => s.getText(ast))
|
||||
.join("\n");
|
||||
const json = vi.fn();
|
||||
const wait = vi.fn(() => {
|
||||
throw new Error("Client has not received the token yet");
|
||||
});
|
||||
await runInNewContext(
|
||||
ts.transpileModule(`(async () => { ${tail} })()`, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText,
|
||||
{
|
||||
waitForGuacdOpen: wait,
|
||||
token: "minted-token",
|
||||
termixConnectId: "correlation-id",
|
||||
req: {},
|
||||
getRequestMeta: () => ({}),
|
||||
logAudit: async () => {},
|
||||
getAuditUsername: async () => "owner",
|
||||
userId: "owner",
|
||||
connectionType: "rdp",
|
||||
hostId: 7,
|
||||
hostname: "host",
|
||||
port: 3389,
|
||||
res: { json },
|
||||
},
|
||||
);
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
token: "minted-token",
|
||||
termixConnectId: "correlation-id",
|
||||
guacamoleConnectionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("only reveals the live session ID to its owner", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../../src/backend/guacamole-server.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
"server.ts",
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const fn = ast.statements.find(
|
||||
(s) =>
|
||||
ts.isFunctionDeclaration(s) &&
|
||||
s.name?.text === "getGuacSessionByConnectId",
|
||||
)!;
|
||||
const info = { ownerUserId: "owner", guacamoleConnectionId: "guacd-id" };
|
||||
const context = {
|
||||
exports: {} as Record<string, (id: string, user: string) => unknown>,
|
||||
guacSessionByConnectId: new Map([["connect-id", info]]),
|
||||
};
|
||||
runInNewContext(
|
||||
ts.transpileModule(fn.getText(ast), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText,
|
||||
context,
|
||||
);
|
||||
const lookup = context.exports.getGuacSessionByConnectId;
|
||||
expect(lookup("connect-id", "owner")).toEqual(info);
|
||||
expect(lookup("connect-id", "other")).toBeNull();
|
||||
expect(lookup("pending", "owner")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../../src/backend/utils/logger.js", () => ({
|
||||
guacLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { GuacamoleTokenService } =
|
||||
await import("../../src/backend/token-service.js");
|
||||
|
||||
describe("GuacamoleTokenService", () => {
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
it("disables RDP pre-authentication when no credentials are configured", () => {
|
||||
const token = tokenService.createRdpToken("windows.example.test", "", "");
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.settings).toMatchObject({
|
||||
hostname: "windows.example.test",
|
||||
port: 3389,
|
||||
"ignore-cert": true,
|
||||
"disable-auth": true,
|
||||
});
|
||||
expect(decrypted?.connection.settings.username).toBeUndefined();
|
||||
expect(decrypted?.connection.settings.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps normal RDP credential authentication unchanged", () => {
|
||||
const token = tokenService.createRdpToken(
|
||||
"windows.example.test",
|
||||
"Administrator",
|
||||
"secret",
|
||||
);
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.settings).toMatchObject({
|
||||
hostname: "windows.example.test",
|
||||
username: "Administrator",
|
||||
password: "secret",
|
||||
port: 3389,
|
||||
});
|
||||
expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves recording metadata outside guacd connection settings", () => {
|
||||
const recording = {
|
||||
hostId: 42,
|
||||
userId: "user-1",
|
||||
protocol: "vnc" as const,
|
||||
path: "recording.guac",
|
||||
startedAt: "2026-07-14T00:00:00.000Z",
|
||||
};
|
||||
const token = tokenService.createVncToken(
|
||||
"vnc.example.test",
|
||||
"user",
|
||||
"secret",
|
||||
{},
|
||||
recording,
|
||||
);
|
||||
|
||||
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
|
||||
});
|
||||
|
||||
it("preserves termixMeta through the encrypt/decrypt round trip", () => {
|
||||
const termixMeta = {
|
||||
termixConnectId: "connect-1",
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
protocol: "rdp" as const,
|
||||
};
|
||||
const token = tokenService.createRdpToken(
|
||||
"windows.example.test",
|
||||
"Administrator",
|
||||
"secret",
|
||||
{},
|
||||
undefined,
|
||||
termixMeta,
|
||||
);
|
||||
|
||||
expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta);
|
||||
});
|
||||
|
||||
it("createJoinToken sets connection.join, not connection.type", () => {
|
||||
const token = tokenService.createJoinToken("guacd-conn-123", true);
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.join).toBe("guacd-conn-123");
|
||||
expect(decrypted?.connection.type).toBeUndefined();
|
||||
expect(decrypted?.connection.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("createJoinToken round-trips a read-write join through decryptToken", () => {
|
||||
const token = tokenService.createJoinToken("guacd-conn-456", false);
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.join).toBe("guacd-conn-456");
|
||||
expect(decrypted?.connection.readOnly).toBe(false);
|
||||
expect(decrypted?.recording).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GuacamoleToolbar } from "./GuacamoleToolbar.js";
|
||||
import type { GuacamoleDisplayHandle } from "./GuacamoleDisplay.js";
|
||||
import { GuacamoleToolbar } from "../../src/frontend/GuacamoleToolbar.js";
|
||||
import type { GuacamoleDisplayHandle } from "../../src/frontend/GuacamoleDisplay.js";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
+1
-1
@@ -25,7 +25,7 @@ import {
|
||||
getGuacdStatus,
|
||||
getGuacamoleConnectionId,
|
||||
getGuacamoleTokenFromHost,
|
||||
} from "./guacamole-api";
|
||||
} from "../../src/frontend/guacamole-api";
|
||||
|
||||
beforeEach(() => {
|
||||
authApiMock.get.mockClear();
|
||||
+1
-1
@@ -3,7 +3,7 @@ import {
|
||||
isPasteShortcut,
|
||||
pasteTextToRemote,
|
||||
type GuacamoleClipboardClient,
|
||||
} from "./guacamole-clipboard.js";
|
||||
} from "../../src/frontend/guacamole-clipboard.js";
|
||||
|
||||
describe("Guacamole clipboard paste", () => {
|
||||
it("recognizes Ctrl+V and Command+V without intercepting Alt+V", () => {
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getGuacamoleDisplaySize,
|
||||
readConfiguredDimension,
|
||||
} from "./guacamole-display-size";
|
||||
} from "../../src/frontend/guacamole-display-size";
|
||||
|
||||
describe("getGuacamoleDisplaySize", () => {
|
||||
it("requests native pixels and matching DPI for HiDPI RDP", () => {
|
||||
+1
-1
@@ -3,7 +3,7 @@ import {
|
||||
canUploadToRdpDrive,
|
||||
getFileDropDisposition,
|
||||
hasDraggedFiles,
|
||||
} from "./guacamole-file-drop.ts";
|
||||
} from "../../src/frontend/guacamole-file-drop.ts";
|
||||
|
||||
describe("Guacamole file drop", () => {
|
||||
it("recognizes external file drags", () => {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeUploadError } from "./guacamole-filesystem";
|
||||
import { describeUploadError } from "../../src/frontend/guacamole-filesystem";
|
||||
|
||||
const t = (key: string) => `#${key}`;
|
||||
|
||||
+4
-1
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectMetaKeyFamily, metaKeyLabels } from "./guacamole-meta-key.js";
|
||||
import {
|
||||
detectMetaKeyFamily,
|
||||
metaKeyLabels,
|
||||
} from "../../src/frontend/guacamole-meta-key.js";
|
||||
|
||||
describe("guacamole toolbar meta key labels", () => {
|
||||
it("keeps Windows labels for RDP regardless of the local device", () => {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bindPointerInput } from "./guacamole-pointer.js";
|
||||
import { bindPointerInput } from "../../src/frontend/guacamole-pointer.js";
|
||||
|
||||
function listenedEvents(): {
|
||||
element: HTMLElement;
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
MAX_SCROLL_CLICKS_PER_FLUSH,
|
||||
SCROLL_FLUSH_INTERVAL_MS,
|
||||
takeScrollClicks,
|
||||
} from "./guacamole-scroll.js";
|
||||
} from "../../src/frontend/guacamole-scroll.js";
|
||||
|
||||
describe("guacamole scroll coalescing", () => {
|
||||
it("caps a click flood so a laggy tunnel cannot queue a backlog", () => {
|
||||
+4
-2
@@ -1,7 +1,9 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
const fetchId = vi.hoisted(() => vi.fn());
|
||||
vi.mock("./guacamole-api", () => ({ getGuacamoleConnectionId: fetchId }));
|
||||
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
|
||||
vi.mock("../../src/frontend/guacamole-api", () => ({
|
||||
getGuacamoleConnectionId: fetchId,
|
||||
}));
|
||||
import { watchGuacamoleConnectionId } from "../../src/frontend/guacamole-session-id";
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchId.mockReset();
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.js";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "../../src/frontend/guacamole-websocket-url.js";
|
||||
|
||||
const httpsLocation = {
|
||||
protocol: "https:",
|
||||
+4
-1
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clampGuacamoleZoom, stepGuacamoleZoom } from "./guacamole-zoom.js";
|
||||
import {
|
||||
clampGuacamoleZoom,
|
||||
stepGuacamoleZoom,
|
||||
} from "../../src/frontend/guacamole-zoom.js";
|
||||
|
||||
describe("guacamole zoom", () => {
|
||||
it("steps in predictable quarter increments", () => {
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildRdpFile, validateNativeRdpOptions } =
|
||||
require("../../../../electron/native-rdp.cjs") as {
|
||||
buildRdpFile: (options: Record<string, unknown>) => string;
|
||||
validateNativeRdpOptions: (options: Record<string, unknown>) => unknown;
|
||||
};
|
||||
|
||||
describe("native Windows RDP", () => {
|
||||
it("creates a credential-prompting RDP file without a password", () => {
|
||||
const result = buildRdpFile({
|
||||
host: "server.example.com",
|
||||
port: 3390,
|
||||
username: "alice",
|
||||
domain: "EXAMPLE",
|
||||
password: "must-not-be-written",
|
||||
});
|
||||
|
||||
expect(result).toContain("full address:s:server.example.com:3390");
|
||||
expect(result).toContain("username:s:EXAMPLE\\alice");
|
||||
expect(result).toContain("prompt for credentials:i:1");
|
||||
expect(result).not.toContain("must-not-be-written");
|
||||
});
|
||||
|
||||
it("rejects unsafe host and port values", () => {
|
||||
expect(() =>
|
||||
validateNativeRdpOptions({ host: "bad\r\nvalue" }),
|
||||
).not.toThrow();
|
||||
expect(() => validateNativeRdpOptions({ host: "server/path" })).toThrow();
|
||||
expect(() =>
|
||||
validateNativeRdpOptions({ host: "server", port: 70000 }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { needsRdpCredentialPrompt } from "./rdp-credential-prompt";
|
||||
import { needsRdpCredentialPrompt } from "../../src/frontend/rdp-credential-prompt";
|
||||
|
||||
describe("needsRdpCredentialPrompt", () => {
|
||||
it("prompts recipients when RDP has no saved authentication", () => {
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/types/*": ["../../src/types/*"],
|
||||
"@/*": ["../../src/ui/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user