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

+16 -6
View File
@@ -167,6 +167,8 @@ export interface SyncWriteEvent {
/** The row as it arrived, before references were resolved. */
wire: SyncRow;
created: boolean;
/** Another entity's syncId to its local id here, for ids serialize put on the wire. */
resolveId?: (entityType: string, syncId: string) => Promise<number | null>;
}
export interface SyncEntityRegistration {
@@ -586,7 +588,7 @@ export interface PluginHostRecord {
/**
* Fields a plugin may set when creating a host it will own or manage. Fields
* that belong to another plugin's host settings (enableRdp, rdpPort) are
* that belong to another plugin's host settings (a protocol switch or port) are
* handed to that plugin's host import normalizer, as on a bulk import.
*/
export type PluginHostCreateInput = Partial<
@@ -964,7 +966,7 @@ export interface PluginSsh {
/**
* A ready client at the end of a jump host chain, each hop resolved and
* authenticated through the pipeline. For forwarding to something that is
* not an SSH server (an RDP port behind a bastion).
* not an SSH server (a remote desktop port behind a bastion).
*/
jumpChain: <Client = unknown>(
jumpHosts: Array<{ hostId: number }>,
@@ -1426,8 +1428,11 @@ export interface PluginDesktop {
available: () => boolean;
}
/** A host protocol whose credentials core keeps next to the host. */
export type PluginHostProtocol = "rdp" | "vnc" | "telnet";
/**
* A host protocol whose login core keeps next to the host: any id a plugin
* declares in contributes.protocols.
*/
export type PluginHostProtocol = string;
/**
* What a plugin needs to hand a host's protocol login to something core
@@ -1452,7 +1457,8 @@ export interface PluginProtocolTarget {
authType: string;
username: string;
password: string;
domain: string;
/** The protocol's declared credentialFields, secret ones included; "" when unset. */
fields: Record<string, string>;
};
}
@@ -1492,7 +1498,11 @@ export interface PluginCredentials {
* permission and an unlocked data key. Audited.
*/
createSshKey: (input: PluginSshKeyCredentialInput) => Promise<{ id: number }>;
/** Null when the host does not exist or the acting user cannot connect to it. */
/**
* The host's login for one of this plugin's own contributes.protocols.
* Null when the host does not exist or the acting user cannot connect to
* it. Needs credentials:read. Audited.
*/
resolveHostProtocol: (
hostId: number,
protocol: PluginHostProtocol,
+48 -2
View File
@@ -45,9 +45,55 @@ export interface PluginHostRecord {
sshOptions?: HostSshOptions | null;
/** Each enabled plugin's host settings, secrets redacted. */
pluginSettings?: Record<string, Record<string, unknown>>;
/** Each declared protocol's login, secrets left out. */
protocolAuth?: Record<string, HostProtocolAuthSummary>;
/** Set on a Quick Connect host, which is never saved. */
quickConnectLogin?: QuickConnectLogin;
[key: string]: unknown;
}
/** The protocol login a Quick Connect host carries, in plain text. */
export interface QuickConnectLogin {
protocol: string;
username?: string;
password?: string;
/** "domain" when the protocol's Quick Connect entry shows that field. */
fields?: Record<string, string>;
}
/** "direct" (a username and password), "credential" (a saved one) or "none". */
export type HostProtocolAuthType = "direct" | "credential" | "none";
/** A host's login for a protocol a plugin declares, as the host API returns it. */
export interface HostProtocolAuthSummary {
authType: HostProtocolAuthType;
/** Left out for a shared recipient at connect level. */
credentialId?: number | null;
username?: string | null;
/** The declared non-secret credential fields. */
fields?: Record<string, string>;
/** Owner only. */
hasPassword?: boolean;
/** Owner only: the secret credential fields that hold a value. */
secretFieldKeys?: string[];
}
/**
* One protocol's login in the host editor form (`form.protocolAuth[id]`).
* Core sends it with the host; `password` and secret fields hold
* HOST_PROTOCOL_SECRET_KEPT while the saved value is left alone.
*/
export interface HostProtocolAuthForm {
authType: HostProtocolAuthType;
credentialId: string;
username: string;
password: string;
fields: Record<string, string>;
}
/** Stands in for a saved protocol secret the editor has not changed. */
export const HOST_PROTOCOL_SECRET_KEPT = "existing_protocol_secret";
/** An open tab as the shell holds it. `data` is the plugin's own payload. */
export interface PluginTabRecord {
id: string;
@@ -328,7 +374,7 @@ export interface HostActionContribution {
titleKey: string;
icon: IconComponent;
/**
* "connect" is a way to open a session (terminal, RDP) and is offered as a
* "connect" is a way to open a session (terminal, remote desktop) and is offered as a
* host's default action by priority. "open" opens a tool for the host.
*/
kind: "connect" | "open";
@@ -1197,7 +1243,7 @@ export function useSshAuthTypes(): {
*/
/**
* The connection protocols a host has switched on: "ssh" plus every protocol
* a running plugin registered with registerHostProtocol (RDP, VNC). Lets a
* a running plugin registered with registerHostProtocol. Lets a
* plugin offer a protocol without knowing which plugin owns its settings.
*/
export function hostProtocols(record: PluginHostRecord): string[] {
+129
View File
@@ -351,8 +351,48 @@ export interface PluginContributions {
* naming a sync entity over sync.
*/
keybindingActions?: PluginKeybindingActionContribution[];
/**
* Connection protocols whose per-host login core stores, encrypts,
* shares, exports and syncs for this plugin, read back with
* ctx.credentials.resolveHostProtocol.
*/
protocols?: PluginProtocolContribution[];
}
/** A field a protocol login carries besides username and password. */
export interface PluginProtocolCredentialField {
key: string;
/** Encrypted with the owner's data key and never sent to a browser. */
secret?: boolean;
}
export interface PluginProtocolContribution {
/** Stored with each login. Never change it once hosts use it. */
id: string;
credentialFields?: PluginProtocolCredentialField[];
/**
* The port an import row without one gets, for a host whose main
* protocol this is.
*/
defaultPort?: number;
/**
* What the owner's login falls back to from the host's own SSH login
* when the protocol login leaves it empty.
*/
hostLoginFallback?: Array<"username" | "password">;
}
/** Protocol ids core keeps for itself. */
export const RESERVED_PROTOCOL_IDS: readonly string[] = ["ssh"];
/** Login keys a declared credential field may not reuse. */
export const RESERVED_PROTOCOL_FIELD_KEYS: readonly string[] = [
"username",
"password",
"authType",
"credentialId",
];
/** One parameter a saved keybinding action carries next to its type. */
export interface PluginKeybindingParam {
type: "string" | "boolean";
@@ -523,6 +563,7 @@ const ALLOWED_CONTRIBUTES = new Set([
"auth",
"syncEntities",
"keybindingActions",
"protocols",
]);
const ALLOWED_SETTINGS_FIELD = [
@@ -993,6 +1034,94 @@ function validateContributes(
validateHttpContribution(contributes.http, pluginId, errors);
validateUiPresets(contributes.uiPresets, errors);
validateKeybindingActions(contributes.keybindingActions, errors);
validateProtocols(contributes.protocols, errors);
}
export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
export const PROTOCOL_FIELD_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9]{0,31}$/;
function validateProtocols(value: unknown, errors: string[]): void {
if (value === undefined) return;
const where = "contributes.protocols";
if (!Array.isArray(value)) {
errors.push(`Field "${where}" must be an array`);
return;
}
const seen = new Set<string>();
value.forEach((raw, index) => {
const at = `${where}[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${at} must be an object`);
return;
}
rejectUnknown(
raw,
["id", "credentialFields", "defaultPort", "hostLoginFallback"],
at,
errors,
);
if (typeof raw.id !== "string" || !PROTOCOL_ID_PATTERN.test(raw.id)) {
errors.push(
`${at}.id must be lowercase letters, digits and dashes, starting with a letter`,
);
} else if (RESERVED_PROTOCOL_IDS.includes(raw.id)) {
errors.push(`${at}.id "${raw.id}" belongs to core`);
} else {
if (seen.has(raw.id)) errors.push(`${at}.id duplicates "${raw.id}"`);
seen.add(raw.id);
}
if (
raw.defaultPort !== undefined &&
(typeof raw.defaultPort !== "number" ||
!Number.isInteger(raw.defaultPort) ||
raw.defaultPort < 1 ||
raw.defaultPort > 65535)
) {
errors.push(`${at}.defaultPort must be a port number`);
}
if (raw.hostLoginFallback !== undefined) {
if (
!Array.isArray(raw.hostLoginFallback) ||
raw.hostLoginFallback.some(
(entry) => entry !== "username" && entry !== "password",
)
) {
errors.push(
`${at}.hostLoginFallback must list "username" and/or "password"`,
);
}
}
if (raw.credentialFields === undefined) return;
if (!Array.isArray(raw.credentialFields)) {
errors.push(`${at}.credentialFields must be an array`);
return;
}
const keys = new Set<string>();
raw.credentialFields.forEach((field, fieldIndex) => {
const fieldAt = `${at}.credentialFields[${fieldIndex}]`;
if (!isPlainObject(field)) {
errors.push(`${fieldAt} must be an object`);
return;
}
rejectUnknown(field, ["key", "secret"], fieldAt, errors);
if (
typeof field.key !== "string" ||
!PROTOCOL_FIELD_KEY_PATTERN.test(field.key)
) {
errors.push(`${fieldAt}.key must be letters and digits`);
} else if (RESERVED_PROTOCOL_FIELD_KEYS.includes(field.key)) {
errors.push(`${fieldAt}.key "${field.key}" is part of every login`);
} else {
if (keys.has(field.key)) {
errors.push(`${fieldAt}.key duplicates "${field.key}"`);
}
keys.add(field.key);
}
if (field.secret !== undefined && typeof field.secret !== "boolean") {
errors.push(`${fieldAt}.secret must be a boolean`);
}
});
});
}
const KEYBINDING_ACTION_PATTERN = /^[a-zA-Z][a-zA-Z0-9.-]{0,63}$/;
+6
View File
@@ -1692,6 +1692,12 @@ export function createMockCtx(
},
resolveHostProtocol: async (hostId, protocol) => {
require("credentials:read");
const declared = ctx.manifest.contributes?.protocols;
if (declared && !declared.some((entry) => entry.id === protocol)) {
throw new Error(
`Plugin ${pluginId} did not declare protocol "${protocol}" in contributes.protocols`,
);
}
return ctx.credentials.resolveHostProtocol(hostId, protocol);
},
registerSecretResolver: (scheme, resolve) => {