mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/ telnet, widens hostCapability to accept an array, and renames the guacamole i18n namespace to remoteDesktop.
This commit is contained in:
1 parent
c6bcfdb80d
commit
76b2e5739a
41 files changed
+361
-686
No files matched your search
@@ -6,11 +6,12 @@
|
|||||||
* tsc only emits .ts, so without a copy step the plugins directory simply
|
* tsc only emits .ts, so without a copy step the plugins directory simply
|
||||||
* would not exist in dist and the loader would find nothing.
|
* would not exist in dist and the loader would find nothing.
|
||||||
*
|
*
|
||||||
* docker and host-metrics are the exception: their backends are real
|
* docker, host-metrics and remote-desktop are the exception: their backends
|
||||||
* TypeScript, physically relocated from src/backend/hosts/docker/ and
|
* are real TypeScript, physically relocated from src/backend/hosts/docker/,
|
||||||
* src/backend/hosts/metrics/ rather than kept as hand-written JS, because
|
* src/backend/hosts/metrics/ and src/backend/hosts/guacamole/ rather than
|
||||||
* each is thousands of lines of typed SSH/session logic that is not worth
|
* kept as hand-written JS, because each is thousands of lines of typed
|
||||||
* hand-transpiling. tsconfig.plugins.json compiles each plugin backend's
|
* SSH/session (or, for remote-desktop, guacd protocol) logic that is not
|
||||||
|
* worth hand-transpiling. tsconfig.plugins.json compiles each plugin backend's
|
||||||
* TypeScript to a sibling .js next to its source. Their imports into core
|
* TypeScript to a sibling .js next to its source. Their imports into core
|
||||||
* (e.g. "../../../src/backend/utils/logger.js") are written relative to the
|
* (e.g. "../../../src/backend/utils/logger.js") are written relative to the
|
||||||
* TypeScript SOURCE tree so tsc can type-check them, since plugins/ is not
|
* TypeScript SOURCE tree so tsc can type-check them, since plugins/ is not
|
||||||
|
|||||||
@@ -154,14 +154,32 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hostCapability": {
|
"hostCapability": {
|
||||||
"type": "object",
|
"oneOf": [
|
||||||
"additionalProperties": false,
|
{
|
||||||
"required": ["key", "labelKey", "editorTab"],
|
"type": "object",
|
||||||
"properties": {
|
"additionalProperties": false,
|
||||||
"key": { "type": "string", "minLength": 1 },
|
"required": ["key", "labelKey", "editorTab"],
|
||||||
"labelKey": { "type": "string", "minLength": 1 },
|
"properties": {
|
||||||
"editorTab": { "type": "string", "minLength": 1 }
|
"key": { "type": "string", "minLength": 1 },
|
||||||
}
|
"labelKey": { "type": "string", "minLength": 1 },
|
||||||
|
"editorTab": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["key", "labelKey", "editorTab"],
|
||||||
|
"properties": {
|
||||||
|
"key": { "type": "string", "minLength": 1 },
|
||||||
|
"labelKey": { "type": "string", "minLength": 1 },
|
||||||
|
"editorTab": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"permissionGroup": {
|
"permissionGroup": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -396,11 +396,26 @@ function validateContributes(contributes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ("hostCapability" in contributes) {
|
if ("hostCapability" in contributes) {
|
||||||
for (const field of ["key", "labelKey", "editorTab"]) {
|
const raw = contributes.hostCapability;
|
||||||
if (!(field in (contributes.hostCapability || {}))) {
|
const entries = Array.isArray(raw) ? raw : [raw];
|
||||||
errors.push(`contributes.hostCapability.${field} is required`);
|
const seenKeys = new Set();
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
const hc = entry || {};
|
||||||
|
const prefix = Array.isArray(raw)
|
||||||
|
? `contributes.hostCapability[${index}]`
|
||||||
|
: "contributes.hostCapability";
|
||||||
|
for (const field of ["key", "labelKey", "editorTab"]) {
|
||||||
|
if (!(field in hc)) {
|
||||||
|
errors.push(`${prefix}.${field} is required`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (typeof hc.key === "string") {
|
||||||
|
if (seenKeys.has(hc.key)) {
|
||||||
|
errors.push(`${prefix}.key is a duplicate: "${hc.key}"`);
|
||||||
|
}
|
||||||
|
seenKeys.add(hc.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("permissionGroup" in contributes) {
|
if ("permissionGroup" in contributes) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import workspaceRoutes from "./routes/workspaces.js";
|
|||||||
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||||
import terminalRoutes from "./routes/terminal.js";
|
import terminalRoutes from "./routes/terminal.js";
|
||||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||||
import guacamoleRoutes from "../hosts/guacamole/routes.js";
|
import guacamoleDispatch from "./routes/guacamole-dispatch.js";
|
||||||
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
||||||
import collabRoutes from "../hosts/collab/routes.js";
|
import collabRoutes from "../hosts/collab/routes.js";
|
||||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||||
@@ -1758,7 +1758,7 @@ app.use("/workspaces", workspaceRoutes);
|
|||||||
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
||||||
app.use("/terminal", terminalRoutes);
|
app.use("/terminal", terminalRoutes);
|
||||||
app.use("/session_logs", sessionLogRoutes);
|
app.use("/session_logs", sessionLogRoutes);
|
||||||
app.use("/guacamole", guacamoleRoutes);
|
app.use("/guacamole", guacamoleDispatch);
|
||||||
app.use("/session-sharing", sessionSharingRoutes);
|
app.use("/session-sharing", sessionSharingRoutes);
|
||||||
app.use("/collab", collabRoutes);
|
app.use("/collab", collabRoutes);
|
||||||
app.use("/network-topology", networkTopologyRoutes);
|
app.use("/network-topology", networkTopologyRoutes);
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
import type { RequestHandler, Router } from "express";
|
import type { RequestHandler, Router } from "express";
|
||||||
import { restartGuacServer } from "../../hosts/guacamole/guacamole-server.js";
|
// 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 "./guacamole-dispatch.js";
|
||||||
import {
|
import {
|
||||||
authLogger,
|
authLogger,
|
||||||
getGlobalLogLevel,
|
getGlobalLogLevel,
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import {
|
|||||||
getAuditUsername,
|
getAuditUsername,
|
||||||
getRequestMeta,
|
getRequestMeta,
|
||||||
} from "../../utils/audit-logger.js";
|
} from "../../utils/audit-logger.js";
|
||||||
import { GuacamoleTokenService } from "../guacamole/token-service.js";
|
// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes
|
||||||
|
// through the same in-core bridge its router is mounted behind, since
|
||||||
|
// plugins/ is compiled separately from src/backend/ and core cannot
|
||||||
|
// statically import across that boundary. See
|
||||||
|
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
|
||||||
|
// README -- this is an accepted, temporary coupling until collab becomes a
|
||||||
|
// plugin of its own.
|
||||||
|
import { createGuacamoleJoinToken } from "../../database/routes/guacamole-dispatch.js";
|
||||||
import { collabRoomHub } from "./room-hub.js";
|
import { collabRoomHub } from "./room-hub.js";
|
||||||
import { getStageController, setStageController } from "./stage-control.js";
|
import { getStageController, setStageController } from "./stage-control.js";
|
||||||
import { isCollabGuestRateLimited } from "./guest-rate-limit.js";
|
import { isCollabGuestRateLimited } from "./guest-rate-limit.js";
|
||||||
@@ -41,7 +48,6 @@ import type { CollabRoomRecord } from "../../database/repositories/collab-room-r
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const tokenService = GuacamoleTokenService.getInstance();
|
|
||||||
|
|
||||||
const STAGE_SHARE_EXPIRY_HOURS = 12;
|
const STAGE_SHARE_EXPIRY_HOURS = 12;
|
||||||
const MAX_INVITE_TARGETS = 200;
|
const MAX_INVITE_TARGETS = 200;
|
||||||
@@ -657,7 +663,7 @@ router.get(
|
|||||||
};
|
};
|
||||||
if (protocol !== "ssh") {
|
if (protocol !== "ssh") {
|
||||||
stage.connectParams = {
|
stage.connectParams = {
|
||||||
token: tokenService.createJoinToken(share.sessionId, true),
|
token: createGuacamoleJoinToken(share.sessionId, true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
res.json({ stage });
|
res.json({ stage });
|
||||||
@@ -1089,7 +1095,7 @@ router.get("/guest/:token", async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
connectParams: {
|
connectParams: {
|
||||||
token: tokenService.createJoinToken(share.sessionId, true),
|
token: createGuacamoleJoinToken(share.sessionId, true),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { sessionManager } from "../terminal/session-manager.js";
|
import { sessionManager } from "../terminal/session-manager.js";
|
||||||
import { getGuacSessionInfo } from "../guacamole/guacamole-server.js";
|
// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes
|
||||||
|
// through the same in-core bridge its router is mounted behind, since
|
||||||
|
// plugins/ is compiled separately from src/backend/ and core cannot
|
||||||
|
// statically import across that boundary. See
|
||||||
|
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
|
||||||
|
// README -- this is an accepted, temporary coupling until collab/
|
||||||
|
// session-sharing themselves become plugins.
|
||||||
|
import { getGuacamoleSessionInfo as getGuacSessionInfo } from "../../database/routes/guacamole-dispatch.js";
|
||||||
import {
|
import {
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentSettingsRepository,
|
createCurrentSettingsRepository,
|
||||||
|
|||||||
@@ -10,14 +10,20 @@ import {
|
|||||||
isLiveSessionOwnedBy,
|
isLiveSessionOwnedBy,
|
||||||
isSharingEnabledForHost,
|
isSharingEnabledForHost,
|
||||||
} from "./live-sessions.js";
|
} from "./live-sessions.js";
|
||||||
import { GuacamoleTokenService } from "../guacamole/token-service.js";
|
// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes
|
||||||
|
// through the same in-core bridge its router is mounted behind, since
|
||||||
|
// plugins/ is compiled separately from src/backend/ and core cannot
|
||||||
|
// statically import across that boundary. See
|
||||||
|
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
|
||||||
|
// README -- this is an accepted, temporary coupling until session-sharing
|
||||||
|
// becomes a plugin of its own.
|
||||||
|
import { createGuacamoleJoinToken } from "../../database/routes/guacamole-dispatch.js";
|
||||||
import { createCurrentSessionShareRepository } from "../../database/repositories/factory.js";
|
import { createCurrentSessionShareRepository } from "../../database/repositories/factory.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const permissionManager = PermissionManager.getInstance();
|
const permissionManager = PermissionManager.getInstance();
|
||||||
const tokenService = GuacamoleTokenService.getInstance();
|
|
||||||
|
|
||||||
const DEFAULT_EXPIRY_HOURS = 24;
|
const DEFAULT_EXPIRY_HOURS = 24;
|
||||||
const MAX_EXPIRY_HOURS = 24 * 30;
|
const MAX_EXPIRY_HOURS = 24 * 30;
|
||||||
@@ -406,7 +412,7 @@ router.get("/resolve/:linkToken", async (req: Request, res: Response) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (protocol !== "ssh") {
|
if (protocol !== "ssh") {
|
||||||
const joinToken = tokenService.createJoinToken(
|
const joinToken = createGuacamoleJoinToken(
|
||||||
share.sessionId,
|
share.sessionId,
|
||||||
share.permissionLevel === "read-only",
|
share.permissionLevel === "read-only",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,6 +62,12 @@
|
|||||||
* do, verifies host keys via SSHHostKeyVerifier, and streams its discovery
|
* do, verifies host keys via SSHHostKeyVerifier, and streams its discovery
|
||||||
* progress as server-sent events. Neither the worker's 13-field read-only
|
* progress as server-sent events. Neither the worker's 13-field read-only
|
||||||
* ctx.hosts nor its single-postMessage-reply ctx.http.route can support that.
|
* ctx.hosts nor its single-postMessage-reply ctx.http.route can support that.
|
||||||
|
*
|
||||||
|
* remote-desktop is here for the same reason ssh-terminal is: it owns a
|
||||||
|
* WebSocket server (guacamole-lite, port 30008) and its
|
||||||
|
* /connect-host/:hostId route does multi-step credential/tunnel resolution
|
||||||
|
* (SSH jump-host tunnels, a macOS VNC compatibility proxy) using raw
|
||||||
|
* net.Socket plumbing that cannot cross a structured-clone boundary.
|
||||||
*/
|
*/
|
||||||
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||||
"ssh-terminal",
|
"ssh-terminal",
|
||||||
@@ -69,6 +75,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
|||||||
"host-metrics",
|
"host-metrics",
|
||||||
"ai",
|
"ai",
|
||||||
"proxmox",
|
"proxmox",
|
||||||
|
"remote-desktop",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
|
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export interface PluginManifest {
|
|||||||
icon: string;
|
icon: string;
|
||||||
openFrom: string[];
|
openFrom: string[];
|
||||||
}>;
|
}>;
|
||||||
hostCapability?: { key: string; labelKey: string; editorTab: string };
|
hostCapability?: HostCapabilityContribution | HostCapabilityContribution[];
|
||||||
permissionGroup?: {
|
permissionGroup?: {
|
||||||
group: string;
|
group: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
@@ -161,6 +161,19 @@ export interface PluginManifest {
|
|||||||
sidecars: Array<{ id: string; binary: string }>;
|
sidecars: Array<{ id: string; binary: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A host-level checkbox this plugin contributes to the host editor (e.g.
|
||||||
|
* "Enable RDP"), backed by an existing boolean column on the host record.
|
||||||
|
* Most plugins declare one; a plugin covering several independent protocols
|
||||||
|
* on the same host (rdp/vnc/telnet) declares several -- see
|
||||||
|
* contributes.hostCapability, which accepts either shape.
|
||||||
|
*/
|
||||||
|
export interface HostCapabilityContribution {
|
||||||
|
key: string;
|
||||||
|
labelKey: string;
|
||||||
|
editorTab: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginServiceProvide {
|
export interface PluginServiceProvide {
|
||||||
service: string;
|
service: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -540,15 +553,26 @@ function validateContributes(contributes: Record<string, unknown>): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ("hostCapability" in contributes) {
|
if ("hostCapability" in contributes) {
|
||||||
const hostCapability = (contributes.hostCapability ?? {}) as Record<
|
const raw = contributes.hostCapability;
|
||||||
string,
|
const entries = Array.isArray(raw) ? raw : [raw];
|
||||||
unknown
|
const seenKeys = new Set<string>();
|
||||||
>;
|
entries.forEach((entry, index) => {
|
||||||
for (const field of ["key", "labelKey", "editorTab"]) {
|
const hc = (entry ?? {}) as Record<string, unknown>;
|
||||||
if (!(field in hostCapability)) {
|
const prefix = Array.isArray(raw)
|
||||||
errors.push(`contributes.hostCapability.${field} is required`);
|
? `contributes.hostCapability[${index}]`
|
||||||
|
: "contributes.hostCapability";
|
||||||
|
for (const field of ["key", "labelKey", "editorTab"]) {
|
||||||
|
if (!(field in hc)) {
|
||||||
|
errors.push(`${prefix}.${field} is required`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (typeof hc.key === "string") {
|
||||||
|
if (seenKeys.has(hc.key)) {
|
||||||
|
errors.push(`${prefix}.key is a duplicate: "${hc.key}"`);
|
||||||
|
}
|
||||||
|
seenKeys.add(hc.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("permissionGroup" in contributes) {
|
if ("permissionGroup" in contributes) {
|
||||||
|
|||||||
+5
-26
@@ -299,11 +299,11 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
|||||||
// ssh-terminal, docker and host-metrics plugins start their own servers,
|
// ssh-terminal, docker and host-metrics plugins start their own servers,
|
||||||
// so disabling any of them stops its WS/HTTP server. See
|
// so disabling any of them stops its WS/HTTP server. See
|
||||||
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
|
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
|
||||||
// AI and Proxmox are also absent for the same reason: each plugin
|
// AI, Proxmox and Remote Desktop are also absent for the same reason:
|
||||||
// registers its router with its own dispatcher in database.ts on
|
// each plugin registers its router with its own dispatcher in
|
||||||
// activate (/ai, /proxmox), so disabling either makes its routes 404
|
// database.ts on activate (/ai, /proxmox, /guacamole), so disabling any
|
||||||
// instead of leaving a dead import here. See plugins/ai and
|
// of them makes its routes 404 instead of leaving a dead import here.
|
||||||
// plugins/proxmox.
|
// See plugins/ai, plugins/proxmox and plugins/remote-desktop.
|
||||||
await import("./hosts/tunnel/index.js");
|
await import("./hosts/tunnel/index.js");
|
||||||
await import("./hosts/file-manager/index.js");
|
await import("./hosts/file-manager/index.js");
|
||||||
await import("./hosts/tmux/index.js");
|
await import("./hosts/tmux/index.js");
|
||||||
@@ -322,27 +322,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Guacamole server for RDP/VNC/Telnet support
|
|
||||||
const guacEnabled = getCurrentSettingValue("guac_enabled") !== "false";
|
|
||||||
|
|
||||||
if (process.env.ENABLE_GUACAMOLE !== "false" && guacEnabled) {
|
|
||||||
import("./hosts/guacamole/guacamole-server.js")
|
|
||||||
.then(() => {
|
|
||||||
systemLogger.info("Guacamole server initialized", {
|
|
||||||
operation: "guac_init",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
systemLogger.warn(
|
|
||||||
"Failed to initialize Guacamole server (guacd may not be available)",
|
|
||||||
{
|
|
||||||
operation: "guac_init_skip",
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// After metrics, which the automation triggers and headless polling hook into.
|
// After metrics, which the automation triggers and headless polling hook into.
|
||||||
const { startAutomationScheduler } =
|
const { startAutomationScheduler } =
|
||||||
await import("./automations/scheduler.js");
|
await import("./automations/scheduler.js");
|
||||||
|
|||||||
@@ -56,13 +56,9 @@ vi.mock("../../../utils/audit-logger.js", () => ({
|
|||||||
getAuditUsername: vi.fn(async (id: string) => id.toUpperCase()),
|
getAuditUsername: vi.fn(async (id: string) => id.toUpperCase()),
|
||||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../hosts/guacamole/token-service.js", () => ({
|
vi.mock("../../../database/routes/guacamole-dispatch.js", () => ({
|
||||||
GuacamoleTokenService: {
|
createGuacamoleJoinToken: (id: string, readOnly: boolean) =>
|
||||||
getInstance: () => ({
|
`join:${id}:${readOnly}`,
|
||||||
createJoinToken: (id: string, readOnly: boolean) =>
|
|
||||||
`join:${id}:${readOnly}`,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../hosts/collab/room-hub.js", () => ({
|
vi.mock("../../../hosts/collab/room-hub.js", () => ({
|
||||||
collabRoomHub: {
|
collabRoomHub: {
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { withDriveSettings } from "../../../hosts/guacamole/drive-settings.js";
|
|
||||||
|
|
||||||
describe("withDriveSettings", () => {
|
|
||||||
it("gives each user a folder under GUACD_DRIVE_PATH and creates it", () => {
|
|
||||||
expect(
|
|
||||||
withDriveSettings({ "enable-drive": true }, "user-1", {
|
|
||||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive/",
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
"enable-drive": true,
|
|
||||||
"drive-path": "/termix-data/rdp-drive/user-1",
|
|
||||||
"create-drive-path": true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to /drive when the environment says nothing", () => {
|
|
||||||
expect(
|
|
||||||
withDriveSettings({ "enable-drive": true }, "user-1", {}),
|
|
||||||
).toMatchObject({ "drive-path": "/drive/user-1" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves a host-chosen drive-path alone", () => {
|
|
||||||
const config = { "enable-drive": true, "drive-path": "/mnt/share" };
|
|
||||||
expect(
|
|
||||||
withDriveSettings(config, "user-1", { GUACD_DRIVE_PATH: "/x" }),
|
|
||||||
).toBe(config);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when the drive is not enabled", () => {
|
|
||||||
const config = { "enable-drive": false };
|
|
||||||
expect(withDriveSettings(config, "user-1")).toBe(config);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { resolveJumpTunnelEndpoint } from "../../../hosts/guacamole/jump-tunnel-endpoint.js";
|
|
||||||
|
|
||||||
describe("resolveJumpTunnelEndpoint", () => {
|
|
||||||
it("keeps an in-process guacd tunnel on loopback", () => {
|
|
||||||
expect(resolveJumpTunnelEndpoint("localhost")).toEqual({
|
|
||||||
bindHost: "127.0.0.1",
|
|
||||||
advertisedHost: "127.0.0.1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes the tunnel to a separate guacd container", () => {
|
|
||||||
expect(resolveJumpTunnelEndpoint("guacd")).toEqual({
|
|
||||||
bindHost: "0.0.0.0",
|
|
||||||
advertisedHost: "termix",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports a custom backend hostname for external guacd", () => {
|
|
||||||
expect(
|
|
||||||
resolveJumpTunnelEndpoint("guacd.example", "termix-backend"),
|
|
||||||
).toEqual({
|
|
||||||
bindHost: "0.0.0.0",
|
|
||||||
advertisedHost: "termix-backend",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import net from "net";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { createMacosVncCompatibilityProxy } from "../../../hosts/guacamole/macos-vnc-proxy.js";
|
|
||||||
|
|
||||||
const closers: Array<() => void> = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const close of closers.splice(0)) close();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function listen(server: net.Server): Promise<number> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
server.once("error", reject);
|
|
||||||
server.listen(0, "127.0.0.1", () => {
|
|
||||||
resolve((server.address() as net.AddressInfo).port);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function read(socket: net.Socket, length: number): Promise<Buffer> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let received = Buffer.alloc(0);
|
|
||||||
const onData = (chunk: Buffer) => {
|
|
||||||
received = Buffer.concat([received, chunk]);
|
|
||||||
if (received.length < length) return;
|
|
||||||
socket.off("data", onData);
|
|
||||||
resolve(received);
|
|
||||||
};
|
|
||||||
socket.on("data", onData);
|
|
||||||
socket.once("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("createMacosVncCompatibilityProxy", () => {
|
|
||||||
it("normalizes Apple's private RFB banner and forces classic VNC auth", async () => {
|
|
||||||
let clientBanner = "";
|
|
||||||
let selectedSecurityType = 0;
|
|
||||||
const target = net.createServer((socket) => {
|
|
||||||
socket.write("RFB 003.");
|
|
||||||
socket.write("889\n");
|
|
||||||
socket.once("data", (data) => {
|
|
||||||
clientBanner = data.toString("ascii");
|
|
||||||
socket.write(Buffer.from([5, 30, 33]));
|
|
||||||
socket.write(Buffer.from([36, 2, 35]));
|
|
||||||
socket.once("data", (selection) => {
|
|
||||||
selectedSecurityType = selection[0];
|
|
||||||
socket.write("desktop-data");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const targetPort = await listen(target);
|
|
||||||
closers.push(() => target.close());
|
|
||||||
|
|
||||||
const proxy = await createMacosVncCompatibilityProxy({
|
|
||||||
targetHost: "127.0.0.1",
|
|
||||||
targetPort,
|
|
||||||
bindHost: "127.0.0.1",
|
|
||||||
});
|
|
||||||
closers.push(proxy.close);
|
|
||||||
|
|
||||||
const client = net.createConnection(proxy.port, "127.0.0.1");
|
|
||||||
closers.push(() => client.destroy());
|
|
||||||
expect((await read(client, 12)).subarray(0, 12).toString("ascii")).toBe(
|
|
||||||
"RFB 003.008\n",
|
|
||||||
);
|
|
||||||
client.write("RFB 003.008\n");
|
|
||||||
expect([...(await read(client, 2))]).toEqual([1, 2]);
|
|
||||||
client.write(Buffer.from([2]));
|
|
||||||
expect((await read(client, 12)).toString("ascii")).toBe("desktop-data");
|
|
||||||
expect(clientBanner).toBe("RFB 003.008\n");
|
|
||||||
expect(selectedSecurityType).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes standard RFB negotiation through unchanged", async () => {
|
|
||||||
const target = net.createServer((socket) => {
|
|
||||||
socket.write("RFB 003.008\n");
|
|
||||||
socket.once("data", () => socket.write(Buffer.from([2, 30, 2])));
|
|
||||||
});
|
|
||||||
const targetPort = await listen(target);
|
|
||||||
closers.push(() => target.close());
|
|
||||||
const proxy = await createMacosVncCompatibilityProxy({
|
|
||||||
targetHost: "127.0.0.1",
|
|
||||||
targetPort,
|
|
||||||
bindHost: "127.0.0.1",
|
|
||||||
});
|
|
||||||
closers.push(proxy.close);
|
|
||||||
|
|
||||||
const client = net.createConnection(proxy.port, "127.0.0.1");
|
|
||||||
closers.push(() => client.destroy());
|
|
||||||
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
|
|
||||||
client.write("RFB 003.008\n");
|
|
||||||
expect([...(await read(client, 3))]).toEqual([2, 30, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes Apple's security types through when VNC auth is unavailable", async () => {
|
|
||||||
const target = net.createServer((socket) => {
|
|
||||||
socket.write("RFB 003.889\n");
|
|
||||||
socket.once("data", () => socket.write(Buffer.from([2, 30, 33])));
|
|
||||||
});
|
|
||||||
const targetPort = await listen(target);
|
|
||||||
closers.push(() => target.close());
|
|
||||||
const proxy = await createMacosVncCompatibilityProxy({
|
|
||||||
targetHost: "127.0.0.1",
|
|
||||||
targetPort,
|
|
||||||
bindHost: "127.0.0.1",
|
|
||||||
});
|
|
||||||
closers.push(proxy.close);
|
|
||||||
|
|
||||||
const client = net.createConnection(proxy.port, "127.0.0.1");
|
|
||||||
closers.push(() => client.destroy());
|
|
||||||
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
|
|
||||||
client.write("RFB 003.008\n");
|
|
||||||
expect([...(await read(client, 3))]).toEqual([2, 30, 33]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
buildRdpSettings,
|
|
||||||
resolveRdpAuthTypeForConnect,
|
|
||||||
resolveRdpDomain,
|
|
||||||
} from "../../../hosts/guacamole/rdp-settings.js";
|
|
||||||
|
|
||||||
describe("buildRdpSettings", () => {
|
|
||||||
it("keeps saved RDP settings authoritative over stale advanced config", () => {
|
|
||||||
expect(
|
|
||||||
buildRdpSettings({
|
|
||||||
port: 3390,
|
|
||||||
domain: "EXAMPLE",
|
|
||||||
security: "nla",
|
|
||||||
ignoreCert: true,
|
|
||||||
guacConfig: {
|
|
||||||
port: 3389,
|
|
||||||
domain: "OLD",
|
|
||||||
security: "rdp",
|
|
||||||
"ignore-cert": false,
|
|
||||||
"color-depth": 24,
|
|
||||||
},
|
|
||||||
guacdOverrides: { guacdHost: "guacd.internal" },
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
port: 3390,
|
|
||||||
domain: "EXAMPLE",
|
|
||||||
security: "nla",
|
|
||||||
"ignore-cert": true,
|
|
||||||
"color-depth": 24,
|
|
||||||
guacdHost: "guacd.internal",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves an advanced security value when no saved value exists", () => {
|
|
||||||
expect(
|
|
||||||
buildRdpSettings({
|
|
||||||
port: 3389,
|
|
||||||
ignoreCert: false,
|
|
||||||
guacConfig: { security: "tls" },
|
|
||||||
guacdOverrides: {},
|
|
||||||
}).security,
|
|
||||||
).toBe("tls");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the prompted domain for prompt-on-connect authentication", () => {
|
|
||||||
expect(resolveRdpDomain("none", "EXAMPLE", "OLD")).toBe("EXAMPLE");
|
|
||||||
expect(resolveRdpDomain("none", "", "OLD")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the stored domain for saved authentication", () => {
|
|
||||||
expect(resolveRdpDomain("direct", "EXAMPLE", "SAVED")).toBe("SAVED");
|
|
||||||
expect(resolveRdpDomain("none", undefined, "SAVED")).toBe("SAVED");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveRdpAuthTypeForConnect", () => {
|
|
||||||
it("keeps prompt-on-connect authentication for a secretless recipient", () => {
|
|
||||||
expect(
|
|
||||||
resolveRdpAuthTypeForConnect({
|
|
||||||
storedAuthType: "none",
|
|
||||||
sharedResolution: { source: "secretless" },
|
|
||||||
}),
|
|
||||||
).toBe("none");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a recipient override instead of prompting", () => {
|
|
||||||
expect(
|
|
||||||
resolveRdpAuthTypeForConnect({
|
|
||||||
storedAuthType: "none",
|
|
||||||
sharedResolution: { source: "personal-override" },
|
|
||||||
}),
|
|
||||||
).toBe("credential");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses authentication shared by the owner", () => {
|
|
||||||
expect(
|
|
||||||
resolveRdpAuthTypeForConnect({
|
|
||||||
storedAuthType: "credential",
|
|
||||||
sharedResolution: { source: "owner-shared", authType: "credential" },
|
|
||||||
}),
|
|
||||||
).toBe("credential");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { withRecordingSettings } from "../../../hosts/guacamole/recording-settings.js";
|
|
||||||
|
|
||||||
const PATH = "/app/data/session_recordings/guacamole";
|
|
||||||
const NAME = "b7e6c0f2-0000-4000-8000-000000000000.guac";
|
|
||||||
|
|
||||||
describe("withRecordingSettings", () => {
|
|
||||||
it("takes ownership of the location and filename", () => {
|
|
||||||
const merged = withRecordingSettings(
|
|
||||||
{
|
|
||||||
"recording-path": "/var/lib/termix/recordings",
|
|
||||||
"recording-name": "${GUAC_USERNAME}-${GUAC_DATE}",
|
|
||||||
"create-recording-path": false,
|
|
||||||
},
|
|
||||||
PATH,
|
|
||||||
NAME,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(merged).toMatchObject({
|
|
||||||
"recording-path": PATH,
|
|
||||||
"recording-name": NAME,
|
|
||||||
"create-recording-path": true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults the content flags when the host has no opinion", () => {
|
|
||||||
expect(withRecordingSettings({}, PATH, NAME)).toMatchObject({
|
|
||||||
"recording-exclude-output": false,
|
|
||||||
"recording-include-keys": true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the host's content flags, including the falsy ones", () => {
|
|
||||||
const merged = withRecordingSettings(
|
|
||||||
{
|
|
||||||
"recording-exclude-output": true,
|
|
||||||
"recording-include-keys": false,
|
|
||||||
},
|
|
||||||
PATH,
|
|
||||||
NAME,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(merged).toMatchObject({
|
|
||||||
"recording-exclude-output": true,
|
|
||||||
"recording-include-keys": false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves unrelated settings alone", () => {
|
|
||||||
const merged = withRecordingSettings(
|
|
||||||
{ "recording-exclude-mouse": true, width: "1920" },
|
|
||||||
PATH,
|
|
||||||
NAME,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(merged).toMatchObject({
|
|
||||||
"recording-exclude-mouse": true,
|
|
||||||
width: "1920",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not mutate the settings it was given", () => {
|
|
||||||
const original = { "recording-path": "/tmp/mine" };
|
|
||||||
withRecordingSettings(original, PATH, NAME);
|
|
||||||
|
|
||||||
expect(original).toEqual({ "recording-path": "/tmp/mine" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { runInNewContext } from "node:vm";
|
|
||||||
import ts from "typescript";
|
|
||||||
import { expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
it("returns the minted token without waiting for a connection that needs that token", async () => {
|
|
||||||
const source = readFileSync(
|
|
||||||
new URL("../../../hosts/guacamole/routes.ts", import.meta.url),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
const ast = ts.createSourceFile(
|
|
||||||
"routes.ts",
|
|
||||||
source,
|
|
||||||
ts.ScriptTarget.Latest,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const route = ast.statements.find(
|
|
||||||
(s) =>
|
|
||||||
ts.isExpressionStatement(s) &&
|
|
||||||
ts.isCallExpression(s.expression) &&
|
|
||||||
s.expression.arguments[0]?.getText(ast) === '"/connect-host/:hostId"',
|
|
||||||
) as ts.ExpressionStatement;
|
|
||||||
const callback = (route.expression as ts.CallExpression)
|
|
||||||
.arguments[1] as ts.ArrowFunction;
|
|
||||||
const block = (callback.body as ts.Block).statements[0] as ts.TryStatement;
|
|
||||||
const index = block.tryBlock.statements
|
|
||||||
.map((s, i) => (ts.isSwitchStatement(s) ? i : -1))
|
|
||||||
.filter((i) => i >= 0)
|
|
||||||
.at(-1)!;
|
|
||||||
expect(index).toBeGreaterThan(-1);
|
|
||||||
const tail = block.tryBlock.statements
|
|
||||||
.slice(index + 1)
|
|
||||||
.map((s) => s.getText(ast))
|
|
||||||
.join("\n");
|
|
||||||
const json = vi.fn();
|
|
||||||
const wait = vi.fn(() => {
|
|
||||||
throw new Error("Client has not received the token yet");
|
|
||||||
});
|
|
||||||
await runInNewContext(
|
|
||||||
ts.transpileModule(`(async () => { ${tail} })()`, {
|
|
||||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
|
||||||
}).outputText,
|
|
||||||
{
|
|
||||||
waitForGuacdOpen: wait,
|
|
||||||
token: "minted-token",
|
|
||||||
termixConnectId: "correlation-id",
|
|
||||||
req: {},
|
|
||||||
getRequestMeta: () => ({}),
|
|
||||||
logAudit: async () => {},
|
|
||||||
getAuditUsername: async () => "owner",
|
|
||||||
userId: "owner",
|
|
||||||
connectionType: "rdp",
|
|
||||||
hostId: 7,
|
|
||||||
hostname: "host",
|
|
||||||
port: 3389,
|
|
||||||
res: { json },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(wait).not.toHaveBeenCalled();
|
|
||||||
expect(json).toHaveBeenCalledWith({
|
|
||||||
token: "minted-token",
|
|
||||||
termixConnectId: "correlation-id",
|
|
||||||
guacamoleConnectionId: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only reveals the live session ID to its owner", () => {
|
|
||||||
const source = readFileSync(
|
|
||||||
new URL("../../../hosts/guacamole/guacamole-server.ts", import.meta.url),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
const ast = ts.createSourceFile(
|
|
||||||
"server.ts",
|
|
||||||
source,
|
|
||||||
ts.ScriptTarget.Latest,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const fn = ast.statements.find(
|
|
||||||
(s) =>
|
|
||||||
ts.isFunctionDeclaration(s) &&
|
|
||||||
s.name?.text === "getGuacSessionByConnectId",
|
|
||||||
)!;
|
|
||||||
const info = { ownerUserId: "owner", guacamoleConnectionId: "guacd-id" };
|
|
||||||
const context = {
|
|
||||||
exports: {} as Record<string, (id: string, user: string) => unknown>,
|
|
||||||
guacSessionByConnectId: new Map([["connect-id", info]]),
|
|
||||||
};
|
|
||||||
runInNewContext(
|
|
||||||
ts.transpileModule(fn.getText(ast), {
|
|
||||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
|
||||||
}).outputText,
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
const lookup = context.exports.getGuacSessionByConnectId;
|
|
||||||
expect(lookup("connect-id", "owner")).toEqual(info);
|
|
||||||
expect(lookup("connect-id", "other")).toBeNull();
|
|
||||||
expect(lookup("pending", "owner")).toBeNull();
|
|
||||||
});
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("../../../utils/logger.js", () => ({
|
|
||||||
guacLogger: {
|
|
||||||
debug: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
success: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { GuacamoleTokenService } =
|
|
||||||
await import("../../../hosts/guacamole/token-service.js");
|
|
||||||
|
|
||||||
describe("GuacamoleTokenService", () => {
|
|
||||||
const tokenService = GuacamoleTokenService.getInstance();
|
|
||||||
|
|
||||||
it("disables RDP pre-authentication when no credentials are configured", () => {
|
|
||||||
const token = tokenService.createRdpToken("windows.example.test", "", "");
|
|
||||||
const decrypted = tokenService.decryptToken(token);
|
|
||||||
|
|
||||||
expect(decrypted?.connection.settings).toMatchObject({
|
|
||||||
hostname: "windows.example.test",
|
|
||||||
port: 3389,
|
|
||||||
"ignore-cert": true,
|
|
||||||
"disable-auth": true,
|
|
||||||
});
|
|
||||||
expect(decrypted?.connection.settings.username).toBeUndefined();
|
|
||||||
expect(decrypted?.connection.settings.password).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps normal RDP credential authentication unchanged", () => {
|
|
||||||
const token = tokenService.createRdpToken(
|
|
||||||
"windows.example.test",
|
|
||||||
"Administrator",
|
|
||||||
"secret",
|
|
||||||
);
|
|
||||||
const decrypted = tokenService.decryptToken(token);
|
|
||||||
|
|
||||||
expect(decrypted?.connection.settings).toMatchObject({
|
|
||||||
hostname: "windows.example.test",
|
|
||||||
username: "Administrator",
|
|
||||||
password: "secret",
|
|
||||||
port: 3389,
|
|
||||||
});
|
|
||||||
expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves recording metadata outside guacd connection settings", () => {
|
|
||||||
const recording = {
|
|
||||||
hostId: 42,
|
|
||||||
userId: "user-1",
|
|
||||||
protocol: "vnc" as const,
|
|
||||||
path: "recording.guac",
|
|
||||||
startedAt: "2026-07-14T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
const token = tokenService.createVncToken(
|
|
||||||
"vnc.example.test",
|
|
||||||
"user",
|
|
||||||
"secret",
|
|
||||||
{},
|
|
||||||
recording,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves termixMeta through the encrypt/decrypt round trip", () => {
|
|
||||||
const termixMeta = {
|
|
||||||
termixConnectId: "connect-1",
|
|
||||||
hostId: 7,
|
|
||||||
ownerUserId: "user-1",
|
|
||||||
protocol: "rdp" as const,
|
|
||||||
};
|
|
||||||
const token = tokenService.createRdpToken(
|
|
||||||
"windows.example.test",
|
|
||||||
"Administrator",
|
|
||||||
"secret",
|
|
||||||
{},
|
|
||||||
undefined,
|
|
||||||
termixMeta,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createJoinToken sets connection.join, not connection.type", () => {
|
|
||||||
const token = tokenService.createJoinToken("guacd-conn-123", true);
|
|
||||||
const decrypted = tokenService.decryptToken(token);
|
|
||||||
|
|
||||||
expect(decrypted?.connection.join).toBe("guacd-conn-123");
|
|
||||||
expect(decrypted?.connection.type).toBeUndefined();
|
|
||||||
expect(decrypted?.connection.readOnly).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createJoinToken round-trips a read-write join through decryptToken", () => {
|
|
||||||
const token = tokenService.createJoinToken("guacd-conn-456", false);
|
|
||||||
const decrypted = tokenService.decryptToken(token);
|
|
||||||
|
|
||||||
expect(decrypted?.connection.join).toBe("guacd-conn-456");
|
|
||||||
expect(decrypted?.connection.readOnly).toBe(false);
|
|
||||||
expect(decrypted?.recording).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -63,18 +63,13 @@ vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../hosts/guacamole/guacamole-server.js", () => ({
|
vi.mock("../../../database/routes/guacamole-dispatch.js", () => ({
|
||||||
getGuacSessionInfo: (guacamoleConnectionId: string) =>
|
getGuacamoleSessionInfo: (guacamoleConnectionId: string) =>
|
||||||
state.guacSessions.get(guacamoleConnectionId) ?? null,
|
state.guacSessions.get(guacamoleConnectionId) ?? null,
|
||||||
}));
|
createGuacamoleJoinToken: (
|
||||||
|
guacamoleConnectionId: string,
|
||||||
vi.mock("../../../hosts/guacamole/token-service.js", () => ({
|
readOnly: boolean,
|
||||||
GuacamoleTokenService: {
|
) => `join-token:${guacamoleConnectionId}:${readOnly}`,
|
||||||
getInstance: () => ({
|
|
||||||
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
|
|
||||||
`join-token:${guacamoleConnectionId}:${readOnly}`,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
|||||||
@@ -186,3 +186,61 @@ describe("bundled host-metrics plugin", () => {
|
|||||||
expect(source).toMatch(/export async function deactivate/);
|
expect(source).toMatch(/export async function deactivate/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("bundled remote-desktop plugin", () => {
|
||||||
|
it("ships a directory with a manifest and both entry points", () => {
|
||||||
|
const dir = path.join(getBundledPluginsDir(), "remote-desktop");
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has a manifest that passes the real validator", () => {
|
||||||
|
const { manifest, errors } = parseManifest(
|
||||||
|
readBundledManifest("remote-desktop"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect(manifest?.id).toBe("remote-desktop");
|
||||||
|
expect(manifest?.category).toBe("Terminal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares the transport-owner capability and qualifies for the tier", () => {
|
||||||
|
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
|
||||||
|
|
||||||
|
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
|
||||||
|
expect(isFirstParty("remote-desktop")).toBe(true);
|
||||||
|
expect(runsInProcess("remote-desktop", manifest!.permissions)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contributes the rdp/vnc/telnet tabs the shell registers", () => {
|
||||||
|
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
|
||||||
|
const tabIds = manifest?.contributes?.tabs?.map((tab) => tab.id);
|
||||||
|
|
||||||
|
expect(tabIds).toEqual(["rdp", "vnc", "telnet"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares three host capabilities as an array", () => {
|
||||||
|
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
|
||||||
|
const hostCapability = manifest?.contributes?.hostCapability;
|
||||||
|
|
||||||
|
expect(Array.isArray(hostCapability)).toBe(true);
|
||||||
|
expect(
|
||||||
|
(hostCapability as Array<{ key: string }>).map((entry) => entry.key),
|
||||||
|
).toEqual(["enableRdp", "enableVnc", "enableTelnet"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports activate and deactivate from its backend entry", () => {
|
||||||
|
const entry = path.join(
|
||||||
|
getBundledPluginsDir(),
|
||||||
|
"remote-desktop",
|
||||||
|
"backend",
|
||||||
|
"index.mjs",
|
||||||
|
);
|
||||||
|
const source = fs.readFileSync(entry, "utf8");
|
||||||
|
|
||||||
|
expect(source).toMatch(/export async function activate/);
|
||||||
|
expect(source).toMatch(/export async function deactivate/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,22 +39,24 @@ function manifest(overrides: Record<string, unknown> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("first-party allowlist", () => {
|
describe("first-party allowlist", () => {
|
||||||
it("contains only ssh-terminal, docker, host-metrics, ai and proxmox", () => {
|
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox and remote-desktop", () => {
|
||||||
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
|
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
|
||||||
"ai",
|
"ai",
|
||||||
"docker",
|
"docker",
|
||||||
"host-metrics",
|
"host-metrics",
|
||||||
"proxmox",
|
"proxmox",
|
||||||
|
"remote-desktop",
|
||||||
"ssh-terminal",
|
"ssh-terminal",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recognises ssh-terminal, docker, host-metrics, ai and proxmox and nothing else", () => {
|
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox and remote-desktop and nothing else", () => {
|
||||||
expect(isFirstParty("ssh-terminal")).toBe(true);
|
expect(isFirstParty("ssh-terminal")).toBe(true);
|
||||||
expect(isFirstParty("docker")).toBe(true);
|
expect(isFirstParty("docker")).toBe(true);
|
||||||
expect(isFirstParty("host-metrics")).toBe(true);
|
expect(isFirstParty("host-metrics")).toBe(true);
|
||||||
expect(isFirstParty("ai")).toBe(true);
|
expect(isFirstParty("ai")).toBe(true);
|
||||||
expect(isFirstParty("proxmox")).toBe(true);
|
expect(isFirstParty("proxmox")).toBe(true);
|
||||||
|
expect(isFirstParty("remote-desktop")).toBe(true);
|
||||||
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
||||||
expect(isFirstParty("community-plugin")).toBe(false);
|
expect(isFirstParty("community-plugin")).toBe(false);
|
||||||
expect(isFirstParty("")).toBe(false);
|
expect(isFirstParty("")).toBe(false);
|
||||||
@@ -72,6 +74,9 @@ describe("runsInProcess", () => {
|
|||||||
);
|
);
|
||||||
expect(runsInProcess("ai", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
|
expect(runsInProcess("ai", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
|
||||||
expect(runsInProcess("proxmox", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
|
expect(runsInProcess("proxmox", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
|
||||||
|
expect(runsInProcess("remote-desktop", [TRANSPORT_OWNER_CAPABILITY])).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
// On the list but not asking for it: stays in a worker, so the manifest
|
// On the list but not asking for it: stays in a worker, so the manifest
|
||||||
// remains an honest description of what the plugin does.
|
// remains an honest description of what the plugin does.
|
||||||
@@ -80,6 +85,7 @@ describe("runsInProcess", () => {
|
|||||||
expect(runsInProcess("host-metrics", ["hosts.read"])).toBe(false);
|
expect(runsInProcess("host-metrics", ["hosts.read"])).toBe(false);
|
||||||
expect(runsInProcess("ai", ["hosts.read"])).toBe(false);
|
expect(runsInProcess("ai", ["hosts.read"])).toBe(false);
|
||||||
expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false);
|
expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false);
|
||||||
|
expect(runsInProcess("remote-desktop", ["hosts.read"])).toBe(false);
|
||||||
|
|
||||||
// Asking for it but not on the list.
|
// Asking for it but not on the list.
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@ const DockerApp = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const GuacamoleApp = lazy(() =>
|
const GuacamoleApp = lazy(() =>
|
||||||
import("@/features/guacamole/GuacamoleApp").then((m) => ({
|
import("../plugins/remote-desktop/frontend/GuacamoleApp").then((m) => ({
|
||||||
default: m.default,
|
default: m.default,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AlertCircle, Eye, Presentation, Users } from "lucide-react";
|
import { AlertCircle, Eye, Presentation, Users } from "lucide-react";
|
||||||
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/frontend/GuacamoleDisplay.tsx";
|
||||||
import {
|
import {
|
||||||
GuestTerminalView,
|
GuestTerminalView,
|
||||||
type SessionParticipantInfo,
|
type SessionParticipantInfo,
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ import { Input } from "@/components/input";
|
|||||||
import { CollabMembersSidebar } from "./CollabMembersSidebar";
|
import { CollabMembersSidebar } from "./CollabMembersSidebar";
|
||||||
import { Terminal } from "@/features/terminal/Terminal";
|
import { Terminal } from "@/features/terminal/Terminal";
|
||||||
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
|
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
|
||||||
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/frontend/GuacamoleDisplay.tsx";
|
||||||
import { getGuacamoleTokenFromHost } from "@/api/guacamole-api";
|
import { getGuacamoleTokenFromHost } from "../../../../plugins/remote-desktop/frontend/guacamole-api";
|
||||||
import { getSSHHosts, getUserList, type SSHHostWithStatus } from "@/main-axios";
|
import { getSSHHosts, getUserList, type SSHHostWithStatus } from "@/main-axios";
|
||||||
import { getRoles } from "@/api/rbac-api";
|
import { getRoles } from "@/api/rbac-api";
|
||||||
import type { Role } from "@/main-axios";
|
import type { Role } from "@/main-axios";
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||||
import { getBasePath } from "@/lib/base-path";
|
import { getBasePath } from "@/lib/base-path";
|
||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/frontend/GuacamoleDisplay.tsx";
|
||||||
|
|
||||||
const PING_INTERVAL_MS = 30000;
|
const PING_INTERVAL_MS = 30000;
|
||||||
|
|
||||||
|
|||||||
@@ -903,7 +903,7 @@
|
|||||||
"useSocks5ProxyDesc": "Route connection through a proxy server",
|
"useSocks5ProxyDesc": "Route connection through a proxy server",
|
||||||
"connectionOrigin": "Connection Origin",
|
"connectionOrigin": "Connection Origin",
|
||||||
"connectionOriginDesc": "Where this host's connection originates from. Overrides the desktop app's global default.",
|
"connectionOriginDesc": "Where this host's connection originates from. Overrides the desktop app's global default.",
|
||||||
"connectionOriginGuacamoleNote": "RDP, VNC and Telnet use the remote server unless you pick \"This device\", which needs a guacd this machine can reach — set one in the guacd URL setting, or per host under guacd Proxy.",
|
"connectionOriginRemoteDesktopNote": "RDP, VNC and Telnet use the remote server unless you pick \"This device\", which needs a guacd this machine can reach — set one in the guacd URL setting, or per host under guacd Proxy.",
|
||||||
"connectionOriginDefault": "Use default",
|
"connectionOriginDefault": "Use default",
|
||||||
"connectionOriginLocal": "This device (local network)",
|
"connectionOriginLocal": "This device (local network)",
|
||||||
"connectionOriginRemote": "Remote server",
|
"connectionOriginRemote": "Remote server",
|
||||||
@@ -2050,7 +2050,7 @@
|
|||||||
"userShareBadge": "User: {{username}}",
|
"userShareBadge": "User: {{username}}",
|
||||||
"loadSharesFailed": "Failed to load active shares"
|
"loadSharesFailed": "Failed to load active shares"
|
||||||
},
|
},
|
||||||
"guacamole": {
|
"remoteDesktop": {
|
||||||
"connecting": "Connecting to {{type}} session...",
|
"connecting": "Connecting to {{type}} session...",
|
||||||
"checkingGuacd": "Checking remote desktop service...",
|
"checkingGuacd": "Checking remote desktop service...",
|
||||||
"requestingToken": "Requesting {{type}} connection token...",
|
"requestingToken": "Requesting {{type}} connection token...",
|
||||||
@@ -3665,9 +3665,9 @@
|
|||||||
"metrics": "Metrics",
|
"metrics": "Metrics",
|
||||||
"sec": "sec",
|
"sec": "sec",
|
||||||
"logLevel": "Log Level",
|
"logLevel": "Log Level",
|
||||||
"enableGuacamole": "Enable Guacamole",
|
"enableRemoteDesktop": "Enable Remote Desktop",
|
||||||
"enableGuacamoleDesc": "RDP/VNC remote desktop",
|
"enableRemoteDesktopDesc": "RDP/VNC remote desktop",
|
||||||
"enableGuacamoleDocsLink": "View docs",
|
"enableRemoteDesktopDocsLink": "View docs",
|
||||||
"guacdUrl": "guacd URL",
|
"guacdUrl": "guacd URL",
|
||||||
"tailscaleApiKey": "Tailscale API Key",
|
"tailscaleApiKey": "Tailscale API Key",
|
||||||
"tailscaleApiKeyDescription": "Used for device discovery in the host editor. Supports Tailscale (tskey-) and Headscale (hskey-) keys. Generate a key at tailscale.com/admin/settings/keys or from your Headscale instance.",
|
"tailscaleApiKeyDescription": "Used for device discovery in the host editor. Supports Tailscale (tskey-) and Headscale (hskey-) keys. Generate a key at tailscale.com/admin/settings/keys or from your Headscale instance.",
|
||||||
@@ -3821,9 +3821,9 @@
|
|||||||
"metricsHistoryRetention": "Metrics History Retention",
|
"metricsHistoryRetention": "Metrics History Retention",
|
||||||
"metricsHistoryRetentionRange": "1 to 90 days",
|
"metricsHistoryRetentionRange": "1 to 90 days",
|
||||||
"days": "days",
|
"days": "days",
|
||||||
"guacamoleSaved": "Guacamole settings saved",
|
"remoteDesktopSaved": "Remote Desktop settings saved",
|
||||||
"guacamoleSaveFailed": "Failed to save Guacamole settings",
|
"remoteDesktopSaveFailed": "Failed to save Remote Desktop settings",
|
||||||
"guacamoleUpdateFailed": "Failed to update Guacamole setting",
|
"remoteDesktopUpdateFailed": "Failed to update Remote Desktop setting",
|
||||||
"tailscaleSettingsSaved": "Tailscale settings saved",
|
"tailscaleSettingsSaved": "Tailscale settings saved",
|
||||||
"tailscaleSettingsSaveFailed": "Failed to save Tailscale settings",
|
"tailscaleSettingsSaveFailed": "Failed to save Tailscale settings",
|
||||||
"logLevelUpdateFailed": "Failed to update log level",
|
"logLevelUpdateFailed": "Failed to update log level",
|
||||||
|
|||||||
@@ -2191,13 +2191,13 @@ export {
|
|||||||
export type {
|
export type {
|
||||||
GuacamoleTokenRequest,
|
GuacamoleTokenRequest,
|
||||||
GuacamoleTokenResponse,
|
GuacamoleTokenResponse,
|
||||||
} from "@/api/guacamole-api";
|
} from "../../plugins/remote-desktop/frontend/guacamole-api";
|
||||||
export {
|
export {
|
||||||
getGuacamoleDpi,
|
getGuacamoleDpi,
|
||||||
getGuacamoleToken,
|
getGuacamoleToken,
|
||||||
getGuacamoleTokenFromHost,
|
getGuacamoleTokenFromHost,
|
||||||
getGuacdStatus,
|
getGuacdStatus,
|
||||||
} from "@/api/guacamole-api";
|
} from "../../plugins/remote-desktop/frontend/guacamole-api";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// RBAC MANAGEMENT
|
// RBAC MANAGEMENT
|
||||||
|
|||||||
@@ -539,11 +539,12 @@ export function CommandPalette({
|
|||||||
handleAction(() => {
|
handleAction(() => {
|
||||||
const type = host.enableSsh
|
const type = host.enableSsh
|
||||||
? "terminal"
|
? "terminal"
|
||||||
: host.enableRdp
|
: host.enableRdp && isTabTypeAvailable("rdp")
|
||||||
? "rdp"
|
? "rdp"
|
||||||
: host.enableVnc
|
: host.enableVnc && isTabTypeAvailable("vnc")
|
||||||
? "vnc"
|
? "vnc"
|
||||||
: host.enableTelnet
|
: host.enableTelnet &&
|
||||||
|
isTabTypeAvailable("telnet")
|
||||||
? "telnet"
|
? "telnet"
|
||||||
: "terminal";
|
: "terminal";
|
||||||
onOpenTab(type, host.name);
|
onOpenTab(type, host.name);
|
||||||
@@ -599,12 +600,13 @@ export function CommandPalette({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
{host.enableSsh &&
|
{host.enableSsh &&
|
||||||
(host.enableRdp ||
|
((host.enableRdp && isTabTypeAvailable("rdp")) ||
|
||||||
host.enableVnc ||
|
(host.enableVnc && isTabTypeAvailable("vnc")) ||
|
||||||
host.enableTelnet) && (
|
(host.enableTelnet &&
|
||||||
|
isTabTypeAvailable("telnet"))) && (
|
||||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||||
)}
|
)}
|
||||||
{host.enableRdp && (
|
{host.enableRdp && isTabTypeAvailable("rdp") && (
|
||||||
<button
|
<button
|
||||||
title="RDP"
|
title="RDP"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -619,7 +621,7 @@ export function CommandPalette({
|
|||||||
RDP
|
RDP
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{host.enableVnc && (
|
{host.enableVnc && isTabTypeAvailable("vnc") && (
|
||||||
<button
|
<button
|
||||||
title="VNC"
|
title="VNC"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -634,21 +636,22 @@ export function CommandPalette({
|
|||||||
VNC
|
VNC
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{host.enableTelnet && (
|
{host.enableTelnet &&
|
||||||
<button
|
isTabTypeAvailable("telnet") && (
|
||||||
title="Telnet"
|
<button
|
||||||
onClick={(e) => {
|
title="Telnet"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
handleAction(() =>
|
e.stopPropagation();
|
||||||
onOpenTab("telnet", host.name),
|
handleAction(() =>
|
||||||
);
|
onOpenTab("telnet", host.name),
|
||||||
}}
|
);
|
||||||
className="flex items-center gap-1 px-2 h-6 rounded text-xs font-medium text-muted-foreground/70 hover:text-foreground hover:bg-muted-foreground/10 transition-colors border border-border/40"
|
}}
|
||||||
>
|
className="flex items-center gap-1 px-2 h-6 rounded text-xs font-medium text-muted-foreground/70 hover:text-foreground hover:bg-muted-foreground/10 transition-colors border border-border/40"
|
||||||
<Terminal className="size-3" />
|
>
|
||||||
Telnet
|
<Terminal className="size-3" />
|
||||||
</button>
|
Telnet
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
{canEditHost(host) && (
|
{canEditHost(host) && (
|
||||||
<>
|
<>
|
||||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
*/
|
*/
|
||||||
const BUILT_IN_TABS_BY_PLUGIN: Record<string, string[]> = {
|
const BUILT_IN_TABS_BY_PLUGIN: Record<string, string[]> = {
|
||||||
"ssh-terminal": ["terminal"],
|
"ssh-terminal": ["terminal"],
|
||||||
|
"remote-desktop": ["rdp", "vnc", "telnet"],
|
||||||
ai: ["ai"],
|
ai: ["ai"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import type {
|
|||||||
TerminalHandle,
|
TerminalHandle,
|
||||||
TerminalHostConfig,
|
TerminalHostConfig,
|
||||||
} from "@/features/terminal/Terminal";
|
} from "@/features/terminal/Terminal";
|
||||||
import type { GuacamoleAppHandle } from "@/features/guacamole/GuacamoleApp";
|
import type { GuacamoleAppHandle } from "../../../plugins/remote-desktop/frontend/GuacamoleApp";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import type { Tab, TabType, Host } from "@/types/ui-types";
|
import type { Tab, TabType, Host } from "@/types/ui-types";
|
||||||
import {
|
import {
|
||||||
@@ -110,7 +110,7 @@ const loadTmuxMonitor = () =>
|
|||||||
}));
|
}));
|
||||||
const TmuxMonitor = lazy(loadTmuxMonitor);
|
const TmuxMonitor = lazy(loadTmuxMonitor);
|
||||||
const loadGuacamoleApp = () =>
|
const loadGuacamoleApp = () =>
|
||||||
import("@/features/guacamole/GuacamoleApp").then((m) => ({
|
import("../../../plugins/remote-desktop/frontend/GuacamoleApp").then((m) => ({
|
||||||
default: m.default,
|
default: m.default,
|
||||||
}));
|
}));
|
||||||
const GuacamoleApp = lazy(loadGuacamoleApp);
|
const GuacamoleApp = lazy(loadGuacamoleApp);
|
||||||
@@ -655,7 +655,10 @@ export function renderTabContent(
|
|||||||
case "telnet":
|
case "telnet":
|
||||||
if (!host)
|
if (!host)
|
||||||
return (
|
return (
|
||||||
<EmptyState icon={Monitor} messageKey="guacamole.noHostSelected" />
|
<EmptyState
|
||||||
|
icon={Monitor}
|
||||||
|
messageKey="remoteDesktop.noHostSelected"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
return withTabSuspense(
|
return withTabSuspense(
|
||||||
<GuacamoleApp
|
<GuacamoleApp
|
||||||
|
|||||||
@@ -866,9 +866,9 @@ export function AdminSettingsPanel({
|
|||||||
async function handleSaveGuacamole() {
|
async function handleSaveGuacamole() {
|
||||||
try {
|
try {
|
||||||
await updateGuacamoleSettings({ enabled: guacEnabled, url: guacUrl });
|
await updateGuacamoleSettings({ enabled: guacEnabled, url: guacUrl });
|
||||||
toast.success(t("admin.guacamoleSaved"));
|
toast.success(t("admin.remoteDesktopSaved"));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("admin.guacamoleSaveFailed"));
|
toast.error(t("admin.remoteDesktopSaveFailed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,7 +879,7 @@ export function AdminSettingsPanel({
|
|||||||
await updateGuacamoleSettings({ enabled: newVal, url: guacUrl });
|
await updateGuacamoleSettings({ enabled: newVal, url: guacUrl });
|
||||||
} catch {
|
} catch {
|
||||||
setGuacEnabled(!newVal);
|
setGuacEnabled(!newVal);
|
||||||
toast.error(t("admin.guacamoleUpdateFailed"));
|
toast.error(t("admin.remoteDesktopUpdateFailed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -520,17 +520,17 @@ export function AdminGeneralSettingsSection({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
|
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("admin.enableGuacamole")}
|
label={t("admin.enableRemoteDesktop")}
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
{t("admin.enableGuacamoleDesc")}{" "}
|
{t("admin.enableRemoteDesktopDesc")}{" "}
|
||||||
<a
|
<a
|
||||||
href="https://docs.termix.site/setup/remote-desktop"
|
href="https://docs.termix.site/setup/remote-desktop"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-accent-brand hover:underline"
|
className="text-accent-brand hover:underline"
|
||||||
>
|
>
|
||||||
{t("admin.enableGuacamoleDocsLink")}
|
{t("admin.enableRemoteDesktopDocsLink")}
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -773,7 +773,7 @@ export function HostEditorGeneralTab({
|
|||||||
protocols.enableRdp ||
|
protocols.enableRdp ||
|
||||||
protocols.enableVnc ||
|
protocols.enableVnc ||
|
||||||
protocols.enableTelnet
|
protocols.enableTelnet
|
||||||
? `${t("hosts.connectionOriginDesc")} ${t("hosts.connectionOriginGuacamoleNote")}`
|
? `${t("hosts.connectionOriginDesc")} ${t("hosts.connectionOriginRemoteDesktopNote")}`
|
||||||
: t("hosts.connectionOriginDesc")
|
: t("hosts.connectionOriginDesc")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { SSHHostData } from "@/types";
|
import type { SSHHostData } from "@/types";
|
||||||
import type { Host } from "@/types/ui-types";
|
import type { Host } from "@/types/ui-types";
|
||||||
import type { GuacamoleQuickHost } from "@/features/guacamole/GuacamoleApp";
|
import type { GuacamoleQuickHost } from "../../../plugins/remote-desktop/frontend/GuacamoleApp";
|
||||||
|
|
||||||
export type QuickConnectProtocol = "ssh" | "rdp" | "vnc";
|
export type QuickConnectProtocol = "ssh" | "rdp" | "vnc";
|
||||||
|
|
||||||
|
|||||||
@@ -493,17 +493,19 @@ export function HostItem({
|
|||||||
const sshActions = getSshActions(host);
|
const sshActions = getSshActions(host);
|
||||||
const availableActions: TabType[] = [
|
const availableActions: TabType[] = [
|
||||||
...sshActions.map(({ type }) => type),
|
...sshActions.map(({ type }) => type),
|
||||||
...(host.enableRdp ? (["rdp"] as const) : []),
|
...(host.enableRdp && isTabTypeAvailable("rdp") ? (["rdp"] as const) : []),
|
||||||
...(host.enableVnc ? (["vnc"] as const) : []),
|
...(host.enableVnc && isTabTypeAvailable("vnc") ? (["vnc"] as const) : []),
|
||||||
...(host.enableTelnet ? (["telnet"] as const) : []),
|
...(host.enableTelnet && isTabTypeAvailable("telnet")
|
||||||
|
? (["telnet"] as const)
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
const defaultAction: TabType = host.enableSsh
|
const defaultAction: TabType = host.enableSsh
|
||||||
? "terminal"
|
? "terminal"
|
||||||
: host.enableRdp
|
: host.enableRdp && isTabTypeAvailable("rdp")
|
||||||
? "rdp"
|
? "rdp"
|
||||||
: host.enableVnc
|
: host.enableVnc && isTabTypeAvailable("vnc")
|
||||||
? "vnc"
|
? "vnc"
|
||||||
: host.enableTelnet
|
: host.enableTelnet && isTabTypeAvailable("telnet")
|
||||||
? "telnet"
|
? "telnet"
|
||||||
: "terminal";
|
: "terminal";
|
||||||
const openHostTab = (
|
const openHostTab = (
|
||||||
@@ -604,11 +606,13 @@ export function HostItem({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
{host.enableSsh &&
|
{host.enableSsh &&
|
||||||
(host.enableRdp || host.enableVnc || host.enableTelnet) &&
|
((host.enableRdp && isTabTypeAvailable("rdp")) ||
|
||||||
|
(host.enableVnc && isTabTypeAvailable("vnc")) ||
|
||||||
|
(host.enableTelnet && isTabTypeAvailable("telnet"))) &&
|
||||||
sshActions.length > 0 && (
|
sshActions.length > 0 && (
|
||||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||||
)}
|
)}
|
||||||
{host.enableRdp && (
|
{host.enableRdp && isTabTypeAvailable("rdp") && (
|
||||||
<button
|
<button
|
||||||
title={t("hosts.connectRdp")}
|
title={t("hosts.connectRdp")}
|
||||||
onPointerEnter={() => preloadTabSurface("rdp")}
|
onPointerEnter={() => preloadTabSurface("rdp")}
|
||||||
@@ -631,7 +635,7 @@ export function HostItem({
|
|||||||
<MonitorUp className="size-3.5" />
|
<MonitorUp className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{host.enableVnc && (
|
{host.enableVnc && isTabTypeAvailable("vnc") && (
|
||||||
<button
|
<button
|
||||||
title={t("hosts.connectVnc")}
|
title={t("hosts.connectVnc")}
|
||||||
onPointerEnter={() => preloadTabSurface("vnc")}
|
onPointerEnter={() => preloadTabSurface("vnc")}
|
||||||
@@ -645,7 +649,7 @@ export function HostItem({
|
|||||||
<MousePointerClick className="size-3.5" />
|
<MousePointerClick className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{host.enableTelnet && (
|
{host.enableTelnet && isTabTypeAvailable("telnet") && (
|
||||||
<button
|
<button
|
||||||
title={t("hosts.connectTelnet")}
|
title={t("hosts.connectTelnet")}
|
||||||
onPointerEnter={() => preloadTabSurface("telnet")}
|
onPointerEnter={() => preloadTabSurface("telnet")}
|
||||||
@@ -822,7 +826,7 @@ export function HostItem({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
{host.enableRdp && (
|
{host.enableRdp && isTabTypeAvailable("rdp") && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -833,7 +837,7 @@ export function HostItem({
|
|||||||
{t("hosts.connectRdp")}
|
{t("hosts.connectRdp")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{host.enableVnc && (
|
{host.enableVnc && isTabTypeAvailable("vnc") && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -844,7 +848,7 @@ export function HostItem({
|
|||||||
{t("hosts.connectVnc")}
|
{t("hosts.connectVnc")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{host.enableTelnet && (
|
{host.enableTelnet && isTabTypeAvailable("telnet") && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -1055,7 +1059,7 @@ export function HostItem({
|
|||||||
{t("hosts.copyTmuxMonitorUrlAction")}
|
{t("hosts.copyTmuxMonitorUrlAction")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{host.enableRdp && (
|
{host.enableRdp && isTabTypeAvailable("rdp") && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -1069,7 +1073,7 @@ export function HostItem({
|
|||||||
{t("hosts.copyRdpUrlAction")}
|
{t("hosts.copyRdpUrlAction")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{host.enableVnc && (
|
{host.enableVnc && isTabTypeAvailable("vnc") && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -1083,7 +1087,7 @@ export function HostItem({
|
|||||||
{t("hosts.copyVncUrlAction")}
|
{t("hosts.copyVncUrlAction")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{host.enableTelnet && (
|
{host.enableTelnet && isTabTypeAvailable("telnet") && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ describe("ConnectionScreen", () => {
|
|||||||
render(
|
render(
|
||||||
<ConnectionScreen
|
<ConnectionScreen
|
||||||
status="disconnected"
|
status="disconnected"
|
||||||
message="guacamole.hostNotFound"
|
message="remoteDesktop.hostNotFound"
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
).not.toThrow();
|
).not.toThrow();
|
||||||
|
|
||||||
expect(screen.getByText("guacamole.hostNotFound")).toBeTruthy();
|
expect(screen.getByText("remoteDesktop.hostNotFound")).toBeTruthy();
|
||||||
expect(screen.getByRole("status").getAttribute("data-status")).toBe(
|
expect(screen.getByRole("status").getAttribute("data-status")).toBe(
|
||||||
"disconnected",
|
"disconnected",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,9 +38,12 @@ vi.mock("@xterm/addon-fit", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/features/guacamole/GuacamoleDisplay.tsx", () => ({
|
vi.mock(
|
||||||
GuacamoleDisplay: () => <div data-testid="guacamole-display" />,
|
"../../../../../plugins/remote-desktop/frontend/GuacamoleDisplay.tsx",
|
||||||
}));
|
() => ({
|
||||||
|
GuacamoleDisplay: () => <div data-testid="guacamole-display" />,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
import SharedSessionView from "../../../features/session-sharing/SharedSessionView";
|
import SharedSessionView from "../../../features/session-sharing/SharedSessionView";
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,38 @@ function aiPlugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function remoteDesktopPlugin(
|
||||||
|
overrides: Partial<PluginSummary> = {},
|
||||||
|
): PluginSummary {
|
||||||
|
return plugin({
|
||||||
|
id: "remote-desktop",
|
||||||
|
name: "Remote Desktop",
|
||||||
|
contributes: {
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: "rdp",
|
||||||
|
titleKey: "hosts.tabRdp",
|
||||||
|
icon: "Monitor",
|
||||||
|
openFrom: ["host-context-menu", "palette"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vnc",
|
||||||
|
titleKey: "hosts.tabVnc",
|
||||||
|
icon: "MousePointerClick",
|
||||||
|
openFrom: ["host-context-menu", "palette"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "telnet",
|
||||||
|
titleKey: "hosts.tabTelnet",
|
||||||
|
icon: "Terminal",
|
||||||
|
openFrom: ["host-context-menu", "palette"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("plugin state applied to the shell", () => {
|
describe("plugin state applied to the shell", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
resetPluginState();
|
resetPluginState();
|
||||||
@@ -181,6 +213,39 @@ describe("plugin state applied to the shell", () => {
|
|||||||
expect(isTabTypeAvailable("terminal")).toBe(true);
|
expect(isTabTypeAvailable("terminal")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows rdp/vnc/telnet tabs while remote-desktop is enabled", () => {
|
||||||
|
applyPluginState([remoteDesktopPlugin({ enabled: true })]);
|
||||||
|
expect(isTabTypeAvailable("rdp")).toBe(true);
|
||||||
|
expect(isTabTypeAvailable("vnc")).toBe(true);
|
||||||
|
expect(isTabTypeAvailable("telnet")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks rdp/vnc/telnet tabs when remote-desktop is disabled", () => {
|
||||||
|
applyPluginState([remoteDesktopPlugin({ enabled: false })]);
|
||||||
|
expect(isTabTypeAvailable("rdp")).toBe(false);
|
||||||
|
expect(isTabTypeAvailable("vnc")).toBe(false);
|
||||||
|
expect(isTabTypeAvailable("telnet")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows rdp/vnc/telnet tabs again when remote-desktop is re-enabled", () => {
|
||||||
|
applyPluginState([remoteDesktopPlugin({ enabled: false })]);
|
||||||
|
expect(isTabTypeAvailable("rdp")).toBe(false);
|
||||||
|
|
||||||
|
applyPluginState([remoteDesktopPlugin({ enabled: true })]);
|
||||||
|
expect(isTabTypeAvailable("rdp")).toBe(true);
|
||||||
|
expect(isTabTypeAvailable("vnc")).toBe(true);
|
||||||
|
expect(isTabTypeAvailable("telnet")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disabling remote-desktop does not affect the terminal, and vice versa", () => {
|
||||||
|
applyPluginState([
|
||||||
|
remoteDesktopPlugin({ enabled: false }),
|
||||||
|
plugin({ enabled: true }),
|
||||||
|
]);
|
||||||
|
expect(isTabTypeAvailable("rdp")).toBe(false);
|
||||||
|
expect(isTabTypeAvailable("terminal")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("reports enablement, defaulting to true before the first load", () => {
|
it("reports enablement, defaulting to true before the first load", () => {
|
||||||
expect(isPluginEnabled("ssh-terminal")).toBe(true);
|
expect(isPluginEnabled("ssh-terminal")).toBe(true);
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@
|
|||||||
"plugins/docker/backend/**/*.ts",
|
"plugins/docker/backend/**/*.ts",
|
||||||
"plugins/host-metrics/backend/**/*.ts",
|
"plugins/host-metrics/backend/**/*.ts",
|
||||||
"plugins/ai/backend/**/*.ts",
|
"plugins/ai/backend/**/*.ts",
|
||||||
"plugins/proxmox/backend/**/*.ts"
|
"plugins/proxmox/backend/**/*.ts",
|
||||||
|
"plugins/remote-desktop/backend/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["src/**/*.test.ts"]
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
+4
-1
@@ -47,7 +47,10 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
name: "frontend",
|
name: "frontend",
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
include: ["src/ui/**/*.test.{ts,tsx}"],
|
include: [
|
||||||
|
"src/ui/**/*.test.{ts,tsx}",
|
||||||
|
"plugins/**/frontend/**/*.test.{ts,tsx}",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user