mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: type the plugin host record, move plugin strings out of core and add lint checks
This commit is contained in:
1 parent
bba06f7ff6
commit
b8bbcd2196
626 files changed
+23884
-4590
No files matched your search
@@ -19,13 +19,18 @@ import type {
|
||||
PluginShareTarget,
|
||||
PluginShareableUser,
|
||||
PluginShareableRole,
|
||||
PluginHostJumpHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { PLUGIN_HOST_INPUT_KEYS } from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { assertCapability, capabilityRefused } from "./permissions.js";
|
||||
import { hostSessionStatus } from "../hosts/host-session-status.js";
|
||||
import { getActor } from "./actor.js";
|
||||
import { hostStatusService } from "../hosts/status/host-status-service.js";
|
||||
import { parseSshOptions } from "../hosts/ssh-options.js";
|
||||
import { hostTerminalExport } from "../database/routes/host-normalizers.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
@@ -76,9 +81,6 @@ export const HOST_SECRET_FIELDS = [
|
||||
"passphrase",
|
||||
"sudoPassword",
|
||||
"socks5Password",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
"vaultToken",
|
||||
] as const;
|
||||
|
||||
@@ -106,30 +108,184 @@ export function redactHostSecrets<T extends Record<string, unknown>>(
|
||||
return copy as T;
|
||||
}
|
||||
|
||||
/** Columns that say who a host belongs to. ctx.hosts.update never moves them. */
|
||||
const PROTECTED_HOST_FIELDS = new Set(["id", "userId", "syncId"]);
|
||||
function asNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function toRecord(host: Record<string, unknown>): PluginHostRecord {
|
||||
return redactHostSecrets({
|
||||
id: host.id as number,
|
||||
userId: host.userId as string,
|
||||
function asFlag(value: unknown, fallback: boolean): boolean {
|
||||
if (value === null || value === undefined) return fallback;
|
||||
return value === true || value === 1 || value === "1" || value === "true";
|
||||
}
|
||||
|
||||
function parseJumpHosts(value: unknown): PluginHostJumpHost[] {
|
||||
let list = value;
|
||||
if (typeof list === "string") {
|
||||
try {
|
||||
list = JSON.parse(list);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list
|
||||
.map((entry) => asNumber((entry as { hostId?: unknown })?.hostId))
|
||||
.filter((hostId): hostId is number => hostId !== null)
|
||||
.map((hostId) => ({ hostId }));
|
||||
}
|
||||
|
||||
/**
|
||||
* A decrypted ssh_data row as PluginHostRecord. Built field by field: a
|
||||
* column reaches plugins only once the SDK types it, and no secret column is
|
||||
* ever copied.
|
||||
*/
|
||||
export function toRecord(
|
||||
host: Record<string, unknown>,
|
||||
pluginSettings: Record<string, unknown>,
|
||||
): PluginHostRecord {
|
||||
const id = Number(host.id);
|
||||
const origin = host.connectionOrigin;
|
||||
return {
|
||||
id,
|
||||
userId: String(host.userId),
|
||||
syncId: (host.syncId as string | null) ?? null,
|
||||
name: (host.name as string | null) ?? null,
|
||||
ip: host.ip as string,
|
||||
port: host.port as number,
|
||||
username: host.username as string,
|
||||
authType: host.authType as string,
|
||||
credentialId: (host.credentialId as number | null) ?? null,
|
||||
overrideCredentialUsername:
|
||||
(host.overrideCredentialUsername as boolean | null) ?? null,
|
||||
connectionType: (host.connectionType as string | null) ?? null,
|
||||
ip: String(host.ip ?? ""),
|
||||
port: asNumber(host.port) ?? 22,
|
||||
username: String(host.username ?? ""),
|
||||
authType: String(host.authType ?? ""),
|
||||
credentialId: asNumber(host.credentialId),
|
||||
overrideCredentialUsername: asFlag(host.overrideCredentialUsername, false),
|
||||
connectionType: (host.connectionType as string | null) || "ssh",
|
||||
tags: (host.tags as string | null) ?? null,
|
||||
folder: (host.folder as string | null) ?? null,
|
||||
jumpHosts: host.jumpHosts,
|
||||
enableSsh: (host.enableSsh as boolean | null) ?? null,
|
||||
parentHostId: asNumber(host.parentHostId),
|
||||
pin: asFlag(host.pin, false),
|
||||
notes: (host.notes as string | null) ?? null,
|
||||
jumpHosts: parseJumpHosts(host.jumpHosts),
|
||||
enableSsh: asFlag(host.enableSsh, true),
|
||||
sshPort: asNumber(host.sshPort),
|
||||
statusCheckEnabled: asFlag(host.statusCheckEnabled, true),
|
||||
statusCheckInterval: asNumber(host.statusCheckInterval),
|
||||
connectionOrigin: origin === "local" || origin === "remote" ? origin : null,
|
||||
sshOptions: hostTerminalExport(host).sshOptions,
|
||||
pluginSettings,
|
||||
status: Number.isInteger(id) ? hostStatusService.get(id) : null,
|
||||
localOnly: asFlag(host.localOnly, false),
|
||||
createdAt: (host.createdAt as string | null) ?? null,
|
||||
updatedAt: (host.updatedAt as string | null) ?? null,
|
||||
...host,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Thrown when a plugin's host write names a field the SDK does not take. */
|
||||
export class PluginHostInputError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PluginHostInputError";
|
||||
}
|
||||
}
|
||||
|
||||
const INPUT_KEYS = new Set<string>(PLUGIN_HOST_INPUT_KEYS);
|
||||
|
||||
// Stops compiling when PluginHostCreateInput gains a key the list misses.
|
||||
const inputKeysComplete: [
|
||||
Exclude<keyof PluginHostCreateInput, (typeof PLUGIN_HOST_INPUT_KEYS)[number]>,
|
||||
] extends [never]
|
||||
? true
|
||||
: never = true;
|
||||
void inputKeysComplete;
|
||||
|
||||
/**
|
||||
* The ssh_data columns for a create or update, plus the host settings to
|
||||
* write afterwards. Refuses any key PluginHostCreateInput does not list, so a
|
||||
* field that moved into a plugin cannot be written into nothing.
|
||||
*/
|
||||
export function toHostWrite(input: PluginHostUpdateInput): {
|
||||
row: Record<string, unknown>;
|
||||
settings: Record<string, Record<string, unknown>>;
|
||||
} {
|
||||
const unknown = Object.keys(input ?? {}).filter(
|
||||
(key) => !INPUT_KEYS.has(key),
|
||||
);
|
||||
if (unknown.length > 0) {
|
||||
throw new PluginHostInputError(
|
||||
`ctx.hosts does not take ${unknown.join(", ")}; host settings go in pluginSettings, keyed by the plugin that declares them`,
|
||||
);
|
||||
}
|
||||
const row: Record<string, unknown> = {};
|
||||
const set = (key: string, value: unknown) => {
|
||||
if (value !== undefined) row[key] = value;
|
||||
};
|
||||
set("name", input.name);
|
||||
set("ip", input.ip);
|
||||
set("port", input.port);
|
||||
set("username", input.username);
|
||||
set("authType", input.authType);
|
||||
set("credentialId", input.credentialId);
|
||||
set("overrideCredentialUsername", input.overrideCredentialUsername);
|
||||
set("connectionType", input.connectionType);
|
||||
set(
|
||||
"tags",
|
||||
Array.isArray(input.tags)
|
||||
? input.tags
|
||||
.map((tag) => String(tag).trim())
|
||||
.filter(Boolean)
|
||||
.join(",")
|
||||
: input.tags,
|
||||
);
|
||||
set("folder", input.folder);
|
||||
set("pin", input.pin);
|
||||
set("notes", input.notes);
|
||||
set(
|
||||
"jumpHosts",
|
||||
input.jumpHosts === undefined
|
||||
? undefined
|
||||
: input.jumpHosts === null
|
||||
? null
|
||||
: JSON.stringify(parseJumpHosts(input.jumpHosts)),
|
||||
);
|
||||
set("enableSsh", input.enableSsh);
|
||||
set("sshPort", input.sshPort);
|
||||
set("statusCheckEnabled", input.statusCheckEnabled);
|
||||
set("statusCheckInterval", input.statusCheckInterval);
|
||||
set(
|
||||
"forceKeyboardInteractive",
|
||||
input.forceKeyboardInteractive === undefined
|
||||
? undefined
|
||||
: input.forceKeyboardInteractive
|
||||
? "true"
|
||||
: "false",
|
||||
);
|
||||
set(
|
||||
"sshOptions",
|
||||
input.sshOptions === undefined
|
||||
? undefined
|
||||
: input.sshOptions === null
|
||||
? null
|
||||
: JSON.stringify(parseSshOptions(input.sshOptions)),
|
||||
);
|
||||
|
||||
const settings: Record<string, Record<string, unknown>> = {};
|
||||
if (input.pluginSettings !== undefined && input.pluginSettings !== null) {
|
||||
if (
|
||||
typeof input.pluginSettings !== "object" ||
|
||||
Array.isArray(input.pluginSettings)
|
||||
) {
|
||||
throw new PluginHostInputError(
|
||||
"pluginSettings must map plugin ids to their host settings",
|
||||
);
|
||||
}
|
||||
for (const [pluginId, values] of Object.entries(input.pluginSettings)) {
|
||||
if (!values || typeof values !== "object" || Array.isArray(values)) {
|
||||
throw new PluginHostInputError(
|
||||
`pluginSettings.${pluginId} must be an object`,
|
||||
);
|
||||
}
|
||||
settings[pluginId] = values;
|
||||
}
|
||||
}
|
||||
return { row, settings };
|
||||
}
|
||||
|
||||
function actingUser(): string {
|
||||
@@ -170,6 +326,53 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
return access.hasAccess;
|
||||
};
|
||||
|
||||
// The calling plugin's own host settings, one query for the whole list.
|
||||
const toRecords = async (
|
||||
rows: Record<string, unknown>[],
|
||||
): Promise<PluginHostRecord[]> => {
|
||||
const { loadHostPluginSettings } =
|
||||
await import("../database/routes/host-plugin-settings.js");
|
||||
const settings = await loadHostPluginSettings(
|
||||
rows.map((row) => Number(row.id)),
|
||||
);
|
||||
return rows.map((row) =>
|
||||
toRecord(row, settings.get(Number(row.id))?.[pluginId] ?? {}),
|
||||
);
|
||||
};
|
||||
|
||||
const checkSettings = async (
|
||||
settings: Record<string, Record<string, unknown>>,
|
||||
) => {
|
||||
if (Object.keys(settings).length === 0) return [];
|
||||
const { checkHostPluginSettingsInput } =
|
||||
await import("../database/routes/host-plugin-settings.js");
|
||||
const { writes, skipped, errors } = checkHostPluginSettingsInput(settings);
|
||||
if (errors.length > 0) throw new PluginHostInputError(errors.join("; "));
|
||||
if (skipped.length > 0) {
|
||||
pluginLogger.warn(
|
||||
"Host settings for plugins that are not running were skipped",
|
||||
{
|
||||
operation: "plugin_hosts_write",
|
||||
pluginId,
|
||||
skipped,
|
||||
},
|
||||
);
|
||||
}
|
||||
return writes;
|
||||
};
|
||||
|
||||
const writeSettings = async (
|
||||
hostId: number,
|
||||
writes: Awaited<ReturnType<typeof checkSettings>>,
|
||||
) => {
|
||||
if (writes.length === 0) return;
|
||||
const { writeHostPluginSettings } =
|
||||
await import("../database/routes/host-plugin-settings.js");
|
||||
for (const { manifest: owner, values } of writes) {
|
||||
await writeHostPluginSettings(owner, hostId, values);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
list: async () => {
|
||||
await requireRead();
|
||||
@@ -254,30 +457,27 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
try {
|
||||
await requireWrite();
|
||||
} catch (error) {
|
||||
await audit("hosts_create", host.name ?? host.ip, {
|
||||
await audit("hosts_create", host?.name ?? host?.ip ?? "host", {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const userId = actingUser();
|
||||
const { row, settings } = toHostWrite(host);
|
||||
const writes = await checkSettings(settings);
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const created =
|
||||
await createCurrentHostRepository().createEncryptedForUser(userId, {
|
||||
...host,
|
||||
...row,
|
||||
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 writeSettings(created.id, writes);
|
||||
await audit("hosts_create", `host ${created.id}`, { success: true });
|
||||
return toRecord(created as unknown as Record<string, unknown>);
|
||||
return (
|
||||
await toRecords([created as unknown as Record<string, unknown>])
|
||||
)[0];
|
||||
},
|
||||
|
||||
update: async (
|
||||
@@ -294,24 +494,22 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
throw error;
|
||||
}
|
||||
const userId = actingUser();
|
||||
const allowed = Object.fromEntries(
|
||||
Object.entries(patch as Record<string, unknown>).filter(
|
||||
([field]) => !PROTECTED_HOST_FIELDS.has(field),
|
||||
),
|
||||
) as PluginHostUpdateInput;
|
||||
const { row, settings } = toHostWrite(patch);
|
||||
const writes = await checkSettings(settings);
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const updated =
|
||||
await createCurrentHostRepository().updateEncryptedForUser(
|
||||
userId,
|
||||
hostId,
|
||||
allowed,
|
||||
row,
|
||||
);
|
||||
if (updated) await writeSettings(hostId, writes);
|
||||
await audit("hosts_update", `host ${hostId}`, {
|
||||
success: updated !== null,
|
||||
});
|
||||
return updated
|
||||
? toRecord(updated as unknown as Record<string, unknown>)
|
||||
? (await toRecords([updated as unknown as Record<string, unknown>]))[0]
|
||||
: null;
|
||||
},
|
||||
|
||||
@@ -354,9 +552,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
await audit("hosts_list_owned", `${rows.length} host(s)`, {
|
||||
success: true,
|
||||
});
|
||||
return rows.map((row) =>
|
||||
toRecord(row as unknown as Record<string, unknown>),
|
||||
);
|
||||
return toRecords(rows as unknown as Record<string, unknown>[]);
|
||||
},
|
||||
|
||||
share: async (
|
||||
|
||||
Reference in new issue
Block a user