feat: store rdp, vnc and telnet logins in a generic per-protocol table

This commit is contained in:
LukeGus committed 2026-09-29 12:33:09 -05:00
1 parent e0c30a4e6f
commit bba06f7ff6
107 files changed
+18750 -1224

No files matched your search

+22
View File
@@ -74,6 +74,28 @@
"accepts": ["button"]
}
],
"protocols": [
{
"id": "rdp",
"credentialFields": [
{
"key": "domain"
}
],
"defaultPort": 3389,
"hostLoginFallback": ["username", "password"]
},
{
"id": "vnc",
"defaultPort": 5900,
"hostLoginFallback": ["password"]
},
{
"id": "telnet",
"defaultPort": 23,
"hostLoginFallback": ["password"]
}
],
"guest": true,
"permissions": [
{
@@ -141,6 +141,42 @@ export function normalizeImportedHost(
return Object.keys(out).length > 0 ? out : null;
}
/**
* Registered as "remote-desktop.hostPayloadLegacy": each protocol's login
* in the flat 2.8 shape (rdpUser, rdpDomain, vncAuthType and so on), for
* Termix-Mobile until it reads protocolAuth. Built from the payload's own
* protocolAuth, which core already reduced for a shared recipient.
*/
export function hostPayloadLegacy(
_values: Record<string, unknown>,
host: Record<string, unknown>,
): Record<string, unknown> | null {
const logins = asObject(host.protocolAuth);
if (!logins) return null;
const out: Record<string, unknown> = {};
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
const login = asObject(logins[protocol]);
if (!login) continue;
out[`${protocol}AuthType`] = login.authType;
if (login.credentialId !== undefined) {
out[`${protocol}CredentialId`] = login.credentialId;
}
if (typeof login.username === "string" && login.username) {
out[`${protocol}User`] = login.username;
}
if (typeof login.hasPassword === "boolean") {
const name = protocol.charAt(0).toUpperCase() + protocol.slice(1);
out[`has${name}Password`] = login.hasPassword;
}
const domain = asObject(login.fields)?.domain;
if (protocol === "rdp" && typeof domain === "string" && domain) {
out.rdpDomain = domain;
out.domain = domain;
}
}
return Object.keys(out).length > 0 ? out : null;
}
/** User setting key to the guacd parameter it sets. */
const USER_DEFAULT_PARAMS: Record<string, string> = {
colorDepth: "color-depth",
@@ -9,6 +9,7 @@ import { resolveGuacdOptions } from "./guacd-config.js";
import { registerRoutes } from "./routes.js";
import { createLiveSessions } from "./live-sessions.js";
import {
hostPayloadLegacy,
normalizeImportedHost,
PORT_KEY,
readHostSettings,
@@ -107,6 +108,7 @@ export async function activate(ctx: PluginContext) {
"remote-desktop.hostImportNormalizer",
normalizeImportedHost,
);
ctx.registry.provide("remote-desktop.hostPayloadLegacy", hostPayloadLegacy);
// Core's status dot pings these hosts on the protocol's own port.
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
+1 -1
View File
@@ -337,7 +337,7 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
const domain = resolveRdpDomain(
target.auth.authType,
body.promptedDomain,
target.auth.domain,
target.auth.fields.domain ?? "",
);
let hostname = target.host.ip;
@@ -44,6 +44,7 @@ import {
errorMessage,
hostRemoteOptions,
type Protocol,
rdpDomain,
type RemoteHostLogin,
} from "./host-remote";
import { GuacamoleToolbar } from "./GuacamoleToolbar.tsx";
@@ -94,9 +95,9 @@ function savedHostConfig(
connectionType:
protocol ??
(options.enableRdp ? "rdp" : options.enableVnc ? "vnc" : "telnet"),
domain: host.domain,
domain: rdpDomain(host),
guacamoleConfig: options.guacamoleConfig,
rdpAuthType: host.rdpAuthType,
rdpAuthType: host.protocolAuth?.rdp?.authType,
authOverrides: host.authOverrides,
syncId: host.syncId,
connectionOrigin: host.connectionOrigin,
@@ -1,4 +1,7 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
HOST_PROTOCOL_SECRET_KEPT,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import {
FakeSwitch,
Input,
@@ -198,17 +201,17 @@ export function HostEditorRdpTab({
<PasswordInput
className="h-8 text-xs pr-8"
placeholder={
form.rdpPassword === "existing_rdp_password"
form.rdpPassword === HOST_PROTOCOL_SECRET_KEPT
? t("hosts.guac.passwordSaved")
: "••••••••"
}
value={
form.rdpPassword === "existing_rdp_password"
form.rdpPassword === HOST_PROTOCOL_SECRET_KEPT
? ""
: form.rdpPassword
}
onFocus={() => {
if (form.rdpPassword === "existing_rdp_password")
if (form.rdpPassword === HOST_PROTOCOL_SECRET_KEPT)
setField("rdpPassword", "");
}}
onChange={(e) => setField("rdpPassword", e.target.value)}
@@ -1053,17 +1056,17 @@ export function HostEditorVncTab({
<PasswordInput
className="h-8 text-xs pr-8"
placeholder={
form.vncPassword === "existing_vnc_password"
form.vncPassword === HOST_PROTOCOL_SECRET_KEPT
? t("hosts.guac.passwordSaved")
: "••••••••"
}
value={
form.vncPassword === "existing_vnc_password"
form.vncPassword === HOST_PROTOCOL_SECRET_KEPT
? ""
: form.vncPassword
}
onFocus={() => {
if (form.vncPassword === "existing_vnc_password")
if (form.vncPassword === HOST_PROTOCOL_SECRET_KEPT)
setField("vncPassword", "");
}}
onChange={(e) => setField("vncPassword", e.target.value)}
@@ -1544,17 +1547,17 @@ export function HostEditorTelnetTab({
<PasswordInput
className="h-8 text-xs pr-8"
placeholder={
form.telnetPassword === "existing_telnet_password"
form.telnetPassword === HOST_PROTOCOL_SECRET_KEPT
? t("hosts.guac.passwordSaved")
: "••••••••"
}
value={
form.telnetPassword === "existing_telnet_password"
form.telnetPassword === HOST_PROTOCOL_SECRET_KEPT
? ""
: form.telnetPassword
}
onFocus={() => {
if (form.telnetPassword === "existing_telnet_password")
if (form.telnetPassword === HOST_PROTOCOL_SECRET_KEPT)
setField("telnetPassword", "");
}}
onChange={(e) => setField("telnetPassword", e.target.value)}
@@ -1,4 +1,8 @@
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
import type {
HostProtocolAuthSummary,
PluginHostRecord,
QuickConnectLogin,
} from "@termix/plugin-sdk/frontend";
import type { GuacamoleConfig } from "./guacamole-config";
export const PLUGIN_ID = "remote-desktop";
@@ -37,19 +41,18 @@ export const DEFAULT_PORT: Record<Protocol, number> = {
telnet: 23,
};
/** The core fields holding a protocol's login, which stay on the host. */
/**
* The host fields a session reads. Each protocol's login is core's, under
* protocolAuth, since this plugin declares its protocols in the manifest.
*/
export interface RemoteHostLogin {
id?: number | string;
name?: string | null;
ip?: string;
domain?: string;
syncId?: string | null;
connectionOrigin?: "local" | "remote" | null;
rdpAuthType?: string;
rdpUser?: string;
rdpPassword?: string;
vncUser?: string;
vncPassword?: string;
protocolAuth?: Record<string, HostProtocolAuthSummary>;
quickConnectLogin?: QuickConnectLogin;
authOverrides?: Record<
string,
{
@@ -61,6 +64,11 @@ export interface RemoteHostLogin {
pluginSettings?: Record<string, Record<string, unknown>>;
}
/** The RDP domain a host's saved login carries. */
export function rdpDomain(host: RemoteHostLogin): string | undefined {
return host.protocolAuth?.rdp?.fields?.domain || undefined;
}
function port(value: unknown, fallback: number): number {
const parsed = typeof value === "string" ? Number(value) : value;
return typeof parsed === "number" && Number.isInteger(parsed) && parsed > 0
@@ -45,6 +45,7 @@ import {
isQuickConnectHost,
protocolEnabled,
type Protocol,
rdpDomain,
type RemoteHostLogin,
} from "./host-remote";
import { remoteDesktopForm } from "./remote-form";
@@ -249,8 +250,8 @@ function registerNativeRdp(app: TermixApp): void {
void openNativeRdp({
host: String(record.ip ?? ""),
port: hostRemoteOptions(record).rdpPort,
username: record.rdpUser,
domain: record.domain,
username: record.protocolAuth?.rdp?.username ?? undefined,
domain: rdpDomain(record),
})
.then((result) => {
if (result.success) toast.success(app.t("hosts.nativeRdpOpened"));
@@ -11,14 +11,18 @@ export function quickConnectGuacHost(
): GuacamoleQuickHost {
const options = hostRemoteOptions(host);
const connectionType: Protocol = options.enableVnc ? "vnc" : "rdp";
const login =
host.quickConnectLogin?.protocol === connectionType
? host.quickConnectLogin
: undefined;
return {
name: host.name ?? undefined,
ip: host.ip ?? "",
connectionType,
domain: host.domain,
domain: login?.fields?.domain,
port: connectionType === "vnc" ? options.vncPort : options.rdpPort,
rdpAuthType: host.rdpAuthType,
username: connectionType === "vnc" ? host.vncUser : host.rdpUser,
password: connectionType === "vnc" ? host.vncPassword : host.rdpPassword,
rdpAuthType: "direct",
username: login?.username,
password: login?.password,
};
}
@@ -1,12 +1,16 @@
import type { HostEditorSectionProps } from "@termix/plugin-sdk/frontend";
import { PLUGIN_ID, remoteOptions } from "./host-remote";
import type {
HostEditorSectionProps,
HostProtocolAuthForm,
} from "@termix/plugin-sdk/frontend";
import { PLUGIN_ID, remoteOptions, type Protocol } from "./host-remote";
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* The host editor fields the RDP, VNC and Telnet tabs edit. The logins are
* core host fields; the options (ports, security, guacd settings) are this
* plugin's host settings, presented here under the names the tabs use.
* core's, under form.protocolAuth (this plugin declares the protocols in its
* manifest); the options (ports, security, guacd settings) are this plugin's
* host settings. Both are presented here under the names the tabs use.
*/
export interface RemoteDesktopForm {
domain: string;
@@ -46,7 +50,45 @@ const SETTING_FOR_FIELD: Partial<Record<keyof RemoteDesktopForm, string>> = {
enableToolbar: "enableToolbar",
};
type LoginPart = "authType" | "credentialId" | "username" | "password";
/** Tab field name to the protocol login it edits. */
const LOGIN_FOR_FIELD: Partial<
Record<keyof RemoteDesktopForm, [Protocol, LoginPart | "domain"]>
> = {
rdpAuthType: ["rdp", "authType"],
rdpCredentialId: ["rdp", "credentialId"],
rdpUser: ["rdp", "username"],
rdpPassword: ["rdp", "password"],
domain: ["rdp", "domain"],
vncAuthType: ["vnc", "authType"],
vncCredentialId: ["vnc", "credentialId"],
vncUser: ["vnc", "username"],
vncPassword: ["vnc", "password"],
telnetAuthType: ["telnet", "authType"],
telnetCredentialId: ["telnet", "credentialId"],
telnetUser: ["telnet", "username"],
telnetPassword: ["telnet", "password"],
};
type PluginSettingsBag = Record<string, Record<string, unknown>>;
type LoginBag = Record<string, HostProtocolAuthForm>;
const EMPTY_LOGIN: HostProtocolAuthForm = {
authType: "direct",
credentialId: "",
username: "",
password: "",
fields: {},
};
export function loginOf(
bag: LoginBag | undefined,
protocol: Protocol,
): HostProtocolAuthForm {
const login = bag?.[protocol];
return login ? { ...EMPTY_LOGIN, ...login } : EMPTY_LOGIN;
}
function patchSettings(
current: Record<string, unknown>,
@@ -59,15 +101,34 @@ function patchSettings(
};
}
/** The form patch that sets one part of a protocol's login. */
export function patchLogin(
current: Record<string, unknown>,
protocol: Protocol,
part: LoginPart | "domain",
value: unknown,
): Record<string, unknown> {
const bag = (current.protocolAuth as LoginBag | undefined) ?? {};
const login = loginOf(bag, protocol);
const next: HostProtocolAuthForm =
part === "domain"
? { ...login, fields: { ...login.fields, domain: String(value ?? "") } }
: ({ ...login, [part]: value } as HostProtocolAuthForm);
return { protocolAuth: { ...bag, [protocol]: next } };
}
export function remoteDesktopForm(props: HostEditorSectionProps): {
form: RemoteDesktopForm;
setField: RemoteFormSetField;
setGuacField: (key: string, value: unknown) => void;
} {
const bag = props.form?.pluginSettings as PluginSettingsBag | undefined;
const logins = props.form?.protocolAuth as LoginBag | undefined;
const options = remoteOptions(bag?.[PLUGIN_ID]);
const rdp = loginOf(logins, "rdp");
const vnc = loginOf(logins, "vnc");
const telnet = loginOf(logins, "telnet");
const form: RemoteDesktopForm = {
...(props.form as RemoteDesktopForm),
rdpPort: options.rdpPort,
vncPort: options.vncPort,
telnetPort: options.telnetPort,
@@ -75,9 +136,29 @@ export function remoteDesktopForm(props: HostEditorSectionProps): {
ignoreCert: options.rdpIgnoreCert,
guacamoleConfig: options.guacamoleConfig as Record<string, any>,
enableToolbar: options.enableToolbar,
domain: rdp.fields.domain ?? "",
rdpAuthType: rdp.authType,
rdpCredentialId: rdp.credentialId,
rdpUser: rdp.username,
rdpPassword: rdp.password,
vncAuthType: vnc.authType === "credential" ? "credential" : "direct",
vncCredentialId: vnc.credentialId,
vncUser: vnc.username,
vncPassword: vnc.password,
telnetAuthType: telnet.authType === "credential" ? "credential" : "direct",
telnetCredentialId: telnet.credentialId,
telnetUser: telnet.username,
telnetPassword: telnet.password,
};
const setField: RemoteFormSetField = (key, value) => {
const login = LOGIN_FOR_FIELD[key];
if (login) {
props.updateForm((current) =>
patchLogin(current, login[0], login[1], value),
);
return;
}
const setting = SETTING_FOR_FIELD[key];
if (!setting) {
props.setField(key, value);
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
hostPayloadLegacy,
normalizeImportedHost,
userDefaultParams,
} from "../../src/backend/host-settings.js";
@@ -80,3 +81,38 @@ describe("userDefaultParams", () => {
});
});
});
describe("hostPayloadLegacy", () => {
it("puts each login back in its 2.8 flat shape for older clients", () => {
expect(
hostPayloadLegacy(
{},
{
protocolAuth: {
rdp: {
authType: "direct",
credentialId: null,
username: "admin",
fields: { domain: "CORP" },
hasPassword: true,
},
vnc: { authType: "credential", credentialId: 4 },
},
},
),
).toEqual({
rdpAuthType: "direct",
rdpCredentialId: null,
rdpUser: "admin",
hasRdpPassword: true,
rdpDomain: "CORP",
domain: "CORP",
vncAuthType: "credential",
vncCredentialId: 4,
});
});
it("adds nothing for a host without logins", () => {
expect(hostPayloadLegacy({}, {})).toBeNull();
});
});
@@ -37,7 +37,7 @@ function target(
authType: "direct",
username: "admin",
password: "secret",
domain: "CORP",
fields: { domain: "CORP" },
...overrides,
},
};
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { quickConnectGuacHost } from "../../src/frontend/quick-connect-guac-host";
// The shape core's Quick Connect builds for a plugin protocol: the switch and
// port in this plugin's host settings, the login on the protocol's fields.
// port in this plugin's host settings, the unsaved login in quickConnectLogin.
function quickHost(protocol: "rdp" | "vnc", extra: Record<string, unknown>) {
return {
id: "quick-connect-1",
@@ -24,9 +24,12 @@ describe("quickConnectGuacHost", () => {
expect(
quickConnectGuacHost(
quickHost("rdp", {
rdpUser: "admin",
rdpPassword: "pw",
domain: "CORP",
quickConnectLogin: {
protocol: "rdp",
username: "admin",
password: "pw",
fields: { domain: "CORP" },
},
}),
),
).toMatchObject({
@@ -41,7 +44,11 @@ describe("quickConnectGuacHost", () => {
it("takes a VNC quick connect's login from the VNC fields", () => {
expect(
quickConnectGuacHost(quickHost("vnc", { vncPassword: "vncpw" })),
quickConnectGuacHost(
quickHost("vnc", {
quickConnectLogin: { protocol: "vnc", password: "vncpw" },
}),
),
).toMatchObject({
connectionType: "vnc",
port: 5901,
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import type { HostEditorSectionProps } from "@termix/plugin-sdk/frontend";
import {
loginOf,
patchLogin,
remoteDesktopForm,
} from "../../src/frontend/remote-form";
function sectionProps(form: Record<string, unknown>) {
const state = { form: { ...form } };
const props = {
form: state.form,
setField: (key: string, value: unknown) => {
state.form = { ...state.form, [key]: value };
},
updateForm: (
patch: (current: Record<string, unknown>) => Record<string, unknown>,
) => {
state.form = { ...state.form, ...patch(state.form) };
},
} as unknown as HostEditorSectionProps;
return { props, state };
}
describe("remote desktop editor form", () => {
it("reads each protocol's login from the host form's protocolAuth", () => {
const { props } = sectionProps({
protocolAuth: {
rdp: {
authType: "credential",
credentialId: "4",
username: "",
password: "",
fields: { domain: "CORP" },
},
vnc: {
authType: "direct",
credentialId: "",
username: "",
password: "saved",
fields: {},
},
},
});
const { form } = remoteDesktopForm(props);
expect(form).toMatchObject({
rdpAuthType: "credential",
rdpCredentialId: "4",
domain: "CORP",
vncPassword: "saved",
telnetAuthType: "direct",
telnetUser: "",
});
});
it("writes the tabs' login fields into protocolAuth", () => {
const { props, state } = sectionProps({});
const { setField } = remoteDesktopForm(props);
setField("rdpUser", "admin");
setField("domain", "CORP");
setField("telnetAuthType", "credential");
expect(state.form.protocolAuth).toEqual({
rdp: {
authType: "direct",
credentialId: "",
username: "admin",
password: "",
fields: { domain: "CORP" },
},
telnet: {
authType: "credential",
credentialId: "",
username: "",
password: "",
fields: {},
},
});
});
it("keeps other protocols when one changes", () => {
const current = {
protocolAuth: { vnc: { ...loginOf(undefined, "vnc"), username: "v" } },
};
const next = patchLogin(current, "rdp", "password", "pw");
expect(next.protocolAuth).toMatchObject({
vnc: { username: "v" },
rdp: { password: "pw" },
});
});
});