mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: finish the remote-desktop plugin (B14)
This commit is contained in:
1 parent
0af2c94983
commit
14eb558b85
215 files changed
+30029
-3921
No files matched your search
@@ -819,9 +819,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
socks5_password TEXT,
|
||||
socks5_proxy_chain TEXT,
|
||||
domain TEXT,
|
||||
security TEXT,
|
||||
ignore_cert INTEGER NOT NULL DEFAULT 0,
|
||||
guacamole_config TEXT,
|
||||
mac_address TEXT,
|
||||
port_knock_sequence TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -931,8 +928,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
const sshHosts =
|
||||
await createCurrentHostRepository().listDecryptedByUserId(userId);
|
||||
const insertHost = exportDb.prepare(`
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, enable_web_ui, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, docker_config, web_ui_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, security, ignore_cert, guacamole_config, mac_address, port_knock_sequence, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, enable_web_ui, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, docker_config, web_ui_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, mac_address, port_knock_sequence, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const decrypted of sshHosts) {
|
||||
@@ -985,9 +982,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
decrypted.socks5Password || null,
|
||||
decrypted.socks5ProxyChain || null,
|
||||
decrypted.domain || null,
|
||||
decrypted.security || null,
|
||||
decrypted.ignoreCert ? 1 : 0,
|
||||
decrypted.guacamoleConfig || null,
|
||||
decrypted.macAddress || null,
|
||||
decrypted.portKnockSequence || null,
|
||||
decrypted.createdAt,
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
assertDataDirIsNotMisconfigured,
|
||||
DataDirMisconfiguredError,
|
||||
} from "../../utils/data-dir-guard.js";
|
||||
import { getDefaultGuacdUrl } from "../../utils/guacd-config.js";
|
||||
import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
|
||||
import { SYSTEM_ROLE_DEFAULTS } from "../../utils/permission-catalog.js";
|
||||
import { connectRemoteDatabase } from "./connect.js";
|
||||
@@ -807,24 +806,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
ensureRawSettingDefault("guac_enabled", "true");
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize guac_enabled setting", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
ensureRawSettingDefault("guac_url", getDefaultGuacdUrl());
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize guac_url setting", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const addColumnIfNotExists = (
|
||||
@@ -948,7 +929,6 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "terminal_defaults", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "rdp_defaults", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "terminal_macros", "TEXT");
|
||||
|
||||
sqlite.exec(`
|
||||
@@ -1164,9 +1144,6 @@ const migrateSchema = () => {
|
||||
|
||||
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
|
||||
addColumnIfNotExists("ssh_data", "domain", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "security", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "ignore_cert", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("ssh_data", "guacamole_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "notes", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER");
|
||||
@@ -1554,9 +1531,6 @@ const migrateSchema = () => {
|
||||
{ column: "show_server_stats_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "quick_actions", sql: "ALTER TABLE ssh_data ADD COLUMN quick_actions TEXT" },
|
||||
{ column: "domain", sql: "ALTER TABLE ssh_data ADD COLUMN domain TEXT" },
|
||||
{ column: "security", sql: "ALTER TABLE ssh_data ADD COLUMN security TEXT" },
|
||||
{ column: "ignore_cert", sql: "ALTER TABLE ssh_data ADD COLUMN ignore_cert INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "guacamole_config", sql: "ALTER TABLE ssh_data ADD COLUMN guacamole_config TEXT" },
|
||||
{ column: "socks5_proxy_chain", sql: "ALTER TABLE ssh_data ADD COLUMN socks5_proxy_chain TEXT" },
|
||||
{ column: "host_key_fingerprint", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_fingerprint TEXT" },
|
||||
{ column: "host_key_type", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_type TEXT" },
|
||||
@@ -1567,18 +1541,10 @@ const migrateSchema = () => {
|
||||
{ column: "mac_address", sql: "ALTER TABLE ssh_data ADD COLUMN mac_address TEXT" },
|
||||
{ column: "port_knock_sequence", sql: "ALTER TABLE ssh_data ADD COLUMN port_knock_sequence TEXT" },
|
||||
{ column: "enable_ssh", sql: "ALTER TABLE ssh_data ADD COLUMN enable_ssh INTEGER NOT NULL DEFAULT 1" },
|
||||
{ column: "enable_rdp", sql: "ALTER TABLE ssh_data ADD COLUMN enable_rdp INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "enable_vnc", sql: "ALTER TABLE ssh_data ADD COLUMN enable_vnc INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "enable_telnet", sql: "ALTER TABLE ssh_data ADD COLUMN enable_telnet INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
|
||||
{ column: "rdp_port", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_port INTEGER DEFAULT 3389" },
|
||||
{ column: "vnc_port", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_port INTEGER DEFAULT 5900" },
|
||||
{ column: "telnet_port", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_port INTEGER DEFAULT 23" },
|
||||
{ column: "rdp_user", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_user TEXT" },
|
||||
{ column: "rdp_password", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_password TEXT" },
|
||||
{ column: "rdp_domain", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_domain TEXT" },
|
||||
{ column: "rdp_security", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_security TEXT" },
|
||||
{ column: "rdp_ignore_cert", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_ignore_cert INTEGER DEFAULT 0" },
|
||||
{ column: "vnc_password", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_password TEXT" },
|
||||
{ column: "vnc_user", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_user TEXT" },
|
||||
{ column: "telnet_user", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_user TEXT" },
|
||||
|
||||
@@ -302,21 +302,13 @@ export const hosts = mysqlTable(
|
||||
quickActions: text("quick_actions"),
|
||||
notes: text("notes"),
|
||||
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||
enableRdp: boolean("enable_rdp").notNull().default(false),
|
||||
enableVnc: boolean("enable_vnc").notNull().default(false),
|
||||
enableTelnet: boolean("enable_telnet").notNull().default(false),
|
||||
|
||||
sshPort: int("ssh_port").default(22),
|
||||
rdpPort: int("rdp_port").default(3389),
|
||||
vncPort: int("vnc_port").default(5900),
|
||||
telnetPort: int("telnet_port").default(23),
|
||||
|
||||
rdpCredentialId: int("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||
rdpUser: text("rdp_user"),
|
||||
rdpPassword: text("rdp_password"),
|
||||
rdpDomain: text("rdp_domain"),
|
||||
rdpSecurity: text("rdp_security"),
|
||||
rdpIgnoreCert: boolean("rdp_ignore_cert").default(false),
|
||||
|
||||
vncCredentialId: int("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||
vncPassword: text("vnc_password"),
|
||||
@@ -331,9 +323,6 @@ export const hosts = mysqlTable(
|
||||
telnetAuthType: text("telnet_auth_type"),
|
||||
|
||||
domain: text("domain"),
|
||||
security: text("security"),
|
||||
ignoreCert: boolean("ignore_cert").default(false),
|
||||
guacamoleConfig: text("guacamole_config"),
|
||||
|
||||
useSocks5: boolean("use_socks5"),
|
||||
socks5Host: text("socks5_host"),
|
||||
@@ -892,7 +881,6 @@ export const userPreferences = mysqlTable("user_preferences", {
|
||||
customThemes: text("custom_themes"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
terminalDefaults: text("terminal_defaults"),
|
||||
rdpDefaults: text("rdp_defaults"),
|
||||
terminalMacros: text("terminal_macros"),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
|
||||
@@ -303,21 +303,13 @@ export const hosts = pgTable(
|
||||
quickActions: text("quick_actions"),
|
||||
notes: text("notes"),
|
||||
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||
enableRdp: boolean("enable_rdp").notNull().default(false),
|
||||
enableVnc: boolean("enable_vnc").notNull().default(false),
|
||||
enableTelnet: boolean("enable_telnet").notNull().default(false),
|
||||
|
||||
sshPort: integer("ssh_port").default(22),
|
||||
rdpPort: integer("rdp_port").default(3389),
|
||||
vncPort: integer("vnc_port").default(5900),
|
||||
telnetPort: integer("telnet_port").default(23),
|
||||
|
||||
rdpCredentialId: integer("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||
rdpUser: text("rdp_user"),
|
||||
rdpPassword: text("rdp_password"),
|
||||
rdpDomain: text("rdp_domain"),
|
||||
rdpSecurity: text("rdp_security"),
|
||||
rdpIgnoreCert: boolean("rdp_ignore_cert").default(false),
|
||||
|
||||
vncCredentialId: integer("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||
vncPassword: text("vnc_password"),
|
||||
@@ -332,9 +324,6 @@ export const hosts = pgTable(
|
||||
telnetAuthType: text("telnet_auth_type"),
|
||||
|
||||
domain: text("domain"),
|
||||
security: text("security"),
|
||||
ignoreCert: boolean("ignore_cert").default(false),
|
||||
guacamoleConfig: text("guacamole_config"),
|
||||
|
||||
useSocks5: boolean("use_socks5"),
|
||||
socks5Host: text("socks5_host"),
|
||||
@@ -893,7 +882,6 @@ export const userPreferences = pgTable("user_preferences", {
|
||||
customThemes: text("custom_themes"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
terminalDefaults: text("terminal_defaults"),
|
||||
rdpDefaults: text("rdp_defaults"),
|
||||
terminalMacros: text("terminal_macros"),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
|
||||
@@ -295,21 +295,13 @@ export const hosts = sqliteTable(
|
||||
quickActions: text("quick_actions"),
|
||||
notes: text("notes"),
|
||||
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
|
||||
enableRdp: integer("enable_rdp", { mode: "boolean" }).notNull().default(false),
|
||||
enableVnc: integer("enable_vnc", { mode: "boolean" }).notNull().default(false),
|
||||
enableTelnet: integer("enable_telnet", { mode: "boolean" }).notNull().default(false),
|
||||
|
||||
sshPort: integer("ssh_port").default(22),
|
||||
rdpPort: integer("rdp_port").default(3389),
|
||||
vncPort: integer("vnc_port").default(5900),
|
||||
telnetPort: integer("telnet_port").default(23),
|
||||
|
||||
rdpCredentialId: integer("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||
rdpUser: text("rdp_user"),
|
||||
rdpPassword: text("rdp_password"),
|
||||
rdpDomain: text("rdp_domain"),
|
||||
rdpSecurity: text("rdp_security"),
|
||||
rdpIgnoreCert: integer("rdp_ignore_cert", { mode: "boolean" }).default(false),
|
||||
|
||||
vncCredentialId: integer("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||
vncPassword: text("vnc_password"),
|
||||
@@ -324,9 +316,6 @@ export const hosts = sqliteTable(
|
||||
telnetAuthType: text("telnet_auth_type"),
|
||||
|
||||
domain: text("domain"),
|
||||
security: text("security"),
|
||||
ignoreCert: integer("ignore_cert", { mode: "boolean" }).default(false),
|
||||
guacamoleConfig: text("guacamole_config"),
|
||||
|
||||
useSocks5: integer("use_socks5", { mode: "boolean" }),
|
||||
socks5Host: text("socks5_host"),
|
||||
@@ -889,7 +878,6 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
customThemes: text("custom_themes"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
terminalDefaults: text("terminal_defaults"),
|
||||
rdpDefaults: text("rdp_defaults"),
|
||||
terminalMacros: text("terminal_macros"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
|
||||
@@ -812,9 +812,6 @@ export function registerHostBulkRoutes(
|
||||
? 1
|
||||
: 0,
|
||||
enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh",
|
||||
enableRdp: hostData.enableRdp ?? false,
|
||||
enableVnc: hostData.enableVnc ?? false,
|
||||
enableTelnet: hostData.enableTelnet ?? false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -828,21 +825,10 @@ export function registerHostBulkRoutes(
|
||||
sshDataObj.rdpUser = hostData.rdpUser || null;
|
||||
sshDataObj.rdpPassword = hostData.rdpPassword || null;
|
||||
sshDataObj.rdpDomain = hostData.rdpDomain || null;
|
||||
sshDataObj.rdpSecurity = hostData.rdpSecurity || null;
|
||||
sshDataObj.rdpIgnoreCert = hostData.rdpIgnoreCert ? 1 : 0;
|
||||
sshDataObj.rdpPort = hostData.rdpPort || 3389;
|
||||
sshDataObj.vncUser = hostData.vncUser || null;
|
||||
sshDataObj.vncPassword = hostData.vncPassword || null;
|
||||
sshDataObj.vncPort = hostData.vncPort || 5900;
|
||||
sshDataObj.telnetUser = hostData.telnetUser || null;
|
||||
sshDataObj.telnetPassword = hostData.telnetPassword || null;
|
||||
sshDataObj.telnetPort = hostData.telnetPort || 23;
|
||||
sshDataObj.enableRdp = hostData.enableRdp ? 1 : 0;
|
||||
sshDataObj.enableVnc = hostData.enableVnc ? 1 : 0;
|
||||
sshDataObj.enableTelnet = hostData.enableTelnet ? 1 : 0;
|
||||
sshDataObj.guacamoleConfig = hostData.guacamoleConfig
|
||||
? JSON.stringify(hostData.guacamoleConfig)
|
||||
: null;
|
||||
} else {
|
||||
sshDataObj.password =
|
||||
hostData.authType === "password" ? hostData.password : null;
|
||||
@@ -855,9 +841,6 @@ export function registerHostBulkRoutes(
|
||||
sshDataObj.keyType =
|
||||
hostData.authType === "key" ? hostData.keyType || "auto" : null;
|
||||
sshDataObj.domain = null;
|
||||
sshDataObj.security = null;
|
||||
sshDataObj.ignoreCert = 0;
|
||||
sshDataObj.guacamoleConfig = null;
|
||||
}
|
||||
|
||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||
@@ -1079,9 +1062,6 @@ export function registerHostBulkRoutes(
|
||||
portKnockSequence: null,
|
||||
overrideCredentialUsername: 0,
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
||||
@@ -23,12 +23,7 @@ export function applyHostKeyTypeUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
const PROTOCOL_ENABLE_FIELDS = [
|
||||
"enableSsh",
|
||||
"enableRdp",
|
||||
"enableVnc",
|
||||
"enableTelnet",
|
||||
] as const;
|
||||
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
|
||||
|
||||
export function normalizeProtocolEnableFields(
|
||||
values: Record<string, unknown>,
|
||||
@@ -214,13 +209,7 @@ export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
portKnockSequence?: unknown;
|
||||
overrideCredentialUsername?: unknown;
|
||||
domain?: unknown;
|
||||
security?: unknown;
|
||||
ignoreCert?: unknown;
|
||||
guacamoleConfig?: unknown;
|
||||
enableSsh: boolean;
|
||||
enableRdp: boolean;
|
||||
enableVnc: boolean;
|
||||
enableTelnet: boolean;
|
||||
};
|
||||
|
||||
export function normalizeImportedHost(
|
||||
@@ -275,18 +264,6 @@ export function normalizeImportedHost(
|
||||
hostData.enableSsh === undefined
|
||||
? connectionType === "ssh"
|
||||
: asBoolean(hostData.enableSsh),
|
||||
enableRdp:
|
||||
hostData.enableRdp === undefined
|
||||
? connectionType === "rdp"
|
||||
: asBoolean(hostData.enableRdp),
|
||||
enableVnc:
|
||||
hostData.enableVnc === undefined
|
||||
? connectionType === "vnc"
|
||||
: asBoolean(hostData.enableVnc),
|
||||
enableTelnet:
|
||||
hostData.enableTelnet === undefined
|
||||
? connectionType === "telnet"
|
||||
: asBoolean(hostData.enableTelnet),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -372,14 +349,8 @@ const CONNECT_LEVEL_FIELDS = new Set([
|
||||
"showDockerInSidebar",
|
||||
"showServerStatsInSidebar",
|
||||
"enableSsh",
|
||||
"enableRdp",
|
||||
"enableVnc",
|
||||
"enableTelnet",
|
||||
"sshPort",
|
||||
"rdpPort",
|
||||
"rdpAuthType",
|
||||
"vncPort",
|
||||
"telnetPort",
|
||||
"defaultPath",
|
||||
"scpLegacy",
|
||||
"tunnelConnections",
|
||||
@@ -472,31 +443,10 @@ export function transformHostResponse(
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
// Old hosts only had connection_type set; the per-protocol enable flags didn't exist yet.
|
||||
// The schema defaults (enableSsh=true, others=false) wrongly mark every old host as SSH.
|
||||
// Detect this migration case: if no non-SSH protocol is explicitly enabled AND
|
||||
// connectionType is set to a non-SSH value, fall back to inferring from connectionType.
|
||||
...(() => {
|
||||
const ct = host.connectionType;
|
||||
const rdp = !!host.enableRdp;
|
||||
const vnc = !!host.enableVnc;
|
||||
const tel = !!host.enableTelnet;
|
||||
const isMigratedNonSsh = !rdp && !vnc && !tel && ct && ct !== "ssh";
|
||||
return {
|
||||
enableSsh: isMigratedNonSsh ? false : !!host.enableSsh,
|
||||
enableRdp: isMigratedNonSsh ? ct === "rdp" : rdp,
|
||||
enableVnc: isMigratedNonSsh ? ct === "vnc" : vnc,
|
||||
enableTelnet: isMigratedNonSsh ? ct === "telnet" : tel,
|
||||
};
|
||||
})(),
|
||||
enableSsh: !!host.enableSsh,
|
||||
sshPort: host.sshPort ?? host.port ?? 22,
|
||||
rdpPort: host.rdpPort ?? 3389,
|
||||
vncPort: host.vncPort ?? 5900,
|
||||
telnetPort: host.telnetPort ?? 23,
|
||||
rdpUser: host.rdpUser || undefined,
|
||||
rdpDomain: host.rdpDomain || undefined,
|
||||
rdpSecurity: host.rdpSecurity || undefined,
|
||||
rdpIgnoreCert: !!host.rdpIgnoreCert,
|
||||
vncUser: host.vncUser || undefined,
|
||||
telnetUser: host.telnetUser || undefined,
|
||||
tunnelConnections: host.tunnelConnections
|
||||
@@ -524,10 +474,5 @@ export function transformHostResponse(
|
||||
? JSON.parse(host.portKnockSequence as string)
|
||||
: [],
|
||||
domain: host.domain || undefined,
|
||||
security: host.security || undefined,
|
||||
ignoreCert: !!host.ignoreCert,
|
||||
guacamoleConfig: host.guacamoleConfig
|
||||
? JSON.parse(host.guacamoleConfig as string)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -68,7 +68,6 @@ import type {
|
||||
HostResolutionCredentialRecord,
|
||||
HostResolutionHostRecord,
|
||||
} from "../repositories/host-resolution-repository.js";
|
||||
import { AUTH_PROTOCOL_METADATA } from "../../../types/auth-protocols.js";
|
||||
import {
|
||||
requiresPersonalHostAuthentication,
|
||||
resolveRecipientSharedHostAuthentication,
|
||||
@@ -216,9 +215,6 @@ router.post(
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
domain,
|
||||
security,
|
||||
ignoreCert,
|
||||
guacamoleConfig,
|
||||
notes,
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
@@ -232,20 +228,12 @@ router.post(
|
||||
macAddress,
|
||||
wolBroadcastAddress,
|
||||
enableSsh,
|
||||
enableRdp,
|
||||
enableVnc,
|
||||
enableTelnet,
|
||||
sshPort,
|
||||
rdpPort,
|
||||
vncPort,
|
||||
telnetPort,
|
||||
rdpAuthType,
|
||||
rdpCredentialId,
|
||||
rdpUser,
|
||||
rdpPassword,
|
||||
rdpDomain,
|
||||
rdpSecurity,
|
||||
rdpIgnoreCert,
|
||||
vncAuthType,
|
||||
vncCredentialId,
|
||||
vncPassword,
|
||||
@@ -267,7 +255,7 @@ router.post(
|
||||
!isNonEmptyString(ip) ||
|
||||
!isValidPort(port) ||
|
||||
!isOptionalBoolean(shareSshAuth) ||
|
||||
![enableSsh, enableRdp, enableVnc, enableTelnet].every(isOptionalBoolean)
|
||||
!isOptionalBoolean(enableSsh)
|
||||
) {
|
||||
sshLogger.warn("Invalid SSH data input validation failed", {
|
||||
operation: "host_create",
|
||||
@@ -364,9 +352,6 @@ router.post(
|
||||
: null,
|
||||
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||
domain: domain || null,
|
||||
security: security || null,
|
||||
ignoreCert: ignoreCert ? 1 : 0,
|
||||
guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null,
|
||||
notes: notes || null,
|
||||
sudoPassword: sudoPassword || null,
|
||||
useSocks5: useSocks5 ? 1 : 0,
|
||||
@@ -388,27 +373,22 @@ router.post(
|
||||
: null,
|
||||
...normalizeProtocolEnableFields(hostData),
|
||||
sshPort: sshPort || port || 22,
|
||||
rdpPort: rdpPort || 3389,
|
||||
vncPort: vncPort || 5900,
|
||||
telnetPort: telnetPort || 23,
|
||||
rdpAuthType: enableRdp ? rdpAuthType || null : null,
|
||||
rdpAuthType: rdpAuthType || null,
|
||||
rdpCredentialId:
|
||||
enableRdp && rdpAuthType === "credential" && rdpCredentialId
|
||||
rdpAuthType === "credential" && rdpCredentialId
|
||||
? rdpCredentialId
|
||||
: null,
|
||||
rdpUser: rdpUser || null,
|
||||
rdpDomain: rdpDomain || null,
|
||||
rdpSecurity: rdpSecurity || null,
|
||||
rdpIgnoreCert: rdpIgnoreCert ? 1 : 0,
|
||||
vncAuthType: enableVnc ? vncAuthType || null : null,
|
||||
vncAuthType: vncAuthType || null,
|
||||
vncCredentialId:
|
||||
enableVnc && vncAuthType === "credential" && vncCredentialId
|
||||
vncAuthType === "credential" && vncCredentialId
|
||||
? vncCredentialId
|
||||
: null,
|
||||
vncUser: vncUser || null,
|
||||
telnetAuthType: enableTelnet ? telnetAuthType || null : null,
|
||||
telnetAuthType: telnetAuthType || null,
|
||||
telnetCredentialId:
|
||||
enableTelnet && telnetAuthType === "credential" && telnetCredentialId
|
||||
telnetAuthType === "credential" && telnetCredentialId
|
||||
? telnetCredentialId
|
||||
: null,
|
||||
telnetUser: telnetUser || null,
|
||||
@@ -932,9 +912,6 @@ router.put(
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
domain,
|
||||
security,
|
||||
ignoreCert,
|
||||
guacamoleConfig,
|
||||
notes,
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
@@ -948,20 +925,12 @@ router.put(
|
||||
macAddress,
|
||||
wolBroadcastAddress,
|
||||
enableSsh,
|
||||
enableRdp,
|
||||
enableVnc,
|
||||
enableTelnet,
|
||||
sshPort,
|
||||
rdpPort,
|
||||
vncPort,
|
||||
telnetPort,
|
||||
rdpAuthType,
|
||||
rdpCredentialId,
|
||||
rdpUser,
|
||||
rdpPassword,
|
||||
rdpDomain,
|
||||
rdpSecurity,
|
||||
rdpIgnoreCert,
|
||||
vncAuthType,
|
||||
vncCredentialId,
|
||||
vncPassword,
|
||||
@@ -983,9 +952,7 @@ router.put(
|
||||
!isNonEmptyString(ip) ||
|
||||
!isValidPort(port) ||
|
||||
!isOptionalBoolean(shareSshAuth) ||
|
||||
![enableSsh, enableRdp, enableVnc, enableTelnet].every(
|
||||
isOptionalBoolean,
|
||||
) ||
|
||||
!isOptionalBoolean(enableSsh) ||
|
||||
!hostId
|
||||
) {
|
||||
sshLogger.warn("Invalid SSH data input validation failed for update", {
|
||||
@@ -1083,9 +1050,6 @@ router.put(
|
||||
: null,
|
||||
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||
domain: domain || null,
|
||||
security: security || null,
|
||||
ignoreCert: ignoreCert ? 1 : 0,
|
||||
guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null,
|
||||
notes: notes || null,
|
||||
sudoPassword: sudoPassword || null,
|
||||
useSocks5: useSocks5 ? 1 : 0,
|
||||
@@ -1107,27 +1071,22 @@ router.put(
|
||||
: null,
|
||||
...normalizeProtocolEnableFields(hostData),
|
||||
sshPort: sshPort || port || 22,
|
||||
rdpPort: rdpPort || 3389,
|
||||
vncPort: vncPort || 5900,
|
||||
telnetPort: telnetPort || 23,
|
||||
rdpAuthType: enableRdp ? rdpAuthType || null : null,
|
||||
rdpAuthType: rdpAuthType || null,
|
||||
rdpCredentialId:
|
||||
enableRdp && rdpAuthType === "credential" && rdpCredentialId
|
||||
rdpAuthType === "credential" && rdpCredentialId
|
||||
? rdpCredentialId
|
||||
: null,
|
||||
rdpUser: rdpUser || null,
|
||||
rdpDomain: rdpDomain || null,
|
||||
rdpSecurity: rdpSecurity || null,
|
||||
rdpIgnoreCert: rdpIgnoreCert ? 1 : 0,
|
||||
vncAuthType: enableVnc ? vncAuthType || null : null,
|
||||
vncAuthType: vncAuthType || null,
|
||||
vncCredentialId:
|
||||
enableVnc && vncAuthType === "credential" && vncCredentialId
|
||||
vncAuthType === "credential" && vncCredentialId
|
||||
? vncCredentialId
|
||||
: null,
|
||||
vncUser: vncUser || null,
|
||||
telnetAuthType: enableTelnet ? telnetAuthType || null : null,
|
||||
telnetAuthType: telnetAuthType || null,
|
||||
telnetCredentialId:
|
||||
enableTelnet && telnetAuthType === "credential" && telnetCredentialId
|
||||
telnetAuthType === "credential" && telnetCredentialId
|
||||
? telnetCredentialId
|
||||
: null,
|
||||
telnetUser: telnetUser || null,
|
||||
@@ -2085,9 +2044,7 @@ router.get(
|
||||
|
||||
const exportedConnectionType =
|
||||
(resolvedHost.connectionType as string) || "ssh";
|
||||
const isRemoteDesktop = ["rdp", "vnc", "telnet"].includes(
|
||||
exportedConnectionType,
|
||||
);
|
||||
const isRemoteDesktop = exportedConnectionType !== "ssh";
|
||||
|
||||
const baseExportData = {
|
||||
exportId: resolvedHost.id,
|
||||
@@ -2104,24 +2061,18 @@ router.get(
|
||||
: resolvedHost.tags || [],
|
||||
pin: !!resolvedHost.pin,
|
||||
notes: resolvedHost.notes || null,
|
||||
// Every plugin's host settings, secrets redacted, for import to hand back.
|
||||
pluginSettings: hostPluginSettings ?? {},
|
||||
};
|
||||
|
||||
const exportData = isRemoteDesktop
|
||||
? {
|
||||
...baseExportData,
|
||||
enableRdp: !!resolvedHost.enableRdp,
|
||||
enableVnc: !!resolvedHost.enableVnc,
|
||||
enableTelnet: !!resolvedHost.enableTelnet,
|
||||
rdpPort: resolvedHost.rdpPort || 3389,
|
||||
vncPort: resolvedHost.vncPort || 5900,
|
||||
telnetPort: resolvedHost.telnetPort || 23,
|
||||
rdpAuthType: resolvedHost.rdpAuthType || null,
|
||||
rdpCredentialId: resolvedHost.rdpCredentialId || null,
|
||||
rdpUser: resolvedHost.rdpUser || null,
|
||||
rdpPassword: resolvedHost.rdpPassword || null,
|
||||
rdpDomain: resolvedHost.rdpDomain || null,
|
||||
rdpSecurity: resolvedHost.rdpSecurity || null,
|
||||
rdpIgnoreCert: !!resolvedHost.rdpIgnoreCert,
|
||||
vncAuthType: resolvedHost.vncAuthType || null,
|
||||
vncCredentialId: resolvedHost.vncCredentialId || null,
|
||||
vncUser: resolvedHost.vncUser || null,
|
||||
@@ -2130,9 +2081,6 @@ router.get(
|
||||
telnetCredentialId: resolvedHost.telnetCredentialId || null,
|
||||
telnetUser: resolvedHost.telnetUser || null,
|
||||
telnetPassword: resolvedHost.telnetPassword || null,
|
||||
guacamoleConfig: resolvedHost.guacamoleConfig
|
||||
? JSON.parse(resolvedHost.guacamoleConfig as string)
|
||||
: null,
|
||||
}
|
||||
: {
|
||||
...baseExportData,
|
||||
@@ -2273,9 +2221,7 @@ router.get(
|
||||
|
||||
const exportedConnectionType =
|
||||
(resolvedHost.connectionType as string) || "ssh";
|
||||
const isRemoteDesktop = ["rdp", "vnc", "telnet"].includes(
|
||||
exportedConnectionType,
|
||||
);
|
||||
const isRemoteDesktop = exportedConnectionType !== "ssh";
|
||||
|
||||
const baseExportData = {
|
||||
exportId: resolvedHost.id,
|
||||
@@ -2292,17 +2238,14 @@ router.get(
|
||||
: resolvedHost.tags || [],
|
||||
pin: !!resolvedHost.pin,
|
||||
notes: resolvedHost.notes || null,
|
||||
// Every plugin's host settings, secrets redacted, for import to hand back.
|
||||
pluginSettings: hostPluginSettings ?? {},
|
||||
};
|
||||
|
||||
const exportData = isRemoteDesktop
|
||||
? {
|
||||
...baseExportData,
|
||||
domain: resolvedHost.domain || null,
|
||||
security: resolvedHost.security || null,
|
||||
ignoreCert: !!resolvedHost.ignoreCert,
|
||||
guacamoleConfig: resolvedHost.guacamoleConfig
|
||||
? JSON.parse(resolvedHost.guacamoleConfig as string)
|
||||
: null,
|
||||
}
|
||||
: {
|
||||
...baseExportData,
|
||||
@@ -2638,8 +2581,9 @@ async function resolveHostCredentials(
|
||||
host.id,
|
||||
requestingUserId,
|
||||
);
|
||||
// Whether a protocol is on is the remote-desktop plugin's host
|
||||
// setting; the client only offers an override for one that is.
|
||||
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
|
||||
if (!host[AUTH_PROTOCOL_METADATA[protocol].enableField]) continue;
|
||||
authOverrides[protocol] = {
|
||||
credentialId: overrideCredentialIds[protocol],
|
||||
required: false,
|
||||
|
||||
@@ -39,11 +39,10 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
customThemes: row?.customThemes ?? null,
|
||||
customKeybindings: row?.customKeybindings ?? null,
|
||||
terminalDefaults: row?.terminalDefaults ?? null,
|
||||
rdpDefaults: row?.rdpDefaults ?? null,
|
||||
terminalMacros: row?.terminalMacros ?? null,
|
||||
});
|
||||
|
||||
const connectionDefaultFields = ["terminalDefaults", "rdpDefaults"] as const;
|
||||
const connectionDefaultFields = ["terminalDefaults"] as const;
|
||||
|
||||
export function validateDefaultsJson(value: string): boolean {
|
||||
if (value.length > 32_768) return false;
|
||||
@@ -233,7 +232,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
customThemes,
|
||||
customKeybindings,
|
||||
terminalDefaults,
|
||||
rdpDefaults,
|
||||
terminalMacros,
|
||||
} = req.body as {
|
||||
reopenTabsOnLogin?: boolean;
|
||||
@@ -256,7 +254,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
customThemes?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
terminalDefaults?: string | null;
|
||||
rdpDefaults?: string | null;
|
||||
terminalMacros?: string | null;
|
||||
};
|
||||
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
|
||||
@@ -289,7 +286,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
customThemes,
|
||||
customKeybindings,
|
||||
terminalDefaults,
|
||||
rdpDefaults,
|
||||
terminalMacros,
|
||||
})) {
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
@@ -299,7 +295,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
const connectionDefaults = {
|
||||
terminalDefaults,
|
||||
rdpDefaults,
|
||||
};
|
||||
for (const key of connectionDefaultFields) {
|
||||
const value = connectionDefaults[key];
|
||||
@@ -435,7 +430,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
updates.customKeybindings = customKeybindings;
|
||||
if (terminalDefaults !== undefined)
|
||||
updates.terminalDefaults = terminalDefaults;
|
||||
if (rdpDefaults !== undefined) updates.rdpDefaults = rdpDefaults;
|
||||
if (terminalMacros !== undefined) updates.terminalMacros = terminalMacros;
|
||||
|
||||
if (Object.keys(updates).length === 1) {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes
|
||||
// through the same in-core bridge its router is mounted behind, rather than
|
||||
// importing the plugin's TypeScript directly, since plugins/ is compiled
|
||||
// separately from src/backend/ and core cannot statically import across that
|
||||
// boundary. See guacamole-dispatch.ts and that plugin's README.
|
||||
import { restartGuacamoleService as restartGuacServer } from "../../hosts/guacamole-sessions.js";
|
||||
import {
|
||||
authLogger,
|
||||
getGlobalLogLevel,
|
||||
@@ -31,10 +25,6 @@ import {
|
||||
} from "../repositories/factory.js";
|
||||
import type { UserRecord } from "../repositories/user-repository.js";
|
||||
|
||||
function getDefaultGuacUrl(): string {
|
||||
return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`;
|
||||
}
|
||||
|
||||
export type HostDefaults = {
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
@@ -66,119 +56,6 @@ export function registerUserSettingsRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/guacamole-settings:
|
||||
* get:
|
||||
* summary: Get Guacamole settings
|
||||
* description: Returns current guacd enabled status and host:port URL. No authentication required.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Guacamole settings.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* url:
|
||||
* type: string
|
||||
* 500:
|
||||
* description: Failed to get guacamole settings.
|
||||
*/
|
||||
router.get("/guacamole-settings", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const enabled = await settings.getBoolean("guac_enabled", true);
|
||||
const url = await settings.get("guac_url");
|
||||
res.json({
|
||||
enabled,
|
||||
url: url ?? getDefaultGuacUrl(),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get guacamole settings", err);
|
||||
res.status(500).json({ error: "Failed to get guacamole settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/guacamole-settings:
|
||||
* patch:
|
||||
* summary: Update Guacamole settings
|
||||
* description: Admin-only. Updates guacd enabled status and/or host:port URL.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* url:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Guacamole settings updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update guacamole settings.
|
||||
*/
|
||||
router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { enabled, url } = req.body;
|
||||
const settings = createCurrentSettingsRepository();
|
||||
if (typeof enabled === "boolean") {
|
||||
await settings.set("guac_enabled", enabled ? "true" : "false");
|
||||
}
|
||||
if (typeof url === "string") {
|
||||
await settings.set("guac_url", url);
|
||||
try {
|
||||
await restartGuacServer();
|
||||
} catch (err) {
|
||||
authLogger.error(
|
||||
"Failed to restart guac server after URL update",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
const currentEnabled = await settings.getBoolean("guac_enabled", true);
|
||||
const currentUrl = await settings.get("guac_url");
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_guacamole_settings",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ enabled, url }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({
|
||||
enabled: currentEnabled,
|
||||
url: currentUrl ?? getDefaultGuacUrl(),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update guacamole settings", err);
|
||||
res.status(500).json({ error: "Failed to update guacamole settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/log-level:
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* What core needs from the Remote Desktop plugin, resolved through the plugin
|
||||
* registry rather than an import: restarting its guacd WebSocket server after
|
||||
* an admin edits the guacd URL setting.
|
||||
*
|
||||
* The plugin publishes "remote-desktop.sessions" through ctx.registry on
|
||||
* activate and the registry revokes it on deactivate. A restart while it is
|
||||
* off does nothing, because resurrecting a server the plugin does not own
|
||||
* would be worse than doing nothing.
|
||||
*/
|
||||
|
||||
import { consume } from "../plugins/registry.js";
|
||||
|
||||
export const GUACAMOLE_SESSIONS_KEY = "remote-desktop.sessions";
|
||||
|
||||
export interface GuacamoleSessionProvider {
|
||||
restart: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Restarts the plugin's guacd WebSocket server. A no-op while it is off. */
|
||||
export async function restartGuacamoleService(): Promise<void> {
|
||||
await consume<GuacamoleSessionProvider>(GUACAMOLE_SESSIONS_KEY)?.restart();
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* ctx.credentials: plaintext protocol credentials for a host, for a plugin
|
||||
* that hands them to a program core does not run (remote desktop gives them
|
||||
* to guacd). Needs credentials:read, the one critical capability, and every
|
||||
* call is audited whether it succeeds or not.
|
||||
*
|
||||
* The protocol credentials themselves stay in core, next to the host, because
|
||||
* core's sharing model (per-recipient secret snapshots, personal overrides)
|
||||
* is what decides which ones a shared recipient may use.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginCredentials,
|
||||
PluginHostProtocol,
|
||||
PluginProtocolTarget,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { getActor } from "./actor.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
details: string,
|
||||
outcome: { success: boolean; errorMessage?: string },
|
||||
) => Promise<void>;
|
||||
|
||||
interface Deps {
|
||||
manifest: PluginManifest;
|
||||
audit: AuditFn;
|
||||
}
|
||||
|
||||
const PROTOCOLS: PluginHostProtocol[] = ["rdp", "vnc", "telnet"];
|
||||
|
||||
type HostRow = Record<string, unknown>;
|
||||
|
||||
function str(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function parseJumpHosts(value: unknown): Array<{ hostId: number }> {
|
||||
let parsed: unknown = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map((hop) => Number((hop as { hostId?: unknown })?.hostId))
|
||||
.filter((id) => Number.isInteger(id) && id > 0)
|
||||
.map((hostId) => ({ hostId }));
|
||||
}
|
||||
|
||||
async function resolveOwnerAuth(
|
||||
host: HostRow,
|
||||
protocol: PluginHostProtocol,
|
||||
): Promise<PluginProtocolTarget["auth"]> {
|
||||
const credentialId = host[`${protocol}CredentialId`] as number | null;
|
||||
const authType =
|
||||
str(host[`${protocol}AuthType`]) ||
|
||||
(credentialId ? "credential" : "direct");
|
||||
let username = str(host[`${protocol}User`]);
|
||||
let password = str(host[`${protocol}Password`]);
|
||||
|
||||
if (authType === "credential" && credentialId) {
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const credential =
|
||||
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
||||
credentialId,
|
||||
host.userId as string,
|
||||
);
|
||||
// The domain never comes from a stored credential.
|
||||
if (credential?.username) username = credential.username;
|
||||
if (credential?.password) password = credential.password;
|
||||
}
|
||||
|
||||
if (protocol === "rdp") username ||= str(host.username);
|
||||
password ||= str(host.password);
|
||||
|
||||
return {
|
||||
authType,
|
||||
username,
|
||||
password,
|
||||
domain: str(host.rdpDomain) || str(host.domain),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveRecipientAuth(
|
||||
host: HostRow,
|
||||
hostId: number,
|
||||
userId: string,
|
||||
protocol: PluginHostProtocol,
|
||||
): Promise<PluginProtocolTarget["auth"]> {
|
||||
const { resolveRecipientSharedHostAuthentication } =
|
||||
await import("../utils/shared-host-auth-resolver.js");
|
||||
// Recipients never read the owner's raw secrets, so start from nothing.
|
||||
const recipientHost = {
|
||||
...host,
|
||||
password: null,
|
||||
rdpUser: null,
|
||||
rdpPassword: null,
|
||||
vncUser: null,
|
||||
vncPassword: null,
|
||||
telnetUser: null,
|
||||
telnetPassword: null,
|
||||
};
|
||||
let resolution: Awaited<
|
||||
ReturnType<typeof resolveRecipientSharedHostAuthentication>
|
||||
>;
|
||||
try {
|
||||
resolution = await resolveRecipientSharedHostAuthentication(
|
||||
recipientHost as never,
|
||||
hostId,
|
||||
userId,
|
||||
protocol,
|
||||
);
|
||||
} catch {
|
||||
// Same as before the move: connect without stored credentials.
|
||||
resolution = { source: "required" };
|
||||
}
|
||||
|
||||
const auth =
|
||||
resolution.source === "personal-override"
|
||||
? { ...resolution.credential, domain: null }
|
||||
: resolution.source === "owner-shared"
|
||||
? resolution.secret
|
||||
: null;
|
||||
|
||||
let authType = "direct";
|
||||
if (resolution.source === "personal-override") authType = "credential";
|
||||
else if (resolution.source === "owner-shared") authType = resolution.authType;
|
||||
else if (
|
||||
resolution.source === "secretless" &&
|
||||
str(host[`${protocol}AuthType`]) === "none"
|
||||
) {
|
||||
authType = "none";
|
||||
}
|
||||
|
||||
return {
|
||||
authType,
|
||||
username: str(auth?.username),
|
||||
password: str(auth?.password),
|
||||
domain: str(auth?.domain) || str(host.rdpDomain) || str(host.domain),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginCredentials({
|
||||
manifest,
|
||||
audit,
|
||||
}: Deps): PluginCredentials {
|
||||
const pluginId = manifest.id;
|
||||
const declared = manifest.capabilities;
|
||||
|
||||
return {
|
||||
resolveHostProtocol: async (hostId, protocol) => {
|
||||
const details = `${protocol} credentials for host ${hostId}`;
|
||||
try {
|
||||
await assertCapability(pluginId, "credentials:read", declared);
|
||||
if (!PROTOCOLS.includes(protocol)) {
|
||||
throw new Error(`Unsupported protocol: ${String(protocol)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
await audit("credentials_read", details, {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const userId = getActor();
|
||||
if (!userId) {
|
||||
throw new Error(
|
||||
"ctx.credentials needs an acting user: call it inside a request or ctx.asUser",
|
||||
);
|
||||
}
|
||||
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
// Shared hosts carry fields encrypted under the owner's key.
|
||||
const ownerId = await repository.findHostOwnerId(hostId);
|
||||
const host = ownerId
|
||||
? ((await repository.findHostById(hostId, ownerId)) as HostRow | null)
|
||||
: null;
|
||||
if (!host) {
|
||||
await audit("credentials_read", details, {
|
||||
success: false,
|
||||
errorMessage: "Host not found",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const shared = host.userId !== userId;
|
||||
if (shared) {
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
await audit("credentials_read", details, {
|
||||
success: false,
|
||||
errorMessage: "No connect access",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const auth = shared
|
||||
? await resolveRecipientAuth(host, hostId, userId, protocol)
|
||||
: await resolveOwnerAuth(host, protocol);
|
||||
|
||||
await audit("credentials_read", details, { success: true });
|
||||
return {
|
||||
host: {
|
||||
id: hostId,
|
||||
name: (host.name as string | null) ?? null,
|
||||
ip: str(host.ip),
|
||||
port: Number(host.port) || 0,
|
||||
ownerUserId: host.userId as string,
|
||||
jumpHosts: parseJumpHosts(host.jumpHosts),
|
||||
},
|
||||
shared,
|
||||
auth,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -78,7 +78,6 @@ function toRecord(host: Record<string, unknown>): PluginHostRecord {
|
||||
folder: (host.folder as string | null) ?? null,
|
||||
jumpHosts: host.jumpHosts,
|
||||
enableSsh: (host.enableSsh as boolean | null) ?? null,
|
||||
enableRdp: (host.enableRdp as boolean | null) ?? null,
|
||||
enableTerminal: (host.enableTerminal as boolean | null) ?? null,
|
||||
enableFileManager: (host.enableFileManager as boolean | null) ?? null,
|
||||
enableTunnel: (host.enableTunnel as boolean | null) ?? null,
|
||||
@@ -205,6 +204,14 @@ export function createPluginHosts({ manifest, audit }: Deps): PluginHosts {
|
||||
...host,
|
||||
userId,
|
||||
});
|
||||
// Fields another plugin owns (a remote desktop switch) go to that
|
||||
// plugin's host settings, the same way a bulk import row's do.
|
||||
const { applyPluginHostImportSettings } =
|
||||
await import("../database/routes/host-plugin-settings.js");
|
||||
await applyPluginHostImportSettings(
|
||||
created.id,
|
||||
host as Record<string, unknown>,
|
||||
);
|
||||
await audit("hosts_create", `host ${created.id}`, { success: true });
|
||||
return toRecord(created as unknown as Record<string, unknown>);
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type PluginContext,
|
||||
type PluginModule,
|
||||
type PluginOpenIsolatedWindowRequest,
|
||||
type PluginNativeRdpRequest,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
||||
import * as syncRegistry from "./sync-registry.js";
|
||||
@@ -52,6 +53,8 @@ import {
|
||||
} from "../database/db/dialect.js";
|
||||
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
|
||||
import { createPluginHosts } from "./ctx-hosts.js";
|
||||
import { createPluginCredentials } from "./ctx-credentials.js";
|
||||
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
|
||||
|
||||
export type { PluginModule };
|
||||
|
||||
@@ -336,6 +339,28 @@ export function createPluginContext(
|
||||
},
|
||||
);
|
||||
|
||||
const desktopLaunchNativeRdp = guarded(
|
||||
manifest,
|
||||
"desktop:window",
|
||||
async (request: PluginNativeRdpRequest) => {
|
||||
const { isElectronIpcAvailable, requestFromElectronMain } =
|
||||
await import("../utils/electron-ipc-bridge.js");
|
||||
if (!isElectronIpcAvailable()) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} tried to open the native RDP client outside the desktop app`,
|
||||
);
|
||||
}
|
||||
return requestFromElectronMain<{ success: boolean; error?: string }>(
|
||||
"launch-native-rdp",
|
||||
request,
|
||||
);
|
||||
},
|
||||
{
|
||||
action: "desktop_launch_native_rdp",
|
||||
details: () => "opened the native RDP client",
|
||||
},
|
||||
);
|
||||
|
||||
const capabilitiesRequire = async (capability: string) => {
|
||||
try {
|
||||
await assertCapability(pluginId, capability, declared);
|
||||
@@ -696,8 +721,12 @@ export function createPluginContext(
|
||||
|
||||
desktop: {
|
||||
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
|
||||
launchNativeRdp: (request) => desktopLaunchNativeRdp(request),
|
||||
available: () => isElectronIpcAvailable(),
|
||||
},
|
||||
|
||||
credentials: createPluginCredentials({ manifest, audit: auditCall }),
|
||||
|
||||
audit: {
|
||||
// Attribution comes from the runtime: the actor, never a plugin value.
|
||||
record: async (entry) => {
|
||||
|
||||
@@ -40,7 +40,6 @@ export type { PluginSettingsScope };
|
||||
*/
|
||||
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
|
||||
"app_name",
|
||||
"guac_enabled",
|
||||
"global_metrics_interval",
|
||||
"global_status_check_interval",
|
||||
];
|
||||
|
||||
@@ -377,6 +377,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
const { runSessionRecordingSettingsMigration } =
|
||||
await import("./utils/crypto-migration/session-recording-settings-migration.js");
|
||||
await runSessionRecordingSettingsMigration();
|
||||
|
||||
const { runRemoteDesktopSettingsMigration } =
|
||||
await import("./utils/crypto-migration/remote-desktop-settings-migration.js");
|
||||
await runRemoteDesktopSettingsMigration();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Plugin runtime failed to initialize", {
|
||||
operation: "plugin_init",
|
||||
|
||||
@@ -105,15 +105,10 @@ describe("normalizeProtocolEnableFields", () => {
|
||||
expect(normalizeProtocolEnableFields({ name: "server" })).toEqual({});
|
||||
});
|
||||
|
||||
it("converts explicitly provided protocol booleans to database integers", () => {
|
||||
it("converts an explicitly provided SSH switch to a database integer", () => {
|
||||
expect(
|
||||
normalizeProtocolEnableFields({
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: undefined,
|
||||
enableTelnet: true,
|
||||
}),
|
||||
).toEqual({ enableSsh: 1, enableRdp: 0, enableTelnet: 1 });
|
||||
normalizeProtocolEnableFields({ enableSsh: true, enableRdp: false }),
|
||||
).toEqual({ enableSsh: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,14 +169,13 @@ describe("normalizeImportedHost", () => {
|
||||
expect(host.connectionType).toBe("ssh");
|
||||
expect(host.port).toBe(22);
|
||||
expect(host.enableSsh).toBe(true);
|
||||
expect(host.enableRdp).toBe(false);
|
||||
});
|
||||
|
||||
it("infers rdp from enableRdp and uses default rdp port", () => {
|
||||
const host = normalizeImportedHost({ enableRdp: true, ip: "10.0.0.2" });
|
||||
expect(host.connectionType).toBe("rdp");
|
||||
expect(host.port).toBe(3389);
|
||||
expect(host.enableRdp).toBe(true);
|
||||
expect(host.enableSsh).toBe(false);
|
||||
});
|
||||
|
||||
it("honors an explicit port over protocol defaults", () => {
|
||||
@@ -321,21 +315,15 @@ describe("transformHostResponse", () => {
|
||||
expect(result.jumpHosts).toEqual([]);
|
||||
});
|
||||
|
||||
it("infers protocol flags for a migrated non-ssh host", () => {
|
||||
const result = transformHostResponse({
|
||||
connectionType: "rdp",
|
||||
enableSsh: true,
|
||||
});
|
||||
expect(result.enableSsh).toBe(false);
|
||||
expect(result.enableRdp).toBe(true);
|
||||
it("passes the stored SSH switch through", () => {
|
||||
// The remote desktop migration corrects connection_type-only hosts once.
|
||||
expect(transformHostResponse({ enableSsh: false }).enableSsh).toBe(false);
|
||||
});
|
||||
|
||||
it("applies default protocol ports", () => {
|
||||
it("applies the default SSH port", () => {
|
||||
const result = transformHostResponse({ port: 22 });
|
||||
expect(result.sshPort).toBe(22);
|
||||
expect(result.rdpPort).toBe(3389);
|
||||
expect(result.vncPort).toBe(5900);
|
||||
expect(result.telnetPort).toBe(23);
|
||||
expect(result).not.toHaveProperty("rdpPort");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -426,8 +414,6 @@ describe("sanitizeHostForRecipient", () => {
|
||||
);
|
||||
expect(result.name).toBe("prod");
|
||||
expect(result.ip).toBe("10.0.0.42");
|
||||
expect(result.enableRdp).toBe(true);
|
||||
expect(result.rdpPort).toBe(3389);
|
||||
expect(result.rdpAuthType).toBe("none");
|
||||
expect(result.permissionLevel).toBe("connect");
|
||||
expect(result.shareSshAuth).toBe(true);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildRdpFile, validateNativeRdpOptions } =
|
||||
require("../../../../electron/native-rdp.cjs") as {
|
||||
buildRdpFile: (options: Record<string, unknown>) => string;
|
||||
validateNativeRdpOptions: (options: Record<string, unknown>) => unknown;
|
||||
};
|
||||
|
||||
describe("native Windows RDP", () => {
|
||||
it("creates a credential-prompting RDP file without a password", () => {
|
||||
const result = buildRdpFile({
|
||||
host: "server.example.com",
|
||||
port: 3390,
|
||||
username: "alice",
|
||||
domain: "EXAMPLE",
|
||||
password: "must-not-be-written",
|
||||
});
|
||||
|
||||
expect(result).toContain("full address:s:server.example.com:3390");
|
||||
expect(result).toContain("username:s:EXAMPLE\\alice");
|
||||
expect(result).toContain("prompt for credentials:i:1");
|
||||
expect(result).not.toContain("must-not-be-written");
|
||||
});
|
||||
|
||||
it("rejects unsafe host and port values", () => {
|
||||
expect(() =>
|
||||
validateNativeRdpOptions({ host: "bad\r\nvalue" }),
|
||||
).not.toThrow();
|
||||
expect(() => validateNativeRdpOptions({ host: "server/path" })).toThrow();
|
||||
expect(() =>
|
||||
validateNativeRdpOptions({ host: "server", port: 70000 }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* ctx.credentials.resolveHostProtocol: credentials:read is checked and every
|
||||
* call audited, the owner gets their stored or credential-mode login, and a
|
||||
* shared recipient only ever sees what core's sharing rules hand them.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
hosts: new Map<number, Record<string, unknown>>(),
|
||||
credentials: new Map<number, { username: string; password: string }>(),
|
||||
canConnect: true,
|
||||
resolution: null as Record<string, unknown> | null,
|
||||
resolverCalls: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostOwnerId: async (hostId: number) =>
|
||||
(state.hosts.get(hostId)?.userId as string) ?? null,
|
||||
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
|
||||
findCredentialByIdForUser: async (id: number) =>
|
||||
state.credentials.get(id) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async () => ({ hasAccess: state.canConnect }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/shared-host-auth-resolver.js", () => ({
|
||||
resolveRecipientSharedHostAuthentication: async (
|
||||
host: Record<string, unknown>,
|
||||
) => {
|
||||
state.resolverCalls.push(host);
|
||||
return state.resolution;
|
||||
},
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(capabilities: string[]) {
|
||||
const manifest = {
|
||||
id: "demo",
|
||||
name: "demo",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle("demo", { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
}
|
||||
|
||||
const baseHost = {
|
||||
id: 7,
|
||||
userId: "owner",
|
||||
name: "Win box",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "sshuser",
|
||||
password: "ssh-secret",
|
||||
jumpHosts: JSON.stringify([{ hostId: 3 }]),
|
||||
rdpUser: "admin",
|
||||
rdpPassword: "rdp-secret",
|
||||
rdpDomain: "CORP",
|
||||
rdpAuthType: null,
|
||||
rdpCredentialId: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
state.hosts.clear();
|
||||
state.credentials.clear();
|
||||
state.canConnect = true;
|
||||
state.resolution = null;
|
||||
state.resolverCalls.length = 0;
|
||||
state.hosts.set(7, { ...baseHost });
|
||||
});
|
||||
|
||||
describe("ctx.credentials.resolveHostProtocol", () => {
|
||||
it("refuses without credentials:read and audits the refusal", async () => {
|
||||
grants.set("demo", []);
|
||||
const ctx = contextFor([]);
|
||||
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "rdp"),
|
||||
),
|
||||
).rejects.toThrow(/credentials:read/);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_credentials_read",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the owner's stored login and audits it", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "rdp"),
|
||||
);
|
||||
expect(target).toEqual({
|
||||
host: {
|
||||
id: 7,
|
||||
name: "Win box",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
ownerUserId: "owner",
|
||||
jumpHosts: [{ hostId: 3 }],
|
||||
},
|
||||
shared: false,
|
||||
auth: {
|
||||
authType: "direct",
|
||||
username: "admin",
|
||||
password: "rdp-secret",
|
||||
domain: "CORP",
|
||||
},
|
||||
});
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_credentials_read",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the stored credential in credential mode", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.hosts.set(7, {
|
||||
...baseHost,
|
||||
vncCredentialId: 11,
|
||||
vncAuthType: "credential",
|
||||
});
|
||||
state.credentials.set(11, { username: "viewer", password: "vnc-pass" });
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "vnc"),
|
||||
);
|
||||
expect(target?.auth).toMatchObject({
|
||||
authType: "credential",
|
||||
username: "viewer",
|
||||
password: "vnc-pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("never hands a shared recipient the owner's raw secrets", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.resolution = {
|
||||
source: "owner-shared",
|
||||
authType: "direct",
|
||||
secret: { username: "shared", password: "snap", domain: null },
|
||||
};
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("guest", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "rdp"),
|
||||
);
|
||||
expect(target?.shared).toBe(true);
|
||||
expect(target?.auth).toMatchObject({
|
||||
username: "shared",
|
||||
password: "snap",
|
||||
});
|
||||
expect(state.resolverCalls[0]).toMatchObject({
|
||||
password: null,
|
||||
rdpUser: null,
|
||||
rdpPassword: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives a recipient with nothing shared an empty login", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.resolution = { source: "required" };
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("guest", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "telnet"),
|
||||
);
|
||||
expect(target?.auth.username).toBe("");
|
||||
expect(target?.auth.password).toBe("");
|
||||
});
|
||||
|
||||
it("returns null without connect access or for a missing host", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.canConnect = false;
|
||||
const ctx = contextFor(["credentials:read"]);
|
||||
|
||||
await expect(
|
||||
runAsActor("guest", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "rdp"),
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(99, "rdp"),
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(auditEntries.at(-1)).toMatchObject({ success: false });
|
||||
});
|
||||
});
|
||||
@@ -121,3 +121,50 @@ describe("ctx.desktop.openIsolatedWindow", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.desktop.launchNativeRdp", () => {
|
||||
const request = { host: "win.example.test", port: 3389, username: "bob" };
|
||||
|
||||
it("refuses without desktop:window", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.desktop.launchNativeRdp(request)).rejects.toThrow(
|
||||
/desktop:window/,
|
||||
);
|
||||
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_launch_native_rdp",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses outside the desktop app", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
expect(ctx.desktop.available()).toBe(false);
|
||||
await expect(ctx.desktop.launchNativeRdp(request)).rejects.toThrow(
|
||||
/desktop app/,
|
||||
);
|
||||
});
|
||||
|
||||
it("relays to Electron main and audits success", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
bridge.available = true;
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
expect(ctx.desktop.available()).toBe(true);
|
||||
await expect(ctx.desktop.launchNativeRdp(request)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
|
||||
"launch-native-rdp",
|
||||
request,
|
||||
);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_launch_native_rdp",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ const h = vi.hoisted(() => ({
|
||||
activities: [] as Array<Record<string, unknown>>,
|
||||
sessions: [] as number[],
|
||||
released: [] as number[],
|
||||
importRows: [] as Array<{ hostId: number; row: Record<string, unknown> }>,
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -110,6 +111,14 @@ vi.mock("../../hosts/host-session-status.js", () => ({
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
applyPluginHostImportSettings: async (
|
||||
hostId: number,
|
||||
row: Record<string, unknown>,
|
||||
) => {
|
||||
h.importRows.push({ hostId, row });
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({
|
||||
@@ -325,6 +334,7 @@ describe("ctx.hosts", () => {
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
enableRdp: true,
|
||||
});
|
||||
expect(created).toMatchObject({
|
||||
id: 99,
|
||||
@@ -332,6 +342,11 @@ describe("ctx.hosts", () => {
|
||||
userId: "user-1",
|
||||
});
|
||||
expect(h.created).toHaveLength(1);
|
||||
// Another plugin's fields go through its import normalizer.
|
||||
expect(h.importRows.at(-1)).toMatchObject({
|
||||
hostId: 99,
|
||||
row: { enableRdp: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("updates a host once granted hosts:write", async () => {
|
||||
|
||||
@@ -83,6 +83,24 @@ describe("contributes.settings", () => {
|
||||
expect(errors.join(" ")).toContain('Unknown field "wat"');
|
||||
});
|
||||
|
||||
it("accepts a hidden field and rejects a non-boolean hidden", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({
|
||||
host: {
|
||||
fields: [{ key: "a", type: "number", labelKey: "k", hidden: true }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "string", labelKey: "k", hidden: "yes" }],
|
||||
}),
|
||||
);
|
||||
expect(errors.join(" ")).toContain("hidden must be a boolean");
|
||||
});
|
||||
|
||||
it("rejects an unknown scope", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({ global: [{ key: "a", type: "string", labelKey: "k" }] }),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const db = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => db.current,
|
||||
}));
|
||||
|
||||
const { selectRows, runStatement } =
|
||||
await import("../../utils/crypto-migration/raw-rows.js");
|
||||
|
||||
describe("raw-rows", () => {
|
||||
beforeEach(() => {
|
||||
db.current = {};
|
||||
});
|
||||
|
||||
it("uses all() and run() on SQLite", async () => {
|
||||
const run = vi.fn();
|
||||
db.current = { all: async () => [{ id: 1 }], run };
|
||||
await expect(selectRows(sql`SELECT 1`)).resolves.toEqual([{ id: 1 }]);
|
||||
await runStatement(sql`UPDATE x SET y = 1`);
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reads rows from node-postgres's { rows }", async () => {
|
||||
db.current = { execute: async () => ({ rows: [{ id: 2 }] }) };
|
||||
await expect(selectRows(sql`SELECT 1`)).resolves.toEqual([{ id: 2 }]);
|
||||
});
|
||||
|
||||
it("reads rows from mysql2's [rows, fields]", async () => {
|
||||
const execute = vi.fn(async () => [[{ id: 3 }], []]);
|
||||
db.current = { execute };
|
||||
await expect(selectRows(sql`SELECT 1`)).resolves.toEqual([{ id: 3 }]);
|
||||
await runStatement(sql`UPDATE x SET y = 1`);
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* The Remote Desktop settings migration.
|
||||
*
|
||||
* An upgrade must be lossless: the admin switch and guacd address, each
|
||||
* user's RDP defaults and every host's RDP/VNC/Telnet options must come
|
||||
* through into the plugin's own settings, whatever shape an old host had.
|
||||
* Running it twice must not duplicate or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
coreSettings: new Map<string, string>(),
|
||||
pluginRows: [] as Array<{
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}>,
|
||||
hostRows: [] as Array<Record<string, unknown>>,
|
||||
userRows: [] as Array<{ user_id: string; rdp_defaults: string | null }>,
|
||||
statements: [] as string[],
|
||||
pluginInstalled: true,
|
||||
}));
|
||||
|
||||
const find = (scope: string, scopeId: string | null, key: string) =>
|
||||
state.pluginRows.find(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "remote-desktop" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
|
||||
state.pluginRows.filter(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId,
|
||||
),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
selectRows: async (query: { queryChunks?: unknown[] }) => {
|
||||
const text = JSON.stringify(query.queryChunks ?? query);
|
||||
return text.includes("user_preferences") ? state.userRows : state.hostRows;
|
||||
},
|
||||
runStatement: async (query: unknown) => {
|
||||
state.statements.push(JSON.stringify(query));
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const { runRemoteDesktopSettingsMigration, hostSettingsFromRow } =
|
||||
await import("../../utils/crypto-migration/remote-desktop-settings-migration.js");
|
||||
|
||||
const value = (scope: string, scopeId: string | null, key: string) => {
|
||||
const row = find(scope, scopeId, key) as Row | undefined;
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
};
|
||||
|
||||
function host(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
id: 1,
|
||||
port: 22,
|
||||
connection_type: "ssh",
|
||||
enable_rdp: 0,
|
||||
enable_vnc: 0,
|
||||
enable_telnet: 0,
|
||||
rdp_port: 3389,
|
||||
vnc_port: 5900,
|
||||
telnet_port: 23,
|
||||
rdp_security: null,
|
||||
rdp_ignore_cert: 0,
|
||||
security: null,
|
||||
ignore_cert: 0,
|
||||
guacamole_config: null,
|
||||
enable_terminal_toolbar: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.coreSettings.clear();
|
||||
state.pluginRows.length = 0;
|
||||
state.hostRows.length = 0;
|
||||
state.userRows.length = 0;
|
||||
state.statements.length = 0;
|
||||
state.pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runRemoteDesktopSettingsMigration", () => {
|
||||
it("moves the admin switch and guacd address", async () => {
|
||||
state.coreSettings.set("guac_enabled", "false");
|
||||
state.coreSettings.set("guac_url", "guacd:4822");
|
||||
|
||||
const result = await runRemoteDesktopSettingsMigration();
|
||||
|
||||
expect(result.moved).toEqual(["guac_enabled", "guac_url"]);
|
||||
expect(value("admin", null, "enabled")).toBe(false);
|
||||
expect(value("admin", null, "guacdUrl")).toBe("guacd:4822");
|
||||
});
|
||||
|
||||
it("moves a host's options, reading SQLite's 0 and 1 as booleans", async () => {
|
||||
state.hostRows.push(
|
||||
host({
|
||||
id: 7,
|
||||
enable_rdp: 1,
|
||||
enable_vnc: 0,
|
||||
rdp_port: 3390,
|
||||
rdp_security: "nla",
|
||||
rdp_ignore_cert: 1,
|
||||
guacamole_config: JSON.stringify({ "color-depth": 24 }),
|
||||
enable_terminal_toolbar: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runRemoteDesktopSettingsMigration();
|
||||
|
||||
expect(result.hostsMoved).toBe(1);
|
||||
expect(value("host", "7", "enableRdp")).toBe(true);
|
||||
expect(value("host", "7", "enableVnc")).toBeUndefined();
|
||||
expect(value("host", "7", "rdpPort")).toBe(3390);
|
||||
expect(value("host", "7", "rdpSecurity")).toBe("nla");
|
||||
expect(value("host", "7", "rdpIgnoreCert")).toBe(true);
|
||||
expect(value("host", "7", "guacamoleConfig")).toEqual({
|
||||
"color-depth": 24,
|
||||
});
|
||||
expect(value("host", "7", "enableToolbar")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a plain SSH host alone", async () => {
|
||||
state.hostRows.push(host({ id: 3 }));
|
||||
const result = await runRemoteDesktopSettingsMigration();
|
||||
expect(result.hostsMoved).toBe(0);
|
||||
expect(state.pluginRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reads real booleans too, as Postgres returns them", async () => {
|
||||
state.hostRows.push(host({ id: 8, enable_vnc: true, vnc_port: 5901 }));
|
||||
await runRemoteDesktopSettingsMigration();
|
||||
expect(value("host", "8", "enableVnc")).toBe(true);
|
||||
expect(value("host", "8", "vncPort")).toBe(5901);
|
||||
});
|
||||
|
||||
it("moves each user's RDP defaults", async () => {
|
||||
state.userRows.push({
|
||||
user_id: "u1",
|
||||
rdp_defaults: JSON.stringify({
|
||||
colorDepth: 24,
|
||||
resizeMethod: "reconnect",
|
||||
disableCopy: true,
|
||||
enableWallpaper: false,
|
||||
}),
|
||||
});
|
||||
state.userRows.push({ user_id: "u2", rdp_defaults: null });
|
||||
|
||||
const result = await runRemoteDesktopSettingsMigration();
|
||||
|
||||
expect(result.usersMoved).toBe(1);
|
||||
expect(value("user", "u1", "colorDepth")).toBe("24");
|
||||
expect(value("user", "u1", "resizeMethod")).toBe("reconnect");
|
||||
expect(value("user", "u1", "disableCopy")).toBe("on");
|
||||
expect(value("user", "u1", "enableWallpaper")).toBe("off");
|
||||
});
|
||||
|
||||
it("changes nothing when run twice", async () => {
|
||||
state.coreSettings.set("guac_url", "guacd:4822");
|
||||
state.hostRows.push(host({ id: 7, enable_rdp: 1, rdp_port: 3390 }));
|
||||
state.userRows.push({
|
||||
user_id: "u1",
|
||||
rdp_defaults: JSON.stringify({ colorDepth: 16 }),
|
||||
});
|
||||
|
||||
await runRemoteDesktopSettingsMigration();
|
||||
const firstRun = JSON.stringify(state.pluginRows);
|
||||
// Changed after the first run: must not be overwritten by the old values.
|
||||
find("host", "7", "rdpPort")!.value = JSON.stringify(4000);
|
||||
const second = await runRemoteDesktopSettingsMigration();
|
||||
|
||||
expect(second.moved).toEqual([]);
|
||||
expect(second.usersMoved).toBe(0);
|
||||
expect(second.hostsMoved).toBe(0);
|
||||
expect(second.hostsSkipped).toBe(1);
|
||||
expect(value("host", "7", "rdpPort")).toBe(4000);
|
||||
expect(JSON.parse(firstRun)).toHaveLength(state.pluginRows.length);
|
||||
});
|
||||
|
||||
it("does nothing until the plugin row exists", async () => {
|
||||
state.pluginInstalled = false;
|
||||
state.coreSettings.set("guac_url", "guacd:4822");
|
||||
state.hostRows.push(host({ id: 7, enable_rdp: 1 }));
|
||||
await runRemoteDesktopSettingsMigration();
|
||||
expect(state.pluginRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hostSettingsFromRow", () => {
|
||||
it("turns a connection_type-only host into its protocol switch and port", () => {
|
||||
expect(
|
||||
hostSettingsFromRow(
|
||||
host({ connection_type: "vnc", port: 5905, vnc_port: null }),
|
||||
),
|
||||
).toEqual({ enableVnc: true, vncPort: 5905 });
|
||||
});
|
||||
|
||||
it("folds the older security and ignore_cert columns in", () => {
|
||||
expect(
|
||||
hostSettingsFromRow(
|
||||
host({
|
||||
enable_rdp: 1,
|
||||
rdp_security: null,
|
||||
security: "tls",
|
||||
rdp_ignore_cert: null,
|
||||
ignore_cert: 1,
|
||||
}),
|
||||
),
|
||||
).toEqual({ enableRdp: true, rdpSecurity: "tls", rdpIgnoreCert: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Raw SQL for boot migrations that read columns schema.ts no longer declares.
|
||||
*
|
||||
* getDb() is typed as the SQLite database, but on Postgres and MySQL it is
|
||||
* that engine's drizzle instance, which has execute() instead of all() and
|
||||
* run(). These two pick whichever exists.
|
||||
*/
|
||||
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { getDb } from "../../database/db/index.js";
|
||||
|
||||
type AnyDb = {
|
||||
all?: (query: SQL) => Promise<unknown[]> | unknown[];
|
||||
run?: (query: SQL) => unknown;
|
||||
execute?: (query: SQL) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export async function selectRows<T>(query: SQL): Promise<T[]> {
|
||||
const db = getDb() as unknown as AnyDb;
|
||||
if (typeof db.all === "function") return (await db.all(query)) as T[];
|
||||
const result = await db.execute!(query);
|
||||
// node-postgres returns { rows }, mysql2 returns [rows, fields].
|
||||
if (Array.isArray(result)) return result[0] as T[];
|
||||
return ((result as { rows?: unknown[] }).rows ?? []) as T[];
|
||||
}
|
||||
|
||||
export async function runStatement(query: SQL): Promise<void> {
|
||||
const db = getDb() as unknown as AnyDb;
|
||||
if (typeof db.run === "function") {
|
||||
await db.run(query);
|
||||
return;
|
||||
}
|
||||
await db.execute!(query);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Moves Remote Desktop's settings out of core and into the remote-desktop
|
||||
* plugin's own settings.
|
||||
*
|
||||
* Admin keys from `settings`: guac_enabled and guac_url. Per user, the RDP
|
||||
* defaults JSON in user_preferences.rdp_defaults. Per host, the option
|
||||
* columns in ssh_data: the three protocol switches and ports, rdp_security,
|
||||
* rdp_ignore_cert and guacamole_config, folding in the older security,
|
||||
* ignore_cert and connection_type-only shapes, plus enable_terminal_toolbar,
|
||||
* which also drove the remote desktop toolbar.
|
||||
*
|
||||
* The logins (users, passwords, credential ids, auth types, domain) stay in
|
||||
* core, next to the host, where sharing needs them.
|
||||
*
|
||||
* The columns stay in the database, unused: core's drizzle migrations run
|
||||
* before this does, so dropping them there would lose the data first.
|
||||
*
|
||||
* Idempotent: an admin key already set, or a user or host that already has
|
||||
* any remote-desktop row, is skipped, so this is safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { runStatement, selectRows } from "./raw-rows.js";
|
||||
|
||||
const PLUGIN_ID = "remote-desktop";
|
||||
const PROTOCOLS = ["rdp", "vnc", "telnet"] as const;
|
||||
const DEFAULT_PORTS = { rdp: 3389, vnc: 5900, telnet: 23 };
|
||||
|
||||
type Flag = number | boolean | string | null | undefined;
|
||||
|
||||
/** true, 1 and "1" are on; null means the column was never set. */
|
||||
function flag(value: Flag): boolean | undefined {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
return value === true || value === 1 || value === "1" || value === "true";
|
||||
}
|
||||
|
||||
function port(value: unknown): number | undefined {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535
|
||||
? parsed
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function jsonObject(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value !== "string" || !value) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? parsed
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface LegacyHostRow {
|
||||
id: number;
|
||||
port: number | null;
|
||||
connection_type: string | null;
|
||||
enable_rdp: Flag;
|
||||
enable_vnc: Flag;
|
||||
enable_telnet: Flag;
|
||||
rdp_port: number | null;
|
||||
vnc_port: number | null;
|
||||
telnet_port: number | null;
|
||||
rdp_security: string | null;
|
||||
rdp_ignore_cert: Flag;
|
||||
security: string | null;
|
||||
ignore_cert: Flag;
|
||||
guacamole_config: string | null;
|
||||
enable_terminal_toolbar: Flag;
|
||||
}
|
||||
|
||||
/** The plugin's host settings for one legacy row, defaults left out. */
|
||||
export function hostSettingsFromRow(
|
||||
row: LegacyHostRow,
|
||||
): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
const flags = {
|
||||
rdp: flag(row.enable_rdp) === true,
|
||||
vnc: flag(row.enable_vnc) === true,
|
||||
telnet: flag(row.enable_telnet) === true,
|
||||
};
|
||||
const legacyType = PROTOCOLS.find((p) => p === row.connection_type);
|
||||
// Hosts from before the per-protocol switches only had connection_type.
|
||||
if (!flags.rdp && !flags.vnc && !flags.telnet && legacyType) {
|
||||
flags[legacyType] = true;
|
||||
}
|
||||
const ports = {
|
||||
rdp: port(row.rdp_port),
|
||||
vnc: port(row.vnc_port),
|
||||
telnet: port(row.telnet_port),
|
||||
};
|
||||
if (legacyType && ports[legacyType] === undefined) {
|
||||
ports[legacyType] = port(row.port);
|
||||
}
|
||||
|
||||
for (const protocol of PROTOCOLS) {
|
||||
const key = `enable${protocol[0].toUpperCase()}${protocol.slice(1)}`;
|
||||
if (flags[protocol]) values[key] = true;
|
||||
const value = ports[protocol];
|
||||
if (value !== undefined && value !== DEFAULT_PORTS[protocol]) {
|
||||
values[`${protocol}Port`] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const security = row.rdp_security || row.security;
|
||||
if (security) values.rdpSecurity = security;
|
||||
const ignoreCert = flag(row.rdp_ignore_cert) ?? flag(row.ignore_cert);
|
||||
if (ignoreCert) values.rdpIgnoreCert = true;
|
||||
const config = jsonObject(row.guacamole_config);
|
||||
if (config && Object.keys(config).length > 0) values.guacamoleConfig = config;
|
||||
if (flag(row.enable_terminal_toolbar) === false) values.enableToolbar = false;
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Old camelCase defaults JSON -> the plugin's user settings. */
|
||||
export function userSettingsFromDefaults(
|
||||
defaults: Record<string, unknown>,
|
||||
): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
const depth = Number(defaults.colorDepth);
|
||||
if ([16, 24, 32].includes(depth)) values.colorDepth = String(depth);
|
||||
if (
|
||||
defaults.resizeMethod === "display-update" ||
|
||||
defaults.resizeMethod === "reconnect"
|
||||
) {
|
||||
values.resizeMethod = defaults.resizeMethod;
|
||||
}
|
||||
for (const key of [
|
||||
"forceLossless",
|
||||
"enableWallpaper",
|
||||
"enableFontSmoothing",
|
||||
"enableDesktopComposition",
|
||||
"disableAudio",
|
||||
"enablePrinting",
|
||||
"enableDrive",
|
||||
"disableCopy",
|
||||
"disablePaste",
|
||||
]) {
|
||||
if (typeof defaults[key] === "boolean") {
|
||||
values[key] = defaults[key] ? "on" : "off";
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export interface RemoteDesktopSettingsMigrationResult {
|
||||
moved: string[];
|
||||
usersMoved: number;
|
||||
hostsMoved: number;
|
||||
hostsSkipped: number;
|
||||
}
|
||||
|
||||
export async function runRemoteDesktopSettingsMigration(): Promise<RemoteDesktopSettingsMigrationResult> {
|
||||
const result: RemoteDesktopSettingsMigrationResult = {
|
||||
moved: [],
|
||||
usersMoved: 0,
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
};
|
||||
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
const write = (
|
||||
scope: "admin" | "user" | "host",
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: unknown,
|
||||
) =>
|
||||
pluginSettings.set(PLUGIN_ID, scope, scopeId, key, JSON.stringify(value));
|
||||
|
||||
try {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const moves: [string, string, (raw: string) => unknown][] = [
|
||||
["guac_enabled", "enabled", (raw) => raw !== "false" && raw !== "0"],
|
||||
["guac_url", "guacdUrl", (raw) => raw],
|
||||
];
|
||||
for (const [legacyKey, field, parse] of moves) {
|
||||
const legacy = await settings.get(legacyKey);
|
||||
if (legacy === null || legacy === "") continue;
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
field,
|
||||
);
|
||||
if (existing && existing.value !== null) continue;
|
||||
await write("admin", null, field, parse(legacy));
|
||||
result.moved.push(legacyKey);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Remote Desktop admin settings migration failed", {
|
||||
operation: "remote_desktop_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await selectRows<{
|
||||
user_id: string;
|
||||
rdp_defaults: string | null;
|
||||
}>(sql`SELECT user_id, rdp_defaults FROM user_preferences`);
|
||||
for (const row of rows) {
|
||||
const defaults = jsonObject(row.rdp_defaults);
|
||||
if (!defaults) continue;
|
||||
const values = userSettingsFromDefaults(defaults);
|
||||
if (Object.keys(values).length === 0) continue;
|
||||
const existing = await pluginSettings.getAll(
|
||||
PLUGIN_ID,
|
||||
"user",
|
||||
row.user_id,
|
||||
);
|
||||
if (existing.length > 0) continue;
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await write("user", row.user_id, key, value);
|
||||
}
|
||||
result.usersMoved++;
|
||||
}
|
||||
} catch {
|
||||
// A fresh install never had the column.
|
||||
}
|
||||
|
||||
try {
|
||||
// Raw SQL: schema.ts no longer declares these columns.
|
||||
const rows = await selectRows<LegacyHostRow>(sql`
|
||||
SELECT id, port, connection_type, enable_rdp, enable_vnc, enable_telnet,
|
||||
rdp_port, vnc_port, telnet_port, rdp_security, rdp_ignore_cert,
|
||||
security, ignore_cert, guacamole_config, enable_terminal_toolbar
|
||||
FROM ssh_data
|
||||
`);
|
||||
for (const row of rows) {
|
||||
const values = hostSettingsFromRow(row);
|
||||
if (Object.keys(values).length === 0) continue;
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
|
||||
if (existing.length > 0) {
|
||||
result.hostsSkipped++;
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await write("host", scopeId, key, value);
|
||||
}
|
||||
result.hostsMoved++;
|
||||
}
|
||||
|
||||
// Those same connection_type-only hosts kept the enable_ssh default of
|
||||
// on, which core used to correct on every read. FALSE and NOT read the
|
||||
// same on SQLite's integers and on real booleans.
|
||||
await runStatement(sql`
|
||||
UPDATE ssh_data SET enable_ssh = FALSE
|
||||
WHERE connection_type IN ('rdp', 'vnc', 'telnet')
|
||||
AND (enable_rdp IS NULL OR NOT enable_rdp)
|
||||
AND (enable_vnc IS NULL OR NOT enable_vnc)
|
||||
AND (enable_telnet IS NULL OR NOT enable_telnet)
|
||||
`);
|
||||
} catch {
|
||||
// A fresh install never had the columns.
|
||||
}
|
||||
|
||||
if (
|
||||
result.moved.length > 0 ||
|
||||
result.usersMoved > 0 ||
|
||||
result.hostsMoved > 0
|
||||
) {
|
||||
databaseLogger.info(
|
||||
`Moved ${result.moved.length} Remote Desktop setting(s), ${result.usersMoved} user(s) and ${result.hostsMoved} host(s) into plugin settings`,
|
||||
{ operation: "remote_desktop_settings_migration" },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
export type GuacdOptions = {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
const DEFAULT_GUACD_OPTIONS: GuacdOptions = {
|
||||
host: "localhost",
|
||||
port: 4822,
|
||||
};
|
||||
|
||||
function parsePort(value: string | undefined, fallback: number) {
|
||||
const port = parseInt(value || "", 10);
|
||||
return Number.isFinite(port) ? port : fallback;
|
||||
}
|
||||
|
||||
export function parseGuacdUrl(
|
||||
value: string,
|
||||
fallback: GuacdOptions = DEFAULT_GUACD_OPTIONS,
|
||||
): GuacdOptions {
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (raw.includes("://")) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return {
|
||||
host: url.hostname || fallback.host,
|
||||
port: parsePort(url.port, fallback.port),
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const bracketedIpv6 = raw.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
||||
if (bracketedIpv6) {
|
||||
return {
|
||||
host: bracketedIpv6[1],
|
||||
port: parsePort(bracketedIpv6[2], fallback.port),
|
||||
};
|
||||
}
|
||||
|
||||
const [host, port] = raw.split(":");
|
||||
return {
|
||||
host: host || fallback.host,
|
||||
port: parsePort(port, fallback.port),
|
||||
};
|
||||
}
|
||||
|
||||
export function getGuacdEnvOptions(): GuacdOptions | null {
|
||||
if (process.env.GUACD_URL) {
|
||||
return parseGuacdUrl(process.env.GUACD_URL);
|
||||
}
|
||||
|
||||
if (!process.env.GUACD_HOST && !process.env.GUACD_PORT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
host: process.env.GUACD_HOST || DEFAULT_GUACD_OPTIONS.host,
|
||||
port: parsePort(process.env.GUACD_PORT, DEFAULT_GUACD_OPTIONS.port),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGuacdOptions(dbUrl?: string | null): GuacdOptions {
|
||||
const envOptions = getGuacdEnvOptions();
|
||||
if (envOptions) {
|
||||
return envOptions;
|
||||
}
|
||||
|
||||
if (dbUrl) {
|
||||
return parseGuacdUrl(dbUrl);
|
||||
}
|
||||
|
||||
return DEFAULT_GUACD_OPTIONS;
|
||||
}
|
||||
|
||||
export function formatGuacdOptions(options: GuacdOptions): string {
|
||||
return `${options.host}:${options.port}`;
|
||||
}
|
||||
|
||||
export function getDefaultGuacdUrl(): string {
|
||||
return formatGuacdOptions(resolveGuacdOptions());
|
||||
}
|
||||
@@ -39,22 +39,19 @@ function snapshotRecordId(
|
||||
return `shared-${hostAccessId}-${targetUserId}-${protocol}`;
|
||||
}
|
||||
|
||||
// Mirrors the connection-type migration fallback in transformHostResponse():
|
||||
// old hosts only set connectionType, the per-protocol enable flags came later.
|
||||
// SSH follows its own switch. The remote desktop protocols' switches are the
|
||||
// remote-desktop plugin's host settings, and the host editor clears a
|
||||
// protocol's login when it is switched off, so a snapshot is taken whenever
|
||||
// login data is present (see collectProtocolSnapshots).
|
||||
function enabledProtocols(
|
||||
host: HostResolutionHostRecord,
|
||||
): Record<ShareProtocol, boolean> {
|
||||
const ct = host.connectionType;
|
||||
const rdp = !!host.enableRdp;
|
||||
const vnc = !!host.enableVnc;
|
||||
const telnet = !!host.enableTelnet;
|
||||
const isMigratedNonSsh = !rdp && !vnc && !telnet && !!ct && ct !== "ssh";
|
||||
|
||||
return {
|
||||
ssh: isMigratedNonSsh ? false : host.enableSsh !== false,
|
||||
rdp: isMigratedNonSsh ? ct === "rdp" : rdp,
|
||||
vnc: isMigratedNonSsh ? ct === "vnc" : vnc,
|
||||
telnet: isMigratedNonSsh ? ct === "telnet" : telnet,
|
||||
ssh: host.enableSsh !== false && (!ct || ct === "ssh"),
|
||||
rdp: true,
|
||||
vnc: true,
|
||||
telnet: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user