mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05: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
@@ -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