mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: finish the remote-desktop plugin (B14)
This commit is contained in:
1 parent
0af2c94983
commit
14eb558b85
215 files changed
+30029
-3921
No files matched your search
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeImportedHost,
|
||||
userDefaultParams,
|
||||
} from "../../src/backend/host-settings.js";
|
||||
|
||||
describe("normalizeImportedHost", () => {
|
||||
it("reads a Termix export's pluginSettings", () => {
|
||||
expect(
|
||||
normalizeImportedHost({
|
||||
pluginSettings: {
|
||||
"remote-desktop": {
|
||||
enableVnc: true,
|
||||
vncPort: 5901,
|
||||
guacamoleConfig: { cursor: "local" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
enableVnc: true,
|
||||
vncPort: 5901,
|
||||
guacamoleConfig: { cursor: "local" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the flat fields hosts had before 2.9.0", () => {
|
||||
expect(
|
||||
normalizeImportedHost({
|
||||
enableRdp: true,
|
||||
rdpPort: "3390",
|
||||
security: "nla",
|
||||
ignoreCert: 1,
|
||||
guacamoleConfig: JSON.stringify({ dpi: 120 }),
|
||||
enableTerminalToolbar: false,
|
||||
}),
|
||||
).toEqual({
|
||||
enableRdp: true,
|
||||
rdpPort: 3390,
|
||||
rdpSecurity: "nla",
|
||||
rdpIgnoreCert: true,
|
||||
guacamoleConfig: { dpi: 120 },
|
||||
enableToolbar: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("turns a connectionType-only host into its switch and port", () => {
|
||||
expect(
|
||||
normalizeImportedHost({ connectionType: "telnet", port: 2323 }),
|
||||
).toEqual({ enableTelnet: true, telnetPort: 2323 });
|
||||
});
|
||||
|
||||
it("writes nothing for a plain SSH row", () => {
|
||||
expect(
|
||||
normalizeImportedHost({ connectionType: "ssh", port: 22 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a port out of range", () => {
|
||||
expect(normalizeImportedHost({ enableRdp: true, rdpPort: 70000 })).toEqual({
|
||||
enableRdp: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("userDefaultParams", () => {
|
||||
it("maps the user settings onto guacd parameters, skipping inherit", () => {
|
||||
expect(
|
||||
userDefaultParams({
|
||||
colorDepth: "24",
|
||||
resizeMethod: "reconnect",
|
||||
disableCopy: "on",
|
||||
enableWallpaper: "off",
|
||||
enableDrive: "inherit",
|
||||
}),
|
||||
).toEqual({
|
||||
"color-depth": 24,
|
||||
"resize-method": "reconnect",
|
||||
"disable-copy": true,
|
||||
"enable-wallpaper": false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRdpSettings,
|
||||
resolveRdpAuthTypeForConnect,
|
||||
resolveRdpDomain,
|
||||
} from "../../src/backend/rdp-settings.js";
|
||||
|
||||
@@ -53,32 +52,3 @@ describe("buildRdpSettings", () => {
|
||||
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,318 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express, { type Router } from "express";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMockCtx,
|
||||
type MockPluginContext,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginProtocolTarget,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
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";
|
||||
|
||||
function target(
|
||||
overrides: Partial<PluginProtocolTarget["auth"]> = {},
|
||||
): PluginProtocolTarget {
|
||||
return {
|
||||
host: {
|
||||
id: 7,
|
||||
name: "Win box",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
ownerUserId: "user-1",
|
||||
jumpHosts: [],
|
||||
},
|
||||
shared: false,
|
||||
auth: {
|
||||
authType: "direct",
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
domain: "CORP",
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Server {
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
let server: Server | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function start(
|
||||
options: {
|
||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||
services?: Record<string, object>;
|
||||
capabilities?: string[];
|
||||
} = {},
|
||||
): Promise<Server> {
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: options.capabilities ?? manifest.capabilities,
|
||||
router: () => (router = express.Router()),
|
||||
protocolTargets: options.protocolTargets,
|
||||
services: options.services,
|
||||
});
|
||||
await activate(mock.ctx as PluginContext);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? "user-1");
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
const httpServer = http.createServer(app);
|
||||
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
|
||||
const { port } = httpServer.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
mock,
|
||||
async request(method, path, body) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: body ? { "content-type": "application/json" } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const tokens = new GuacamoleTokenService();
|
||||
|
||||
describe("POST /connect-host/:hostId", () => {
|
||||
it("mints a token from the resolved login and the host's own settings", async () => {
|
||||
server = await start({ protocolTargets: { "7:rdp": target() } });
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
await server.mock.ctx.settings.setHost(7, "rdpPort", 3390);
|
||||
await server.mock.ctx.settings.setHost(7, "rdpSecurity", "nla");
|
||||
await server.mock.ctx.settings.setHost(7, "guacamoleConfig", {
|
||||
"color-depth": 16,
|
||||
"enable-wallpaper": "auto",
|
||||
});
|
||||
|
||||
const response = await server.request("POST", "/connect-host/7", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.termixConnectId).toEqual(expect.any(String));
|
||||
const token = tokens.decryptToken(response.body.token);
|
||||
expect(token?.connection.settings).toMatchObject({
|
||||
hostname: "10.0.0.7",
|
||||
port: 3390,
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
domain: "CORP",
|
||||
security: "nla",
|
||||
"color-depth": 16,
|
||||
});
|
||||
expect(token?.connection.settings).not.toHaveProperty("enable-wallpaper");
|
||||
expect(token?.termixMeta).toMatchObject({
|
||||
hostId: 7,
|
||||
hostName: "Win box",
|
||||
protocol: "rdp",
|
||||
});
|
||||
expect(server.mock.audits.at(-1)).toMatchObject({
|
||||
action: "rdp_connect",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("puts the user's RDP defaults under the host's own values", async () => {
|
||||
server = await start({ protocolTargets: { "7:rdp": target() } });
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
await server.mock.ctx.settings.setHost(7, "guacamoleConfig", {
|
||||
"color-depth": 32,
|
||||
});
|
||||
await server.mock.ctx.settings.setUser("user-1", "colorDepth", "16");
|
||||
await server.mock.ctx.settings.setUser("user-1", "disableCopy", "on");
|
||||
|
||||
const response = await server.request("POST", "/connect-host/7", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
const settings = tokens.decryptToken(response.body.token)?.connection
|
||||
.settings;
|
||||
expect(settings?.["color-depth"]).toBe(32);
|
||||
expect(settings?.["disable-copy"]).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the prompted login when the host asks at connect time", async () => {
|
||||
server = await start({
|
||||
protocolTargets: { "7:rdp": target({ authType: "none", username: "" }) },
|
||||
});
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
|
||||
const response = await server.request("POST", "/connect-host/7", {
|
||||
protocol: "rdp",
|
||||
promptedUsername: "bob",
|
||||
promptedPassword: "pw",
|
||||
promptedDomain: "",
|
||||
});
|
||||
const settings = tokens.decryptToken(response.body.token)?.connection
|
||||
.settings;
|
||||
expect(settings).toMatchObject({ username: "bob", password: "pw" });
|
||||
});
|
||||
|
||||
it("refuses a protocol the host has switched off", async () => {
|
||||
server = await start({ protocolTargets: { "7:vnc": target() } });
|
||||
const response = await server.request("POST", "/connect-host/7", {
|
||||
protocol: "vnc",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("answers 404 when the caller cannot reach the host", async () => {
|
||||
server = await start();
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
const response = await server.request("POST", "/connect-host/7", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses everything while an admin has turned it off", async () => {
|
||||
server = await start({ protocolTargets: { "7:rdp": target() } });
|
||||
await server.mock.ctx.settings.set("enabled", false);
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
|
||||
expect(
|
||||
(await server.request("POST", "/connect-host/7", { protocol: "rdp" }))
|
||||
.status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/token", {
|
||||
type: "rdp",
|
||||
hostname: "10.0.0.9",
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
const status = await server.request("GET", "/status?probe=0");
|
||||
expect(status.body.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("records only when session recording is on for the host", async () => {
|
||||
const enabledFor = async (hostId: number) => hostId === 7;
|
||||
server = await start({
|
||||
protocolTargets: { "7:rdp": target() },
|
||||
services: {
|
||||
"recordings.writer": {
|
||||
enabledFor,
|
||||
createFinished: async () => ({ id: 1 }),
|
||||
},
|
||||
},
|
||||
});
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
const response = await server.request("POST", "/connect-host/7", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
const token = tokens.decryptToken(response.body.token);
|
||||
expect(token?.recording).toMatchObject({ hostId: 7, protocol: "rdp" });
|
||||
expect(token?.connection.settings["recording-name"]).toEqual(
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rest of the routes", () => {
|
||||
it("mints a quick connect token with the caller's defaults", async () => {
|
||||
server = await start();
|
||||
await server.mock.ctx.settings.setUser("user-1", "colorDepth", "24");
|
||||
const response = await server.request("POST", "/token", {
|
||||
type: "rdp",
|
||||
hostname: "10.0.0.9",
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(
|
||||
tokens.decryptToken(response.body.token)?.connection.settings,
|
||||
).toMatchObject({ hostname: "10.0.0.9", port: 3389, "color-depth": 24 });
|
||||
});
|
||||
|
||||
it("offers native RDP only in the desktop app", async () => {
|
||||
server = await start();
|
||||
expect((await server.request("GET", "/native-rdp")).body).toEqual({
|
||||
available: false,
|
||||
});
|
||||
expect(
|
||||
(await server.request("POST", "/native-rdp", { host: "10.0.0.7" }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it("does not hand out another user's session id", async () => {
|
||||
server = await start();
|
||||
const response = await server.request("GET", "/connection/unknown");
|
||||
expect(response.body).toEqual({ guacamoleConnectionId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("activate", () => {
|
||||
it("fails closed without network:serve", async () => {
|
||||
await expect(
|
||||
start({
|
||||
capabilities: manifest.capabilities.filter(
|
||||
(capability) => capability !== "network:serve",
|
||||
),
|
||||
}),
|
||||
).rejects.toThrow(/network:serve/);
|
||||
});
|
||||
|
||||
it("cannot read a host's login without credentials:read", async () => {
|
||||
server = await start({
|
||||
protocolTargets: { "7:rdp": target() },
|
||||
capabilities: manifest.capabilities.filter(
|
||||
(capability) => capability !== "credentials:read",
|
||||
),
|
||||
});
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
const response = await server.request("POST", "/connect-host/7", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
expect(server.mock.checked).toContain("credentials:read");
|
||||
});
|
||||
|
||||
it("provides sessions.live for rdp, vnc and telnet", async () => {
|
||||
server = await start();
|
||||
expect([...server.mock.services.keys()].sort()).toEqual(
|
||||
expect.arrayContaining([
|
||||
"sessions.live#rdp",
|
||||
"sessions.live#telnet",
|
||||
"sessions.live#vnc",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RemoteSessions } from "../../src/backend/sessions.js";
|
||||
import { createLiveSessions } from "../../src/backend/live-sessions.js";
|
||||
import { GuacamoleTokenService } from "../../src/backend/token-service.js";
|
||||
|
||||
const meta = {
|
||||
termixConnectId: "c1",
|
||||
hostId: 7,
|
||||
hostName: "Win box",
|
||||
ownerUserId: "user-1",
|
||||
protocol: "rdp" as const,
|
||||
tabInstanceId: "tab-1",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("RemoteSessions", () => {
|
||||
it("moves parked resources onto the session and releases them on close", () => {
|
||||
const sessions = new RemoteSessions();
|
||||
const tunnel = vi.fn();
|
||||
const tracking = vi.fn();
|
||||
sessions.park("c1", [tunnel]);
|
||||
sessions.opened(meta, "g1", [tracking]);
|
||||
|
||||
expect(sessions.byConnect("c1")?.guacamoleConnectionId).toBe("g1");
|
||||
expect(sessions.byGuacamole("g1")?.hostName).toBe("Win box");
|
||||
expect(tunnel).not.toHaveBeenCalled();
|
||||
|
||||
sessions.closed("c1");
|
||||
expect(tunnel).toHaveBeenCalledOnce();
|
||||
expect(tracking).toHaveBeenCalledOnce();
|
||||
expect(sessions.byGuacamole("g1")).toBeNull();
|
||||
});
|
||||
|
||||
it("releases a tunnel guacd never used", () => {
|
||||
vi.useFakeTimers();
|
||||
const sessions = new RemoteSessions(1000);
|
||||
const tunnel = vi.fn();
|
||||
sessions.park("c1", [tunnel]);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(tunnel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("releases everything on clear, and one failing close does not stop the rest", () => {
|
||||
const sessions = new RemoteSessions();
|
||||
const failing = vi.fn(() => {
|
||||
throw new Error("already closed");
|
||||
});
|
||||
const pending = vi.fn();
|
||||
const open = vi.fn();
|
||||
sessions.park("c2", [failing, pending]);
|
||||
sessions.park("c1", [open]);
|
||||
sessions.opened(meta, "g1");
|
||||
sessions.clear();
|
||||
expect(pending).toHaveBeenCalledOnce();
|
||||
expect(open).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessions.live providers", () => {
|
||||
const tokens = new GuacamoleTokenService();
|
||||
|
||||
it("describes a live session of its own protocol only", () => {
|
||||
const sessions = new RemoteSessions();
|
||||
sessions.opened(meta, "g1");
|
||||
const deps = { sessions, tokens, endSession: vi.fn(() => true) };
|
||||
const rdp = createLiveSessions("rdp", deps);
|
||||
const vnc = createLiveSessions("vnc", deps);
|
||||
|
||||
expect(rdp.getSession("g1")).toMatchObject({
|
||||
id: "g1",
|
||||
userId: "user-1",
|
||||
hostId: 7,
|
||||
hostName: "Win box",
|
||||
isConnected: true,
|
||||
tabInstanceId: "tab-1",
|
||||
});
|
||||
expect(vnc.getSession("g1")).toBeNull();
|
||||
});
|
||||
|
||||
it("mints a join token for a viewer and refuses an unknown session", () => {
|
||||
const sessions = new RemoteSessions();
|
||||
sessions.opened(meta, "g1");
|
||||
const rdp = createLiveSessions("rdp", {
|
||||
sessions,
|
||||
tokens,
|
||||
endSession: vi.fn(() => true),
|
||||
});
|
||||
|
||||
const token = tokens.decryptToken(rdp.createViewerToken("g1", true));
|
||||
expect(token?.connection).toMatchObject({ join: "g1", readOnly: true });
|
||||
expect(token?.recording).toBeUndefined();
|
||||
expect(() => rdp.createViewerToken("nope", false)).toThrow();
|
||||
});
|
||||
|
||||
it("ends the session for its owner", () => {
|
||||
const sessions = new RemoteSessions();
|
||||
sessions.opened(meta, "g1");
|
||||
const endSession = vi.fn(() => true);
|
||||
const rdp = createLiveSessions("rdp", { sessions, tokens, endSession });
|
||||
rdp.ownerEndSession("g1", "owner ended");
|
||||
expect(endSession).toHaveBeenCalledWith("g1");
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
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();
|
||||
});
|
||||
@@ -1,20 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } 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");
|
||||
import { GuacamoleTokenService } from "../../src/backend/token-service.js";
|
||||
|
||||
describe("GuacamoleTokenService", () => {
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
const tokenService = new GuacamoleTokenService();
|
||||
|
||||
it("disables RDP pre-authentication when no credentials are configured", () => {
|
||||
const token = tokenService.createRdpToken("windows.example.test", "", "");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
@@ -45,9 +45,39 @@ describe(`${manifest.id} activate`, () => {
|
||||
expect(app.registered.panels()).toEqual([]);
|
||||
expect(app.registered.railItems()).toEqual([]);
|
||||
expect(app.registered.hostActions()).toEqual([]);
|
||||
expect(app.registered.hostProtocols()).toEqual([]);
|
||||
expect(app.registered.hostEditorSections()).toEqual([]);
|
||||
expect(app.registered.dashboardCards()).toEqual([]);
|
||||
expect(app.registered.settingsComponents()).toEqual([]);
|
||||
expect(app.registered.actions()).toEqual([]);
|
||||
});
|
||||
|
||||
it("adds RDP, VNC and Telnet as host protocols while turned on", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: { get: async () => ({ data: { enabled: true } }) } as never,
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(rendered!.registered.hostProtocols().sort()).toEqual([
|
||||
"rdp",
|
||||
"telnet",
|
||||
"vnc",
|
||||
]),
|
||||
);
|
||||
expect(rendered.registered.hostActions().map((a) => a.id)).toEqual(
|
||||
expect.arrayContaining(["rdp", "vnc", "telnet"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers no way in while an admin has turned it off", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: { get: async () => ({ data: { enabled: false } }) } as never,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(rendered.registered.hostProtocols()).toEqual([]);
|
||||
expect(rendered.registered.hostActions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -11,22 +11,27 @@ const remoteApiMock = vi.hoisted(() => ({
|
||||
const isElectronMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const resolveRemoteHostIdMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: authApiMock,
|
||||
getRemoteGuacamoleApi: () => remoteApiMock,
|
||||
vi.mock("@termix/plugin-sdk/ui", () => ({
|
||||
isElectron: isElectronMock,
|
||||
handleApiError: (error: unknown) => error,
|
||||
}));
|
||||
vi.mock("@/lib/remote-server-api", () => ({
|
||||
resolveRemoteHostId: resolveRemoteHostIdMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
setRemoteDesktopApp,
|
||||
getGuacdStatus,
|
||||
getGuacamoleConnectionId,
|
||||
getGuacamoleTokenFromHost,
|
||||
} from "../../src/frontend/guacamole-api";
|
||||
|
||||
// Mirrors app.apiFor: the remote server only for a remote origin in the
|
||||
// desktop app, the ordinary client otherwise.
|
||||
setRemoteDesktopApp({
|
||||
apiFor: (origin) =>
|
||||
(origin === "remote" && isElectronMock()
|
||||
? remoteApiMock
|
||||
: authApiMock) as never,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
authApiMock.get.mockClear();
|
||||
authApiMock.post.mockClear();
|
||||
@@ -42,9 +47,7 @@ describe("guacamole API origin", () => {
|
||||
await getGuacdStatus("remote");
|
||||
await getGuacamoleTokenFromHost(9, "remote", "vnc");
|
||||
|
||||
expect(authApiMock.get).toHaveBeenCalledWith(
|
||||
"/plugin-api/remote-desktop/status",
|
||||
);
|
||||
expect(authApiMock.get).toHaveBeenCalledWith("/status");
|
||||
expect(authApiMock.post).toHaveBeenCalledOnce();
|
||||
expect(remoteApiMock.get).not.toHaveBeenCalled();
|
||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||
@@ -60,9 +63,7 @@ describe("guacamole API origin", () => {
|
||||
|
||||
expect(status.guacd.status).toBe("connected");
|
||||
expect(token.token).toBe("remote-token");
|
||||
expect(remoteApiMock.get).toHaveBeenCalledWith(
|
||||
"/plugin-api/remote-desktop/status",
|
||||
);
|
||||
expect(remoteApiMock.get).toHaveBeenCalledWith("/status");
|
||||
expect(remoteApiMock.post).toHaveBeenCalledOnce();
|
||||
expect(authApiMock.get).not.toHaveBeenCalled();
|
||||
expect(authApiMock.post).not.toHaveBeenCalled();
|
||||
@@ -84,15 +85,12 @@ describe("guacamole API origin", () => {
|
||||
"host-sync-id",
|
||||
);
|
||||
|
||||
expect(remoteApiMock.post).toHaveBeenCalledWith(
|
||||
"/plugin-api/remote-desktop/connect-host/41",
|
||||
{
|
||||
protocol: "rdp",
|
||||
promptedUsername: "admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "EXAMPLE",
|
||||
},
|
||||
);
|
||||
expect(remoteApiMock.post).toHaveBeenCalledWith("/connect-host/41", {
|
||||
protocol: "rdp",
|
||||
promptedUsername: "admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "EXAMPLE",
|
||||
});
|
||||
expect(resolveRemoteHostIdMock).toHaveBeenCalledWith("host-sync-id");
|
||||
});
|
||||
|
||||
@@ -105,15 +103,12 @@ describe("guacamole API origin", () => {
|
||||
domain: "",
|
||||
});
|
||||
|
||||
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||
"/plugin-api/remote-desktop/connect-host/9",
|
||||
{
|
||||
protocol: "rdp",
|
||||
promptedUsername: "local-admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "",
|
||||
},
|
||||
);
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/connect-host/9", {
|
||||
protocol: "rdp",
|
||||
promptedUsername: "local-admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "",
|
||||
});
|
||||
});
|
||||
|
||||
// Support#1240: a host set to "This Device" is served by the embedded
|
||||
@@ -125,12 +120,9 @@ describe("guacamole API origin", () => {
|
||||
const token = await getGuacamoleTokenFromHost(9, "local", "rdp");
|
||||
|
||||
expect(token.token).toBe("local-token");
|
||||
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||
"/plugin-api/remote-desktop/connect-host/9",
|
||||
{
|
||||
protocol: "rdp",
|
||||
},
|
||||
);
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/connect-host/9", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||
expect(resolveRemoteHostIdMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -141,9 +133,7 @@ describe("guacamole API origin", () => {
|
||||
const status = await getGuacdStatus("local");
|
||||
|
||||
expect(status.guacd.status).toBe("disconnected");
|
||||
expect(authApiMock.get).toHaveBeenCalledWith(
|
||||
"/plugin-api/remote-desktop/status",
|
||||
);
|
||||
expect(authApiMock.get).toHaveBeenCalledWith("/status");
|
||||
expect(remoteApiMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -177,9 +167,8 @@ it("looks up the session on the backend that issued its token", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
const signal = new AbortController().signal;
|
||||
await getGuacamoleConnectionId("connect/id", "remote", signal);
|
||||
expect(remoteApiMock.get).toHaveBeenCalledWith(
|
||||
"/plugin-api/remote-desktop/connection/connect%2Fid",
|
||||
{ signal },
|
||||
);
|
||||
expect(remoteApiMock.get).toHaveBeenCalledWith("/connection/connect%2Fid", {
|
||||
signal,
|
||||
});
|
||||
expect(authApiMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
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,41 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createQuickConnectHost } from "@/sidebar/quick-connect-host";
|
||||
import { quickConnectGuacHost } from "../../src/frontend/quick-connect-guac-host";
|
||||
|
||||
// The shape core's Quick Connect builds for a plugin protocol: the switch and
|
||||
// port in this plugin's host settings, the login on the protocol's fields.
|
||||
function quickHost(protocol: "rdp" | "vnc", extra: Record<string, unknown>) {
|
||||
return {
|
||||
id: "quick-connect-1",
|
||||
name: "admin@10.0.0.2",
|
||||
ip: "10.0.0.2",
|
||||
pluginSettings: {
|
||||
"remote-desktop": {
|
||||
[protocol === "rdp" ? "enableRdp" : "enableVnc"]: true,
|
||||
[protocol === "rdp" ? "rdpPort" : "vncPort"]:
|
||||
protocol === "rdp" ? 3390 : 5901,
|
||||
},
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("quickConnectGuacHost", () => {
|
||||
it("carries an RDP quick connect's address and credentials", () => {
|
||||
const host = createQuickConnectHost({
|
||||
ip: "10.0.0.2",
|
||||
port: 3390,
|
||||
username: "admin",
|
||||
authType: "password",
|
||||
password: "pw",
|
||||
protocol: "rdp",
|
||||
domain: "CORP",
|
||||
});
|
||||
expect(quickConnectGuacHost(host)).toMatchObject({
|
||||
expect(
|
||||
quickConnectGuacHost(
|
||||
quickHost("rdp", {
|
||||
rdpUser: "admin",
|
||||
rdpPassword: "pw",
|
||||
domain: "CORP",
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
ip: "10.0.0.2",
|
||||
connectionType: "rdp",
|
||||
rdpPort: 3390,
|
||||
rdpUser: "admin",
|
||||
rdpPassword: "pw",
|
||||
port: 3390,
|
||||
username: "admin",
|
||||
password: "pw",
|
||||
domain: "CORP",
|
||||
});
|
||||
});
|
||||
|
||||
it("puts a VNC quick connect's password on the VNC fields", () => {
|
||||
const host = createQuickConnectHost({
|
||||
ip: "10.0.0.3",
|
||||
port: 5901,
|
||||
username: "",
|
||||
authType: "password",
|
||||
password: "vncpw",
|
||||
protocol: "vnc",
|
||||
});
|
||||
expect(quickConnectGuacHost(host)).toMatchObject({
|
||||
it("takes a VNC quick connect's login from the VNC fields", () => {
|
||||
expect(
|
||||
quickConnectGuacHost(quickHost("vnc", { vncPassword: "vncpw" })),
|
||||
).toMatchObject({
|
||||
connectionType: "vnc",
|
||||
vncPort: 5901,
|
||||
vncPassword: "vncpw",
|
||||
port: 5901,
|
||||
password: "vncpw",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user