feat: type the plugin host record, move plugin strings out of core and add lint checks

This commit is contained in:
LukeGus committed 2026-09-29 13:08:39 -05:00
1 parent bba06f7ff6
commit b8bbcd2196
626 files changed
+23884 -4590

No files matched your search

+2 -11
View File
@@ -721,9 +721,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
key_password TEXT,
key_type TEXT,
sudo_password TEXT,
autostart_password TEXT,
autostart_key TEXT,
autostart_key_password TEXT,
credential_id INTEGER,
override_credential_username INTEGER,
jump_hosts TEXT,
@@ -817,8 +814,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
const sshHosts =
await createCurrentHostRepository().listDecryptedByUserId(userId);
const insertHost = exportDb.prepare(`
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, terminal_config, ssh_options, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, terminal_config, ssh_options, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const decrypted of sshHosts) {
@@ -840,9 +837,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
decrypted.keyPassword || null,
decrypted.keyType || null,
decrypted.sudoPassword || null,
decrypted.autostartPassword || null,
decrypted.autostartKey || null,
decrypted.autostartKeyPassword || null,
decrypted.credentialId || null,
decrypted.overrideCredentialUsername ? 1 : 0,
decrypted.jumpHosts || null,
@@ -1177,9 +1171,6 @@ app.post(
keyPassword: host.key_password,
keyType: host.key_type,
sudoPassword: host.sudo_password,
autostartPassword: host.autostart_password,
autostartKey: host.autostart_key,
autostartKeyPassword: host.autostart_key_password,
credentialId: host.credential_id || null,
overrideCredentialUsername: Boolean(
host.override_credential_username,
-9
View File
@@ -304,9 +304,6 @@ async function initializeCompleteDatabase(): Promise<void> {
key TEXT,
key_password TEXT,
key_type TEXT,
autostart_password TEXT,
autostart_key TEXT,
autostart_key_password TEXT,
force_keyboard_interactive TEXT,
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER,
@@ -910,9 +907,6 @@ const migrateSchema = () => {
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
);
addColumnIfNotExists("ssh_data", "force_keyboard_interactive", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
addColumnIfNotExists(
"ssh_data",
"credential_id",
@@ -924,9 +918,6 @@ const migrateSchema = () => {
"INTEGER",
);
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
addColumnIfNotExists(
"ssh_data",
"status_check_enabled",
-4
View File
@@ -189,10 +189,6 @@ export const hosts = mysqlTable(
keyType: text("key_type"),
sudoPassword: text("sudo_password"),
autostartPassword: text("autostart_password"),
autostartKey: text("autostart_key"),
autostartKeyPassword: text("autostart_key_password"),
credentialId: int("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
overrideCredentialUsername: boolean("override_credential_username"),
jumpHosts: text("jump_hosts"),
-4
View File
@@ -189,10 +189,6 @@ export const hosts = pgTable(
keyType: text("key_type"),
sudoPassword: text("sudo_password"),
autostartPassword: text("autostart_password"),
autostartKey: text("autostart_key"),
autostartKeyPassword: text("autostart_key_password"),
credentialId: integer("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
overrideCredentialUsername: boolean("override_credential_username"),
jumpHosts: text("jump_hosts"),
-4
View File
@@ -179,10 +179,6 @@ export const hosts = sqliteTable(
keyType: text("key_type"),
sudoPassword: text("sudo_password"),
autostartPassword: text("autostart_password"),
autostartKey: text("autostart_key", { length: 8192 }),
autostartKeyPassword: text("autostart_key_password"),
credentialId: integer("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
overrideCredentialUsername: integer("override_credential_username", {
mode: "boolean",
@@ -253,12 +253,9 @@ export function normalizeImportedHost(
const SENSITIVE_FIELDS = [
"key",
"keyPassword",
"autostartKey",
"autostartKeyPassword",
"password",
"sudoPassword",
"socks5Password",
"autostartPassword",
];
export function stripSensitiveFields(
@@ -15,9 +15,14 @@ import { createCurrentPluginSettingsRepository } from "../repositories/factory.j
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
import {
declaredFields,
findField,
resolveFieldValue,
setSetting,
} from "../../plugins/settings.js";
import {
coerceSettingValue,
validateSettingValue,
} from "@termix/plugin-sdk/settings";
import { getPluginRuntime } from "../../plugins/index.js";
import { consume } from "../../plugins/registry.js";
import { sshLogger } from "../../utils/logger.js";
@@ -262,6 +267,49 @@ export async function writeHostPluginSettings(
}
}
/**
* Checks the host settings a ctx.hosts write carries, keyed by plugin id,
* before anything is written. A key the plugin does not declare, or a value
* its field refuses, is an error. A plugin that is not running has nowhere to
* keep them, so its entry is skipped and named in `skipped`.
*/
export function checkHostPluginSettingsInput(
input: Record<string, Record<string, unknown>>,
): {
writes: Array<{ manifest: PluginManifest; values: Record<string, unknown> }>;
skipped: string[];
errors: string[];
} {
const running = new Map(hostSettingsPlugins().map((m) => [m.id, m]));
const writes: Array<{
manifest: PluginManifest;
values: Record<string, unknown>;
}> = [];
const skipped: string[] = [];
const errors: string[] = [];
for (const [pluginId, values] of Object.entries(input)) {
const manifest = running.get(pluginId);
if (!manifest) {
skipped.push(pluginId);
continue;
}
for (const [key, value] of Object.entries(values)) {
const field = findField(manifest, "host", key);
if (!field) {
errors.push(`${pluginId}.${key} is not a host setting it declares`);
continue;
}
const error = validateSettingValue(
field,
coerceSettingValue(field, value),
);
if (error) errors.push(`${pluginId}.${key}: ${error}`);
}
writes.push({ manifest, values });
}
return { writes, skipped, errors };
}
/**
* A plugin's own validator for the fields it accepts inline on a bulk host
* import row (host-bulk-routes.ts's Termix-JSON import path). Registered
-3
View File
@@ -96,9 +96,6 @@ export async function resolveHostById(
if (!ownerEquivalent) {
// Owner-only operational secrets are never shared.
host.sudoPassword = null;
host.autostartPassword = null;
host.autostartKey = null;
host.autostartKeyPassword = null;
}
// Parse JSON fields
+238 -42
View File
@@ -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 (
@@ -78,6 +78,7 @@ const {
writeHostPluginSettings,
applyPluginHostImportSettings,
setHostPluginEnabled,
checkHostPluginSettingsInput,
} = await import("../../../database/routes/host-plugin-settings.js");
function manifest(id: string, host: unknown): PluginManifest {
@@ -453,3 +454,38 @@ describe("share levels, legacy fields and generic writes", () => {
expect(await setHostPluginEnabled("missing", [3], true)).toBe(false);
});
});
describe("checkHostPluginSettingsInput", () => {
it("passes declared keys through to the plugin that owns them", () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
const result = checkHostPluginSettingsInput({
docker: { enableDocker: true, socketPath: "/run/docker.sock" },
});
expect(result.errors).toEqual([]);
expect(result.skipped).toEqual([]);
expect(result.writes).toEqual([
{
manifest: DOCKER,
values: { enableDocker: true, socketPath: "/run/docker.sock" },
},
]);
});
it("refuses a key the plugin does not declare and a value it rejects", () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
const { errors } = checkHostPluginSettingsInput({
docker: { dockerConfig: {}, enableDocker: "sure" },
});
expect(errors).toHaveLength(2);
expect(errors[0]).toContain("docker.dockerConfig");
expect(errors[1]).toContain("docker.enableDocker");
});
it("skips a plugin that is not running", () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "disabled" });
const result = checkHostPluginSettingsInput({
docker: { enableDocker: true },
});
expect(result).toEqual({ writes: [], skipped: ["docker"], errors: [] });
});
});
+7
View File
@@ -443,6 +443,13 @@ ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_credential_i
CREATE INDEX `idx_host_protocol_auth_user` ON `host_protocol_auth` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_host_protocol_auth_credential` ON `host_protocol_auth` (`credential_id`);
-- ==== core 0064_flippant_killer_shrike ====
-- ssh_data.autostart_password, autostart_key and autostart_key_password held
-- copies of a host's login for 2.8's autostart endpoint, which nothing has read
-- since. They leave the schema but stay in place, unused, until 3.0.0, so no
-- install loses what it stored.
SELECT 1;
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
@@ -462,6 +462,13 @@ CREATE UNIQUE INDEX "idx_host_protocol_auth_host_protocol" ON "host_protocol_aut
CREATE INDEX "idx_host_protocol_auth_user" ON "host_protocol_auth" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_host_protocol_auth_credential" ON "host_protocol_auth" USING btree ("credential_id");
-- ==== core 0064_sticky_pestilence ====
-- ssh_data.autostart_password, autostart_key and autostart_key_password held
-- copies of a host's login for 2.8's autostart endpoint, which nothing has read
-- since. They leave the schema but stay in place, unused, until 3.0.0, so no
-- install loses what it stored.
SELECT 1;
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
@@ -112,9 +112,6 @@ function baseHost(overrides: Record<string, unknown> = {}) {
credentialId: null,
shareSshAuth: false,
sudoPassword: "owner-sudo",
autostartPassword: "auto-pass",
autostartKey: null,
autostartKeyPassword: null,
jumpHosts: null,
tunnelConnections: null,
terminalConfig: null,
@@ -519,7 +516,6 @@ describe("resolveHostById", () => {
expect(host.username).toBe("cred-user");
// Owner-only operational secrets are NOT stripped for the admin.
expect(host.sudoPassword).toBe("owner-sudo");
expect(host.autostartPassword).toBe("auto-pass");
});
it("audits every admin-bypass host resolution", async () => {
+114 -10
View File
@@ -140,11 +140,40 @@ vi.mock("../../hosts/status/host-status-service.js", () => ({
},
}));
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
applyPluginHostImportSettings: async (
hostId: number,
row: Record<string, unknown>,
loadHostPluginSettings: async (hostIds: number[]) =>
new Map(
hostIds.map((id) => [
id,
{ fixture: { mine: `own-${id}` }, other: { theirs: true } },
]),
),
checkHostPluginSettingsInput: (
input: Record<string, Record<string, unknown>>,
) => {
h.importRows.push({ hostId, row });
const errors: string[] = [];
const writes: Array<{
manifest: { id: string };
values: Record<string, unknown>;
}> = [];
const skipped: string[] = [];
for (const [pluginId, values] of Object.entries(input)) {
if (pluginId === "off") {
skipped.push(pluginId);
continue;
}
for (const key of Object.keys(values)) {
if (key === "undeclared") errors.push(`${pluginId}.${key}`);
}
writes.push({ manifest: { id: pluginId }, values });
}
return { writes, skipped, errors };
},
writeHostPluginSettings: async (
manifest: { id: string },
hostId: number,
values: Record<string, unknown>,
) => {
h.importRows.push({ hostId, row: { [manifest.id]: values } });
},
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
@@ -361,6 +390,7 @@ describe("ctx.hosts", () => {
it("creates a host once granted hosts:write", async () => {
h.granted = new Set(["hosts:write"]);
h.importRows = [];
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
@@ -371,19 +401,92 @@ describe("ctx.hosts", () => {
port: 22,
username: "root",
authType: "password",
enableRdp: true,
tags: ["a", "b"],
jumpHosts: [{ hostId: 3 }],
pluginSettings: { "remote-desktop": { enableRdp: true } },
});
expect(created).toMatchObject({
id: 99,
name: "pve-guest",
userId: "user-1",
tags: "a,b",
jumpHosts: [{ hostId: 3 }],
pluginSettings: { mine: "own-99" },
});
expect(h.created).toHaveLength(1);
// Another plugin's fields go through its import normalizer.
expect(h.importRows.at(-1)).toMatchObject({
hostId: 99,
row: { enableRdp: true },
expect(h.created[0]).toMatchObject({
tags: "a,b",
jumpHosts: '[{"hostId":3}]',
});
// Another plugin's settings go to that plugin, validated by core.
expect(h.importRows).toEqual([
{ hostId: 99, row: { "remote-desktop": { enableRdp: true } } },
]);
});
it("refuses a field the SDK does not take instead of dropping it", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(
hosts.create({
name: "n",
ip: "10.0.0.1",
port: 22,
username: "root",
authType: "password",
enableRdp: true,
} as never),
).rejects.toThrow(/enableRdp/);
await expect(
hosts.update(5, { userId: "someone-else" } as never),
).rejects.toThrow(/userId/);
await expect(
hosts.update(5, { pluginSettings: { docker: { undeclared: 1 } } }),
).rejects.toThrow(/undeclared/);
expect(h.created).toHaveLength(0);
expect(h.updates).toHaveLength(0);
});
it("builds the record field by field, with no secret and no other plugin's settings", async () => {
h.granted = new Set(["hosts:write"]);
h.ownedHosts = [
{
id: 4,
userId: "user-1",
name: "own",
ip: "10.0.0.4",
port: 22,
username: "root",
authType: "password",
password: "secret",
key: "PRIVATE",
autostartPassword: "x",
terminalConfig: '{"keepaliveInterval":5,"fontSize":14}',
enableDocker: true,
jumpHosts: "[]",
},
];
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
const [record] = await hosts.listOwned();
const loose = record as unknown as Record<string, unknown>;
for (const field of [
"password",
"key",
"autostartPassword",
"terminalConfig",
"enableDocker",
]) {
expect(loose).not.toHaveProperty(field);
}
expect(record.sshOptions).toEqual({ keepaliveInterval: 5 });
expect(record.pluginSettings).toEqual({ mine: "own-4" });
expect(record.status).toEqual({ status: "reachable", lastChecked: "t" });
expect(record.enableSsh).toBe(true);
});
it("updates a host once granted hosts:write", async () => {
@@ -398,6 +501,7 @@ describe("ctx.hosts", () => {
expect(h.updates).toEqual([
{ userId: "user-1", hostId: 5, patch: { name: "new" } },
]);
expect(updated?.pluginSettings).toEqual({ mine: "own-5" });
});
it("returns null updating a host that does not exist for this user", async () => {
@@ -31,9 +31,6 @@ function secretHost(id: number): Record<string, unknown> {
keyPassword: "s3cret-value",
sudoPassword: "s3cret-value",
socks5Password: "s3cret-value",
autostartPassword: "s3cret-value",
autostartKey: "s3cret-value",
autostartKeyPassword: "s3cret-value",
terminalConfig: { sudoPassword: "s3cret-value", fontSize: 14 },
socks5ProxyChain: [{ host: "p", password: "s3cret-value" }],
};
@@ -98,7 +95,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
applyPluginHostImportSettings: async () => {},
loadHostPluginSettings: async () => new Map(),
}));
vi.mock("../../../hosts/host-resolver.js", () => ({
resolveHostById: async (id: number) => secretHost(id),
@@ -36,9 +36,6 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
key: "k",
key_password: "kp",
key_type: "ed25519",
autostart_password: "ap",
autostart_key: "ak",
autostart_key_password: "akp",
sudo_password: "sp",
});
store.updateCredentialSensitiveFields(13, {
@@ -56,17 +53,7 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
expect(db.prepare).toHaveBeenCalledWith(
expect.stringContaining("UPDATE ssh_credentials"),
);
expect(run).toHaveBeenCalledWith(
"p",
"k",
"kp",
"ed25519",
"ap",
"ak",
"akp",
"sp",
12,
);
expect(run).toHaveBeenCalledWith("p", "k", "kp", "ed25519", "sp", 12);
expect(run).toHaveBeenCalledWith("p", "k", "kp", "priv", "pub", "rsa", 13);
});
-3
View File
@@ -21,9 +21,6 @@ class FieldCrypto {
"key",
"keyPassword",
"sudoPassword",
"autostartPassword",
"autostartKey",
"autostartKeyPassword",
"socks5Password",
]),
ssh_credentials: new Set([
@@ -7,9 +7,6 @@ export class LazyFieldEncryption {
private static readonly LEGACY_FIELD_NAME_MAP: Record<string, string> = {
key_password: "keyPassword",
sudo_password: "sudoPassword",
autostart_password: "autostartPassword",
autostart_key: "autostartKey",
autostart_key_password: "autostartKeyPassword",
socks5_password: "socks5Password",
private_key: "privateKey",
public_key: "publicKey",
@@ -21,9 +18,6 @@ export class LazyFieldEncryption {
keyPassword: "key_password",
sudoPassword: "sudo_password",
autostartPassword: "autostart_password",
autostartKey: "autostart_key",
autostartKeyPassword: "autostart_key_password",
socks5Password: "socks5_password",
privateKey: "private_key",
publicKey: "public_key",
@@ -98,9 +92,6 @@ export class LazyFieldEncryption {
"key",
"keyPassword",
"sudoPassword",
"autostartPassword",
"autostartKey",
"autostartKeyPassword",
"socks5Password",
"privateKey",
"publicKey",
@@ -249,9 +240,6 @@ export class LazyFieldEncryption {
privateKey: "private_key",
publicKey: "public_key",
sudoPassword: "sudo_password",
autostartPassword: "autostart_password",
autostartKey: "autostart_key",
autostartKeyPassword: "autostart_key_password",
socks5Password: "socks5_password",
clientSecret: "client_secret",
oidcIdentifier: "oidc_identifier",
@@ -264,9 +252,6 @@ export class LazyFieldEncryption {
"key",
"keyPassword",
"sudoPassword",
"autostartPassword",
"autostartKey",
"autostartKeyPassword",
"socks5Password",
],
ssh_credentials: [
-3
View File
@@ -51,9 +51,6 @@ const SENSITIVE_FIELDS = [
"secret",
"clientSecret",
"keyPassword",
"autostartPassword",
"autostartKey",
"autostartKeyPassword",
"credentialId",
"authToken",
"jwt",
@@ -55,7 +55,7 @@ export class RawSqliteUserEncryptionMigrationStore implements UserEncryptionMigr
.prepare(
`
UPDATE ssh_data
SET password = ?, key = ?, key_password = ?, key_type = ?, autostart_password = ?, autostart_key = ?, autostart_key_password = ?, sudo_password = ?, updated_at = CURRENT_TIMESTAMP
SET password = ?, key = ?, key_password = ?, key_type = ?, sudo_password = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
)
@@ -64,9 +64,6 @@ export class RawSqliteUserEncryptionMigrationStore implements UserEncryptionMigr
record.key || null,
record.key_password || null,
record.key_type || null,
record.autostart_password || null,
record.autostart_key || null,
record.autostart_key_password || null,
record.sudo_password || null,
recordId,
);