mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: store rdp, vnc and telnet logins in a generic per-protocol table
This commit is contained in:
1 parent
e0c30a4e6f
commit
bba06f7ff6
107 files changed
+18750
-1224
No files matched your search
@@ -279,6 +279,55 @@
|
||||
"guest": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"protocols": {
|
||||
"type": "array",
|
||||
"description": "Connection protocols whose per-host login core stores, encrypts, shares and syncs for the plugin.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,31}$",
|
||||
"not": { "enum": ["ssh"] }
|
||||
},
|
||||
"credentialFields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["key"],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9]{0,31}$",
|
||||
"not": {
|
||||
"enum": [
|
||||
"username",
|
||||
"password",
|
||||
"authType",
|
||||
"credentialId"
|
||||
]
|
||||
}
|
||||
},
|
||||
"secret": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultPort": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"hostLoginFallback": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "enum": ["username", "password"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"keybindingActions": {
|
||||
"type": "array",
|
||||
"description": "Keybinding actions the plugin adds, with the parameters a saved binding carries.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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}$/;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user