mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -1,18 +1,20 @@
|
||||
import GuacamoleLite from "guacamole-lite";
|
||||
import { guacLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
GuacamoleTokenService,
|
||||
type GuacamoleRecordingMetadata,
|
||||
} from "./token-service.js";
|
||||
import { getCurrentSettingValue } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveGuacdOptions } from "../../../../src/backend/utils/guacd-config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { Duplex } from "stream";
|
||||
import type {
|
||||
GuacamoleRecordingMetadata,
|
||||
GuacamoleTokenService,
|
||||
TermixGuacMeta,
|
||||
} from "./token-service.js";
|
||||
import type { GuacdOptions } from "./guacd-config.js";
|
||||
import type { RemoteSessions } from "./sessions.js";
|
||||
import { errorMessage, type RemoteDesktopLogger } from "./log.js";
|
||||
|
||||
/** The recordings.writer service, when the session-recording plugin provides it. */
|
||||
export interface RecordingsWriter {
|
||||
enabledFor: (hostId: number) => Promise<boolean>;
|
||||
createFinished: (input: {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
@@ -25,147 +27,38 @@ export interface RecordingsWriter {
|
||||
}) => Promise<{ id: number }>;
|
||||
}
|
||||
|
||||
let pluginRecordings: RecordingsWriter | null = null;
|
||||
|
||||
export function setPluginRecordings(recordings: RecordingsWriter | null): void {
|
||||
pluginRecordings = recordings;
|
||||
}
|
||||
|
||||
function readGuacdOptions(): { host: string; port: number } {
|
||||
let dbUrl: string | undefined;
|
||||
try {
|
||||
dbUrl = getCurrentSettingValue("guac_url") ?? undefined;
|
||||
} catch {
|
||||
// DB not available yet, use env var defaults
|
||||
}
|
||||
return resolveGuacdOptions(dbUrl);
|
||||
}
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
const GUACAMOLE_RECORDINGS_DIR =
|
||||
process.env.GUACD_RECORDING_BACKEND_PATH ||
|
||||
path.join(DATA_DIR, "session_recordings", "guacamole");
|
||||
|
||||
type GuacamoleClientConnection = {
|
||||
/** Where the backend reads guacd's recordings from. */
|
||||
export function recordingsDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return (
|
||||
env.GUACD_RECORDING_BACKEND_PATH ||
|
||||
path.join(DATA_DIR, "session_recordings", "guacamole")
|
||||
);
|
||||
}
|
||||
|
||||
type ClientConnection = {
|
||||
guacamoleConnectionId?: string;
|
||||
connectionSettings?: {
|
||||
connection?: { type?: string; join?: string; readOnly?: boolean };
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
termixMeta?: {
|
||||
termixConnectId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
};
|
||||
termixMeta?: TermixGuacMeta;
|
||||
};
|
||||
close?: () => void;
|
||||
};
|
||||
|
||||
export interface GuacSessionInfo {
|
||||
guacamoleConnectionId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
// Keyed by termixConnectId (routes.ts's correlation id), populated once the
|
||||
// primary connection's guacd handshake completes.
|
||||
const guacSessionByConnectId = new Map<string, GuacSessionInfo>();
|
||||
// Keyed by guacd's own guacamoleConnectionId, for join-time lookups.
|
||||
const guacSessionByGuacamoleId = new Map<string, GuacSessionInfo>();
|
||||
export function getGuacSessionByConnectId(
|
||||
connectId: string,
|
||||
userId: string,
|
||||
): GuacSessionInfo | null {
|
||||
const info = guacSessionByConnectId.get(connectId);
|
||||
return info?.ownerUserId === userId ? info : null;
|
||||
}
|
||||
|
||||
export function getGuacSessionInfo(
|
||||
guacamoleConnectionId: string,
|
||||
): GuacSessionInfo | null {
|
||||
return guacSessionByGuacamoleId.get(guacamoleConnectionId) ?? null;
|
||||
}
|
||||
|
||||
async function persistGuacamoleRecording(
|
||||
clientConnection: GuacamoleClientConnection,
|
||||
): Promise<void> {
|
||||
const recording = clientConnection.connectionSettings?.recording;
|
||||
if (!recording) return;
|
||||
|
||||
const resolvedPath = path.resolve(GUACAMOLE_RECORDINGS_DIR, recording.path);
|
||||
const allowedBase = `${path.resolve(GUACAMOLE_RECORDINGS_DIR)}${path.sep}`;
|
||||
if (!resolvedPath.startsWith(allowedBase)) return;
|
||||
|
||||
// guacd may flush/rename the recording just after the websocket closes.
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 10 && !fs.existsSync(resolvedPath);
|
||||
attempt++
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
const guacdPath = recording.guacdPath ?? GUACAMOLE_RECORDINGS_DIR;
|
||||
guacLogger.warn("Guacamole recording file was not found", {
|
||||
operation: "guac_recording_missing",
|
||||
hostId: recording.hostId,
|
||||
path: resolvedPath,
|
||||
guacdPath,
|
||||
hint:
|
||||
"guacd writes the recording to guacdPath, the backend reads it from path. " +
|
||||
"When guacd runs in its own container these must be the same volume — set " +
|
||||
"GUACD_RECORDING_PATH to guacd's mount point and GUACD_RECORDING_BACKEND_PATH to this one.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pluginRecordings) {
|
||||
guacLogger.warn(
|
||||
"Session Recording plugin is off; guacamole recording not saved to the log",
|
||||
{ operation: "guac_recording_no_writer", hostId: recording.hostId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const endedAt = new Date();
|
||||
const startedAt = new Date(recording.startedAt);
|
||||
await pluginRecordings.createFinished({
|
||||
hostId: recording.hostId,
|
||||
userId: recording.userId,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
duration: Math.max(
|
||||
0,
|
||||
Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000),
|
||||
),
|
||||
recordingPath: resolvedPath,
|
||||
protocol: recording.protocol,
|
||||
format: "guacamole",
|
||||
});
|
||||
}
|
||||
|
||||
// noServer: core owns the listening socket. guacamole-lite passes this object
|
||||
// straight to ws, and exposes the WebSocketServer it builds, so the plugin can
|
||||
// hand it an upgrade from /plugin-ws/remote-desktop/display. There is no
|
||||
// guacamole port any more.
|
||||
const websocketOptions = {
|
||||
noServer: true,
|
||||
};
|
||||
|
||||
const clientOptions = {
|
||||
const clientOptions = (
|
||||
tokens: GuacamoleTokenService,
|
||||
log: RemoteDesktopLogger,
|
||||
) => ({
|
||||
crypt: {
|
||||
cypher: "AES-256-CBC",
|
||||
key: tokenService.getEncryptionKey(),
|
||||
key: tokens.getEncryptionKey(),
|
||||
},
|
||||
log: {
|
||||
level: "ERRORS",
|
||||
stdLog: (...args: unknown[]) => {
|
||||
guacLogger.info(args.join(" "));
|
||||
},
|
||||
errorLog: (...args: unknown[]) => {
|
||||
guacLogger.error(args.join(" "));
|
||||
},
|
||||
stdLog: (...args: unknown[]) => log.info(args.join(" ")),
|
||||
errorLog: (...args: unknown[]) => log.error(args.join(" ")),
|
||||
},
|
||||
allowedUnencryptedConnectionSettings: {
|
||||
rdp: ["width", "height", "dpi"],
|
||||
@@ -202,150 +95,224 @@ const clientOptions = {
|
||||
"terminal-type": "xterm-256color",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const _origConsoleLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
const msg = args[0];
|
||||
if (typeof msg === "string" && msg.startsWith("New client connection"))
|
||||
return;
|
||||
_origConsoleLog(...args);
|
||||
};
|
||||
export interface GuacamoleServerDeps {
|
||||
log: RemoteDesktopLogger;
|
||||
tokens: GuacamoleTokenService;
|
||||
sessions: RemoteSessions;
|
||||
guacd: () => GuacdOptions;
|
||||
recordings: () => RecordingsWriter | null;
|
||||
/** A socket event has no request behind it, so the row is written as the session's user. */
|
||||
asUser: <T>(userId: string, fn: () => Promise<T>) => Promise<T>;
|
||||
/** Called when a primary session opens; returns what to release on close. */
|
||||
onOpen?: (hostId: number) => () => void;
|
||||
}
|
||||
|
||||
function createGuacServer(): GuacamoleLite {
|
||||
const guacdOptions = readGuacdOptions();
|
||||
const server = new GuacamoleLite(
|
||||
websocketOptions,
|
||||
guacdOptions,
|
||||
clientOptions,
|
||||
);
|
||||
export interface GuacamoleServer {
|
||||
handleUpgrade: (
|
||||
request: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
) => void;
|
||||
/** Rebuilds the server so a changed guacd address takes effect. */
|
||||
restart: () => void;
|
||||
/** Closes a session and everyone watching it. */
|
||||
endSession: (guacamoleConnectionId: string) => boolean;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
server.on("open", (clientConnection: GuacamoleClientConnection) => {
|
||||
guacLogger.info("Guacamole connection opened", {
|
||||
operation: "guac_connection_open",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
/**
|
||||
* guacamole-lite in noServer mode: core owns the listening socket and hands
|
||||
* upgrades from /plugin-ws/remote-desktop/display to its WebSocketServer.
|
||||
*/
|
||||
export function createGuacamoleServer(
|
||||
deps: GuacamoleServerDeps,
|
||||
): GuacamoleServer {
|
||||
const { log, sessions } = deps;
|
||||
let server: GuacamoleLite | null = null;
|
||||
|
||||
const termixMeta = clientConnection.connectionSettings?.termixMeta;
|
||||
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
|
||||
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
|
||||
const persistRecording = async (connection: ClientConnection) => {
|
||||
const recording = connection.connectionSettings?.recording;
|
||||
if (!recording) return;
|
||||
|
||||
if (!isJoin && termixMeta && guacamoleConnectionId) {
|
||||
const info: GuacSessionInfo = {
|
||||
guacamoleConnectionId,
|
||||
hostId: termixMeta.hostId,
|
||||
ownerUserId: termixMeta.ownerUserId,
|
||||
protocol: termixMeta.protocol,
|
||||
openedAt: Date.now(),
|
||||
};
|
||||
guacSessionByConnectId.set(termixMeta.termixConnectId, info);
|
||||
guacSessionByGuacamoleId.set(guacamoleConnectionId, info);
|
||||
const dir = recordingsDir();
|
||||
const resolvedPath = path.resolve(dir, recording.path);
|
||||
if (!resolvedPath.startsWith(`${path.resolve(dir)}${path.sep}`)) return;
|
||||
|
||||
// guacd may flush or rename the recording just after the socket closes.
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 10 && !fs.existsSync(resolvedPath);
|
||||
attempt++
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
});
|
||||
|
||||
server.on("close", (clientConnection: GuacamoleClientConnection) => {
|
||||
guacLogger.info("Guacamole connection closed", {
|
||||
operation: "guac_connection_close",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
|
||||
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
|
||||
const termixMeta = clientConnection.connectionSettings?.termixMeta;
|
||||
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
|
||||
if (!isJoin && termixMeta && guacamoleConnectionId) {
|
||||
guacSessionByConnectId.delete(termixMeta.termixConnectId);
|
||||
guacSessionByGuacamoleId.delete(guacamoleConnectionId);
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
log.warn("Guacamole recording file was not found", {
|
||||
operation: "guac_recording_missing",
|
||||
hostId: recording.hostId,
|
||||
path: resolvedPath,
|
||||
guacdPath: recording.guacdPath ?? dir,
|
||||
hint:
|
||||
"guacd writes the recording to guacdPath, the backend reads it from path. " +
|
||||
"When guacd runs in its own container these must be the same volume: set " +
|
||||
"GUACD_RECORDING_PATH to guacd's mount point and GUACD_RECORDING_BACKEND_PATH to this one.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
persistGuacamoleRecording(clientConnection).catch((error) => {
|
||||
guacLogger.error("Failed to persist Guacamole recording", error, {
|
||||
operation: "guac_recording_persist_error",
|
||||
const writer = await deps.asUser(recording.userId, async () =>
|
||||
deps.recordings(),
|
||||
);
|
||||
if (!writer) {
|
||||
log.warn(
|
||||
"Session Recording is off; the recording was kept on disk only",
|
||||
{
|
||||
operation: "guac_recording_no_writer",
|
||||
hostId: recording.hostId,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const endedAt = new Date();
|
||||
const startedAt = new Date(recording.startedAt);
|
||||
await deps.asUser(recording.userId, () =>
|
||||
writer.createFinished({
|
||||
hostId: recording.hostId,
|
||||
userId: recording.userId,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
duration: Math.max(
|
||||
0,
|
||||
Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000),
|
||||
),
|
||||
recordingPath: resolvedPath,
|
||||
protocol: recording.protocol,
|
||||
format: "guacamole",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const build = (): GuacamoleLite => {
|
||||
// guacamole-lite fills in port 8080 unless it sees a server key, and ws
|
||||
// refuses a port next to noServer, so the port is cleared explicitly.
|
||||
const guacamole = new GuacamoleLite(
|
||||
{ noServer: true, port: undefined },
|
||||
deps.guacd(),
|
||||
clientOptions(deps.tokens, log),
|
||||
);
|
||||
// It also installs its own SIGTERM and SIGINT handlers. Core decides
|
||||
// when the process stops and closes this through deactivate instead.
|
||||
const handlers = guacamole as unknown as {
|
||||
sigTermHandler?: () => void;
|
||||
sigIntHandler?: () => void;
|
||||
};
|
||||
if (handlers.sigTermHandler) {
|
||||
process.off("SIGTERM", handlers.sigTermHandler);
|
||||
}
|
||||
if (handlers.sigIntHandler) {
|
||||
process.off("SIGINT", handlers.sigIntHandler);
|
||||
}
|
||||
|
||||
guacamole.on("open", (connection: ClientConnection) => {
|
||||
const meta = connection.connectionSettings?.termixMeta;
|
||||
const guacamoleConnectionId = connection.guacamoleConnectionId;
|
||||
const isJoin = !!connection.connectionSettings?.connection?.join;
|
||||
if (isJoin || !meta || !guacamoleConnectionId) return;
|
||||
const release = deps.onOpen?.(meta.hostId);
|
||||
sessions.opened(meta, guacamoleConnectionId, release ? [release] : []);
|
||||
});
|
||||
|
||||
guacamole.on("close", (connection: ClientConnection) => {
|
||||
const meta = connection.connectionSettings?.termixMeta;
|
||||
const isJoin = !!connection.connectionSettings?.connection?.join;
|
||||
if (!isJoin && meta) sessions.closed(meta.termixConnectId);
|
||||
persistRecording(connection).catch((error) => {
|
||||
log.error("Failed to persist Guacamole recording", {
|
||||
operation: "guac_recording_persist_error",
|
||||
error: errorMessage(error),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
server.on(
|
||||
"error",
|
||||
(clientConnection: GuacamoleClientConnection, error: Error) => {
|
||||
guacLogger.error("Guacamole connection error", error, {
|
||||
guacamole.on("error", (connection: ClientConnection, error: Error) => {
|
||||
log.error("Guacamole connection error", {
|
||||
operation: "guac_connection_error",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
type: connection.connectionSettings?.connection?.type,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
});
|
||||
|
||||
return guacamole;
|
||||
};
|
||||
|
||||
const closeServer = () => {
|
||||
if (!server) return;
|
||||
try {
|
||||
server.close();
|
||||
} catch (error) {
|
||||
log.error("Error closing the Guacamole server", {
|
||||
error: errorMessage(error),
|
||||
});
|
||||
}
|
||||
server = null;
|
||||
};
|
||||
|
||||
server = build();
|
||||
|
||||
return {
|
||||
handleUpgrade(request, socket, head) {
|
||||
const wss = (
|
||||
server as unknown as {
|
||||
webSocketServer?: {
|
||||
handleUpgrade: (
|
||||
request: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
done: (ws: unknown) => void,
|
||||
) => void;
|
||||
emit: (event: string, ...args: unknown[]) => void;
|
||||
};
|
||||
} | null
|
||||
)?.webSocketServer;
|
||||
if (!wss) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, request);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
restart() {
|
||||
if (!server) return;
|
||||
closeServer();
|
||||
server = build();
|
||||
},
|
||||
|
||||
endSession(guacamoleConnectionId) {
|
||||
const active = (
|
||||
server as unknown as {
|
||||
activeConnections?: Map<unknown, ClientConnection>;
|
||||
} | null
|
||||
)?.activeConnections;
|
||||
if (!active) return false;
|
||||
let closed = false;
|
||||
for (const connection of [...active.values()]) {
|
||||
const join = connection.connectionSettings?.connection?.join;
|
||||
if (
|
||||
connection.guacamoleConnectionId === guacamoleConnectionId ||
|
||||
join === guacamoleConnectionId
|
||||
) {
|
||||
connection.close?.();
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
return closed;
|
||||
},
|
||||
|
||||
close: closeServer,
|
||||
};
|
||||
}
|
||||
|
||||
let guacServer: GuacamoleLite | null = null;
|
||||
|
||||
/**
|
||||
* Builds the guacamole-lite server. Called from this plugin's activate().
|
||||
* Nothing binds a port: handleGuacamoleUpgrade below feeds it connections.
|
||||
*/
|
||||
export async function startGuacamoleService(): Promise<void> {
|
||||
guacServer = createGuacServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands one upgrade to guacamole-lite's own WebSocketServer.
|
||||
*
|
||||
* guacamole-lite constructs that server internally and has no "attach to this
|
||||
* http server" option, so giving it the upgrade is the only way in. Core has
|
||||
* already authenticated the request by the time this runs.
|
||||
*/
|
||||
export function handleGuacamoleUpgrade(
|
||||
request: import("http").IncomingMessage,
|
||||
socket: import("stream").Duplex,
|
||||
head: Buffer,
|
||||
): void {
|
||||
const server = guacServer as unknown as {
|
||||
webSocketServer?: {
|
||||
handleUpgrade: (
|
||||
request: import("http").IncomingMessage,
|
||||
socket: import("stream").Duplex,
|
||||
head: Buffer,
|
||||
done: (ws: unknown) => void,
|
||||
) => void;
|
||||
emit: (event: string, ...args: unknown[]) => void;
|
||||
};
|
||||
} | null;
|
||||
|
||||
const wss = server?.webSocketServer;
|
||||
if (!wss) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the guacamole-lite WebSocket server. Called from this plugin's
|
||||
* deactivate().
|
||||
*/
|
||||
export async function stopGuacamoleService(): Promise<void> {
|
||||
if (!guacServer) return;
|
||||
try {
|
||||
guacServer.close();
|
||||
} catch (err) {
|
||||
guacLogger.error("Error closing guac server during shutdown", err as Error);
|
||||
}
|
||||
guacServer = null;
|
||||
}
|
||||
|
||||
export async function restartGuacServer(): Promise<void> {
|
||||
// A no-op while the plugin is disabled: creating a new server here would
|
||||
// silently resurrect the WS server behind deactivate()'s back.
|
||||
if (!guacServer) return;
|
||||
try {
|
||||
guacServer.close();
|
||||
} catch (err) {
|
||||
guacLogger.error("Error closing guac server during restart", err as Error);
|
||||
}
|
||||
guacServer = createGuacServer();
|
||||
}
|
||||
|
||||
export { guacServer, tokenService };
|
||||
@@ -0,0 +1,86 @@
|
||||
export type GuacdOptions = {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
const DEFAULT_GUACD_OPTIONS: GuacdOptions = {
|
||||
host: "localhost",
|
||||
port: 4822,
|
||||
};
|
||||
|
||||
function parsePort(value: string | undefined, fallback: number) {
|
||||
const port = parseInt(value || "", 10);
|
||||
return Number.isFinite(port) ? port : fallback;
|
||||
}
|
||||
|
||||
export function parseGuacdUrl(
|
||||
value: string,
|
||||
fallback: GuacdOptions = DEFAULT_GUACD_OPTIONS,
|
||||
): GuacdOptions {
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (raw.includes("://")) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return {
|
||||
host: url.hostname || fallback.host,
|
||||
port: parsePort(url.port, fallback.port),
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const bracketedIpv6 = raw.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
||||
if (bracketedIpv6) {
|
||||
return {
|
||||
host: bracketedIpv6[1],
|
||||
port: parsePort(bracketedIpv6[2], fallback.port),
|
||||
};
|
||||
}
|
||||
|
||||
const [host, port] = raw.split(":");
|
||||
return {
|
||||
host: host || fallback.host,
|
||||
port: parsePort(port, fallback.port),
|
||||
};
|
||||
}
|
||||
|
||||
export function getGuacdEnvOptions(): GuacdOptions | null {
|
||||
if (process.env.GUACD_URL) {
|
||||
return parseGuacdUrl(process.env.GUACD_URL);
|
||||
}
|
||||
|
||||
if (!process.env.GUACD_HOST && !process.env.GUACD_PORT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
host: process.env.GUACD_HOST || DEFAULT_GUACD_OPTIONS.host,
|
||||
port: parsePort(process.env.GUACD_PORT, DEFAULT_GUACD_OPTIONS.port),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGuacdOptions(dbUrl?: string | null): GuacdOptions {
|
||||
const envOptions = getGuacdEnvOptions();
|
||||
if (envOptions) {
|
||||
return envOptions;
|
||||
}
|
||||
|
||||
if (dbUrl) {
|
||||
return parseGuacdUrl(dbUrl);
|
||||
}
|
||||
|
||||
return DEFAULT_GUACD_OPTIONS;
|
||||
}
|
||||
|
||||
export function formatGuacdOptions(options: GuacdOptions): string {
|
||||
return `${options.host}:${options.port}`;
|
||||
}
|
||||
|
||||
export function getDefaultGuacdUrl(): string {
|
||||
return formatGuacdOptions(resolveGuacdOptions());
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type RemoteProtocol = "rdp" | "vnc" | "telnet";
|
||||
|
||||
export const PLUGIN_ID = "remote-desktop";
|
||||
|
||||
export interface RemoteDesktopHostSettings {
|
||||
enableRdp: boolean;
|
||||
enableVnc: boolean;
|
||||
enableTelnet: boolean;
|
||||
rdpPort: number;
|
||||
vncPort: number;
|
||||
telnetPort: number;
|
||||
rdpSecurity: string;
|
||||
rdpIgnoreCert: boolean;
|
||||
guacamoleConfig: Record<string, unknown>;
|
||||
enableToolbar: boolean;
|
||||
}
|
||||
|
||||
export const ENABLE_KEY: Record<
|
||||
RemoteProtocol,
|
||||
keyof RemoteDesktopHostSettings
|
||||
> = { rdp: "enableRdp", vnc: "enableVnc", telnet: "enableTelnet" };
|
||||
|
||||
export const PORT_KEY: Record<RemoteProtocol, keyof RemoteDesktopHostSettings> =
|
||||
{ rdp: "rdpPort", vnc: "vncPort", telnet: "telnetPort" };
|
||||
|
||||
export const DEFAULT_PORT: Record<RemoteProtocol, number> = {
|
||||
rdp: 3389,
|
||||
vnc: 5900,
|
||||
telnet: 23,
|
||||
};
|
||||
|
||||
export function isRemoteProtocol(value: unknown): value is RemoteProtocol {
|
||||
return value === "rdp" || value === "vnc" || value === "telnet";
|
||||
}
|
||||
|
||||
function asPort(value: unknown): number | undefined {
|
||||
const port = typeof value === "string" ? Number(value) : value;
|
||||
return typeof port === "number" &&
|
||||
Number.isInteger(port) &&
|
||||
port > 0 &&
|
||||
port <= 65535
|
||||
? port
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
let parsed = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === 1 || value === "1" || value === "true") return true;
|
||||
if (value === 0 || value === "0" || value === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function readHostSettings(
|
||||
ctx: PluginContext,
|
||||
hostId: number,
|
||||
): Promise<RemoteDesktopHostSettings> {
|
||||
const values = await ctx.settings.getAll("host", hostId);
|
||||
return {
|
||||
enableRdp: values.enableRdp === true,
|
||||
enableVnc: values.enableVnc === true,
|
||||
enableTelnet: values.enableTelnet === true,
|
||||
rdpPort: asPort(values.rdpPort) ?? DEFAULT_PORT.rdp,
|
||||
vncPort: asPort(values.vncPort) ?? DEFAULT_PORT.vnc,
|
||||
telnetPort: asPort(values.telnetPort) ?? DEFAULT_PORT.telnet,
|
||||
rdpSecurity:
|
||||
typeof values.rdpSecurity === "string" ? values.rdpSecurity : "",
|
||||
rdpIgnoreCert: values.rdpIgnoreCert === true,
|
||||
guacamoleConfig: asObject(values.guacamoleConfig) ?? {},
|
||||
enableToolbar: values.enableToolbar !== false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns an imported host row into this plugin's host settings.
|
||||
*
|
||||
* A Termix export carries them under pluginSettings["remote-desktop"]. Older
|
||||
* exports, other tools and a plugin creating a host (proxmox) use the flat
|
||||
* fields the host row had before 2.9.0, including the connectionType-only
|
||||
* shape of hosts from before the per-protocol switches existed.
|
||||
*/
|
||||
export function normalizeImportedHost(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const nested =
|
||||
asObject(asObject(raw.pluginSettings)?.[PLUGIN_ID]) ??
|
||||
({} as Record<string, unknown>);
|
||||
const pick = (key: string, legacy?: string) =>
|
||||
nested[key] !== undefined
|
||||
? nested[key]
|
||||
: raw[key] !== undefined
|
||||
? raw[key]
|
||||
: legacy !== undefined
|
||||
? raw[legacy]
|
||||
: undefined;
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
|
||||
const key = ENABLE_KEY[protocol];
|
||||
const flag = asBool(pick(key));
|
||||
if (flag !== undefined) out[key] = flag;
|
||||
const port = asPort(pick(PORT_KEY[protocol]));
|
||||
if (port !== undefined) out[PORT_KEY[protocol]] = port;
|
||||
}
|
||||
|
||||
const anyFlag = ["enableRdp", "enableVnc", "enableTelnet"].some(
|
||||
(key) => out[key] === true,
|
||||
);
|
||||
if (!anyFlag && isRemoteProtocol(raw.connectionType)) {
|
||||
out[ENABLE_KEY[raw.connectionType]] = true;
|
||||
const port = asPort(raw.port);
|
||||
if (port !== undefined && out[PORT_KEY[raw.connectionType]] === undefined) {
|
||||
out[PORT_KEY[raw.connectionType]] = port;
|
||||
}
|
||||
}
|
||||
|
||||
const security = pick("rdpSecurity", "security");
|
||||
if (typeof security === "string" && security) out.rdpSecurity = security;
|
||||
const ignoreCert = asBool(pick("rdpIgnoreCert", "ignoreCert"));
|
||||
if (ignoreCert !== undefined) out.rdpIgnoreCert = ignoreCert;
|
||||
const config = asObject(pick("guacamoleConfig"));
|
||||
if (config && Object.keys(config).length > 0) out.guacamoleConfig = config;
|
||||
const toolbar = asBool(pick("enableToolbar", "enableTerminalToolbar"));
|
||||
if (toolbar !== undefined) out.enableToolbar = toolbar;
|
||||
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
|
||||
/** User setting key to the guacd parameter it sets. */
|
||||
const USER_DEFAULT_PARAMS: Record<string, string> = {
|
||||
colorDepth: "color-depth",
|
||||
resizeMethod: "resize-method",
|
||||
forceLossless: "force-lossless",
|
||||
enableWallpaper: "enable-wallpaper",
|
||||
enableFontSmoothing: "enable-font-smoothing",
|
||||
enableDesktopComposition: "enable-desktop-composition",
|
||||
disableAudio: "disable-audio",
|
||||
enablePrinting: "enable-printing",
|
||||
enableDrive: "enable-drive",
|
||||
disableCopy: "disable-copy",
|
||||
disablePaste: "disable-paste",
|
||||
};
|
||||
|
||||
/**
|
||||
* The user's RDP defaults as guacd parameters. "inherit" leaves a parameter
|
||||
* to the host and guacd; on and off become booleans.
|
||||
*/
|
||||
export function userDefaultParams(
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {};
|
||||
for (const [key, param] of Object.entries(USER_DEFAULT_PARAMS)) {
|
||||
const value = values[key];
|
||||
if (value === undefined || value === null || value === "inherit") continue;
|
||||
if (key === "colorDepth") {
|
||||
const depth = Number(value);
|
||||
if (Number.isInteger(depth) && depth > 0) params[param] = depth;
|
||||
} else if (key === "resizeMethod") {
|
||||
if (typeof value === "string" && value) params[param] = value;
|
||||
} else if (value === "on" || value === "off") {
|
||||
params[param] = value === "on";
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function readUserDefaults(
|
||||
ctx: PluginContext,
|
||||
userId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return userDefaultParams(await ctx.settings.getAll("user", userId));
|
||||
}
|
||||
@@ -1,63 +1,108 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { RemoteSessions } from "./sessions.js";
|
||||
import {
|
||||
getGuacSessionInfo,
|
||||
handleGuacamoleUpgrade,
|
||||
restartGuacServer,
|
||||
setPluginRecordings,
|
||||
startGuacamoleService,
|
||||
stopGuacamoleService,
|
||||
tokenService,
|
||||
createGuacamoleServer,
|
||||
type RecordingsWriter,
|
||||
} from "./guacamole-server.js";
|
||||
import { startRemoteDesktopService } from "./routes.js";
|
||||
import { resolveGuacdOptions } from "./guacd-config.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { createLiveSessions } from "./live-sessions.js";
|
||||
import { normalizeImportedHost } from "./host-settings.js";
|
||||
import { createLogger } from "./log.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
const log = createLogger(ctx.log);
|
||||
const tokens = new GuacamoleTokenService(log);
|
||||
const sessions = new RemoteSessions();
|
||||
ctx.disposables.add(() => sessions.clear());
|
||||
|
||||
// Optional: without the session-recording plugin, a guacd recording is
|
||||
// still made on disk but never gets a row, so it never shows in the list.
|
||||
const recordings = ctx.services.get<RecordingsWriter>("recordings.writer");
|
||||
setPluginRecordings("createFinished" in recordings ? recordings : null);
|
||||
ctx.disposables.add(() => setPluginRecordings(null));
|
||||
// The admin setting, unless GUACD_URL / GUACD_HOST / GUACD_PORT override it.
|
||||
let guacdUrl = (await ctx.settings.get<string>("guacdUrl")) ?? "";
|
||||
const guacd = () => resolveGuacdOptions(guacdUrl || undefined);
|
||||
const enabled = async () =>
|
||||
(await ctx.settings.get<boolean>("enabled")) !== false;
|
||||
|
||||
await startGuacamoleService();
|
||||
// Optional: without session-recording nothing is recorded.
|
||||
const recordings = (): RecordingsWriter | null => {
|
||||
const service =
|
||||
ctx.services.get<Partial<RecordingsWriter>>("recordings.writer");
|
||||
return typeof service.createFinished === "function" &&
|
||||
typeof service.enabledFor === "function"
|
||||
? (service as RecordingsWriter)
|
||||
: null;
|
||||
};
|
||||
|
||||
startRemoteDesktopService(ctx.http.router());
|
||||
// guacamole-lite prints every new connection to stdout.
|
||||
const consoleLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].startsWith("New client connection")
|
||||
)
|
||||
return;
|
||||
consoleLog(...args);
|
||||
};
|
||||
ctx.disposables.add(() => {
|
||||
console.log = consoleLog;
|
||||
});
|
||||
|
||||
const server = createGuacamoleServer({
|
||||
log,
|
||||
tokens,
|
||||
sessions,
|
||||
guacd,
|
||||
recordings,
|
||||
asUser: (userId, fn) => ctx.asUser(userId, fn),
|
||||
onOpen: (hostId) => ctx.hosts.trackSession(hostId),
|
||||
});
|
||||
ctx.disposables.add(() => server.close());
|
||||
|
||||
ctx.settings.onChange("guacdUrl", (value) => {
|
||||
guacdUrl = typeof value === "string" ? value : "";
|
||||
server.restart();
|
||||
});
|
||||
|
||||
registerRoutes(ctx.http.router(), {
|
||||
ctx,
|
||||
log,
|
||||
tokens,
|
||||
sessions,
|
||||
guacd,
|
||||
enabled,
|
||||
recordings,
|
||||
});
|
||||
|
||||
// guacamole-lite owns its own WebSocketServer, so it takes the raw upgrade.
|
||||
//
|
||||
// Public because a Guacamole display authenticates with the encrypted,
|
||||
// single-use connection token minted by POST /token, not with a session
|
||||
// JWT. guacamole-lite decrypts and validates that token itself, and the
|
||||
// route that mints one is authenticated normally.
|
||||
// Public because a display authenticates with the encrypted, single-use
|
||||
// token minted by an authenticated route, not with a session JWT.
|
||||
ctx.ws.upgrade(
|
||||
"/display",
|
||||
(request, socket, head) =>
|
||||
handleGuacamoleUpgrade(
|
||||
request as Parameters<typeof handleGuacamoleUpgrade>[0],
|
||||
socket as Parameters<typeof handleGuacamoleUpgrade>[1],
|
||||
server.handleUpgrade(
|
||||
request as Parameters<typeof server.handleUpgrade>[0],
|
||||
socket as Parameters<typeof server.handleUpgrade>[1],
|
||||
head as Buffer,
|
||||
),
|
||||
{ public: true },
|
||||
);
|
||||
|
||||
// What collab, session-sharing and the admin guacd setting need from a
|
||||
// running Guacamole server. Revoked automatically on deactivate, and
|
||||
// hosts/guacamole-sessions.ts degrades safely while it is gone.
|
||||
ctx.registry.provide("remote-desktop.sessions", {
|
||||
restart: restartGuacServer,
|
||||
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
|
||||
tokenService.createJoinToken(guacamoleConnectionId, readOnly),
|
||||
getSessionInfo: getGuacSessionInfo,
|
||||
});
|
||||
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
|
||||
ctx.services.provide(
|
||||
"sessions.live",
|
||||
createLiveSessions(protocol, {
|
||||
sessions,
|
||||
tokens,
|
||||
endSession: server.endSession,
|
||||
}),
|
||||
{ name: protocol },
|
||||
);
|
||||
}
|
||||
|
||||
ctx.log.info(
|
||||
"Remote Desktop mounted at /plugin-api/remote-desktop and /plugin-ws/remote-desktop/display",
|
||||
ctx.registry.provide(
|
||||
"remote-desktop.hostImportNormalizer",
|
||||
normalizeImportedHost,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
await stopGuacamoleService();
|
||||
}
|
||||
export async function deactivate() {}
|
||||
@@ -0,0 +1,145 @@
|
||||
import net from "net";
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { RemoteDesktopLogger } from "./log.js";
|
||||
|
||||
/** The tunnels plugin's service, as ctx.services.get("tunnels.access") returns it. */
|
||||
interface TunnelsAccess {
|
||||
forward(
|
||||
sourceHostId: number,
|
||||
target: { targetHost: string; targetPort: number; bindHost?: string },
|
||||
options?: { name?: string; idleTimeoutMs?: number },
|
||||
): Promise<{
|
||||
bindHost: string;
|
||||
bindPort: number;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** The tunnels plugin keeps these names for on-demand forwards. */
|
||||
const TUNNEL_NAME_PREFIX = "web:";
|
||||
/** A forward nothing is using closes itself after this long. */
|
||||
const FORWARD_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface JumpTunnel {
|
||||
port: number;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface JumpTunnelRequest {
|
||||
jumpHosts: Array<{ hostId: number }>;
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
bindHost: string;
|
||||
/** Names the forward, so each session gets one of its own. */
|
||||
connectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A local port guacd can dial that reaches the target through the host's
|
||||
* jump hosts.
|
||||
*
|
||||
* One hop is exactly what the tunnels plugin's forward does, so that is used
|
||||
* when the plugin is running and the user may use it. A chain, or no tunnels
|
||||
* plugin, goes through ctx.ssh.jumpChain with a listener of our own. Either
|
||||
* way the caller closes it when the session ends.
|
||||
*/
|
||||
export async function openJumpTunnel(
|
||||
ctx: PluginContext,
|
||||
log: RemoteDesktopLogger,
|
||||
request: JumpTunnelRequest,
|
||||
): Promise<JumpTunnel> {
|
||||
if (request.jumpHosts.length === 1) {
|
||||
const viaTunnels = await forwardThroughTunnels(ctx, log, request);
|
||||
if (viaTunnels) return viaTunnels;
|
||||
}
|
||||
return forwardThroughChain(ctx, request);
|
||||
}
|
||||
|
||||
async function forwardThroughTunnels(
|
||||
ctx: PluginContext,
|
||||
log: RemoteDesktopLogger,
|
||||
request: JumpTunnelRequest,
|
||||
): Promise<JumpTunnel | null> {
|
||||
const tunnels = ctx.services.get<Partial<TunnelsAccess>>("tunnels.access");
|
||||
if (typeof tunnels.forward !== "function") return null;
|
||||
const hopId = request.jumpHosts[0].hostId;
|
||||
try {
|
||||
const handle = await tunnels.forward(
|
||||
hopId,
|
||||
{
|
||||
targetHost: request.targetHost,
|
||||
targetPort: request.targetPort,
|
||||
bindHost: request.bindHost,
|
||||
},
|
||||
{
|
||||
name: `${TUNNEL_NAME_PREFIX}${hopId}:rd-${request.connectId}`,
|
||||
idleTimeoutMs: FORWARD_IDLE_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
return {
|
||||
port: handle.bindPort,
|
||||
close: () => void handle.close().catch(() => undefined),
|
||||
};
|
||||
} catch (error) {
|
||||
// A user without the tunnels permission still reaches their host.
|
||||
log.warn("Tunnels forward failed, using a jump chain instead", {
|
||||
operation: "guac_tunnels_forward",
|
||||
hopId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardThroughChain(
|
||||
ctx: PluginContext,
|
||||
request: JumpTunnelRequest,
|
||||
): Promise<JumpTunnel> {
|
||||
// The chain dials the first hop through that hop's own SOCKS5 settings;
|
||||
// the target host's proxy config does not apply to it.
|
||||
const { client, dispose } = await ctx.ssh.jumpChain<Client>(
|
||||
request.jumpHosts,
|
||||
);
|
||||
|
||||
const sockets = new Set<net.Socket>();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
client.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
request.targetHost,
|
||||
request.targetPort,
|
||||
(error, stream) => {
|
||||
if (error) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
socket.pipe(stream).pipe(socket);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
let port: number;
|
||||
try {
|
||||
port = await new Promise<number>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, request.bindHost, () =>
|
||||
resolve((server.address() as net.AddressInfo).port),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
await dispose();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
close: () => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
server.close();
|
||||
void dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { GuacamoleTokenService } from "./token-service.js";
|
||||
import type { RemoteSessions } from "./sessions.js";
|
||||
import type { RemoteProtocol } from "./host-settings.js";
|
||||
|
||||
/** sessions.live v1, as session-sharing reads it (plugins/session-sharing/src/backend/live.ts). */
|
||||
export interface LiveSessionInfo {
|
||||
id: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
tabInstanceId: string | null;
|
||||
}
|
||||
|
||||
export interface RemoteLiveSessions {
|
||||
getSession: (sessionId: string) => LiveSessionInfo | null;
|
||||
ownerEndSession: (sessionId: string, reason: string) => void;
|
||||
createViewerToken: (sessionId: string, readOnly: boolean) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider per protocol. A session id is guacd's own connection id,
|
||||
* which is what a viewer's join token names.
|
||||
*/
|
||||
export function createLiveSessions(
|
||||
protocol: RemoteProtocol,
|
||||
deps: {
|
||||
sessions: RemoteSessions;
|
||||
tokens: GuacamoleTokenService;
|
||||
endSession: (guacamoleConnectionId: string) => boolean;
|
||||
},
|
||||
): RemoteLiveSessions {
|
||||
const find = (sessionId: string) => {
|
||||
const session = deps.sessions.byGuacamole(sessionId);
|
||||
return session && session.protocol === protocol ? session : null;
|
||||
};
|
||||
|
||||
return {
|
||||
getSession(sessionId) {
|
||||
const session = find(sessionId);
|
||||
if (!session) return null;
|
||||
return {
|
||||
id: session.guacamoleConnectionId,
|
||||
userId: session.ownerUserId,
|
||||
hostId: session.hostId,
|
||||
hostName: session.hostName,
|
||||
isConnected: true,
|
||||
createdAt: session.openedAt,
|
||||
tabInstanceId: session.tabInstanceId,
|
||||
};
|
||||
},
|
||||
|
||||
ownerEndSession(sessionId) {
|
||||
if (find(sessionId)) deps.endSession(sessionId);
|
||||
},
|
||||
|
||||
createViewerToken(sessionId, readOnly) {
|
||||
if (!find(sessionId)) {
|
||||
throw new Error(`No live ${protocol} session ${sessionId}`);
|
||||
}
|
||||
return deps.tokens.createJoinToken(sessionId, readOnly);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type Meta = Record<string, unknown>;
|
||||
|
||||
export interface RemoteDesktopLogger {
|
||||
info: (message: string, meta?: Meta) => void;
|
||||
warn: (message: string, meta?: Meta) => void;
|
||||
error: (message: string, meta?: Meta) => void;
|
||||
}
|
||||
|
||||
function withMeta(message: string, meta?: Meta): string {
|
||||
return meta && Object.keys(meta).length > 0
|
||||
? `${message} ${JSON.stringify(meta)}`
|
||||
: message;
|
||||
}
|
||||
|
||||
/** ctx.log with the structured context the old guac logger carried. */
|
||||
export function createLogger(log: PluginLogger): RemoteDesktopLogger {
|
||||
return {
|
||||
info: (message, meta) => log.info(withMeta(message, meta)),
|
||||
warn: (message, meta) => log.warn(withMeta(message, meta)),
|
||||
error: (message, meta) => log.error(withMeta(message, meta)),
|
||||
};
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown, fallback = "Unknown error") {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === "string" && error) return error;
|
||||
return fallback;
|
||||
}
|
||||
@@ -34,31 +34,3 @@ export function resolveRdpDomain(
|
||||
? promptedDomain
|
||||
: storedDomain;
|
||||
}
|
||||
|
||||
type SharedRdpAuthResolution =
|
||||
| { source: "personal-override" }
|
||||
| { source: "owner-shared"; authType: string }
|
||||
| { source: "secretless" }
|
||||
| { source: "required" }
|
||||
| null;
|
||||
|
||||
export function resolveRdpAuthTypeForConnect({
|
||||
storedAuthType,
|
||||
credentialId,
|
||||
sharedResolution,
|
||||
}: {
|
||||
storedAuthType?: string | null;
|
||||
credentialId?: number | null;
|
||||
sharedResolution?: SharedRdpAuthResolution;
|
||||
}): string {
|
||||
if (sharedResolution === undefined) {
|
||||
return storedAuthType || (credentialId ? "credential" : "direct");
|
||||
}
|
||||
if (sharedResolution?.source === "personal-override") return "credential";
|
||||
if (sharedResolution?.source === "owner-shared") {
|
||||
return sharedResolution.authType;
|
||||
}
|
||||
return sharedResolution?.source === "secretless" && storedAuthType === "none"
|
||||
? "none"
|
||||
: "direct";
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,116 @@
|
||||
import type { TermixGuacMeta } from "./token-service.js";
|
||||
|
||||
export interface RemoteSession {
|
||||
guacamoleConnectionId: string;
|
||||
termixConnectId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
ownerUserId: string;
|
||||
protocol: TermixGuacMeta["protocol"];
|
||||
tabInstanceId: string | null;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
type Cleanup = () => void;
|
||||
|
||||
/** How long a minted token's tunnel waits for guacd to connect through it. */
|
||||
export const PENDING_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Live guacd sessions and what each one holds open.
|
||||
*
|
||||
* A connect request builds its jump tunnel or VNC proxy before guacd dials
|
||||
* in, so those resources are parked under the request's connect id and move
|
||||
* onto the session when it opens. They are released when the session
|
||||
* closes, when guacd never shows up, or when the plugin stops.
|
||||
*/
|
||||
export class RemoteSessions {
|
||||
private readonly pending = new Map<
|
||||
string,
|
||||
{ cleanups: Cleanup[]; timer: ReturnType<typeof setTimeout> }
|
||||
>();
|
||||
private readonly byConnectId = new Map<string, RemoteSession>();
|
||||
private readonly byGuacamoleId = new Map<string, RemoteSession>();
|
||||
private readonly cleanupsByConnectId = new Map<string, Cleanup[]>();
|
||||
|
||||
constructor(private readonly pendingTimeoutMs = PENDING_TIMEOUT_MS) {}
|
||||
|
||||
/** Resources for a connection that guacd has not opened yet. */
|
||||
park(termixConnectId: string, cleanups: Cleanup[]): void {
|
||||
if (cleanups.length === 0) return;
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(termixConnectId);
|
||||
run(cleanups);
|
||||
}, this.pendingTimeoutMs);
|
||||
timer.unref?.();
|
||||
this.pending.set(termixConnectId, { cleanups, timer });
|
||||
}
|
||||
|
||||
opened(
|
||||
meta: TermixGuacMeta,
|
||||
guacamoleConnectionId: string,
|
||||
extra: Cleanup[] = [],
|
||||
): RemoteSession {
|
||||
const session: RemoteSession = {
|
||||
guacamoleConnectionId,
|
||||
termixConnectId: meta.termixConnectId,
|
||||
hostId: meta.hostId,
|
||||
hostName: meta.hostName,
|
||||
ownerUserId: meta.ownerUserId,
|
||||
protocol: meta.protocol,
|
||||
tabInstanceId: meta.tabInstanceId ?? null,
|
||||
openedAt: Date.now(),
|
||||
};
|
||||
const parked = this.pending.get(meta.termixConnectId);
|
||||
if (parked) {
|
||||
clearTimeout(parked.timer);
|
||||
this.pending.delete(meta.termixConnectId);
|
||||
}
|
||||
this.cleanupsByConnectId.set(meta.termixConnectId, [
|
||||
...(parked?.cleanups ?? []),
|
||||
...extra,
|
||||
]);
|
||||
this.byConnectId.set(meta.termixConnectId, session);
|
||||
this.byGuacamoleId.set(guacamoleConnectionId, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
closed(termixConnectId: string): void {
|
||||
const session = this.byConnectId.get(termixConnectId);
|
||||
if (session) this.byGuacamoleId.delete(session.guacamoleConnectionId);
|
||||
this.byConnectId.delete(termixConnectId);
|
||||
run(this.cleanupsByConnectId.get(termixConnectId) ?? []);
|
||||
this.cleanupsByConnectId.delete(termixConnectId);
|
||||
}
|
||||
|
||||
byConnect(termixConnectId: string): RemoteSession | null {
|
||||
return this.byConnectId.get(termixConnectId) ?? null;
|
||||
}
|
||||
|
||||
byGuacamole(guacamoleConnectionId: string): RemoteSession | null {
|
||||
return this.byGuacamoleId.get(guacamoleConnectionId) ?? null;
|
||||
}
|
||||
|
||||
/** Releases everything, for deactivate. */
|
||||
clear(): void {
|
||||
for (const { cleanups, timer } of this.pending.values()) {
|
||||
clearTimeout(timer);
|
||||
run(cleanups);
|
||||
}
|
||||
this.pending.clear();
|
||||
for (const cleanups of this.cleanupsByConnectId.values()) run(cleanups);
|
||||
this.cleanupsByConnectId.clear();
|
||||
this.byConnectId.clear();
|
||||
this.byGuacamoleId.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function run(cleanups: Cleanup[]): void {
|
||||
for (const cleanup of cleanups) {
|
||||
try {
|
||||
cleanup();
|
||||
} catch {
|
||||
// One failed close must not keep the rest open.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type {
|
||||
PluginSsh,
|
||||
PluginSshConnection,
|
||||
PluginSshConnectOptions,
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginSsh | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setPluginSsh(ssh: PluginSsh | null): void {
|
||||
current = ssh;
|
||||
}
|
||||
|
||||
export function pluginSsh(): PluginSsh {
|
||||
if (!current) throw new Error("The plugin is not active");
|
||||
return current;
|
||||
}
|
||||
|
||||
/** withConnection with ssh2's Client type filled in. */
|
||||
export function withSshConnection<T>(
|
||||
host: number | PluginSshHost,
|
||||
options: PluginSshConnectOptions & { pool: string },
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return pluginSsh().withConnection<T, Client>(host, options, fn);
|
||||
}
|
||||
|
||||
/** connect with ssh2's Client type filled in. */
|
||||
export function connectSsh(
|
||||
host: number | PluginSshHost,
|
||||
options?: PluginSshConnectOptions,
|
||||
): Promise<PluginSshConnection<Client>> {
|
||||
return pluginSsh().connect<Client>(host, options);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import crypto from "crypto";
|
||||
import { guacLogger } from "../../../../src/backend/utils/logger.js";
|
||||
|
||||
interface TokenLogger {
|
||||
warn: (message: string, meta?: Record<string, unknown>) => void;
|
||||
error: (message: string, meta?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export interface GuacamoleConnectionSettings {
|
||||
type?: "rdp" | "vnc" | "telnet";
|
||||
@@ -33,8 +37,10 @@ export interface GuacamoleConnectionSettings {
|
||||
export interface TermixGuacMeta {
|
||||
termixConnectId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
ownerUserId: string;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
tabInstanceId: string | null;
|
||||
}
|
||||
|
||||
export interface GuacamoleToken {
|
||||
@@ -57,21 +63,19 @@ export interface GuacamoleRecordingMetadata {
|
||||
const CIPHER = "aes-256-cbc";
|
||||
const KEY_LENGTH = 32;
|
||||
|
||||
const silentLogger: TokenLogger = { warn: () => {}, error: () => {} };
|
||||
|
||||
/**
|
||||
* Encrypts the connection tokens guacamole-lite decrypts. Built in activate;
|
||||
* the key is derived from the environment, so a restart keeps it.
|
||||
*/
|
||||
export class GuacamoleTokenService {
|
||||
private static instance: GuacamoleTokenService;
|
||||
private encryptionKey: Buffer;
|
||||
|
||||
private constructor() {
|
||||
constructor(private readonly log: TokenLogger = silentLogger) {
|
||||
this.encryptionKey = this.initializeKey();
|
||||
}
|
||||
|
||||
static getInstance(): GuacamoleTokenService {
|
||||
if (!GuacamoleTokenService.instance) {
|
||||
GuacamoleTokenService.instance = new GuacamoleTokenService();
|
||||
}
|
||||
return GuacamoleTokenService.instance;
|
||||
}
|
||||
|
||||
private initializeKey(): Buffer {
|
||||
const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY;
|
||||
if (existingKey) {
|
||||
@@ -91,12 +95,9 @@ export class GuacamoleTokenService {
|
||||
.digest();
|
||||
}
|
||||
|
||||
guacLogger.warn(
|
||||
"No persistent encryption key found, generating random key",
|
||||
{
|
||||
operation: "guac_key_generation",
|
||||
},
|
||||
);
|
||||
this.log.warn("No persistent encryption key found, generating random key", {
|
||||
operation: "guac_key_generation",
|
||||
});
|
||||
return crypto.randomBytes(KEY_LENGTH);
|
||||
}
|
||||
|
||||
@@ -134,8 +135,9 @@ export class GuacamoleTokenService {
|
||||
|
||||
return JSON.parse(decrypted) as GuacamoleToken;
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to decrypt guacamole token", error, {
|
||||
this.log.error("Failed to decrypt guacamole token", {
|
||||
operation: "guac_token_decrypt_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -14,40 +13,42 @@ import {
|
||||
type GuacamoleDisplayHandle,
|
||||
type GuacamoleTouchMode,
|
||||
} from "./GuacamoleDisplay.tsx";
|
||||
import { getSSHHosts, logActivity, isElectron } from "@/main-axios";
|
||||
import { getGuacamoleTokenFromHost, getGuacdStatus } from "./guacamole-api";
|
||||
import { readConfiguredDimension } from "./guacamole-display-size.ts";
|
||||
import { getGuacamoleToken, parseGuacamoleConfig } from "./guacamole-api";
|
||||
import {
|
||||
resolveConnectionOrigin,
|
||||
type ConnectionOrigin,
|
||||
} from "@/lib/connection-origin.ts";
|
||||
import { getGuacamoleToken } from "./guacamole-api";
|
||||
import {
|
||||
useHost,
|
||||
useTranslation,
|
||||
useConnectionRetry,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { GuacamoleToolbar } from "./GuacamoleToolbar.tsx";
|
||||
import { GuacamoleFileBrowser } from "./GuacamoleFileBrowser.tsx";
|
||||
import { describeUploadError } from "./guacamole-filesystem.ts";
|
||||
import { canUploadToRdpDrive } from "./guacamole-file-drop.ts";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import {
|
||||
Button,
|
||||
ConnectionLogProvider,
|
||||
ConnectionScreen,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
Input,
|
||||
PasswordInput,
|
||||
isElectron,
|
||||
logActivity,
|
||||
resolveConnectionOrigin,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
|
||||
import { resolveConnectionDefaults } from "@/lib/connection-defaults";
|
||||
type ConnectionOrigin,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { GuacamoleConfig } from "./guacamole-config";
|
||||
import {
|
||||
errorMessage,
|
||||
hostRemoteOptions,
|
||||
type Protocol,
|
||||
type RemoteHostLogin,
|
||||
} from "./host-remote";
|
||||
import { GuacamoleToolbar } from "./GuacamoleToolbar.tsx";
|
||||
import { GuacamoleFileBrowser } from "./GuacamoleFileBrowser.tsx";
|
||||
import { describeUploadError } from "./guacamole-filesystem.ts";
|
||||
import { canUploadToRdpDrive } from "./guacamole-file-drop.ts";
|
||||
import { needsRdpCredentialPrompt } from "./rdp-credential-prompt";
|
||||
|
||||
interface GuacamoleAppProps {
|
||||
@@ -59,10 +60,48 @@ interface GuacamoleAppProps {
|
||||
quickConnectHost?: GuacamoleQuickHost;
|
||||
}
|
||||
|
||||
/** What GuacamoleApp needs from a host that has no database row. */
|
||||
export type GuacamoleQuickHost = GuacamoleAppInnerProps["hostConfig"] & {
|
||||
/**
|
||||
* What a session needs from its host. A saved host brings its options; a
|
||||
* quick-connect host has no row, so it also carries its address and login.
|
||||
*/
|
||||
export interface GuacamoleHostConfig {
|
||||
name?: string;
|
||||
};
|
||||
ip: string;
|
||||
connectionType?: Protocol;
|
||||
domain?: string;
|
||||
guacamoleConfig?: GuacamoleConfig;
|
||||
rdpAuthType?: string;
|
||||
authOverrides?: RemoteHostLogin["authOverrides"];
|
||||
syncId?: string | null;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
showToolbar?: boolean;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export type GuacamoleQuickHost = GuacamoleHostConfig;
|
||||
|
||||
function savedHostConfig(
|
||||
host: RemoteHostLogin,
|
||||
protocol: Protocol | undefined,
|
||||
): GuacamoleHostConfig {
|
||||
const options = hostRemoteOptions(host);
|
||||
return {
|
||||
name: host.name ?? undefined,
|
||||
ip: host.ip ?? "",
|
||||
connectionType:
|
||||
protocol ??
|
||||
(options.enableRdp ? "rdp" : options.enableVnc ? "vnc" : "telnet"),
|
||||
domain: host.domain,
|
||||
guacamoleConfig: options.guacamoleConfig,
|
||||
rdpAuthType: host.rdpAuthType,
|
||||
authOverrides: host.authOverrides,
|
||||
syncId: host.syncId,
|
||||
connectionOrigin: host.connectionOrigin,
|
||||
showToolbar: options.enableToolbar,
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuacamoleAppHandle {
|
||||
disconnect: () => void;
|
||||
@@ -77,50 +116,20 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const defaults = useConnectionDefaults();
|
||||
const [hostConfig, setHostConfig] = useState<GuacamoleQuickHost | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const savedHost = useHost(quickConnectHost ? undefined : hostId);
|
||||
const hostConfig: GuacamoleHostConfig | null = quickConnectHost
|
||||
? quickConnectHost
|
||||
: savedHost
|
||||
? savedHostConfig(savedHost as RemoteHostLogin, protocol)
|
||||
: null;
|
||||
// The host list loads with the shell; a tab restored before it arrives
|
||||
// waits for it instead of reporting the host missing.
|
||||
const [waited, setWaited] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!defaults.ready) return;
|
||||
if (quickConnectHost) {
|
||||
const connectionType = protocol ?? quickConnectHost.connectionType;
|
||||
setHostConfig({
|
||||
...quickConnectHost,
|
||||
guacamoleConfig: resolveConnectionDefaults(
|
||||
connectionType === "rdp" ? defaults.rdp : {},
|
||||
{},
|
||||
),
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const host = hosts.find((h) => h.id === parseInt(hostId, 10));
|
||||
if (!host) {
|
||||
setHostConfig(null);
|
||||
return;
|
||||
}
|
||||
const connectionType = protocol ?? host.connectionType;
|
||||
const protocolDefaults = connectionType === "rdp" ? defaults.rdp : {};
|
||||
setHostConfig({
|
||||
...host,
|
||||
guacamoleConfig: resolveConnectionDefaults(
|
||||
protocolDefaults,
|
||||
parseGuacamoleConfig(host.guacamoleConfig),
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(() => setHostConfig(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [hostId, protocol, defaults.ready, defaults.rdp, quickConnectHost]);
|
||||
const timer = setTimeout(() => setWaited(true), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
const loading = !hostConfig && !!hostId && !waited;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -159,23 +168,7 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||
|
||||
interface GuacamoleAppInnerProps {
|
||||
hostId: number;
|
||||
hostConfig: Pick<
|
||||
SSHHost,
|
||||
| "connectionType"
|
||||
| "domain"
|
||||
| "guacamoleConfig"
|
||||
| "rdpAuthType"
|
||||
| "authOverrides"
|
||||
| "syncId"
|
||||
| "connectionOrigin"
|
||||
| "ip"
|
||||
| "rdpPort"
|
||||
| "vncPort"
|
||||
| "rdpUser"
|
||||
| "rdpPassword"
|
||||
| "vncUser"
|
||||
| "vncPassword"
|
||||
> & { enableTerminalToolbar?: boolean };
|
||||
hostConfig: GuacamoleHostConfig;
|
||||
hostName: string;
|
||||
tabId?: string;
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
@@ -223,7 +216,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
const [toolbarHidden, setToolbarHidden] = useState(false);
|
||||
const [pendingUploads, setPendingUploads] = useState<File[]>([]);
|
||||
|
||||
const guacConfig = parseGuacamoleConfig(hostConfig.guacamoleConfig);
|
||||
const guacConfig = hostConfig.guacamoleConfig ?? {};
|
||||
const allowUpload = guacConfig.disableUpload !== true;
|
||||
const allowDownload = guacConfig.disableDownload !== true;
|
||||
|
||||
@@ -311,10 +304,10 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
let resolvedOrigin: ConnectionOrigin = "local";
|
||||
|
||||
if (isElectron()) {
|
||||
resolvedOrigin = await resolveConnectionOrigin({
|
||||
connectionType: resolvedProtocolForConnect,
|
||||
connectionOrigin: hostConfig.connectionOrigin,
|
||||
});
|
||||
resolvedOrigin = await resolveConnectionOrigin(
|
||||
{ connectionOrigin: hostConfig.connectionOrigin },
|
||||
{ defaultRemote: true },
|
||||
);
|
||||
if (resolvedOrigin === "remote") {
|
||||
const remoteConfig = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
@@ -331,6 +324,9 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
message: t("remoteDesktop.checkingGuacd"),
|
||||
});
|
||||
const status = await getGuacdStatus(resolvedOrigin);
|
||||
if (status.enabled === false) {
|
||||
throw new Error(t("remoteDesktop.disabled"));
|
||||
}
|
||||
if (status.guacd.status !== "connected") {
|
||||
throw new Error(t("remoteDesktop.guacdUnavailable"));
|
||||
}
|
||||
@@ -351,21 +347,12 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
{
|
||||
protocol: resolvedProtocolForConnect,
|
||||
hostname: hostConfig.ip,
|
||||
port:
|
||||
resolvedProtocolForConnect === "vnc"
|
||||
? hostConfig.vncPort
|
||||
: hostConfig.rdpPort,
|
||||
username:
|
||||
resolvedProtocolForConnect === "vnc"
|
||||
? hostConfig.vncUser
|
||||
: hostConfig.rdpUser,
|
||||
password:
|
||||
resolvedProtocolForConnect === "vnc"
|
||||
? hostConfig.vncPassword
|
||||
: hostConfig.rdpPassword,
|
||||
port: hostConfig.port,
|
||||
username: hostConfig.username,
|
||||
password: hostConfig.password,
|
||||
domain: hostConfig.domain,
|
||||
ignoreCert: true,
|
||||
guacamoleConfig: parseGuacamoleConfig(hostConfig.guacamoleConfig),
|
||||
guacamoleConfig: hostConfig.guacamoleConfig,
|
||||
},
|
||||
resolvedOrigin,
|
||||
)
|
||||
@@ -375,6 +362,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
protocol,
|
||||
promptedCredentials ?? undefined,
|
||||
hostConfig.syncId,
|
||||
tabId,
|
||||
);
|
||||
if (result) {
|
||||
setSessionLookup(
|
||||
@@ -397,6 +385,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
promptedCredentials,
|
||||
resolvedProtocolForConnect,
|
||||
hostConfig,
|
||||
tabId,
|
||||
addLog,
|
||||
t,
|
||||
]);
|
||||
@@ -407,10 +396,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
await fetchToken();
|
||||
tokenRetryRef.current.markConnected();
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(
|
||||
err,
|
||||
t("remoteDesktop.failedToConnect"),
|
||||
);
|
||||
const message = errorMessage(err, t("remoteDesktop.failedToConnect"));
|
||||
setError(message || t("remoteDesktop.failedToConnect"));
|
||||
addLog({ type: "error", stage: "error", message });
|
||||
tokenRetryRef.current.markFailed();
|
||||
@@ -632,7 +618,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
onClose={() => setFileBrowserOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{hostConfig.enableTerminalToolbar !== false && !toolbarHidden && (
|
||||
{hostConfig.showToolbar !== false && !toolbarHidden && (
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol={resolvedProtocol}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import type React from "react";
|
||||
import {
|
||||
useEffect,
|
||||
@@ -11,15 +10,18 @@ import {
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { Upload } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { isElectron } from "@/main-axios";
|
||||
import { getGuacamoleToken } from "./guacamole-api";
|
||||
import { getBasePath } from "@/lib/base-path.ts";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
|
||||
import {
|
||||
resolveConnectionOrigin,
|
||||
buildOriginWsUrl,
|
||||
getBasePath,
|
||||
guacStateToStage,
|
||||
isElectron,
|
||||
resolveConnectionOrigin,
|
||||
type ConnectionOrigin,
|
||||
} from "@/lib/connection-origin.ts";
|
||||
type ConnectionStage,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { errorMessage } from "./host-remote";
|
||||
import { isPasteShortcut, pasteTextToRemote } from "./guacamole-clipboard.ts";
|
||||
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
|
||||
import { bindPointerInput } from "./guacamole-pointer.ts";
|
||||
@@ -31,8 +33,6 @@ import {
|
||||
uploadFileToClient,
|
||||
type GuacamoleFileStreamClient,
|
||||
} from "./guacamole-filesystem.ts";
|
||||
import { guacStateToStage } from "@/components/connection/connection-status.ts";
|
||||
import type { ConnectionStage } from "@/types/connection-log.ts";
|
||||
import { clampGuacamoleZoom, stepGuacamoleZoom } from "./guacamole-zoom.ts";
|
||||
|
||||
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||
@@ -243,10 +243,10 @@ export const GuacamoleDisplay = forwardRef<
|
||||
// Resolved before the token is minted, not just before the socket is
|
||||
// opened: the token has to come from whichever backend will serve the
|
||||
// session, so both steps have to agree on the origin.
|
||||
const origin = await resolveConnectionOrigin({
|
||||
connectionType: connectionProtocol,
|
||||
connectionOrigin: connectionConfig.connectionOrigin,
|
||||
});
|
||||
const origin = await resolveConnectionOrigin(
|
||||
{ connectionOrigin: connectionConfig.connectionOrigin },
|
||||
{ defaultRemote: true },
|
||||
);
|
||||
|
||||
if (connectionConfig.token) {
|
||||
token = connectionConfig.token;
|
||||
@@ -317,8 +317,8 @@ export const GuacamoleDisplay = forwardRef<
|
||||
if (displaySize.dpi) params.set("dpi", String(displaySize.dpi));
|
||||
return { url: wsBase, query: params.toString() };
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
onError?.(errorMessage);
|
||||
const message = errorMessage(error);
|
||||
onError?.(message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
@@ -698,7 +698,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
if (!isMountedRef.current) return;
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
onError?.(getErrorMessage(error, t("remoteDesktop.connectionError")));
|
||||
onError?.(errorMessage(error, t("remoteDesktop.connectionError")));
|
||||
}
|
||||
}, [
|
||||
getWebSocketConnection,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { errorMessage } from "./host-remote";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
downloadFile,
|
||||
listDirectory,
|
||||
@@ -56,7 +56,7 @@ export function GuacamoleFileBrowser({
|
||||
setEntries(await listDirectory(filesystem, target));
|
||||
} catch (err) {
|
||||
setEntries([]);
|
||||
setError(getErrorMessage(err, t("remoteDesktop.files.listFailed")));
|
||||
setError(errorMessage(err, t("remoteDesktop.files.listFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -26,14 +26,14 @@ import {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/tooltip.tsx";
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type {
|
||||
GuacamoleDisplayHandle,
|
||||
GuacamoleTouchMode,
|
||||
} from "./GuacamoleDisplay.tsx";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { ActionSlot } from "@termix/plugin-sdk/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
detectRuntimeMetaKeyFamily,
|
||||
metaKeyLabels,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import {
|
||||
FakeSwitch,
|
||||
Input,
|
||||
PasswordInput,
|
||||
SectionCard,
|
||||
Select2,
|
||||
SettingRow,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
Activity,
|
||||
Copy,
|
||||
@@ -16,13 +20,10 @@ import {
|
||||
Zap,
|
||||
Cpu,
|
||||
} from "lucide-react";
|
||||
import type { HostEditorForm } from "@/sidebar/HostEditorData";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import type { RemoteDesktopForm, RemoteFormSetField } from "./remote-form";
|
||||
|
||||
type HostEditorSetField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
value: HostEditorForm[K],
|
||||
) => void;
|
||||
type HostEditorForm = RemoteDesktopForm;
|
||||
type HostEditorSetField = RemoteFormSetField;
|
||||
|
||||
type GuacFieldSetter = (key: string, value: unknown) => void;
|
||||
|
||||
@@ -49,7 +50,7 @@ export function HostEditorRdpTab({
|
||||
form: HostEditorForm;
|
||||
setField: HostEditorSetField;
|
||||
setGuacField: GuacFieldSetter;
|
||||
host?: Host | null;
|
||||
host?: { macAddress?: string | null } | null;
|
||||
credentials?: { id: string; name: string; username: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -984,7 +985,7 @@ export function HostEditorVncTab({
|
||||
form: HostEditorForm;
|
||||
setField: HostEditorSetField;
|
||||
setGuacField: GuacFieldSetter;
|
||||
host?: Host | null;
|
||||
host?: { macAddress?: string | null } | null;
|
||||
credentials?: { id: string; name: string; username: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -1523,7 +1524,6 @@ export function HostEditorTelnetTab({
|
||||
form: HostEditorForm;
|
||||
setField: HostEditorSetField;
|
||||
setGuacField: GuacFieldSetter;
|
||||
host?: Host | null;
|
||||
credentials?: { id: string; name: string; username: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
authApi,
|
||||
getRemoteGuacamoleApi,
|
||||
handleApiError,
|
||||
isElectron,
|
||||
} from "@/main-axios";
|
||||
import type { AxiosInstance } from "axios";
|
||||
import type { GuacamoleConfig } from "@/types/guacamole-config";
|
||||
import { resolveRemoteHostId } from "@/lib/remote-server-api";
|
||||
import type { ConnectionOrigin } from "@/lib/connection-origin";
|
||||
resolveRemoteHostId,
|
||||
type ConnectionOrigin,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { GuacamoleConfig } from "./guacamole-config";
|
||||
import { errorMessage } from "./host-remote";
|
||||
|
||||
let app: Pick<TermixApp, "apiFor"> | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setRemoteDesktopApp(next: Pick<TermixApp, "apiFor"> | null) {
|
||||
app = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the backend that will actually serve this session, so the token and
|
||||
@@ -24,9 +29,13 @@ import type { ConnectionOrigin } from "@/lib/connection-origin";
|
||||
* silently sent a missed call site to the remote server, which fails with a
|
||||
* bare network error on a desktop that has none configured.
|
||||
*/
|
||||
function guacamoleApi(origin: ConnectionOrigin): AxiosInstance {
|
||||
if (!isElectron()) return authApi;
|
||||
return origin === "local" ? authApi : getRemoteGuacamoleApi();
|
||||
function guacamoleApi(origin: ConnectionOrigin): PluginApiClient {
|
||||
if (!app) throw new Error("Remote Desktop is not active");
|
||||
return app.apiFor(origin);
|
||||
}
|
||||
|
||||
function apiError(error: unknown, action: string): Error {
|
||||
return new Error(errorMessage(error, `Failed to ${action}`));
|
||||
}
|
||||
|
||||
export interface GuacamoleTokenRequest {
|
||||
@@ -51,18 +60,6 @@ type GuacamoleConfigSource = {
|
||||
guacamoleConfig?: string | Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function parseGuacamoleConfig(
|
||||
config?: string | GuacamoleConfig | null,
|
||||
): GuacamoleConfig {
|
||||
if (!config) return {};
|
||||
if (typeof config !== "string") return config;
|
||||
try {
|
||||
return JSON.parse(config) as GuacamoleConfig;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function getGuacamoleDpi(
|
||||
source?: GuacamoleConfigSource,
|
||||
): number | undefined {
|
||||
@@ -174,8 +171,8 @@ export async function getGuacamoleToken(
|
||||
try {
|
||||
const guacParams = toGuacamoleParams(request.guacamoleConfig);
|
||||
|
||||
const response = await guacamoleApi(origin).post(
|
||||
"/plugin-api/remote-desktop/token",
|
||||
const response = await guacamoleApi(origin).post<GuacamoleTokenResponse>(
|
||||
"/token",
|
||||
{
|
||||
type: request.protocol,
|
||||
hostname: request.hostname,
|
||||
@@ -190,7 +187,7 @@ export async function getGuacamoleToken(
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get guacamole token");
|
||||
throw apiError(error, "get a connection token");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +201,7 @@ export async function getGuacamoleTokenFromHost(
|
||||
domain?: string;
|
||||
},
|
||||
syncId?: string | null,
|
||||
tabInstanceId?: string,
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
// A locally-originated session is served by the embedded backend, which
|
||||
@@ -216,10 +214,11 @@ export async function getGuacamoleTokenFromHost(
|
||||
throw new Error("The synced host does not exist on the remote server");
|
||||
}
|
||||
const targetHostId = remoteHostId ?? hostId;
|
||||
const response = await guacamoleApi(origin).post(
|
||||
`/plugin-api/remote-desktop/connect-host/${targetHostId}`,
|
||||
const response = await guacamoleApi(origin).post<GuacamoleTokenResponse>(
|
||||
`/connect-host/${targetHostId}`,
|
||||
{
|
||||
...(protocol ? { protocol } : {}),
|
||||
...(tabInstanceId ? { tabInstanceId } : {}),
|
||||
...(promptedCredentials?.username
|
||||
? { promptedUsername: promptedCredentials.username }
|
||||
: {}),
|
||||
@@ -233,15 +232,22 @@ export async function getGuacamoleTokenFromHost(
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get guacamole token from host");
|
||||
throw apiError(error, "get a connection token");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGuacdStatus(origin: ConnectionOrigin): Promise<{
|
||||
export interface RemoteDesktopStatus {
|
||||
enabled: boolean;
|
||||
guacd: { status: string };
|
||||
}> {
|
||||
const response = await guacamoleApi(origin).get(
|
||||
"/plugin-api/remote-desktop/status",
|
||||
}
|
||||
|
||||
/** `probe: false` skips dialing guacd, for a caller that only needs `enabled`. */
|
||||
export async function getGuacdStatus(
|
||||
origin: ConnectionOrigin,
|
||||
options: { probe?: boolean } = {},
|
||||
): Promise<RemoteDesktopStatus> {
|
||||
const response = await guacamoleApi(origin).get<RemoteDesktopStatus>(
|
||||
options.probe === false ? "/status?probe=0" : "/status",
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -251,9 +257,34 @@ export async function getGuacamoleConnectionId(
|
||||
origin: ConnectionOrigin,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | null> {
|
||||
const response = await guacamoleApi(origin).get(
|
||||
`/plugin-api/remote-desktop/connection/${encodeURIComponent(connectId)}`,
|
||||
{ signal },
|
||||
);
|
||||
const response = await guacamoleApi(origin).get<{
|
||||
guacamoleConnectionId?: string | null;
|
||||
}>(`/connection/${encodeURIComponent(connectId)}`, { signal });
|
||||
return response.data.guacamoleConnectionId ?? null;
|
||||
}
|
||||
|
||||
export async function nativeRdpAvailable(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
try {
|
||||
const response = await guacamoleApi("local").get<{ available: boolean }>(
|
||||
"/native-rdp",
|
||||
);
|
||||
return response.data.available === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Always the embedded backend: it is the one that can reach Electron. */
|
||||
export async function openNativeRdp(request: {
|
||||
host: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
domain?: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const response = await guacamoleApi("local").post<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>("/native-rdp", request);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Per-host Guacamole connection settings, as edited in the host editor and
|
||||
* forwarded to guacd. Field names are camelCase here and translated to the
|
||||
* hyphenated protocol parameters in toGuacamoleParams().
|
||||
*/
|
||||
export interface GuacamoleConfig {
|
||||
colorDepth?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
resizeMethod?: string;
|
||||
forceLossless?: boolean;
|
||||
disableAudio?: boolean;
|
||||
enableAudioInput?: boolean;
|
||||
enableWallpaper?: boolean;
|
||||
enableTheming?: boolean;
|
||||
enableFontSmoothing?: boolean;
|
||||
enableFullWindowDrag?: boolean;
|
||||
enableDesktopComposition?: boolean;
|
||||
enableMenuAnimations?: boolean;
|
||||
disableBitmapCaching?: boolean;
|
||||
disableOffscreenCaching?: boolean;
|
||||
disableGlyphCaching?: boolean;
|
||||
disableGfx?: boolean;
|
||||
enablePrinting?: boolean;
|
||||
printerName?: string;
|
||||
enableDrive?: boolean;
|
||||
driveName?: string;
|
||||
drivePath?: string;
|
||||
createDrivePath?: boolean;
|
||||
disableDownload?: boolean;
|
||||
disableUpload?: boolean;
|
||||
enableTouch?: boolean;
|
||||
clientName?: string;
|
||||
console?: boolean;
|
||||
initialProgram?: string;
|
||||
serverLayout?: string;
|
||||
timezone?: string;
|
||||
gatewayHostname?: string;
|
||||
gatewayPort?: number;
|
||||
gatewayUsername?: string;
|
||||
gatewayPassword?: string;
|
||||
gatewayDomain?: string;
|
||||
remoteApp?: string;
|
||||
remoteAppDir?: string;
|
||||
remoteAppArgs?: string;
|
||||
normalizeClipboard?: string;
|
||||
disableCopy?: boolean;
|
||||
disablePaste?: boolean;
|
||||
cursor?: string;
|
||||
swapRedBlue?: boolean;
|
||||
readOnly?: boolean;
|
||||
recordingPath?: string;
|
||||
recordingName?: string;
|
||||
createRecordingPath?: boolean;
|
||||
recordingExcludeOutput?: boolean;
|
||||
recordingExcludeMouse?: boolean;
|
||||
recordingIncludeKeys?: boolean;
|
||||
wolSendPacket?: boolean;
|
||||
wolMacAddr?: string;
|
||||
wolBroadcastAddr?: string;
|
||||
wolUdpPort?: number;
|
||||
wolWaitTime?: number;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getGuacamoleConnectionId } from "./guacamole-api";
|
||||
import type { ConnectionOrigin } from "@/lib/connection-origin";
|
||||
import type { ConnectionOrigin } from "@termix/plugin-sdk/ui";
|
||||
|
||||
export function watchGuacamoleConnectionId(
|
||||
connectId: string,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
|
||||
import type { GuacamoleConfig } from "./guacamole-config";
|
||||
|
||||
export const PLUGIN_ID = "remote-desktop";
|
||||
|
||||
export type Protocol = "rdp" | "vnc" | "telnet";
|
||||
|
||||
/** This plugin's host settings, as they arrive on host.pluginSettings. */
|
||||
export interface RemoteHostOptions {
|
||||
enableRdp: boolean;
|
||||
enableVnc: boolean;
|
||||
enableTelnet: boolean;
|
||||
rdpPort: number;
|
||||
vncPort: number;
|
||||
telnetPort: number;
|
||||
rdpSecurity: string;
|
||||
rdpIgnoreCert: boolean;
|
||||
guacamoleConfig: GuacamoleConfig;
|
||||
enableToolbar: boolean;
|
||||
}
|
||||
|
||||
export const ENABLE_KEY = {
|
||||
rdp: "enableRdp",
|
||||
vnc: "enableVnc",
|
||||
telnet: "enableTelnet",
|
||||
} as const satisfies Record<Protocol, keyof RemoteHostOptions>;
|
||||
|
||||
export const PORT_KEY = {
|
||||
rdp: "rdpPort",
|
||||
vnc: "vncPort",
|
||||
telnet: "telnetPort",
|
||||
} as const satisfies Record<Protocol, keyof RemoteHostOptions>;
|
||||
|
||||
export const DEFAULT_PORT: Record<Protocol, number> = {
|
||||
rdp: 3389,
|
||||
vnc: 5900,
|
||||
telnet: 23,
|
||||
};
|
||||
|
||||
/** The core fields holding a protocol's login, which stay on the host. */
|
||||
export interface RemoteHostLogin {
|
||||
id?: number | string;
|
||||
name?: string | null;
|
||||
ip?: string;
|
||||
domain?: string;
|
||||
syncId?: string | null;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
rdpAuthType?: string;
|
||||
rdpUser?: string;
|
||||
rdpPassword?: string;
|
||||
vncUser?: string;
|
||||
vncPassword?: string;
|
||||
authOverrides?: Record<
|
||||
string,
|
||||
{
|
||||
credentialId?: number | string | null;
|
||||
required?: boolean;
|
||||
ownerAuthShared?: boolean;
|
||||
}
|
||||
>;
|
||||
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function port(value: unknown, fallback: number): number {
|
||||
const parsed = typeof value === "string" ? Number(value) : value;
|
||||
return typeof parsed === "number" && Number.isInteger(parsed) && parsed > 0
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function parseGuacamoleConfig(value: unknown): GuacamoleConfig {
|
||||
if (!value) return {};
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return typeof value === "object" ? (value as GuacamoleConfig) : {};
|
||||
}
|
||||
|
||||
export function remoteOptions(
|
||||
settings: Record<string, unknown> | undefined,
|
||||
): RemoteHostOptions {
|
||||
const values = settings ?? {};
|
||||
return {
|
||||
enableRdp: values.enableRdp === true,
|
||||
enableVnc: values.enableVnc === true,
|
||||
enableTelnet: values.enableTelnet === true,
|
||||
rdpPort: port(values.rdpPort, DEFAULT_PORT.rdp),
|
||||
vncPort: port(values.vncPort, DEFAULT_PORT.vnc),
|
||||
telnetPort: port(values.telnetPort, DEFAULT_PORT.telnet),
|
||||
rdpSecurity:
|
||||
typeof values.rdpSecurity === "string" ? values.rdpSecurity : "",
|
||||
rdpIgnoreCert: values.rdpIgnoreCert === true,
|
||||
guacamoleConfig: parseGuacamoleConfig(values.guacamoleConfig),
|
||||
enableToolbar: values.enableToolbar !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export function hostRemoteOptions(
|
||||
host:
|
||||
| Pick<RemoteHostLogin, "pluginSettings">
|
||||
| PluginHostRecord
|
||||
| null
|
||||
| undefined,
|
||||
): RemoteHostOptions {
|
||||
const bag = (host as RemoteHostLogin | null | undefined)?.pluginSettings;
|
||||
return remoteOptions(bag?.[PLUGIN_ID]);
|
||||
}
|
||||
|
||||
export function protocolEnabled(
|
||||
host:
|
||||
| Pick<RemoteHostLogin, "pluginSettings">
|
||||
| PluginHostRecord
|
||||
| null
|
||||
| undefined,
|
||||
protocol: Protocol,
|
||||
): boolean {
|
||||
return hostRemoteOptions(host)[ENABLE_KEY[protocol]];
|
||||
}
|
||||
|
||||
/** Quick Connect hosts are never saved; their ids carry this prefix. */
|
||||
const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
|
||||
|
||||
export function isQuickConnectHost(host: { id?: unknown } | null | undefined) {
|
||||
return String(host?.id ?? "").startsWith(QUICK_CONNECT_ID_PREFIX);
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown, fallback = "Unknown error") {
|
||||
const data = (error as { response?: { data?: { error?: unknown } } })
|
||||
?.response?.data?.error;
|
||||
if (typeof data === "string" && data) return data;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === "string" && error) return error;
|
||||
return fallback;
|
||||
}
|
||||
@@ -1,23 +1,21 @@
|
||||
import type { ComponentType, Ref } from "react";
|
||||
import i18next from "i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Info,
|
||||
MessagesSquare,
|
||||
Monitor,
|
||||
MonitorUp,
|
||||
MousePointerClick,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
type PluginHostRecord,
|
||||
type StandaloneViewProps,
|
||||
type TabProps,
|
||||
type TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { FakeSwitch, SectionCard, SettingRow } from "@termix/plugin-sdk/ui";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { isQuickConnectHost } from "@/sidebar/quick-connect-host";
|
||||
import { SectionCard, isElectron } from "@termix/plugin-sdk/ui";
|
||||
import { toast } from "sonner";
|
||||
import GuacamoleApp, { type GuacamoleAppHandle } from "./GuacamoleApp";
|
||||
import { GuacamoleDisplay } from "./GuacamoleDisplay";
|
||||
import {
|
||||
@@ -25,48 +23,70 @@ import {
|
||||
HostEditorTelnetTab,
|
||||
HostEditorVncTab,
|
||||
} from "./HostEditorGuacamoleTabs";
|
||||
import { getGuacamoleTokenFromHost } from "./guacamole-api";
|
||||
import {
|
||||
getGuacamoleTokenFromHost,
|
||||
getGuacdStatus,
|
||||
nativeRdpAvailable,
|
||||
openNativeRdp,
|
||||
setRemoteDesktopApp,
|
||||
} from "./guacamole-api";
|
||||
import { quickConnectGuacHost } from "./quick-connect-guac-host";
|
||||
import { REMOTE_DESKTOP_TOOLBAR_SLOT } from "./GuacamoleToolbar.tsx";
|
||||
|
||||
type Protocol = "rdp" | "vnc" | "telnet";
|
||||
import {
|
||||
DEFAULT_PORT,
|
||||
ENABLE_KEY,
|
||||
PORT_KEY,
|
||||
hostRemoteOptions,
|
||||
isQuickConnectHost,
|
||||
protocolEnabled,
|
||||
type Protocol,
|
||||
type RemoteHostLogin,
|
||||
} from "./host-remote";
|
||||
import { remoteDesktopForm } from "./remote-form";
|
||||
|
||||
const PROTOCOLS: {
|
||||
id: Protocol;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
paletteKey: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
enableKey: "enableRdp" | "enableVnc" | "enableTelnet";
|
||||
priority: number;
|
||||
order: number;
|
||||
quickConnect?: { showDomain?: boolean };
|
||||
}[] = [
|
||||
{
|
||||
id: "rdp",
|
||||
titleKey: "hosts.tabRdp",
|
||||
descriptionKey: "hosts.remoteDesktop",
|
||||
paletteKey: "palette.connectRdp",
|
||||
icon: Monitor,
|
||||
enableKey: "enableRdp",
|
||||
priority: 50,
|
||||
order: 100,
|
||||
quickConnect: { showDomain: true },
|
||||
},
|
||||
{
|
||||
id: "vnc",
|
||||
titleKey: "hosts.tabVnc",
|
||||
descriptionKey: "hosts.virtualNetwork",
|
||||
paletteKey: "palette.connectVnc",
|
||||
icon: MousePointerClick,
|
||||
enableKey: "enableVnc",
|
||||
priority: 40,
|
||||
order: 110,
|
||||
quickConnect: {},
|
||||
},
|
||||
{
|
||||
id: "telnet",
|
||||
titleKey: "hosts.tabTelnet",
|
||||
descriptionKey: "hosts.unencryptedShell",
|
||||
paletteKey: "palette.connectTelnet",
|
||||
icon: MessagesSquare,
|
||||
enableKey: "enableTelnet",
|
||||
priority: 30,
|
||||
order: 120,
|
||||
},
|
||||
];
|
||||
|
||||
function RemoteDesktopTab({ tab, host, isVisible, handleRef }: TabProps) {
|
||||
const shellHost = host as unknown as Host;
|
||||
const record = host as unknown as RemoteHostLogin | undefined;
|
||||
return (
|
||||
<GuacamoleApp
|
||||
ref={handleRef as Ref<GuacamoleAppHandle>}
|
||||
@@ -75,8 +95,8 @@ function RemoteDesktopTab({ tab, host, isVisible, handleRef }: TabProps) {
|
||||
protocol={tab.type as Protocol}
|
||||
isVisible={isVisible}
|
||||
quickConnectHost={
|
||||
isQuickConnectHost(shellHost)
|
||||
? quickConnectGuacHost(shellHost)
|
||||
record && isQuickConnectHost(record)
|
||||
? quickConnectGuacHost(record)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -111,79 +131,65 @@ function RemoteDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
/** Applies one remote desktop field and stops inheriting user defaults. */
|
||||
function useGuacFieldSetter(
|
||||
updateForm: HostEditorSectionProps["updateForm"],
|
||||
): (key: string, value: unknown) => void {
|
||||
return (key, value) =>
|
||||
updateForm((current) => ({
|
||||
inheritRemoteDesktopDefaults: false,
|
||||
guacamoleConfig: {
|
||||
...((current.guacamoleConfig as Record<string, unknown>) ?? {}),
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
function SectionNotes({ protocol }: { protocol: Protocol }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("hosts.remoteDesktopNotes")}
|
||||
icon={<Info className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-2 py-3 text-xs text-muted-foreground">
|
||||
{protocol === "rdp" && <p>{t("hosts.userDefaultsNote")}</p>}
|
||||
{isElectron() && <p>{t("hosts.connectionOriginNote")}</p>}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function RdpSection(props: HostEditorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const setGuacField = useGuacFieldSetter(props.updateForm);
|
||||
const { form, setField, setGuacField } = remoteDesktopForm(props);
|
||||
return (
|
||||
<>
|
||||
<SectionCard
|
||||
title={t("hosts.rdpDefaults", { defaultValue: "RDP defaults" })}
|
||||
icon={<Settings className="size-3.5" />}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("hosts.useUserDefaults", {
|
||||
defaultValue: "Use user defaults",
|
||||
})}
|
||||
description={t("hosts.useUserRdpDefaultsDesc", {
|
||||
defaultValue:
|
||||
"Inherit performance, redirection, and clipboard settings from User Profile.",
|
||||
})}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={!!props.form.inheritRemoteDesktopDefaults}
|
||||
onChange={(value) =>
|
||||
props.setField("inheritRemoteDesktopDefaults", value)
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
<HostEditorRdpTab
|
||||
form={props.form}
|
||||
setField={props.setField as never}
|
||||
setGuacField={setGuacField as never}
|
||||
host={props.host as unknown as Host}
|
||||
form={form}
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
host={props.host as { macAddress?: string | null } | undefined}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
<SectionNotes protocol="rdp" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VncSection(props: HostEditorSectionProps) {
|
||||
const setGuacField = useGuacFieldSetter(props.updateForm);
|
||||
const { form, setField, setGuacField } = remoteDesktopForm(props);
|
||||
return (
|
||||
<HostEditorVncTab
|
||||
form={props.form}
|
||||
setField={props.setField as never}
|
||||
setGuacField={setGuacField as never}
|
||||
host={props.host as unknown as Host}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
<>
|
||||
<HostEditorVncTab
|
||||
form={form}
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
host={props.host as { macAddress?: string | null } | undefined}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
<SectionNotes protocol="vnc" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TelnetSection(props: HostEditorSectionProps) {
|
||||
const setGuacField = useGuacFieldSetter(props.updateForm);
|
||||
const { form, setField, setGuacField } = remoteDesktopForm(props);
|
||||
return (
|
||||
<HostEditorTelnetTab
|
||||
form={props.form}
|
||||
setField={props.setField as never}
|
||||
setGuacField={setGuacField as never}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
<>
|
||||
<HostEditorTelnetTab
|
||||
form={form}
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
<SectionNotes protocol="telnet" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,23 +199,96 @@ const SECTIONS: Record<Protocol, ComponentType<HostEditorSectionProps>> = {
|
||||
telnet: TelnetSection,
|
||||
};
|
||||
|
||||
async function openNativeRdp(host: Record<string, unknown>): Promise<void> {
|
||||
const t = (key: string) => i18next.t(`remote-desktop:${key}`);
|
||||
try {
|
||||
const result = await window.electronAPI.openNativeRdp({
|
||||
host: String(host.ip),
|
||||
port: (host.rdpPort as number | undefined) ?? 3389,
|
||||
username: host.rdpUser as string | undefined,
|
||||
domain: host.domain as string | undefined,
|
||||
function registerNativeRdp(app: TermixApp): void {
|
||||
let disposed = false;
|
||||
app.onDispose(() => {
|
||||
disposed = true;
|
||||
});
|
||||
void nativeRdpAvailable().then((available) => {
|
||||
if (disposed || !available) return;
|
||||
app.registerHostAction({
|
||||
id: "rdp-native",
|
||||
titleKey: "hosts.openNativeRdp",
|
||||
icon: MonitorUp,
|
||||
kind: "open",
|
||||
order: 105,
|
||||
when: (host) => protocolEnabled(host, "rdp"),
|
||||
run: (host) => {
|
||||
const record = host as unknown as RemoteHostLogin;
|
||||
void openNativeRdp({
|
||||
host: String(record.ip ?? ""),
|
||||
port: hostRemoteOptions(record).rdpPort,
|
||||
username: record.rdpUser,
|
||||
domain: record.domain,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.success) toast.success(app.t("hosts.nativeRdpOpened"));
|
||||
else toast.error(result.error || app.t("hosts.nativeRdpFailed"));
|
||||
})
|
||||
.catch(() => toast.error(app.t("hosts.nativeRdpFailed")));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function registerHostSurfaces(app: TermixApp): void {
|
||||
for (const protocol of PROTOCOLS) {
|
||||
const when = (host: PluginHostRecord) => protocolEnabled(host, protocol.id);
|
||||
|
||||
app.registerHostProtocol({
|
||||
id: protocol.id,
|
||||
settingKey: ENABLE_KEY[protocol.id],
|
||||
portKey: PORT_KEY[protocol.id],
|
||||
defaultPort: DEFAULT_PORT[protocol.id],
|
||||
titleKey: protocol.titleKey,
|
||||
descriptionKey: protocol.descriptionKey,
|
||||
icon: protocol.icon,
|
||||
order: protocol.order,
|
||||
quickConnect: protocol.quickConnect,
|
||||
});
|
||||
|
||||
app.registerHostAction({
|
||||
id: protocol.id,
|
||||
titleKey: protocol.titleKey,
|
||||
icon: protocol.icon,
|
||||
kind: "connect",
|
||||
priority: protocol.priority,
|
||||
order: protocol.order,
|
||||
tabType: protocol.id,
|
||||
copyUrlView: protocol.id,
|
||||
when,
|
||||
});
|
||||
|
||||
app.registerPaletteEntry({
|
||||
id: `remote-desktop.${protocol.id}`,
|
||||
titleKey: protocol.paletteKey,
|
||||
icon: protocol.icon,
|
||||
keywords: [protocol.id],
|
||||
scope: "host",
|
||||
when: (host) => !!host && when(host),
|
||||
run: (shell, host) => {
|
||||
if (host) shell.openTab(host, protocol.id);
|
||||
},
|
||||
});
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: protocol.id,
|
||||
group: "top",
|
||||
titleKey: protocol.titleKey,
|
||||
icon: protocol.icon,
|
||||
order: protocol.order / 5,
|
||||
visible: (protocols) => !!protocols[ENABLE_KEY[protocol.id]],
|
||||
component: SECTIONS[protocol.id],
|
||||
});
|
||||
if (result.success) toast.success(t("hosts.nativeRdpOpened"));
|
||||
else toast.error(result.error || t("hosts.nativeRdpFailed"));
|
||||
} catch {
|
||||
toast.error(t("hosts.nativeRdpFailed"));
|
||||
}
|
||||
|
||||
if (isElectron()) registerNativeRdp(app);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
setRemoteDesktopApp(app);
|
||||
app.onDispose(() => setRemoteDesktopApp(null));
|
||||
|
||||
// Guest pages only draw shared streams.
|
||||
app.registerSlotContribution("session.remoteDisplay", {
|
||||
actionId: "remote-desktop.display",
|
||||
@@ -253,57 +332,24 @@ export function activate(app: TermixApp): void {
|
||||
noHostMessageKey: "remoteDesktop.noHostSelected",
|
||||
persistent: true,
|
||||
session: true,
|
||||
restore: (host) => !!host[protocol.enableKey],
|
||||
restore: (host) => protocolEnabled(host, protocol.id),
|
||||
activityTypes: [protocol.id],
|
||||
standalone: RemoteDesktopStandalone,
|
||||
preload: () => import("./GuacamoleApp"),
|
||||
});
|
||||
|
||||
app.registerHostAction({
|
||||
id: protocol.id,
|
||||
titleKey: protocol.titleKey,
|
||||
icon: protocol.icon,
|
||||
kind: "connect",
|
||||
priority: protocol.priority,
|
||||
order: protocol.order,
|
||||
tabType: protocol.id,
|
||||
copyUrlView: protocol.id,
|
||||
when: (host) => !!host[protocol.enableKey],
|
||||
});
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: protocol.id,
|
||||
group: "top",
|
||||
titleKey: protocol.titleKey,
|
||||
icon: protocol.icon,
|
||||
order: protocol.order / 5,
|
||||
visible: (protocols) => !!protocols[protocol.enableKey],
|
||||
component: SECTIONS[protocol.id],
|
||||
});
|
||||
}
|
||||
|
||||
// The desktop app on Windows can hand RDP to the system client instead.
|
||||
if (window.electronAPI?.isElectron) {
|
||||
let disposed = false;
|
||||
app.onDispose(() => {
|
||||
disposed = true;
|
||||
// An admin can turn Remote Desktop off; its ways in go with it.
|
||||
let disposed = false;
|
||||
app.onDispose(() => {
|
||||
disposed = true;
|
||||
});
|
||||
void getGuacdStatus("local", { probe: false })
|
||||
.then((status) => status.enabled !== false)
|
||||
.catch(() => true)
|
||||
.then((enabled) => {
|
||||
if (!disposed && enabled) registerHostSurfaces(app);
|
||||
});
|
||||
window.electronAPI
|
||||
.getPlatform()
|
||||
.then((platform: string) => {
|
||||
if (disposed || platform !== "win32") return;
|
||||
app.registerHostAction({
|
||||
id: "rdp-native",
|
||||
titleKey: "hosts.openNativeRdp",
|
||||
icon: MonitorUp,
|
||||
kind: "open",
|
||||
order: 105,
|
||||
when: (host) => !!host.enableRdp,
|
||||
run: (host) => void openNativeRdp(host),
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
app.registerSlotContribution("onboarding.features", {
|
||||
actionId: "remote-desktop.feature",
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { GuacamoleQuickHost } from "./GuacamoleApp";
|
||||
import {
|
||||
hostRemoteOptions,
|
||||
type Protocol,
|
||||
type RemoteHostLogin,
|
||||
} from "./host-remote";
|
||||
|
||||
/** The slice of a quick-connect host that GuacamoleApp mints a token from. */
|
||||
export function quickConnectGuacHost(host: Host): GuacamoleQuickHost {
|
||||
export function quickConnectGuacHost(
|
||||
host: RemoteHostLogin,
|
||||
): GuacamoleQuickHost {
|
||||
const options = hostRemoteOptions(host);
|
||||
const connectionType: Protocol = options.enableVnc ? "vnc" : "rdp";
|
||||
return {
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
connectionType: host.enableVnc ? "vnc" : "rdp",
|
||||
name: host.name ?? undefined,
|
||||
ip: host.ip ?? "",
|
||||
connectionType,
|
||||
domain: host.domain,
|
||||
rdpPort: host.rdpPort,
|
||||
vncPort: host.vncPort,
|
||||
port: connectionType === "vnc" ? options.vncPort : options.rdpPort,
|
||||
rdpAuthType: host.rdpAuthType,
|
||||
rdpUser: host.rdpUser,
|
||||
rdpPassword: host.rdpPassword,
|
||||
vncUser: host.vncUser,
|
||||
vncPassword: host.vncPassword,
|
||||
username: connectionType === "vnc" ? host.vncUser : host.rdpUser,
|
||||
password: connectionType === "vnc" ? host.vncPassword : host.rdpPassword,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HostAuthOverrides } from "@/types/auth-protocols";
|
||||
import type { RemoteHostLogin } from "./host-remote";
|
||||
|
||||
export function needsRdpCredentialPrompt({
|
||||
protocol,
|
||||
@@ -7,7 +7,7 @@ export function needsRdpCredentialPrompt({
|
||||
}: {
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
rdpAuthType?: string;
|
||||
authOverrides?: HostAuthOverrides;
|
||||
authOverrides?: RemoteHostLogin["authOverrides"];
|
||||
}): boolean {
|
||||
return (
|
||||
protocol === "rdp" &&
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { HostEditorSectionProps } from "@termix/plugin-sdk/frontend";
|
||||
import { PLUGIN_ID, remoteOptions } from "./host-remote";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* The host editor fields the RDP, VNC and Telnet tabs edit. The logins are
|
||||
* core host fields; the options (ports, security, guacd settings) are this
|
||||
* plugin's host settings, presented here under the names the tabs use.
|
||||
*/
|
||||
export interface RemoteDesktopForm {
|
||||
domain: string;
|
||||
security: string;
|
||||
ignoreCert: boolean;
|
||||
rdpPort: number;
|
||||
vncPort: number;
|
||||
telnetPort: number;
|
||||
guacamoleConfig: Record<string, any>;
|
||||
rdpAuthType: "direct" | "credential" | "none";
|
||||
rdpCredentialId: string;
|
||||
rdpUser: string;
|
||||
rdpPassword: string;
|
||||
vncAuthType: "direct" | "credential";
|
||||
vncCredentialId: string;
|
||||
vncUser: string;
|
||||
vncPassword: string;
|
||||
telnetAuthType: "direct" | "credential";
|
||||
telnetCredentialId: string;
|
||||
telnetUser: string;
|
||||
telnetPassword: string;
|
||||
statsConfig: Record<string, any>;
|
||||
}
|
||||
|
||||
export type RemoteFormSetField = <K extends keyof RemoteDesktopForm>(
|
||||
key: K,
|
||||
value: RemoteDesktopForm[K],
|
||||
) => void;
|
||||
|
||||
/** Tab field name to this plugin's host setting key. */
|
||||
const SETTING_FOR_FIELD: Partial<Record<keyof RemoteDesktopForm, string>> = {
|
||||
rdpPort: "rdpPort",
|
||||
vncPort: "vncPort",
|
||||
telnetPort: "telnetPort",
|
||||
security: "rdpSecurity",
|
||||
ignoreCert: "rdpIgnoreCert",
|
||||
};
|
||||
|
||||
type PluginSettingsBag = Record<string, Record<string, unknown>>;
|
||||
|
||||
function patchSettings(
|
||||
current: Record<string, unknown>,
|
||||
patch: (settings: Record<string, unknown>) => Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const bag = (current.pluginSettings as PluginSettingsBag | undefined) ?? {};
|
||||
const mine = bag[PLUGIN_ID] ?? {};
|
||||
return {
|
||||
pluginSettings: { ...bag, [PLUGIN_ID]: { ...mine, ...patch(mine) } },
|
||||
};
|
||||
}
|
||||
|
||||
export function remoteDesktopForm(props: HostEditorSectionProps): {
|
||||
form: RemoteDesktopForm;
|
||||
setField: RemoteFormSetField;
|
||||
setGuacField: (key: string, value: unknown) => void;
|
||||
} {
|
||||
const bag = props.form?.pluginSettings as PluginSettingsBag | undefined;
|
||||
const options = remoteOptions(bag?.[PLUGIN_ID]);
|
||||
const form: RemoteDesktopForm = {
|
||||
...(props.form as RemoteDesktopForm),
|
||||
rdpPort: options.rdpPort,
|
||||
vncPort: options.vncPort,
|
||||
telnetPort: options.telnetPort,
|
||||
security: options.rdpSecurity,
|
||||
ignoreCert: options.rdpIgnoreCert,
|
||||
guacamoleConfig: options.guacamoleConfig as Record<string, any>,
|
||||
};
|
||||
|
||||
const setField: RemoteFormSetField = (key, value) => {
|
||||
const setting = SETTING_FOR_FIELD[key];
|
||||
if (!setting) {
|
||||
props.setField(key, value);
|
||||
return;
|
||||
}
|
||||
props.updateForm((current) =>
|
||||
patchSettings(current, () => ({ [setting]: value })),
|
||||
);
|
||||
};
|
||||
|
||||
const setGuacField = (key: string, value: unknown) =>
|
||||
props.updateForm((current) =>
|
||||
patchSettings(current, (mine) => ({
|
||||
guacamoleConfig: {
|
||||
...remoteOptions(mine).guacamoleConfig,
|
||||
[key]: value,
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
return { form, setField, setGuacField };
|
||||
}
|
||||
Reference in new issue
Block a user