mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
Relocates guacamole backend/frontend source and tests into plugins/remote-desktop, following the ssh-terminal/docker pattern.
832 lines
27 KiB
TypeScript
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 };
|