feat: finish the remote-desktop plugin (B14)

This commit is contained in:
LukeGus committed 2026-09-24 01:02:45 -05:00
1 parent 0af2c94983
commit 14eb558b85
215 files changed
+30029 -3921

No files matched your search

@@ -812,9 +812,6 @@ export function registerHostBulkRoutes(
? 1
: 0,
enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh",
enableRdp: hostData.enableRdp ?? false,
enableVnc: hostData.enableVnc ?? false,
enableTelnet: hostData.enableTelnet ?? false,
updatedAt: new Date().toISOString(),
};
@@ -828,21 +825,10 @@ export function registerHostBulkRoutes(
sshDataObj.rdpUser = hostData.rdpUser || null;
sshDataObj.rdpPassword = hostData.rdpPassword || null;
sshDataObj.rdpDomain = hostData.rdpDomain || null;
sshDataObj.rdpSecurity = hostData.rdpSecurity || null;
sshDataObj.rdpIgnoreCert = hostData.rdpIgnoreCert ? 1 : 0;
sshDataObj.rdpPort = hostData.rdpPort || 3389;
sshDataObj.vncUser = hostData.vncUser || null;
sshDataObj.vncPassword = hostData.vncPassword || null;
sshDataObj.vncPort = hostData.vncPort || 5900;
sshDataObj.telnetUser = hostData.telnetUser || null;
sshDataObj.telnetPassword = hostData.telnetPassword || null;
sshDataObj.telnetPort = hostData.telnetPort || 23;
sshDataObj.enableRdp = hostData.enableRdp ? 1 : 0;
sshDataObj.enableVnc = hostData.enableVnc ? 1 : 0;
sshDataObj.enableTelnet = hostData.enableTelnet ? 1 : 0;
sshDataObj.guacamoleConfig = hostData.guacamoleConfig
? JSON.stringify(hostData.guacamoleConfig)
: null;
} else {
sshDataObj.password =
hostData.authType === "password" ? hostData.password : null;
@@ -855,9 +841,6 @@ export function registerHostBulkRoutes(
sshDataObj.keyType =
hostData.authType === "key" ? hostData.keyType || "auto" : null;
sshDataObj.domain = null;
sshDataObj.security = null;
sshDataObj.ignoreCert = 0;
sshDataObj.guacamoleConfig = null;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
@@ -1079,9 +1062,6 @@ export function registerHostBulkRoutes(
portKnockSequence: null,
overrideCredentialUsername: 0,
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
updatedAt: new Date().toISOString(),
};
@@ -23,12 +23,7 @@ export function applyHostKeyTypeUpdate(
}
}
const PROTOCOL_ENABLE_FIELDS = [
"enableSsh",
"enableRdp",
"enableVnc",
"enableTelnet",
] as const;
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
export function normalizeProtocolEnableFields(
values: Record<string, unknown>,
@@ -214,13 +209,7 @@ export type NormalizedImportedHost = Record<string, unknown> & {
portKnockSequence?: unknown;
overrideCredentialUsername?: unknown;
domain?: unknown;
security?: unknown;
ignoreCert?: unknown;
guacamoleConfig?: unknown;
enableSsh: boolean;
enableRdp: boolean;
enableVnc: boolean;
enableTelnet: boolean;
};
export function normalizeImportedHost(
@@ -275,18 +264,6 @@ export function normalizeImportedHost(
hostData.enableSsh === undefined
? connectionType === "ssh"
: asBoolean(hostData.enableSsh),
enableRdp:
hostData.enableRdp === undefined
? connectionType === "rdp"
: asBoolean(hostData.enableRdp),
enableVnc:
hostData.enableVnc === undefined
? connectionType === "vnc"
: asBoolean(hostData.enableVnc),
enableTelnet:
hostData.enableTelnet === undefined
? connectionType === "telnet"
: asBoolean(hostData.enableTelnet),
};
}
@@ -372,14 +349,8 @@ const CONNECT_LEVEL_FIELDS = new Set([
"showDockerInSidebar",
"showServerStatsInSidebar",
"enableSsh",
"enableRdp",
"enableVnc",
"enableTelnet",
"sshPort",
"rdpPort",
"rdpAuthType",
"vncPort",
"telnetPort",
"defaultPath",
"scpLegacy",
"tunnelConnections",
@@ -472,31 +443,10 @@ export function transformHostResponse(
showTunnelInSidebar: !!host.showTunnelInSidebar,
showDockerInSidebar: !!host.showDockerInSidebar,
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
// Old hosts only had connection_type set; the per-protocol enable flags didn't exist yet.
// The schema defaults (enableSsh=true, others=false) wrongly mark every old host as SSH.
// Detect this migration case: if no non-SSH protocol is explicitly enabled AND
// connectionType is set to a non-SSH value, fall back to inferring from connectionType.
...(() => {
const ct = host.connectionType;
const rdp = !!host.enableRdp;
const vnc = !!host.enableVnc;
const tel = !!host.enableTelnet;
const isMigratedNonSsh = !rdp && !vnc && !tel && ct && ct !== "ssh";
return {
enableSsh: isMigratedNonSsh ? false : !!host.enableSsh,
enableRdp: isMigratedNonSsh ? ct === "rdp" : rdp,
enableVnc: isMigratedNonSsh ? ct === "vnc" : vnc,
enableTelnet: isMigratedNonSsh ? ct === "telnet" : tel,
};
})(),
enableSsh: !!host.enableSsh,
sshPort: host.sshPort ?? host.port ?? 22,
rdpPort: host.rdpPort ?? 3389,
vncPort: host.vncPort ?? 5900,
telnetPort: host.telnetPort ?? 23,
rdpUser: host.rdpUser || undefined,
rdpDomain: host.rdpDomain || undefined,
rdpSecurity: host.rdpSecurity || undefined,
rdpIgnoreCert: !!host.rdpIgnoreCert,
vncUser: host.vncUser || undefined,
telnetUser: host.telnetUser || undefined,
tunnelConnections: host.tunnelConnections
@@ -524,10 +474,5 @@ export function transformHostResponse(
? JSON.parse(host.portKnockSequence as string)
: [],
domain: host.domain || undefined,
security: host.security || undefined,
ignoreCert: !!host.ignoreCert,
guacamoleConfig: host.guacamoleConfig
? JSON.parse(host.guacamoleConfig as string)
: undefined,
};
}
+22 -78
View File
@@ -68,7 +68,6 @@ import type {
HostResolutionCredentialRecord,
HostResolutionHostRecord,
} from "../repositories/host-resolution-repository.js";
import { AUTH_PROTOCOL_METADATA } from "../../../types/auth-protocols.js";
import {
requiresPersonalHostAuthentication,
resolveRecipientSharedHostAuthentication,
@@ -216,9 +215,6 @@ router.post(
terminalConfig,
forceKeyboardInteractive,
domain,
security,
ignoreCert,
guacamoleConfig,
notes,
useSocks5,
socks5Host,
@@ -232,20 +228,12 @@ router.post(
macAddress,
wolBroadcastAddress,
enableSsh,
enableRdp,
enableVnc,
enableTelnet,
sshPort,
rdpPort,
vncPort,
telnetPort,
rdpAuthType,
rdpCredentialId,
rdpUser,
rdpPassword,
rdpDomain,
rdpSecurity,
rdpIgnoreCert,
vncAuthType,
vncCredentialId,
vncPassword,
@@ -267,7 +255,7 @@ router.post(
!isNonEmptyString(ip) ||
!isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) ||
![enableSsh, enableRdp, enableVnc, enableTelnet].every(isOptionalBoolean)
!isOptionalBoolean(enableSsh)
) {
sshLogger.warn("Invalid SSH data input validation failed", {
operation: "host_create",
@@ -364,9 +352,6 @@ router.post(
: null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
domain: domain || null,
security: security || null,
ignoreCert: ignoreCert ? 1 : 0,
guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null,
notes: notes || null,
sudoPassword: sudoPassword || null,
useSocks5: useSocks5 ? 1 : 0,
@@ -388,27 +373,22 @@ router.post(
: null,
...normalizeProtocolEnableFields(hostData),
sshPort: sshPort || port || 22,
rdpPort: rdpPort || 3389,
vncPort: vncPort || 5900,
telnetPort: telnetPort || 23,
rdpAuthType: enableRdp ? rdpAuthType || null : null,
rdpAuthType: rdpAuthType || null,
rdpCredentialId:
enableRdp && rdpAuthType === "credential" && rdpCredentialId
rdpAuthType === "credential" && rdpCredentialId
? rdpCredentialId
: null,
rdpUser: rdpUser || null,
rdpDomain: rdpDomain || null,
rdpSecurity: rdpSecurity || null,
rdpIgnoreCert: rdpIgnoreCert ? 1 : 0,
vncAuthType: enableVnc ? vncAuthType || null : null,
vncAuthType: vncAuthType || null,
vncCredentialId:
enableVnc && vncAuthType === "credential" && vncCredentialId
vncAuthType === "credential" && vncCredentialId
? vncCredentialId
: null,
vncUser: vncUser || null,
telnetAuthType: enableTelnet ? telnetAuthType || null : null,
telnetAuthType: telnetAuthType || null,
telnetCredentialId:
enableTelnet && telnetAuthType === "credential" && telnetCredentialId
telnetAuthType === "credential" && telnetCredentialId
? telnetCredentialId
: null,
telnetUser: telnetUser || null,
@@ -932,9 +912,6 @@ router.put(
terminalConfig,
forceKeyboardInteractive,
domain,
security,
ignoreCert,
guacamoleConfig,
notes,
useSocks5,
socks5Host,
@@ -948,20 +925,12 @@ router.put(
macAddress,
wolBroadcastAddress,
enableSsh,
enableRdp,
enableVnc,
enableTelnet,
sshPort,
rdpPort,
vncPort,
telnetPort,
rdpAuthType,
rdpCredentialId,
rdpUser,
rdpPassword,
rdpDomain,
rdpSecurity,
rdpIgnoreCert,
vncAuthType,
vncCredentialId,
vncPassword,
@@ -983,9 +952,7 @@ router.put(
!isNonEmptyString(ip) ||
!isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) ||
![enableSsh, enableRdp, enableVnc, enableTelnet].every(
isOptionalBoolean,
) ||
!isOptionalBoolean(enableSsh) ||
!hostId
) {
sshLogger.warn("Invalid SSH data input validation failed for update", {
@@ -1083,9 +1050,6 @@ router.put(
: null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
domain: domain || null,
security: security || null,
ignoreCert: ignoreCert ? 1 : 0,
guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null,
notes: notes || null,
sudoPassword: sudoPassword || null,
useSocks5: useSocks5 ? 1 : 0,
@@ -1107,27 +1071,22 @@ router.put(
: null,
...normalizeProtocolEnableFields(hostData),
sshPort: sshPort || port || 22,
rdpPort: rdpPort || 3389,
vncPort: vncPort || 5900,
telnetPort: telnetPort || 23,
rdpAuthType: enableRdp ? rdpAuthType || null : null,
rdpAuthType: rdpAuthType || null,
rdpCredentialId:
enableRdp && rdpAuthType === "credential" && rdpCredentialId
rdpAuthType === "credential" && rdpCredentialId
? rdpCredentialId
: null,
rdpUser: rdpUser || null,
rdpDomain: rdpDomain || null,
rdpSecurity: rdpSecurity || null,
rdpIgnoreCert: rdpIgnoreCert ? 1 : 0,
vncAuthType: enableVnc ? vncAuthType || null : null,
vncAuthType: vncAuthType || null,
vncCredentialId:
enableVnc && vncAuthType === "credential" && vncCredentialId
vncAuthType === "credential" && vncCredentialId
? vncCredentialId
: null,
vncUser: vncUser || null,
telnetAuthType: enableTelnet ? telnetAuthType || null : null,
telnetAuthType: telnetAuthType || null,
telnetCredentialId:
enableTelnet && telnetAuthType === "credential" && telnetCredentialId
telnetAuthType === "credential" && telnetCredentialId
? telnetCredentialId
: null,
telnetUser: telnetUser || null,
@@ -2085,9 +2044,7 @@ router.get(
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
const isRemoteDesktop = ["rdp", "vnc", "telnet"].includes(
exportedConnectionType,
);
const isRemoteDesktop = exportedConnectionType !== "ssh";
const baseExportData = {
exportId: resolvedHost.id,
@@ -2104,24 +2061,18 @@ router.get(
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: hostPluginSettings ?? {},
};
const exportData = isRemoteDesktop
? {
...baseExportData,
enableRdp: !!resolvedHost.enableRdp,
enableVnc: !!resolvedHost.enableVnc,
enableTelnet: !!resolvedHost.enableTelnet,
rdpPort: resolvedHost.rdpPort || 3389,
vncPort: resolvedHost.vncPort || 5900,
telnetPort: resolvedHost.telnetPort || 23,
rdpAuthType: resolvedHost.rdpAuthType || null,
rdpCredentialId: resolvedHost.rdpCredentialId || null,
rdpUser: resolvedHost.rdpUser || null,
rdpPassword: resolvedHost.rdpPassword || null,
rdpDomain: resolvedHost.rdpDomain || null,
rdpSecurity: resolvedHost.rdpSecurity || null,
rdpIgnoreCert: !!resolvedHost.rdpIgnoreCert,
vncAuthType: resolvedHost.vncAuthType || null,
vncCredentialId: resolvedHost.vncCredentialId || null,
vncUser: resolvedHost.vncUser || null,
@@ -2130,9 +2081,6 @@ router.get(
telnetCredentialId: resolvedHost.telnetCredentialId || null,
telnetUser: resolvedHost.telnetUser || null,
telnetPassword: resolvedHost.telnetPassword || null,
guacamoleConfig: resolvedHost.guacamoleConfig
? JSON.parse(resolvedHost.guacamoleConfig as string)
: null,
}
: {
...baseExportData,
@@ -2273,9 +2221,7 @@ router.get(
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
const isRemoteDesktop = ["rdp", "vnc", "telnet"].includes(
exportedConnectionType,
);
const isRemoteDesktop = exportedConnectionType !== "ssh";
const baseExportData = {
exportId: resolvedHost.id,
@@ -2292,17 +2238,14 @@ router.get(
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: hostPluginSettings ?? {},
};
const exportData = isRemoteDesktop
? {
...baseExportData,
domain: resolvedHost.domain || null,
security: resolvedHost.security || null,
ignoreCert: !!resolvedHost.ignoreCert,
guacamoleConfig: resolvedHost.guacamoleConfig
? JSON.parse(resolvedHost.guacamoleConfig as string)
: null,
}
: {
...baseExportData,
@@ -2638,8 +2581,9 @@ async function resolveHostCredentials(
host.id,
requestingUserId,
);
// Whether a protocol is on is the remote-desktop plugin's host
// setting; the client only offers an override for one that is.
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
if (!host[AUTH_PROTOCOL_METADATA[protocol].enableField]) continue;
authOverrides[protocol] = {
credentialId: overrideCredentialIds[protocol],
required: false,
@@ -39,11 +39,10 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
customThemes: row?.customThemes ?? null,
customKeybindings: row?.customKeybindings ?? null,
terminalDefaults: row?.terminalDefaults ?? null,
rdpDefaults: row?.rdpDefaults ?? null,
terminalMacros: row?.terminalMacros ?? null,
});
const connectionDefaultFields = ["terminalDefaults", "rdpDefaults"] as const;
const connectionDefaultFields = ["terminalDefaults"] as const;
export function validateDefaultsJson(value: string): boolean {
if (value.length > 32_768) return false;
@@ -233,7 +232,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
customThemes,
customKeybindings,
terminalDefaults,
rdpDefaults,
terminalMacros,
} = req.body as {
reopenTabsOnLogin?: boolean;
@@ -256,7 +254,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
customThemes?: string | null;
customKeybindings?: string | null;
terminalDefaults?: string | null;
rdpDefaults?: string | null;
terminalMacros?: string | null;
};
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
@@ -289,7 +286,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
customThemes,
customKeybindings,
terminalDefaults,
rdpDefaults,
terminalMacros,
})) {
if (value !== undefined && value !== null && typeof value !== "string") {
@@ -299,7 +295,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
const connectionDefaults = {
terminalDefaults,
rdpDefaults,
};
for (const key of connectionDefaultFields) {
const value = connectionDefaults[key];
@@ -435,7 +430,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
updates.customKeybindings = customKeybindings;
if (terminalDefaults !== undefined)
updates.terminalDefaults = terminalDefaults;
if (rdpDefaults !== undefined) updates.rdpDefaults = rdpDefaults;
if (terminalMacros !== undefined) updates.terminalMacros = terminalMacros;
if (Object.keys(updates).length === 1) {
@@ -1,12 +1,6 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import { getErrorMessage } from "../../utils/error-message.js";
import type { RequestHandler, Router } from "express";
// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes
// through the same in-core bridge its router is mounted behind, rather than
// importing the plugin's TypeScript directly, since plugins/ is compiled
// separately from src/backend/ and core cannot statically import across that
// boundary. See guacamole-dispatch.ts and that plugin's README.
import { restartGuacamoleService as restartGuacServer } from "../../hosts/guacamole-sessions.js";
import {
authLogger,
getGlobalLogLevel,
@@ -31,10 +25,6 @@ import {
} from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
function getDefaultGuacUrl(): string {
return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`;
}
export type HostDefaults = {
useSocks5?: boolean;
socks5Host?: string;
@@ -66,119 +56,6 @@ export function registerUserSettingsRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/guacamole-settings:
* get:
* summary: Get Guacamole settings
* description: Returns current guacd enabled status and host:port URL. No authentication required.
* tags:
* - Users
* responses:
* 200:
* description: Guacamole settings.
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* url:
* type: string
* 500:
* description: Failed to get guacamole settings.
*/
router.get("/guacamole-settings", authenticateJWT, async (_req, res) => {
try {
const settings = createCurrentSettingsRepository();
const enabled = await settings.getBoolean("guac_enabled", true);
const url = await settings.get("guac_url");
res.json({
enabled,
url: url ?? getDefaultGuacUrl(),
});
} catch (err) {
authLogger.error("Failed to get guacamole settings", err);
res.status(500).json({ error: "Failed to get guacamole settings" });
}
});
/**
* @openapi
* /users/guacamole-settings:
* patch:
* summary: Update Guacamole settings
* description: Admin-only. Updates guacd enabled status and/or host:port URL.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* url:
* type: string
* responses:
* 200:
* description: Guacamole settings updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update guacamole settings.
*/
router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { enabled, url } = req.body;
const settings = createCurrentSettingsRepository();
if (typeof enabled === "boolean") {
await settings.set("guac_enabled", enabled ? "true" : "false");
}
if (typeof url === "string") {
await settings.set("guac_url", url);
try {
await restartGuacServer();
} catch (err) {
authLogger.error(
"Failed to restart guac server after URL update",
err,
);
}
}
const currentEnabled = await settings.getBoolean("guac_enabled", true);
const currentUrl = await settings.get("guac_url");
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_guacamole_settings",
resourceType: "setting",
details: JSON.stringify({ enabled, url }),
ipAddress,
userAgent,
success: true,
});
res.json({
enabled: currentEnabled,
url: currentUrl ?? getDefaultGuacUrl(),
});
} catch (err) {
authLogger.error("Failed to update guacamole settings", err);
res.status(500).json({ error: "Failed to update guacamole settings" });
}
});
/**
* @openapi
* /users/log-level: