Files
Termix/plugins/remote-desktop/backend/routes.ts
T
LukeGus c6bcfdb80d feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
2026-09-21 20:25:52 -05:00

832 lines
27 KiB
TypeScript

import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
import express from "express";
import { GuacamoleTokenService } from "./token-service.js";
import { withRecordingSettings } from "./recording-settings.js";
import { withDriveSettings } from "./drive-settings.js";
import { guacLogger } from "../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
import {
resolveRecipientSharedHostAuthentication,
type RecipientSharedHostAuthResolution,
} from "../../../src/backend/utils/shared-host-auth-resolver.js";
import type { AuthOverrideProtocol } from "../../../src/types/auth-protocols.js";
import net from "net";
import crypto from "crypto";
import path from "path";
import type { AuthenticatedRequest } from "../../../src/types/index.js";
import {
createCurrentHostResolutionRepository,
createCurrentSettingsRepository,
} from "../../../src/backend/database/repositories/factory.js";
import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js";
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
import { getGuacSessionByConnectId } from "./guacamole-server.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../../src/backend/utils/audit-logger.js";
import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js";
import {
buildRdpSettings,
resolveRdpAuthTypeForConnect,
resolveRdpDomain,
} from "./rdp-settings.js";
import { createMacosVncCompatibilityProxy } from "./macos-vnc-proxy.js";
const router = express.Router();
const tokenService = GuacamoleTokenService.getInstance();
const authManager = AuthManager.getInstance();
const DATA_DIR = process.env.DATA_DIR || "./db/data";
router.use(authManager.createAuthMiddleware());
router.get("/connection/:connectId", (req: AuthenticatedRequest, res) => {
if (!req.userId)
return res.status(401).json({ error: "Authentication required" });
const session = getGuacSessionByConnectId(
String(req.params.connectId),
req.userId,
);
res.json({ guacamoleConnectionId: session?.guacamoleConnectionId ?? null });
});
/**
* @openapi
* /guacamole/token:
* post:
* summary: Generate an encrypted Guacamole connection token
* description: Creates an AES-256-CBC encrypted token for guacamole-lite with the given connection parameters
* tags:
* - Guacamole
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - type
* - hostname
* properties:
* type:
* type: string
* enum: [rdp, vnc, telnet]
* hostname:
* type: string
* port:
* type: integer
* username:
* type: string
* password:
* type: string
* domain:
* type: string
* responses:
* 200:
* description: Encrypted connection token
* content:
* application/json:
* schema:
* type: object
* properties:
* token:
* type: string
* 400:
* description: Invalid request
* 500:
* description: Server error
*/
router.post("/token", async (req, res) => {
try {
const { type, hostname, port, username, password, domain, ...rawOptions } =
req.body;
// Strip "auto" sentinel values before forwarding to guacd
const options: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawOptions)) {
if (value !== "auto") options[key] = value;
}
if (!type || !hostname) {
return res
.status(400)
.json({ error: "Missing required fields: type and hostname" });
}
if (!["rdp", "vnc", "telnet"].includes(type)) {
return res.status(400).json({
error: "Invalid connection type. Must be rdp, vnc, or telnet",
});
}
let token: string;
switch (type) {
case "rdp":
token = tokenService.createRdpToken(
hostname,
username || "",
password || "",
{
port: port || 3389,
domain,
...options,
},
);
break;
case "vnc":
token = tokenService.createVncToken(
hostname,
username || undefined,
password,
{
port: port || 5900,
...options,
},
);
break;
case "telnet":
token = tokenService.createTelnetToken(hostname, username, password, {
port: port || 23,
...options,
});
break;
default:
return res.status(400).json({ error: "Invalid connection type" });
}
res.json({ token });
} catch (error) {
guacLogger.error("Failed to generate guacamole token", error, {
operation: "guac_token_error",
});
res.status(500).json({ error: "Failed to generate connection token" });
}
});
/**
* @openapi
* /guacamole/connect-host/{hostId}:
* post:
* summary: Generate Guacamole connection token from host configuration
* description: Fetches host configuration from database and generates a connection token for RDP/VNC/Telnet
* tags:
* - Guacamole
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* description: Host ID to connect to
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* protocol:
* type: string
* enum: [rdp, vnc, telnet]
* description: Override the host's default connection type
* promptedUsername:
* type: string
* description: Username for this connection only, used when the host's RDP auth type is "none". Not persisted.
* promptedPassword:
* type: string
* description: Password for this connection only, used when the host's RDP auth type is "none". Not persisted.
* responses:
* 200:
* description: Connection token generated successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* token:
* type: string
* description: Encrypted connection token
* guacamoleConnectionId:
* type: string
* nullable: true
* description: Null until the WebSocket handshake completes. Query /guacamole/connection/{connectId} for the session-sharing ID.
* termixConnectId:
* type: string
* description: Correlation ID for looking up this connection after the WebSocket opens.
* 400:
* description: Invalid request or unsupported connection type
* 403:
* description: Access denied to host
* 404:
* description: Host not found
* 500:
* description: Server error
*/
router.post(
"/connect-host/:hostId",
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const hostId = Number.parseInt(String(req.params.hostId), 10);
if (!hostId || isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
const hostResolutionRepository = createCurrentHostResolutionRepository();
// Decrypt under the owner's DEK; shared hosts carry owner-encrypted fields.
const hostOwnerId =
await hostResolutionRepository.findHostOwnerId(hostId);
const host = hostOwnerId
? await hostResolutionRepository.findHostById(hostId, hostOwnerId)
: null;
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
if (host.userId !== userId) {
const permissionManager = PermissionManager.getInstance();
const accessInfo = await permissionManager.canAccessHost(
userId,
hostId,
"connect",
);
if (!accessInfo.hasAccess) {
guacLogger.warn("User attempted to access host without permission", {
operation: "guac_access_denied",
userId,
hostId,
});
return res.status(403).json({ error: "Access denied to this host" });
}
}
const requestedProtocol = req.body?.protocol as string | undefined;
const connectionType =
requestedProtocol || (host.connectionType as string);
if (!["rdp", "vnc", "telnet"].includes(connectionType)) {
return res.status(400).json({
error: `Connection type '${connectionType}' is not supported for remote desktop. Only RDP, VNC, and Telnet are supported.`,
});
}
// Old hosts only had connectionType set; enableRdp/enableVnc/enableTelnet defaulted to false.
// Apply the same migration fallback used in host.ts GET routes.
const ct = host.connectionType as string;
const rdpRaw = !!host.enableRdp;
const vncRaw = !!host.enableVnc;
const telRaw = !!host.enableTelnet;
const isMigratedNonSsh =
!rdpRaw && !vncRaw && !telRaw && ct && ct !== "ssh";
const protocolEnabledMap: Record<string, boolean> = {
rdp: isMigratedNonSsh ? ct === "rdp" : rdpRaw,
vnc: isMigratedNonSsh ? ct === "vnc" : vncRaw,
telnet: isMigratedNonSsh ? ct === "telnet" : telRaw,
};
if (!protocolEnabledMap[connectionType]) {
return res.status(400).json({
error: `${connectionType.toUpperCase()} is not enabled for this host.`,
});
}
let guacConfig: Record<string, unknown> = {};
if (host.guacamoleConfig) {
try {
guacConfig =
typeof host.guacamoleConfig === "string"
? JSON.parse(host.guacamoleConfig as string)
: (host.guacamoleConfig as Record<string, unknown>);
} catch (error) {
guacLogger.warn("Failed to parse guacamole config", {
operation: "guac_config_parse_error",
hostId,
error: getErrorMessage(error),
});
}
}
// Strip "auto" sentinel values — these mean "use guacd default" in the UI
// but guacd doesn't recognise "auto" as a valid parameter value.
for (const key of Object.keys(guacConfig)) {
if (guacConfig[key] === "auto") {
delete guacConfig[key];
}
}
// Extract per-connection guacd proxy settings before passing the rest as connection settings
const perConnectionGuacdHost = guacConfig["guacd-hostname"] as
string | undefined;
const perConnectionGuacdPortRaw = guacConfig["guacd-port"];
const perConnectionGuacdPort = perConnectionGuacdPortRaw
? parseInt(String(perConnectionGuacdPortRaw), 10) || undefined
: undefined;
delete guacConfig["guacd-hostname"];
delete guacConfig["guacd-port"];
if (guacConfig.dpi != null) {
const parsed = parseInt(String(guacConfig.dpi), 10);
guacConfig.dpi =
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
const hostRecord = host as Record<string, unknown>;
const hostRepository = hostResolutionRepository;
const isSharedConnection = host.userId !== userId;
let sharedAuthResolution: RecipientSharedHostAuthResolution | null = null;
if (isSharedConnection) {
// Recipients never read the owner's raw secrets; wipe them and use
// the per-recipient snapshot for the requested protocol instead.
host.password = null;
host.rdpUser = null;
host.rdpPassword = null;
host.vncUser = null;
host.vncPassword = null;
host.telnetUser = null;
host.telnetPassword = null;
try {
const resolution = await resolveRecipientSharedHostAuthentication(
host,
hostId,
userId,
connectionType as AuthOverrideProtocol,
);
sharedAuthResolution = resolution;
const auth =
resolution.source === "personal-override"
? { ...resolution.credential, domain: null }
: resolution.source === "owner-shared"
? resolution.secret
: null;
if (auth) {
if (connectionType === "rdp") {
host.rdpUser = auth.username ?? null;
host.rdpPassword = auth.password ?? null;
if (auth.domain) host.rdpDomain = auth.domain;
} else if (connectionType === "vnc") {
host.vncUser = auth.username ?? null;
host.vncPassword = auth.password ?? null;
} else if (connectionType === "telnet") {
host.telnetUser = auth.username ?? null;
host.telnetPassword = auth.password ?? null;
}
}
} catch (e) {
guacLogger.warn("Failed to resolve shared host auth", {
operation: "guac_shared_auth_resolve",
hostId,
protocol: connectionType,
error: getErrorMessage(e, "Unknown"),
});
}
} else {
// Backward compat: if authType is not stored but a credentialId is, treat as credential mode
const rdpEffectiveAuthType =
(host.rdpAuthType as string) ||
(host.rdpCredentialId ? "credential" : "direct");
const vncEffectiveAuthType =
(host.vncAuthType as string) ||
(host.vncCredentialId ? "credential" : "direct");
const telnetEffectiveAuthType =
(host.telnetAuthType as string) ||
(hostRecord.telnetCredentialId ? "credential" : "direct");
if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) {
try {
const cred = await hostRepository.findCredentialByIdForUser(
host.rdpCredentialId as number,
host.userId as string,
);
if (cred) {
if (cred.username) host.rdpUser = cred.username;
if (cred.password) host.rdpPassword = cred.password;
// domain is never sourced from credential
}
} catch (e) {
guacLogger.warn("Failed to resolve RDP credential", {
operation: "guac_rdp_credential_resolve",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
}
if (vncEffectiveAuthType === "credential" && host.vncCredentialId) {
try {
const cred = await hostRepository.findCredentialByIdForUser(
host.vncCredentialId as number,
host.userId as string,
);
if (cred) {
if (cred.password) host.vncPassword = cred.password;
if (cred.username) host.vncUser = cred.username;
}
} catch (e) {
guacLogger.warn("Failed to resolve VNC credential", {
operation: "guac_vnc_credential_resolve",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
}
if (
telnetEffectiveAuthType === "credential" &&
hostRecord.telnetCredentialId
) {
try {
const cred = await hostRepository.findCredentialByIdForUser(
hostRecord.telnetCredentialId as number,
host.userId as string,
);
if (cred) {
if (cred.username) host.telnetUser = cred.username;
if (cred.password) host.telnetPassword = cred.password;
}
} catch (e) {
guacLogger.warn("Failed to resolve Telnet credential", {
operation: "guac_telnet_credential_resolve",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
}
}
let token: string;
let hostname = host.ip as string;
let port = host.port as number;
let username: string;
let password: string;
const rdpAuthTypeForConnect = resolveRdpAuthTypeForConnect({
storedAuthType: host.rdpAuthType as string | null,
credentialId: host.rdpCredentialId as number | null,
sharedResolution: isSharedConnection ? sharedAuthResolution : undefined,
});
switch (connectionType) {
case "rdp":
if (rdpAuthTypeForConnect === "none") {
username = String(req.body?.promptedUsername || "");
password = String(req.body?.promptedPassword || "");
} else {
username =
(host.rdpUser as string) || (host.username as string) || "";
password =
(host.rdpPassword as string) || (host.password as string) || "";
}
port = (host.rdpPort as number) || port || 3389;
break;
case "vnc":
username = (host.vncUser as string) || "";
password =
(host.vncPassword as string) || (host.password as string) || "";
port = (host.vncPort as number) || port || 5900;
break;
case "telnet":
username = (host.telnetUser as string) || "";
password =
(host.telnetPassword as string) || (host.password as string) || "";
port = (host.telnetPort as number) || port || 23;
break;
default:
username = "";
password = "";
}
const storedDomain =
(host.rdpDomain as string) || (host.domain as string) || "";
const domain = resolveRdpDomain(
rdpAuthTypeForConnect,
req.body?.promptedDomain,
storedDomain,
);
// Establish SSH tunnel if jump hosts are configured
let jumpHosts: Array<{ hostId: number }> = [];
if (host.jumpHosts) {
try {
jumpHosts =
typeof host.jumpHosts === "string"
? JSON.parse(host.jumpHosts as string)
: (host.jumpHosts as Array<{ hostId: number }>);
} catch {
jumpHosts = [];
}
}
let tunnelEndpoint: ReturnType<typeof resolveJumpTunnelEndpoint> | null =
null;
if (jumpHosts.length > 0 || (connectionType === "vnc" && !username)) {
let guacdUrl: string | undefined;
try {
guacdUrl =
(await createCurrentSettingsRepository().get("guac_url")) ??
undefined;
} catch {
// Environment/default guacd configuration remains available.
}
const guacdHost =
perConnectionGuacdHost || resolveGuacdOptions(guacdUrl).host;
tunnelEndpoint = resolveJumpTunnelEndpoint(guacdHost);
}
if (jumpHosts.length > 0) {
try {
// 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 jumpClient = await createJumpHostChain(jumpHosts, userId);
if (!jumpClient) {
guacLogger.error(
"Failed to establish jump host chain for guacamole",
undefined,
{ operation: "guac_ssh_tunnel_error", hostId },
);
return res.status(500).json({
error: "Failed to establish SSH tunnel to remote host",
});
}
const targetHostname = hostname;
const targetPort = port;
const tunnelPort = await new Promise<number>((resolve, reject) => {
const server = net.createServer((sock) => {
jumpClient.forwardOut(
"127.0.0.1",
0,
targetHostname,
targetPort,
(err, stream) => {
if (err) {
sock.destroy();
return;
}
sock.pipe(stream).pipe(sock);
},
);
});
server.on("error", reject);
server.listen(0, tunnelEndpoint!.bindHost, () => {
const addr = server.address() as net.AddressInfo;
// Auto-cleanup after 1 hour
setTimeout(
() => {
server.close();
jumpClient.end();
},
60 * 60 * 1000,
);
resolve(addr.port);
});
});
hostname = tunnelEndpoint!.advertisedHost;
port = tunnelPort;
guacLogger.info("SSH tunnel established for guacamole", {
operation: "guac_ssh_tunnel",
hostId,
tunnelPort,
});
} catch (tunnelError) {
guacLogger.error("Failed to establish SSH tunnel", tunnelError, {
operation: "guac_ssh_tunnel_error",
hostId,
});
return res.status(500).json({
error: "Failed to establish SSH tunnel to remote host",
});
}
}
if (connectionType === "vnc" && !username) {
try {
const proxy = await createMacosVncCompatibilityProxy({
targetHost: hostname,
targetPort: port,
bindHost: tunnelEndpoint!.bindHost,
});
hostname = tunnelEndpoint!.advertisedHost;
port = proxy.port;
setTimeout(proxy.close, 60 * 60 * 1000).unref();
guacLogger.info("VNC compatibility proxy established", {
operation: "guac_vnc_compatibility_proxy",
hostId,
proxyPort: port,
});
} catch (proxyError) {
guacLogger.error(
"Failed to establish VNC compatibility proxy",
proxyError,
{
operation: "guac_vnc_compatibility_proxy_error",
hostId,
},
);
return res.status(500).json({
error: "Failed to establish VNC compatibility proxy",
});
}
}
const guacdOverrides = {
...(perConnectionGuacdHost
? { guacdHost: perConnectionGuacdHost }
: {}),
...(perConnectionGuacdPort
? { guacdPort: perConnectionGuacdPort }
: {}),
};
const recordingEnabled =
connectionType !== "vnc" && host.enableSessionLogging !== false;
const recordingName = `${crypto.randomUUID()}.guac`;
const recordingPath =
process.env.GUACD_RECORDING_PATH ||
process.env.GUACD_RECORDING_BACKEND_PATH ||
path.resolve(DATA_DIR, "session_recordings", "guacamole");
const recordingMetadata = recordingEnabled
? {
hostId,
userId,
protocol: connectionType as "rdp" | "vnc" | "telnet",
path: recordingName,
guacdPath: recordingPath,
startedAt: new Date().toISOString(),
}
: undefined;
if (recordingEnabled) {
guacConfig = withRecordingSettings(
guacConfig,
recordingPath,
recordingName,
);
}
const termixConnectId = crypto.randomUUID();
const termixMeta = {
termixConnectId,
hostId,
ownerUserId: userId,
protocol: connectionType as "rdp" | "vnc" | "telnet",
};
switch (connectionType) {
case "rdp":
guacConfig = withDriveSettings(guacConfig, userId);
token = tokenService.createRdpToken(
hostname,
username,
password,
buildRdpSettings({
port,
domain,
security:
(host.rdpSecurity as string) ||
(host.security as string) ||
undefined,
ignoreCert:
host.rdpIgnoreCert !== undefined
? !!host.rdpIgnoreCert
: host.ignoreCert !== undefined
? !!host.ignoreCert
: true,
guacConfig,
guacdOverrides,
}),
recordingMetadata,
termixMeta,
);
break;
case "vnc":
token = tokenService.createVncToken(
hostname,
username || undefined,
password,
{
port,
...guacConfig,
...guacdOverrides,
},
recordingMetadata,
termixMeta,
);
break;
case "telnet":
token = tokenService.createTelnetToken(
hostname,
username,
password,
{
port,
...guacConfig,
...guacdOverrides,
},
recordingMetadata,
termixMeta,
);
break;
default:
return res.status(400).json({ error: "Invalid connection type" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: `${connectionType}_connect`,
resourceType: "host",
resourceId: String(hostId),
resourceName: `${hostname}:${port}`,
ipAddress,
userAgent,
success: true,
});
res.json({
token,
termixConnectId,
guacamoleConnectionId: null,
});
} catch (error) {
guacLogger.error("Failed to generate guacamole token for host", error, {
operation: "guac_host_token_error",
});
res.status(500).json({ error: "Failed to generate connection token" });
}
},
);
/**
* GET /guacamole/status
* Check if guacd is reachable
*/
router.get("/status", async (req, res) => {
try {
let dbUrl: string | undefined;
try {
dbUrl =
(await createCurrentSettingsRepository().get("guac_url")) ?? undefined;
} catch {
// Fall back to env vars
}
const { host: guacdHost, port: guacdPort } = resolveGuacdOptions(dbUrl);
const net = await import("net");
const checkConnection = (): Promise<boolean> => {
return new Promise((resolve) => {
const socket = new net.Socket();
socket.setTimeout(3000);
socket.on("connect", () => {
socket.destroy();
resolve(true);
});
socket.on("timeout", () => {
socket.destroy();
resolve(false);
});
socket.on("error", () => {
socket.destroy();
resolve(false);
});
socket.connect(guacdPort, guacdHost);
});
};
const isConnected = await checkConnection();
res.json({
guacd: {
host: guacdHost,
port: guacdPort,
status: isConnected ? "connected" : "disconnected",
},
websocket: {
port: 30008,
status: "running",
},
});
} catch (error) {
guacLogger.error("Failed to check guacamole status", error, {
operation: "guac_status_error",
});
res.status(500).json({ error: "Failed to check status" });
}
});
export { router };