chore: lock the plugin boundary and remove 2.9.0 leftovers

This commit is contained in:
LukeGus committed 2026-09-25 07:24:46 -05:00
1 parent 6489b62ece
commit 2961297989
399 files changed
+3964 -5627

No files matched your search

@@ -0,0 +1,127 @@
/**
* Moves the 2.8 ACME configuration into the acme-ssl plugin.
*
* 2.8 kept it as one JSON row, `acme_ssl_settings`, in core settings. Each
* field becomes a plugin admin setting. "manual" was never ACME (it meant an
* uploaded certificate, which core still handles), so it turns renewal off.
* The Cloudflare token was plaintext and lands as an encrypted secret; it is
* then removed from the old row. The rest of the row stays for one release.
*
* Idempotent: a field already set in plugin_settings is never overwritten.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
const PLUGIN_ID = "acme-ssl";
const LEGACY_KEY = "acme_ssl_settings";
export interface AcmeSslSettingsMigrationResult {
moved: string[];
}
interface LegacyAcmeSettings {
enabled?: unknown;
domain?: unknown;
email?: unknown;
challengeType?: unknown;
cloudflareToken?: unknown;
}
const text = (value: unknown) =>
typeof value === "string" && value.trim() ? value.trim() : null;
export async function runAcmeSslSettingsMigration(): Promise<AcmeSslSettingsMigrationResult> {
const result: AcmeSslSettingsMigrationResult = { moved: [] };
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
const settings = createCurrentSettingsRepository();
const raw = await settings.get(LEGACY_KEY);
if (!raw) return result;
let legacy: LegacyAcmeSettings;
try {
legacy = JSON.parse(raw) as LegacyAcmeSettings;
} catch {
return result;
}
if (!legacy || typeof legacy !== "object") return result;
const pluginSettings = createCurrentPluginSettingsRepository();
const writeIfUnset = async (
field: string,
value: unknown,
secret = false,
) => {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) return;
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
field,
JSON.stringify(value),
secret,
);
result.moved.push(field);
};
const challenge = legacy.challengeType;
const manual = challenge === "manual";
const domain = text(legacy.domain);
if (domain) await writeIfUnset("domain", domain);
const email = text(legacy.email);
if (email) await writeIfUnset("email", email);
if (challenge === "dns-cloudflare") {
await writeIfUnset("challengeType", "dns-cloudflare");
} else if (challenge === "http-webroot") {
await writeIfUnset("challengeType", "http-01");
}
if (typeof legacy.enabled === "boolean") {
await writeIfUnset("autoRenew", legacy.enabled && !manual);
}
const token = text(legacy.cloudflareToken);
if (token) {
await writeIfUnset(
"cloudflareToken",
await encryptSystemSecret(token),
true,
);
const { cloudflareToken: _dropped, ...rest } = legacy;
await settings.set(LEGACY_KEY, JSON.stringify(rest));
}
if (result.moved.length > 0) {
databaseLogger.info(
`Moved ${result.moved.length} ACME setting(s) into plugin settings`,
{
operation: "acme_ssl_settings_migration",
moved: result.moved.join(", "),
},
);
}
} catch (error) {
// Must not stop the backend: the plugin then shows "not configured".
databaseLogger.warn("ACME settings migration failed", {
operation: "acme_ssl_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -0,0 +1,254 @@
/**
* Moves the AI assistant's settings and provider keys into the ai plugin.
*
* - settings.ai_globally_enabled and ai_private_endpoint_allowlist become the
* admin settings globallyEnabled and privateEndpoints (one host per line).
* - user_preferences.ai_assistant_enabled and ai_read_only_commands become
* each user's enabled and allowReadOnlyCommands.
* - ssh_data.enable_ai_assistant becomes the host setting enableAiAssistant.
* - Each provider's api_key, encrypted under its owner's data key, becomes a
* ctx.secrets row "provider:<id>" for that owner, and the column is emptied
* so no key material is left in the plugin's table.
*
* The old columns stay in the database, unused: core's drizzle migrations
* run before this does, so dropping them there would lose the data first.
* The providers table is ai_providers until the plugin adopts it and
* p_ai_providers after, so both names are tried.
*
* Idempotent: nothing is written where the plugin already has a row, and a
* key is only cleared once its copy exists. Safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { LazyFieldEncryption } from "../utils/lazy-field-encryption.js";
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "ai";
export interface AiSettingsMigrationResult {
admin: string[];
users: number;
hosts: number;
keys: number;
}
type Flag = number | boolean | string | null | undefined;
function isTrue(value: Flag): boolean {
return value === true || value === 1 || value === "1" || value === "true";
}
function isSet(value: Flag): boolean {
return value !== null && value !== undefined;
}
/** The legacy JSON array as the textarea the plugin now reads. */
export function allowlistToText(raw: string): string | null {
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
return parsed
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean)
.join("\n");
} catch {
return null;
}
}
async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
try {
return await selectRows<T>(query);
} catch {
// A fresh install never had the table or column.
return [];
}
}
export async function runAiSettingsMigration(): Promise<AiSettingsMigrationResult> {
const result: AiSettingsMigrationResult = {
admin: [],
users: 0,
hosts: 0,
keys: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
const setIfMissing = async (
scope: "admin" | "user" | "host",
scopeId: string | null,
key: string,
value: unknown,
): Promise<boolean> => {
const existing = await pluginSettings.get(PLUGIN_ID, scope, scopeId, key);
if (existing) return false;
await pluginSettings.set(
PLUGIN_ID,
scope,
scopeId,
key,
JSON.stringify(value),
);
return true;
};
try {
const settings = createCurrentSettingsRepository();
const enabled = await settings.get("ai_globally_enabled");
if (enabled !== null && enabled !== "") {
if (await setIfMissing("admin", null, "globallyEnabled", isTrue(enabled)))
result.admin.push("globallyEnabled");
}
const allowlist = await settings.get("ai_private_endpoint_allowlist");
const text = allowlist ? allowlistToText(allowlist) : null;
if (text !== null) {
if (await setIfMissing("admin", null, "privateEndpoints", text))
result.admin.push("privateEndpoints");
}
} catch (error) {
warn("admin settings", error);
}
const users = await tryRows<{
user_id: string;
ai_assistant_enabled: Flag;
ai_read_only_commands: Flag;
}>(
sql`SELECT user_id, ai_assistant_enabled, ai_read_only_commands FROM user_preferences`,
);
for (const row of users) {
try {
let moved = false;
if (isSet(row.ai_assistant_enabled)) {
moved =
(await setIfMissing(
"user",
row.user_id,
"enabled",
isTrue(row.ai_assistant_enabled),
)) || moved;
}
if (isSet(row.ai_read_only_commands)) {
moved =
(await setIfMissing(
"user",
row.user_id,
"allowReadOnlyCommands",
isTrue(row.ai_read_only_commands),
)) || moved;
}
if (moved) result.users++;
} catch (error) {
warn("a user's settings", error);
}
}
const hosts = await tryRows<{ id: number; enable_ai_assistant: Flag }>(
sql`SELECT id, enable_ai_assistant FROM ssh_data`,
);
for (const row of hosts) {
if (!isTrue(row.enable_ai_assistant)) continue;
try {
if (
await setIfMissing("host", String(row.id), "enableAiAssistant", true)
) {
result.hosts++;
}
} catch (error) {
warn("a host's setting", error);
}
}
for (const table of ["ai_providers", "p_ai_providers"]) {
const providers = await tryRows<{
id: number;
user_id: string;
api_key: string | null;
}>(
sql`SELECT id, user_id, api_key FROM ${sql.identifier(table)} WHERE api_key IS NOT NULL AND api_key <> ''`,
);
for (const row of providers) {
try {
const key = `provider:${row.id}`;
const existing = await pluginSettings.get(
PLUGIN_ID,
"secret",
row.user_id,
key,
);
if (!existing) {
const dek = DataCrypto.getUserDataKey(row.user_id);
if (!dek) continue;
const plaintext = LazyFieldEncryption.safeGetFieldValue(
row.api_key as string,
dek,
String(row.id),
"apiKey",
);
if (!plaintext) continue;
await pluginSettings.set(
PLUGIN_ID,
"secret",
row.user_id,
key,
JSON.stringify(await encryptSystemSecret(plaintext)),
true,
);
result.keys++;
}
await runStatement(
sql`UPDATE ${sql.identifier(table)} SET api_key = NULL WHERE id = ${row.id}`,
);
} catch (error) {
warn("a provider key", error);
}
}
}
if (
result.admin.length > 0 ||
result.users > 0 ||
result.hosts > 0 ||
result.keys > 0
) {
databaseLogger.info("Moved AI assistant settings into the ai plugin", {
operation: "ai_settings_migration",
admin: result.admin.join(", "),
users: result.users,
hosts: result.hosts,
keys: result.keys,
});
}
return result;
}
function warn(what: string, error: unknown): void {
// A failed copy must not stop the backend; the next boot tries again.
databaseLogger.warn(`AI settings migration failed for ${what}`, {
operation: "ai_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Boot copies of 2.8 data into plugin settings and plugin tables.
*
* They run after the plugins are seeded and activated: plugin_settings has a
* foreign key to plugins, and some copies write into tables a plugin adopts
* in its own migrations. Each one is idempotent, so they also run again when
* an admin enables a plugin without a restart.
*/
import { databaseLogger } from "../utils/logger.js";
import { getErrorMessage } from "../utils/error-message.js";
import { runTailscaleSettingsMigration } from "./tailscale-settings-migration.js";
import { runProxmoxSettingsMigration } from "./proxmox-settings-migration.js";
import { runFileManagerSettingsMigration } from "./file-manager-settings-migration.js";
import { runTunnelsSettingsMigration } from "./tunnels-settings-migration.js";
import { runWebEndpointSettingsMigration } from "./web-endpoint-settings-migration.js";
import { runSshTerminalSettingsMigration } from "./ssh-terminal-settings-migration.js";
import { runTmuxMonitorSettingsMigration } from "./tmux-monitor-settings-migration.js";
import { runSessionSharingSettingsMigration } from "./session-sharing-settings-migration.js";
import { runSessionRecordingSettingsMigration } from "./session-recording-settings-migration.js";
import { runRemoteDesktopSettingsMigration } from "./remote-desktop-settings-migration.js";
import { runHostMetricsSettingsMigration } from "./host-metrics-settings-migration.js";
import { runDockerSettingsMigration } from "./docker-settings-migration.js";
import { runAiSettingsMigration } from "./ai-settings-migration.js";
import { runWakeOnLanSettingsMigration } from "./wake-on-lan-settings-migration.js";
import { runWarpgateSettingsMigration } from "./warpgate-settings-migration.js";
import { runStepCaSettingsMigration } from "./step-ca-settings-migration.js";
import { runAcmeSslSettingsMigration } from "./acme-ssl-settings-migration.js";
import { runVaultSettingsMigration } from "./vault-settings-migration.js";
import { runSecretSourcesTokenMigration } from "./secret-sources-token-migration.js";
import { runTotpMigration } from "./totp-migration.js";
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
["runTailscaleSettingsMigration", runTailscaleSettingsMigration],
["runProxmoxSettingsMigration", runProxmoxSettingsMigration],
["runFileManagerSettingsMigration", runFileManagerSettingsMigration],
["runTunnelsSettingsMigration", runTunnelsSettingsMigration],
["runWebEndpointSettingsMigration", runWebEndpointSettingsMigration],
["runSshTerminalSettingsMigration", runSshTerminalSettingsMigration],
["runTmuxMonitorSettingsMigration", runTmuxMonitorSettingsMigration],
["runSessionSharingSettingsMigration", runSessionSharingSettingsMigration],
[
"runSessionRecordingSettingsMigration",
runSessionRecordingSettingsMigration,
],
["runRemoteDesktopSettingsMigration", runRemoteDesktopSettingsMigration],
["runHostMetricsSettingsMigration", runHostMetricsSettingsMigration],
["runDockerSettingsMigration", runDockerSettingsMigration],
["runAiSettingsMigration", runAiSettingsMigration],
["runWakeOnLanSettingsMigration", runWakeOnLanSettingsMigration],
["runWarpgateSettingsMigration", runWarpgateSettingsMigration],
["runStepCaSettingsMigration", runStepCaSettingsMigration],
["runAcmeSslSettingsMigration", runAcmeSslSettingsMigration],
["runVaultSettingsMigration", runVaultSettingsMigration],
["runSecretSourcesTokenMigration", runSecretSourcesTokenMigration],
["runTotpMigration", runTotpMigration],
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
];
export async function runPluginDataMigrations(): Promise<void> {
for (const [name, run] of MIGRATIONS) {
try {
await run();
} catch (error) {
databaseLogger.warn(`Plugin data migration ${name} failed`, {
operation: "plugin_data_migration",
error: getErrorMessage(error),
});
}
}
}
@@ -0,0 +1,118 @@
/**
* Moves each host's Docker options out of ssh_data and into the docker
* plugin's host settings: enable_docker becomes enableDocker and the runtime
* in docker_config becomes containerRuntime.
*
* The columns stay in the database, unused: core's drizzle migrations run
* before this does, so dropping them there would lose the data first.
*
* Idempotent: a host that already has any docker row is skipped, so this is
* safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
import { selectRows } from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "docker";
interface LegacyHostRow {
id: number;
enable_docker: number | boolean | string | null;
docker_config: string | null;
}
/** The plugin's host settings for one legacy row, defaults left out. */
export function dockerSettingsFromRow(
row: LegacyHostRow,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
const flag = row.enable_docker;
if (flag === true || flag === 1 || flag === "1" || flag === "true") {
values.enableDocker = true;
}
if (row.docker_config) {
try {
const config = JSON.parse(row.docker_config) as { runtime?: unknown };
if (config?.runtime === "podman") values.containerRuntime = "podman";
} catch {
// unreadable config means the default runtime
}
}
return values;
}
export interface DockerSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
}
export async function runDockerSettingsMigration(): Promise<DockerSettingsMigrationResult> {
const result: DockerSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
let rows: LegacyHostRow[];
try {
// Raw SQL: schema.ts no longer declares these columns.
rows = await selectRows<LegacyHostRow>(
sql`SELECT id, enable_docker, docker_config FROM ssh_data`,
);
} catch {
// A fresh install never had the columns.
return result;
}
for (const row of rows) {
const values = dockerSettingsFromRow(row);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
try {
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
key,
JSON.stringify(value),
);
}
result.hostsMoved++;
} catch (error) {
databaseLogger.warn("Docker host settings migration failed for a host", {
operation: "docker_settings_migration",
hostId: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
if (result.hostsMoved > 0) {
databaseLogger.info(
`Moved Docker settings for ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "docker_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,109 @@
/**
* Moves sign-in identities into their own table.
*
* users.oidc_identifier packed provider and subject into one string:
* "ldap:<provider>:<id>", "github:<provider>:<id>", or a bare OIDC subject
* whose provider was users.sso_provider_id. Each becomes a
* user_external_identities row; LDAP ones under provider "ldap:<provider>",
* the id the ldap plugin signs in with. TOTP enrolment moves in totp-migration.ts.
*
* Lossless: the old column is left as it is. Idempotent: rows that already
* exist are skipped, so it runs on every boot.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentUserAuthRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
/** The provider id the sso plugin gives its env-configured provider. */
const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
export interface ExternalIdentityMigrationResult {
identities: number;
skipped: number;
}
/** Splits a stored identifier into provider and subject. */
export function parseLegacyIdentifier(
identifier: string,
ssoProviderId: number | null | undefined,
): { providerId: string; subject: string } | null {
if (!identifier) return null;
const prefixed = /^(ldap|github):([^:]+):(.+)$/.exec(identifier);
if (prefixed) {
const provider = prefixed[2];
const missing = provider === "null" || provider === "undefined";
// LDAP providers moved to their own table in 2.9 and their identities
// carry the "ldap:" prefix so their ids cannot clash with SSO ones.
if (prefixed[1] === "ldap") {
return missing
? null
: { providerId: `ldap:${provider}`, subject: prefixed[3] };
}
return {
providerId: missing ? LEGACY_OIDC_PROVIDER_ID : provider,
subject: prefixed[3],
};
}
return {
providerId:
ssoProviderId !== null && ssoProviderId !== undefined
? String(ssoProviderId)
: LEGACY_OIDC_PROVIDER_ID,
subject: identifier,
};
}
export async function runExternalIdentityMigration(): Promise<ExternalIdentityMigrationResult> {
const result: ExternalIdentityMigrationResult = {
identities: 0,
skipped: 0,
};
try {
const users = await createCurrentUserRepository().listAll();
const auth = createCurrentUserAuthRepository();
for (const user of users) {
if (user.oidcIdentifier) {
const parsed = parseLegacyIdentifier(
user.oidcIdentifier,
user.ssoProviderId,
);
if (parsed) {
const existing = await auth.findIdentity(
parsed.providerId,
parsed.subject,
);
if (existing) {
result.skipped++;
} else {
await auth.linkIdentity({
userId: user.id,
providerId: parsed.providerId,
subject: parsed.subject,
});
result.identities++;
}
}
}
}
if (result.identities > 0) {
databaseLogger.info("Moved sign-in identities", {
operation: "external_identity_migration",
...result,
});
}
} catch (error) {
databaseLogger.error("External identity migration failed", error, {
operation: "external_identity_migration",
});
}
return result;
}
@@ -0,0 +1,102 @@
/**
* Moves the three file-manager host columns into the file-manager plugin's
* host-scope settings, before those columns are dropped from ssh_data.
*
* Idempotent: skips a host that already has a file-manager settings row, so
* it is safe to run on every boot. Lossless: reads straight off the
* still-present ssh_data columns, so it must run and finish before those
* columns are ever dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
legacyFlag,
selectLegacyRows,
} from "../utils/crypto-migration/raw-rows.js";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface FileManagerSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyFileManagerRow {
id: number;
enable_file_manager: number | boolean | null;
default_path: string | null;
scp_legacy: number | boolean | null;
}
export async function runFileManagerSettingsMigration(): Promise<FileManagerSettingsMigrationResult> {
const result: FileManagerSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
// Raw SQL, not the typed schema: this runs once schema.ts has already
// dropped these columns, so the query is the only thing left that still
// knows they used to exist.
const rows = await selectLegacyRows<LegacyFileManagerRow>(sql`
SELECT id, enable_file_manager, default_path, scp_legacy
FROM ssh_data
WHERE enable_file_manager = false OR default_path IS NOT NULL
OR scp_legacy = true
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"file-manager",
"host",
scopeId,
"enableFileManager",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"enableFileManager",
JSON.stringify(legacyFlag(row.enable_file_manager, true)),
);
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"defaultPath",
JSON.stringify(row.default_path ?? null),
);
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"scpLegacy",
JSON.stringify(!!row.scp_legacy),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved file manager settings for ${result.moved} host(s) into plugin settings`,
{ operation: "file_manager_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep
// whatever file-manager settings they already had (none, on a fresh run).
databaseLogger.warn("File manager settings migration failed", {
operation: "file_manager_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -0,0 +1,182 @@
/**
* Moves Host Metrics' settings out of core and into the host-metrics
* plugin's own settings.
*
* Admin keys from `settings`: global_metrics_interval,
* metrics_history_retention_days and metricsEnabled inside host_defaults (the
* default for new hosts). Per host, the metrics half of
* ssh_data.stats_config: metricsEnabled, the host's own interval, the enabled
* widgets and the mount lists. The status half is core and moved by
* host-status-config-migration.ts.
*
* The old settings rows and the column stay in place, unused.
*
* Idempotent: an admin key already set, or a host that already has any
* host-metrics row, is skipped, so this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { selectRows } from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "host-metrics";
/** A JSON object stored as text, or {} when it is anything else. */
function parseStatsConfig(raw: string | null): Record<string, unknown> {
try {
let parsed: unknown = raw ? JSON.parse(raw) : null;
if (typeof parsed === "string") parsed = JSON.parse(parsed);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
function seconds(value: unknown, min: number, max: number): number | null {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= min && parsed <= max
? parsed
: null;
}
/** The plugin's host settings for one stats_config value, defaults left out. */
export function hostSettingsFromStatsConfig(
raw: string | null,
): Record<string, unknown> {
const config = parseStatsConfig(raw);
const values: Record<string, unknown> = {};
if (config.metricsEnabled === false) values.metricsEnabled = false;
if (config.useGlobalMetricsInterval === false) {
const interval = seconds(config.metricsInterval, 5, 3600);
if (interval !== null) values.metricsInterval = interval;
}
if (Array.isArray(config.enabledWidgets)) {
values.enabledWidgets = config.enabledWidgets.filter(
(widget): widget is string => typeof widget === "string",
);
}
if (Array.isArray(config.excludedMounts) && config.excludedMounts.length) {
values.excludedMounts = config.excludedMounts.filter(
(mount): mount is string => typeof mount === "string",
);
}
if (Array.isArray(config.monitoredMounts) && config.monitoredMounts.length) {
values.monitoredMounts = config.monitoredMounts.filter(
(mount) =>
!!mount &&
typeof mount === "object" &&
typeof (mount as { path?: unknown }).path === "string",
);
}
return values;
}
export interface HostMetricsSettingsMigrationResult {
moved: string[];
hostsMoved: number;
hostsSkipped: number;
}
export async function runHostMetricsSettingsMigration(): Promise<HostMetricsSettingsMigrationResult> {
const result: HostMetricsSettingsMigrationResult = {
moved: [],
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
const write = (
scope: "admin" | "host",
scopeId: string | null,
key: string,
value: unknown,
) =>
pluginSettings.set(PLUGIN_ID, scope, scopeId, key, JSON.stringify(value));
try {
const settings = createCurrentSettingsRepository();
const moves: [string, string, number, number][] = [
["global_metrics_interval", "metricsInterval", 5, 3600],
["metrics_history_retention_days", "historyRetentionDays", 1, 90],
];
for (const [legacyKey, field, min, max] of moves) {
const value = seconds(await settings.get(legacyKey), min, max);
if (value === null) continue;
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) continue;
await write("admin", null, field, value);
result.moved.push(legacyKey);
}
const defaults = parseStatsConfig(await settings.get("host_defaults"));
if (defaults.metricsEnabled === false) {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
"enabledForNewHosts",
);
if (!existing || existing.value === null) {
await write("admin", null, "enabledForNewHosts", false);
result.moved.push("host_defaults.metricsEnabled");
}
}
} catch (error) {
databaseLogger.warn("Host Metrics admin settings migration failed", {
operation: "host_metrics_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
try {
// Raw SQL: schema.ts no longer declares stats_config.
const rows = await selectRows<{ id: number; stats_config: string | null }>(
sql`SELECT id, stats_config FROM ssh_data WHERE stats_config IS NOT NULL`,
);
for (const row of rows) {
const values = hostSettingsFromStatsConfig(row.stats_config);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await write("host", scopeId, key, value);
}
result.hostsMoved++;
}
} catch {
// A fresh install never had the column.
}
if (result.moved.length > 0 || result.hostsMoved > 0) {
databaseLogger.info(
`Moved ${result.moved.length} Host Metrics setting(s) and ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "host_metrics_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,148 @@
/**
* Moves 2.8 LDAP directories into the ldap plugin.
*
* 2.8 kept LDAP and SSO providers in one sso_providers table. The sso plugin
* adopts that table (as p_sso_providers) and ignores its LDAP rows; this
* copies them into p_ldap_providers once the ldap plugin has created it, then
* deletes them from the source.
*
* Ids are kept, because users.oidc_identifier ("ldap:<id>:<uid>") and their
* identities ("ldap:<id>") name them. Bare "<id>" identities from before the
* prefix are renamed too. It runs before the ldap plugin first activates, so
* the plugin cannot have used an id yet; if it somehow has, the old row gets
* a fresh id and only its bare identities follow it.
*
* Needs neither plugin to be running, only their tables. Idempotent.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { resolveDatabaseDialect } from "../database/db/dialect.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const TARGET = "p_ldap_providers";
const SOURCES = ["p_sso_providers", "sso_providers"];
interface LegacyRow {
id: number;
name: string;
enabled: unknown;
display_order: number;
config: string;
created_at: string;
updated_at: string;
}
export interface LdapProviderMigrationResult {
/** Directories copied into the plugin's table. */
moved: number;
/** Of those, how many needed a new id. */
renumbered: number;
}
async function tableExists(name: string): Promise<boolean> {
try {
await selectRows(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
return true;
} catch {
return false;
}
}
function isOn(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "t";
}
async function renameIdentities(from: string, to: string): Promise<void> {
await runStatement(
sql`UPDATE user_external_identities SET provider_id = ${to} WHERE provider_id = ${from}`,
);
}
/** Explicit ids leave a Postgres sequence behind them. */
async function syncSequence(): Promise<void> {
if (resolveDatabaseDialect() !== "postgres") return;
await runStatement(
sql`SELECT setval(pg_get_serial_sequence(${TARGET}, 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${sql.identifier(TARGET)}))`,
);
}
async function insertRow(row: LegacyRow, id: number | null): Promise<number> {
const enabled = isOn(row.enabled);
const values = sql`${row.name}, ${resolveDatabaseDialect() === "sqlite" ? (enabled ? 1 : 0) : enabled}, ${row.display_order ?? 0}, ${row.config}, ${row.created_at}, ${row.updated_at}`;
if (id !== null) {
await runStatement(
sql`INSERT INTO ${sql.identifier(TARGET)} (id, name, enabled, display_order, config, created_at, updated_at) VALUES (${id}, ${values})`,
);
return id;
}
await runStatement(
sql`INSERT INTO ${sql.identifier(TARGET)} (name, enabled, display_order, config, created_at, updated_at) VALUES (${values})`,
);
const [inserted] = await selectRows<{ id: number }>(
sql`SELECT MAX(id) AS id FROM ${sql.identifier(TARGET)}`,
);
return Number(inserted.id);
}
export async function runLdapProviderMigration(): Promise<LdapProviderMigrationResult> {
const result: LdapProviderMigrationResult = { moved: 0, renumbered: 0 };
try {
if (!(await tableExists(TARGET))) return result;
for (const source of SOURCES) {
if (!(await tableExists(source))) continue;
const rows = await selectRows<LegacyRow>(
sql`SELECT id, name, enabled, display_order, config, created_at, updated_at FROM ${sql.identifier(source)} WHERE type = 'ldap' ORDER BY id`,
);
// Rows whose id is free keep it; the rest go last, so a fresh id can
// never land on one that is still waiting to be copied.
const clashing: LegacyRow[] = [];
for (const row of rows) {
const taken = await selectRows(
sql`SELECT id FROM ${sql.identifier(TARGET)} WHERE id = ${row.id}`,
);
if (taken.length > 0) {
clashing.push(row);
continue;
}
await insertRow(row, row.id);
await renameIdentities(String(row.id), `ldap:${row.id}`);
await runStatement(
sql`DELETE FROM ${sql.identifier(source)} WHERE id = ${row.id}`,
);
result.moved++;
}
if (clashing.length > 0) await syncSequence();
for (const row of clashing) {
const id = await insertRow(row, null);
// "ldap:<id>" already names the plugin's own directory, so only the
// bare ids from before the prefix can follow this one.
await renameIdentities(String(row.id), `ldap:${id}`);
await runStatement(
sql`DELETE FROM ${sql.identifier(source)} WHERE id = ${row.id}`,
);
result.moved++;
result.renumbered++;
}
}
if (result.moved > 0) await syncSequence();
if (result.moved > 0) {
databaseLogger.info("Moved LDAP directories into the ldap plugin", {
operation: "ldap_provider_migration",
...result,
});
}
} catch (error) {
databaseLogger.error("LDAP directory migration failed", error, {
operation: "ldap_provider_migration_failed",
});
}
return result;
}
@@ -0,0 +1,95 @@
/**
* Moves a 2.8 OPKSSH setup into the opkssh plugin.
*
* The config file moves from DATA_DIR/.opk/config.yml to the plugin's own
* folder (DATA_DIR/plugins/opkssh/config.yml). The old file is copied, not
* moved, so a downgrade still finds it. An install that had a config also
* keeps sending the pre-2.9 redirect URI (/host/opkssh-callback), because
* that is the one registered with its identity providers: the plugin's
* legacyCallback admin setting is turned on.
*
* Idempotent: an existing target file or setting is never overwritten.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
const PLUGIN_ID = "opkssh";
export interface OpksshConfigMigrationResult {
copied: boolean;
legacyCallback: boolean;
}
async function exists(file: string): Promise<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}
export async function runOpksshConfigMigration(
dataDir: string = process.env.DATA_DIR ||
path.join(process.cwd(), "db", "data"),
): Promise<OpksshConfigMigrationResult> {
const result: OpksshConfigMigrationResult = {
copied: false,
legacyCallback: false,
};
const legacyConfig = path.join(dataDir, ".opk", "config.yml");
if (!(await exists(legacyConfig))) return result;
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
try {
const target = path.join(dataDir, "plugins", PLUGIN_ID, "config.yml");
if (!(await exists(target))) {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.copyFile(legacyConfig, target);
result.copied = true;
}
const settings = createCurrentPluginSettingsRepository();
const current = await settings.get(
PLUGIN_ID,
"admin",
null,
"legacyCallback",
);
if (!current) {
await settings.set(
PLUGIN_ID,
"admin",
null,
"legacyCallback",
JSON.stringify(true),
);
result.legacyCallback = true;
}
} catch (error) {
databaseLogger.warn("OPKSSH config migration failed", {
operation: "opkssh_config_migration",
error: error instanceof Error ? error.message : String(error),
});
}
if (result.copied) {
databaseLogger.info("Copied the OPKSSH config into the opkssh plugin", {
operation: "opkssh_config_migration",
});
}
return result;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Core data that moves into a plugin's tables once they exist. Runs whenever
* a plugin's migrations applied something, at boot or when a plugin is
* enabled later, so the data is in place before the plugin's activate runs. Each move is
* idempotent and does nothing until its target table exists.
*/
import { runLdapProviderMigration } from "./ldap-provider-migration.js";
import { runOpksshConfigMigration } from "./opkssh-config-migration.js";
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
export async function runPluginDataMoves(): Promise<void> {
await runLdapProviderMigration();
await runOpksshConfigMigration();
await runTermixIdentityCaMigration();
}
@@ -0,0 +1,119 @@
/**
* Moves the four Proxmox host columns into the proxmox plugin's host-scope
* settings, before those columns are dropped from ssh_data.
*
* Idempotent: skips a host that already has a proxmox settings row, so it is
* safe to run on every boot. Lossless: reads straight off the still-present
* ssh_data columns, so it must run and finish before those columns are ever
* dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface ProxmoxSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyProxmoxRow {
id: number;
enable_proxmox: number | boolean | null;
proxmox_config: string | null;
enable_proxmox_stats: number | boolean | null;
proxmox_stats_config: string | null;
}
export async function runProxmoxSettingsMigration(): Promise<ProxmoxSettingsMigrationResult> {
const result: ProxmoxSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
// Raw SQL, not the typed schema: this runs once schema.ts has already
// dropped these columns, so the query is the only thing left that still
// knows they used to exist.
const rows = await selectLegacyRows<LegacyProxmoxRow>(sql`
SELECT id, enable_proxmox, proxmox_config, enable_proxmox_stats, proxmox_stats_config
FROM ssh_data
WHERE enable_proxmox = true OR proxmox_config IS NOT NULL
OR enable_proxmox_stats = true OR proxmox_stats_config IS NOT NULL
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"proxmox",
"host",
scopeId,
"enableProxmox",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"enableProxmox",
JSON.stringify(!!row.enable_proxmox),
);
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"proxmoxConfig",
JSON.stringify(
row.proxmox_config ? safeParse(row.proxmox_config) : null,
),
);
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"enableProxmoxStats",
JSON.stringify(!!row.enable_proxmox_stats),
);
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"proxmoxStatsConfig",
JSON.stringify(
row.proxmox_stats_config ? safeParse(row.proxmox_stats_config) : null,
),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved Proxmox settings for ${result.moved} host(s) into plugin settings`,
{ operation: "proxmox_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep
// whatever proxmox settings they already had (none, on a fresh run).
databaseLogger.warn("Proxmox settings migration failed", {
operation: "proxmox_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
function safeParse(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return null;
}
}
@@ -0,0 +1,289 @@
/**
* Moves Remote Desktop's settings out of core and into the remote-desktop
* plugin's own settings.
*
* Admin keys from `settings`: guac_enabled and guac_url. Per user, the RDP
* defaults JSON in user_preferences.rdp_defaults. Per host, the option
* columns in ssh_data: the three protocol switches and ports, rdp_security,
* rdp_ignore_cert and guacamole_config, folding in the older security,
* ignore_cert and connection_type-only shapes, plus enable_terminal_toolbar,
* which also drove the remote desktop toolbar.
*
* The logins (users, passwords, credential ids, auth types, domain) stay in
* core, next to the host, where sharing needs them.
*
* The columns stay in the database, unused: core's drizzle migrations run
* before this does, so dropping them there would lose the data first.
*
* Idempotent: an admin key already set, or a user or host that already has
* any remote-desktop row, is skipped, so this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "remote-desktop";
const PROTOCOLS = ["rdp", "vnc", "telnet"] as const;
const DEFAULT_PORTS = { rdp: 3389, vnc: 5900, telnet: 23 };
type Flag = number | boolean | string | null | undefined;
/** true, 1 and "1" are on; null means the column was never set. */
function flag(value: Flag): boolean | undefined {
if (value === null || value === undefined) return undefined;
return value === true || value === 1 || value === "1" || value === "true";
}
function port(value: unknown): number | undefined {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535
? parsed
: undefined;
}
function jsonObject(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== "string" || !value) return undefined;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: undefined;
} catch {
return undefined;
}
}
interface LegacyHostRow {
id: number;
port: number | null;
connection_type: string | null;
enable_rdp: Flag;
enable_vnc: Flag;
enable_telnet: Flag;
rdp_port: number | null;
vnc_port: number | null;
telnet_port: number | null;
rdp_security: string | null;
rdp_ignore_cert: Flag;
security: string | null;
ignore_cert: Flag;
guacamole_config: string | null;
enable_terminal_toolbar: Flag;
}
/** The plugin's host settings for one legacy row, defaults left out. */
export function hostSettingsFromRow(
row: LegacyHostRow,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
const flags = {
rdp: flag(row.enable_rdp) === true,
vnc: flag(row.enable_vnc) === true,
telnet: flag(row.enable_telnet) === true,
};
const legacyType = PROTOCOLS.find((p) => p === row.connection_type);
// Hosts from before the per-protocol switches only had connection_type.
if (!flags.rdp && !flags.vnc && !flags.telnet && legacyType) {
flags[legacyType] = true;
}
const ports = {
rdp: port(row.rdp_port),
vnc: port(row.vnc_port),
telnet: port(row.telnet_port),
};
if (legacyType && ports[legacyType] === undefined) {
ports[legacyType] = port(row.port);
}
for (const protocol of PROTOCOLS) {
const key = `enable${protocol[0].toUpperCase()}${protocol.slice(1)}`;
if (flags[protocol]) values[key] = true;
const value = ports[protocol];
if (value !== undefined && value !== DEFAULT_PORTS[protocol]) {
values[`${protocol}Port`] = value;
}
}
const security = row.rdp_security || row.security;
if (security) values.rdpSecurity = security;
const ignoreCert = flag(row.rdp_ignore_cert) ?? flag(row.ignore_cert);
if (ignoreCert) values.rdpIgnoreCert = true;
const config = jsonObject(row.guacamole_config);
if (config && Object.keys(config).length > 0) values.guacamoleConfig = config;
if (flag(row.enable_terminal_toolbar) === false) values.enableToolbar = false;
return values;
}
/** Old camelCase defaults JSON -> the plugin's user settings. */
function userSettingsFromDefaults(
defaults: Record<string, unknown>,
): Record<string, string> {
const values: Record<string, string> = {};
const depth = Number(defaults.colorDepth);
if ([16, 24, 32].includes(depth)) values.colorDepth = String(depth);
if (
defaults.resizeMethod === "display-update" ||
defaults.resizeMethod === "reconnect"
) {
values.resizeMethod = defaults.resizeMethod;
}
for (const key of [
"forceLossless",
"enableWallpaper",
"enableFontSmoothing",
"enableDesktopComposition",
"disableAudio",
"enablePrinting",
"enableDrive",
"disableCopy",
"disablePaste",
]) {
if (typeof defaults[key] === "boolean") {
values[key] = defaults[key] ? "on" : "off";
}
}
return values;
}
export interface RemoteDesktopSettingsMigrationResult {
moved: string[];
usersMoved: number;
hostsMoved: number;
hostsSkipped: number;
}
export async function runRemoteDesktopSettingsMigration(): Promise<RemoteDesktopSettingsMigrationResult> {
const result: RemoteDesktopSettingsMigrationResult = {
moved: [],
usersMoved: 0,
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
const write = (
scope: "admin" | "user" | "host",
scopeId: string | null,
key: string,
value: unknown,
) =>
pluginSettings.set(PLUGIN_ID, scope, scopeId, key, JSON.stringify(value));
try {
const settings = createCurrentSettingsRepository();
const moves: [string, string, (raw: string) => unknown][] = [
["guac_enabled", "enabled", (raw) => raw !== "false" && raw !== "0"],
["guac_url", "guacdUrl", (raw) => raw],
];
for (const [legacyKey, field, parse] of moves) {
const legacy = await settings.get(legacyKey);
if (legacy === null || legacy === "") continue;
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) continue;
await write("admin", null, field, parse(legacy));
result.moved.push(legacyKey);
}
} catch (error) {
databaseLogger.warn("Remote Desktop admin settings migration failed", {
operation: "remote_desktop_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
try {
const rows = await selectRows<{
user_id: string;
rdp_defaults: string | null;
}>(sql`SELECT user_id, rdp_defaults FROM user_preferences`);
for (const row of rows) {
const defaults = jsonObject(row.rdp_defaults);
if (!defaults) continue;
const values = userSettingsFromDefaults(defaults);
if (Object.keys(values).length === 0) continue;
const existing = await pluginSettings.getAll(
PLUGIN_ID,
"user",
row.user_id,
);
if (existing.length > 0) continue;
for (const [key, value] of Object.entries(values)) {
await write("user", row.user_id, key, value);
}
result.usersMoved++;
}
} catch {
// A fresh install never had the column.
}
try {
// Raw SQL: schema.ts no longer declares these columns.
const rows = await selectRows<LegacyHostRow>(sql`
SELECT id, port, connection_type, enable_rdp, enable_vnc, enable_telnet,
rdp_port, vnc_port, telnet_port, rdp_security, rdp_ignore_cert,
security, ignore_cert, guacamole_config, enable_terminal_toolbar
FROM ssh_data
`);
for (const row of rows) {
const values = hostSettingsFromRow(row);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await write("host", scopeId, key, value);
}
result.hostsMoved++;
}
// Those same connection_type-only hosts kept the enable_ssh default of
// on, which core used to correct on every read. FALSE and NOT read the
// same on SQLite's integers and on real booleans.
await runStatement(sql`
UPDATE ssh_data SET enable_ssh = FALSE
WHERE connection_type IN ('rdp', 'vnc', 'telnet')
AND (enable_rdp IS NULL OR NOT enable_rdp)
AND (enable_vnc IS NULL OR NOT enable_vnc)
AND (enable_telnet IS NULL OR NOT enable_telnet)
`);
} catch {
// A fresh install never had the columns.
}
if (
result.moved.length > 0 ||
result.usersMoved > 0 ||
result.hostsMoved > 0
) {
databaseLogger.info(
`Moved ${result.moved.length} Remote Desktop setting(s), ${result.usersMoved} user(s) and ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "remote_desktop_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,117 @@
/**
* Moves each secret source's access token out of secret_sources.token,
* encrypted under its owner's data key, into a ctx.secrets row "source:<id>"
* for that owner, encrypted under the installation key - the same move the
* ai plugin made for provider API keys. The column is then emptied so no
* token material is left in the adopted table.
*
* The old column stays in the database, unused, until this runs: core's
* drizzle migrations run before this does, so dropping it there would lose
* the token first. The table is secret_sources until the plugin adopts it
* and p_secret_sources_sources after, so both names are tried.
*
* Idempotent: a source whose ctx.secrets row already exists is skipped, and
* a token is only cleared once its copy exists. Safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { LazyFieldEncryption } from "../utils/lazy-field-encryption.js";
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "secret-sources";
export interface SecretSourcesTokenMigrationResult {
moved: number;
}
async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
try {
return await selectRows<T>(query);
} catch {
// A fresh install never had the table or column.
return [];
}
}
export async function runSecretSourcesTokenMigration(): Promise<SecretSourcesTokenMigrationResult> {
const result: SecretSourcesTokenMigrationResult = { moved: 0 };
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
for (const table of ["secret_sources", "p_secret_sources_sources"]) {
const rows = await tryRows<{
id: string;
user_id: string;
token: string | null;
}>(
sql`SELECT id, user_id, token FROM ${sql.identifier(table)} WHERE token IS NOT NULL AND token <> ''`,
);
for (const row of rows) {
try {
const key = `source:${row.id}`;
const existing = await pluginSettings.get(
PLUGIN_ID,
"secret",
row.user_id,
key,
);
if (!existing) {
const dek = DataCrypto.getUserDataKey(row.user_id);
if (!dek) continue;
const plaintext = LazyFieldEncryption.safeGetFieldValue(
row.token as string,
dek,
row.id,
"token",
);
if (!plaintext) continue;
await pluginSettings.set(
PLUGIN_ID,
"secret",
row.user_id,
key,
JSON.stringify(await encryptSystemSecret(plaintext)),
true,
);
result.moved++;
}
await runStatement(
sql`UPDATE ${sql.identifier(table)} SET token = NULL WHERE id = ${row.id}`,
);
} catch (error) {
databaseLogger.warn("Secret source token migration failed for a row", {
operation: "secret_sources_token_migration",
error: error instanceof Error ? error.message : String(error),
});
}
}
}
if (result.moved > 0) {
databaseLogger.info(
`Moved ${result.moved} secret source token(s) into the secret-sources plugin`,
{ operation: "secret_sources_token_migration" },
);
}
return result;
}
@@ -0,0 +1,91 @@
/**
* Moves the session logging host column into the session-recording plugin's
* host-scope settings, before the column is dropped from ssh_data.
*
* The legacy column defaulted to true; the plugin's enable switch defaults to
* false like every other plugin's. So unlike a migration whose old and new
* defaults agree, every host needs an explicit row here, not just the ones
* that turned the feature off.
*
* Idempotent: skips a host that already has a session-recording settings row,
* so it is safe to run on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
legacyFlag,
selectLegacyRows,
} from "../utils/crypto-migration/raw-rows.js";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface SessionRecordingSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacySessionLoggingRow {
id: number;
enable_session_logging: number | boolean | null;
}
export async function runSessionRecordingSettingsMigration(): Promise<SessionRecordingSettingsMigrationResult> {
const result: SessionRecordingSettingsMigrationResult = {
moved: 0,
skipped: 0,
};
try {
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring this column.
const rows = await selectLegacyRows<LegacySessionLoggingRow>(sql`
SELECT id, enable_session_logging FROM ssh_data
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"session-recording",
"host",
scopeId,
"enableSessionRecording",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"session-recording",
"host",
scopeId,
"enableSessionRecording",
JSON.stringify(legacyFlag(row.enable_session_logging, true)),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved session recording settings for ${result.moved} host(s) into plugin settings`,
{
operation: "session_recording_settings_migration",
moved: result.moved,
},
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep the
// plugin's own default (off) until this runs successfully.
databaseLogger.warn("Session recording settings migration failed", {
operation: "session_recording_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -0,0 +1,107 @@
/**
* Moves session sharing's two settings into the session-sharing plugin:
* the instance-wide switch (settings.session_sharing_globally_enabled) into
* its admin setting, and ssh_data.allow_session_sharing into its host setting.
*
* Idempotent: nothing is written where the plugin already has a row, so it is
* safe on every boot. Both default to on, so only a switch that was turned off
* needs a row. The legacy settings row is left in place for a release.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
import {
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
const PLUGIN_ID = "session-sharing";
const LEGACY_GLOBAL_KEY = "session_sharing_globally_enabled";
export interface SessionSharingSettingsMigrationResult {
movedGlobal: boolean;
movedHosts: number;
skipped: number;
}
interface LegacyHostRow {
id: number;
}
export async function runSessionSharingSettingsMigration(): Promise<SessionSharingSettingsMigrationResult> {
const result: SessionSharingSettingsMigrationResult = {
movedGlobal: false,
movedHosts: 0,
skipped: 0,
};
try {
const pluginSettings = createCurrentPluginSettingsRepository();
const legacy =
await createCurrentSettingsRepository().get(LEGACY_GLOBAL_KEY);
if (legacy === "false") {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
"globallyEnabled",
);
if (existing && existing.value !== null) {
result.skipped++;
} else {
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
"globallyEnabled",
JSON.stringify(false),
);
result.movedGlobal = true;
}
}
// Raw SQL, so this keeps working once schema.ts stops declaring the column.
const rows = await selectLegacyRows<LegacyHostRow>(sql`
SELECT id FROM ssh_data WHERE allow_session_sharing = false
`);
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettings.get(
PLUGIN_ID,
"host",
scopeId,
"allowSessionSharing",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
"allowSessionSharing",
JSON.stringify(false),
);
result.movedHosts++;
}
if (result.movedGlobal || result.movedHosts > 0) {
databaseLogger.info("Moved session sharing settings into the plugin", {
operation: "session_sharing_settings_migration",
movedGlobal: result.movedGlobal,
movedHosts: result.movedHosts,
});
}
} catch (error) {
// Never stops the backend. Sharing then stays on, the plugin's default.
databaseLogger.warn("Session sharing settings migration failed", {
operation: "session_sharing_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -0,0 +1,248 @@
/**
* Moves the terminal's settings out of core and into the ssh-terminal
* plugin's own settings.
*
* Admin keys from the core `settings` table: session persistence, command
* history, touch input tuning and terminal image storage. The image storage
* local directory was never sent to a browser, so it lands as a secret field,
* which keeps it that way. The legacy rows are left in place for a release;
* nothing reads them any more.
*
* Host columns from ssh_data: enable_terminal, enable_terminal_toolbar and
* enable_command_history. All three default to on in the plugin, so only
* hosts that turned one off need a row.
*
* Idempotent: an admin key already set in the plugin, or a host that already
* has an enableTerminal row, is skipped, so this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
legacyFlag,
selectLegacyRows,
} from "../utils/crypto-migration/raw-rows.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
const PLUGIN_ID = "ssh-terminal";
type Parse = (raw: string) => unknown;
const asNumber: Parse = (raw) => {
const value = Number(raw);
return Number.isFinite(value) ? value : undefined;
};
const asBoolean: Parse = (raw) =>
raw === "true" || raw === "1"
? true
: raw === "false" || raw === "0"
? false
: undefined;
const asString: Parse = (raw) => raw;
const asJson: Parse = (raw) => {
try {
return JSON.parse(raw);
} catch {
return undefined;
}
};
/** Legacy core key -> the admin field the plugin declares. */
const ADMIN_MOVES: {
legacyKey: string;
field: string;
parse: Parse;
secret?: boolean;
}[] = [
{
legacyKey: "terminal_session_timeout_minutes",
field: "sessionTimeoutMinutes",
parse: asNumber,
},
{
legacyKey: "terminal_session_persistence_enabled",
field: "sessionPersistence",
parse: asBoolean,
},
{
legacyKey: "command_history_enabled",
field: "commandHistoryEnabled",
parse: asBoolean,
},
{
// The admin host defaults carried the new-host command history switch.
legacyKey: "host_defaults",
field: "commandHistoryForNewHosts",
parse: (raw) => {
const value = (asJson(raw) as { enableCommandHistory?: unknown } | null)
?.enableCommandHistory;
return typeof value === "boolean" ? value : undefined;
},
},
{ legacyKey: "touch_input_settings", field: "touchInput", parse: asJson },
{
legacyKey: "terminal_image_storage_mode",
field: "imageStorageMode",
parse: asString,
},
{
legacyKey: "terminal_image_local_dir",
field: "imageLocalDir",
parse: asString,
secret: true,
},
{
legacyKey: "terminal_image_host_path",
field: "imageHostPath",
parse: asString,
},
{ legacyKey: "terminal_image_ttl_ms", field: "imageTtlMs", parse: asNumber },
{
legacyKey: "terminal_image_max_count",
field: "imageMaxCount",
parse: asNumber,
},
{
legacyKey: "terminal_image_max_storage_bytes",
field: "imageMaxBytes",
parse: asNumber,
},
];
interface LegacyTerminalHostRow {
id: number;
enable_terminal: number | boolean | null;
enable_terminal_toolbar: number | boolean | null;
enable_command_history: number | boolean | null;
}
/** A column that was never set keeps the old default, on. */
function isOn(value: number | boolean | null): boolean {
return legacyFlag(value, true);
}
export interface SshTerminalSettingsMigrationResult {
moved: string[];
skipped: string[];
hostsMoved: number;
hostsSkipped: number;
}
export async function runSshTerminalSettingsMigration(): Promise<SshTerminalSettingsMigrationResult> {
const result: SshTerminalSettingsMigrationResult = {
moved: [],
skipped: [],
hostsMoved: 0,
hostsSkipped: 0,
};
try {
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
const settingsRepository = createCurrentSettingsRepository();
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const move of ADMIN_MOVES) {
const legacyValue = await settingsRepository.get(move.legacyKey);
if (legacyValue === null || legacyValue === "") continue;
const existing = await pluginSettingsRepository.get(
PLUGIN_ID,
"admin",
null,
move.field,
);
if (existing && existing.value !== null) {
result.skipped.push(move.legacyKey);
continue;
}
const parsed = move.parse(legacyValue);
if (parsed === undefined) {
result.skipped.push(move.legacyKey);
continue;
}
const stored = move.secret
? await encryptSystemSecret(String(parsed))
: parsed;
await pluginSettingsRepository.set(
PLUGIN_ID,
"admin",
null,
move.field,
JSON.stringify(stored),
!!move.secret,
);
result.moved.push(move.legacyKey);
}
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring these columns.
const rows = await selectLegacyRows<LegacyTerminalHostRow>(sql`
SELECT id, enable_terminal, enable_terminal_toolbar, enable_command_history
FROM ssh_data
WHERE enable_terminal = false
OR enable_terminal_toolbar = false
OR enable_command_history = false
`);
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
PLUGIN_ID,
"host",
scopeId,
"enableTerminal",
);
if (existing && existing.value !== null) {
result.hostsSkipped++;
continue;
}
const values: Record<string, boolean> = {
enableTerminal: isOn(row.enable_terminal),
enableTerminalToolbar: isOn(row.enable_terminal_toolbar),
enableCommandHistory: isOn(row.enable_command_history),
};
for (const [key, value] of Object.entries(values)) {
await pluginSettingsRepository.set(
PLUGIN_ID,
"host",
scopeId,
key,
JSON.stringify(value),
);
}
result.hostsMoved++;
}
if (result.moved.length > 0 || result.hostsMoved > 0) {
databaseLogger.info(
`Moved ${result.moved.length} terminal setting(s) and ${result.hostsMoved} host(s) into plugin settings`,
{
operation: "ssh_terminal_settings_migration",
moved: result.moved.join(", "),
hostsMoved: result.hostsMoved,
},
);
}
} catch (error) {
// A failed migration must not stop the backend. The terminal falls back
// to its defaults, which is a visible, recoverable state.
databaseLogger.warn("SSH terminal settings migration failed", {
operation: "ssh_terminal_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -0,0 +1,110 @@
/**
* Moves the 2.8 Step CA configuration into the step-ca plugin.
*
* The CA URL, root fingerprint, OIDC provisioner and private endpoint
* allowlist lived in core `settings` rows. They become the plugin's admin
* settings. An install that had a CA configured keeps sending the pre-2.9
* redirect URI (/host/step-ca-callback), because that is the one registered
* with its identity provider: the plugin's legacyCallback setting is turned
* on.
*
* Idempotent: a value already present in plugin_settings is never
* overwritten. The old rows stay for one release so a downgrade still works.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { parseNotificationAllowlist } from "../utils/notification-egress.js";
const PLUGIN_ID = "step-ca";
const MOVES: { legacyKey: string; field: string }[] = [
{ legacyKey: "step_ca_url", field: "caUrl" },
{ legacyKey: "step_ca_fingerprint", field: "fingerprint" },
{ legacyKey: "step_ca_provisioner", field: "provisioner" },
];
const LEGACY_ALLOWLIST_KEY = "step_ca_private_endpoint_allowlist";
export interface StepCaSettingsMigrationResult {
moved: string[];
legacyCallback: boolean;
}
export async function runStepCaSettingsMigration(): Promise<StepCaSettingsMigrationResult> {
const result: StepCaSettingsMigrationResult = {
moved: [],
legacyCallback: false,
};
try {
// plugin_settings has a foreign key to plugins: nothing to move into yet.
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
const settings = createCurrentSettingsRepository();
const pluginSettings = createCurrentPluginSettingsRepository();
const writeIfUnset = async (field: string, value: unknown) => {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) return false;
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
field,
JSON.stringify(value),
);
return true;
};
for (const move of MOVES) {
const value = (await settings.get(move.legacyKey))?.trim();
if (!value) continue;
if (await writeIfUnset(move.field, value)) {
result.moved.push(move.legacyKey);
}
}
const hosts = parseNotificationAllowlist(
await settings.get(LEGACY_ALLOWLIST_KEY),
);
if (
hosts.length > 0 &&
(await writeIfUnset("privateEndpoints", hosts.join(", ")))
) {
result.moved.push(LEGACY_ALLOWLIST_KEY);
}
if ((await settings.get("step_ca_url"))?.trim()) {
result.legacyCallback = await writeIfUnset("legacyCallback", true);
}
if (result.moved.length > 0) {
databaseLogger.info(
`Moved ${result.moved.length} Step CA setting(s) into plugin settings`,
{
operation: "step_ca_settings_migration",
moved: result.moved.join(", "),
},
);
}
} catch (error) {
// Must not stop the backend: the plugin then reports "not configured".
databaseLogger.warn("Step CA settings migration failed", {
operation: "step_ca_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -0,0 +1,105 @@
/**
* Moves the Tailscale API key and base URL out of core settings.
*
* Both lived in the core `settings` table, written by a core route that existed
* only to serve one plugin. A6 gives plugins their own settings, so they belong
* in plugin_settings under the tailscale plugin.
*
* The key was stored in plaintext, and the route that read it back masked only
* its tail while being open to any authenticated user. Landing it as a secret
* field encrypts it at rest, so this migration is a fix as much as a move.
*
* Idempotent in two ways: it stops when a value is already present in the new
* home, and encryptSystemSecret returns an already-encrypted value unchanged,
* so a second pass can neither duplicate nor double-encrypt.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
const PLUGIN_ID = "tailscale";
/** Legacy core key -> the field the plugin now declares. */
const MOVES: { legacyKey: string; field: string; secret: boolean }[] = [
{ legacyKey: "tailscale_api_key", field: "apiKey", secret: true },
{ legacyKey: "tailscale_api_base_url", field: "apiBaseUrl", secret: false },
];
export interface TailscaleSettingsMigrationResult {
moved: string[];
skipped: string[];
}
export async function runTailscaleSettingsMigration(): Promise<TailscaleSettingsMigrationResult> {
const moved: string[] = [];
const skipped: string[] = [];
try {
// The FK to plugins means there is nothing to migrate into until the
// plugin has been seeded. Not an error: a build without it just has no
// tailscale rows.
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return { moved, skipped };
const settingsRepository = createCurrentSettingsRepository();
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const move of MOVES) {
const legacyValue = await settingsRepository.get(move.legacyKey);
if (legacyValue === null || legacyValue === "") {
continue;
}
const existing = await pluginSettingsRepository.get(
PLUGIN_ID,
"admin",
null,
move.field,
);
if (existing && existing.value !== null) {
// Already moved. Drop the stale copy rather than leaving a plaintext
// key behind for a reader that predates this change.
await settingsRepository.delete(move.legacyKey);
skipped.push(move.legacyKey);
continue;
}
const stored = move.secret
? await encryptSystemSecret(legacyValue)
: legacyValue;
await pluginSettingsRepository.set(
PLUGIN_ID,
"admin",
null,
move.field,
JSON.stringify(stored),
move.secret,
);
await settingsRepository.delete(move.legacyKey);
moved.push(move.legacyKey);
}
if (moved.length > 0) {
databaseLogger.info(
`Moved ${moved.length} Tailscale setting(s) into plugin settings`,
{ operation: "tailscale_settings_migration", moved: moved.join(", ") },
);
}
} catch (error) {
// A failed migration must not stop the backend. The plugin falls back to
// "no key configured", which is a visible, recoverable state.
databaseLogger.warn("Tailscale settings migration failed", {
operation: "tailscale_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return { moved, skipped };
}
@@ -0,0 +1,80 @@
/**
* Reseals 2.8 Termix ID certificate authority keys for the termix-identity
* plugin.
*
* Core encrypted termix_identity_ca.private_key with the owner's data key.
* The plugin adopts that table as p_termix_identity_ca and reads the key with
* ctx.secrets.unseal, which only opens installation-key encryption. Each row
* still under a data key is decrypted and written back with
* encryptSystemSecret, what ctx.secrets.seal writes.
*
* Does nothing until the plugin's table exists. A row whose owner's data key
* cannot be opened is skipped and tried again next time. Idempotent.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { LazyFieldEncryption } from "../utils/lazy-field-encryption.js";
import {
encryptSystemSecret,
isSystemEncrypted,
} from "../utils/system-secret-crypto.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const CA_TABLE = "p_termix_identity_ca";
interface CaRow {
id: number;
user_id: string;
private_key: string | null;
}
export async function runTermixIdentityCaMigration(): Promise<number> {
let rows: CaRow[];
try {
rows = await selectRows<CaRow>(
sql`SELECT id, user_id, private_key FROM ${sql.identifier(CA_TABLE)}`,
);
} catch {
// The plugin has not adopted the table yet.
return 0;
}
let resealed = 0;
for (const row of rows) {
if (!row.private_key || isSystemEncrypted(row.private_key)) continue;
try {
const dek = DataCrypto.getUserDataKey(row.user_id);
if (!dek) continue;
const plain = LazyFieldEncryption.safeGetFieldValue(
row.private_key,
dek,
String(row.id),
"privateKey",
);
if (!plain) continue;
await runStatement(
sql`UPDATE ${sql.identifier(CA_TABLE)} SET private_key = ${await encryptSystemSecret(plain)} WHERE id = ${row.id}`,
);
resealed++;
} catch (error) {
databaseLogger.warn("Termix ID CA reseal failed for a row", {
operation: "termix_identity_ca_migration",
caId: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
if (resealed > 0) {
databaseLogger.info("Resealed Termix ID CA keys for the plugin", {
operation: "termix_identity_ca_migration",
resealed,
});
}
return resealed;
}
@@ -0,0 +1,83 @@
/**
* Moves the tmux monitor host column into the tmux-monitor plugin's host-scope
* settings, before the column is dropped from ssh_data.
*
* Idempotent: skips a host that already has a tmux-monitor settings row, so it
* is safe to run on every boot. Lossless: reads straight off the still-present
* ssh_data column, so it must run and finish before that column is ever
* dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface TmuxMonitorSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyTmuxMonitorRow {
id: number;
enable_tmux_monitor: number | boolean | null;
}
export async function runTmuxMonitorSettingsMigration(): Promise<TmuxMonitorSettingsMigrationResult> {
const result: TmuxMonitorSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring this column. The plugin's enable switch defaults to
// off, matching the old column's default, so only hosts that turned it
// on need a row.
const rows = await selectLegacyRows<LegacyTmuxMonitorRow>(sql`
SELECT id, enable_tmux_monitor
FROM ssh_data
WHERE enable_tmux_monitor = true
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"tmux-monitor",
"host",
scopeId,
"enableTmuxMonitor",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"tmux-monitor",
"host",
scopeId,
"enableTmuxMonitor",
JSON.stringify(!!row.enable_tmux_monitor),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved tmux monitor settings for ${result.moved} host(s) into plugin settings`,
{ operation: "tmux_monitor_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep the
// monitor off, the plugin's default.
databaseLogger.warn("Tmux monitor settings migration failed", {
operation: "tmux_monitor_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Moves 2.8 TOTP enrolments into the totp plugin.
*
* 1. Every user_second_factors row A8 wrote for core's TOTP ("core"/"totp")
* becomes the plugin's ("totp"/"totp"), and every user with
* users.totp_enabled gets one. This runs whether or not the plugin is
* enabled, so those users stay behind a second factor: with the plugin off
* the login fails closed instead of skipping it.
* 2. Once the plugin's p_totp_enrollments table exists, each enabled user's
* secret and backup codes are decrypted with their data key and written
* there sealed with the installation key (what ctx.secrets.seal writes),
* then the users columns are cleared.
*
* The columns stay in the database until 3.0.0, since core's drizzle
* migrations run before this does. A user whose data key cannot be opened
* at boot is skipped and tried again on the next one. Idempotent.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { LazyFieldEncryption } from "../utils/lazy-field-encryption.js";
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "totp";
const FACTOR_ID = "totp";
const ENROLLMENTS_TABLE = "p_totp_enrollments";
export interface TotpMigrationResult {
/** user_second_factors rows written or moved to the plugin. */
factors: number;
/** Secrets moved into the plugin's table. */
secrets: number;
}
interface LegacyTotpUser {
id: string;
totp_enabled: unknown;
totp_secret: string | null;
totp_backup_codes: string | null;
}
async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[] | null> {
try {
return await selectRows<T>(query);
} catch {
// A fresh install never had the column or the table.
return null;
}
}
function isOn(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "t";
}
function readBackupCodes(
stored: string | null,
dek: Buffer,
userId: string,
): string[] {
if (!stored) return [];
const raw = LazyFieldEncryption.safeGetFieldValue(
stored,
dek,
userId,
"totpBackupCodes",
);
try {
const codes = raw ? JSON.parse(raw) : [];
return Array.isArray(codes)
? codes.filter((code): code is string => typeof code === "string")
: [];
} catch {
return [];
}
}
async function factorUsers(pluginId: string): Promise<Set<string>> {
const rows = await selectRows<{ user_id: string }>(
sql`SELECT user_id FROM user_second_factors WHERE plugin_id = ${pluginId} AND factor_id = ${FACTOR_ID}`,
);
return new Set(rows.map((row) => row.user_id));
}
/**
* Pass a user id to move only that user, as a password login does once it
* has opened a data key the boot run could not.
*/
export async function runTotpMigration(
onlyUserId?: string,
): Promise<TotpMigrationResult> {
const result: TotpMigrationResult = { factors: 0, secrets: 0 };
try {
const enrolled = await factorUsers(PLUGIN_ID);
for (const userId of await factorUsers("core")) {
if (enrolled.has(userId)) {
await runStatement(
sql`DELETE FROM user_second_factors WHERE user_id = ${userId} AND plugin_id = 'core' AND factor_id = ${FACTOR_ID}`,
);
} else {
await runStatement(
sql`UPDATE user_second_factors SET plugin_id = ${PLUGIN_ID} WHERE user_id = ${userId} AND plugin_id = 'core' AND factor_id = ${FACTOR_ID}`,
);
enrolled.add(userId);
result.factors++;
}
}
const users = (
(await tryRows<LegacyTotpUser>(
onlyUserId
? sql`SELECT id, totp_enabled, totp_secret, totp_backup_codes FROM users WHERE id = ${onlyUserId}`
: sql`SELECT id, totp_enabled, totp_secret, totp_backup_codes FROM users`,
)) ?? []
).filter((user) => isOn(user.totp_enabled));
for (const user of users) {
if (enrolled.has(user.id)) continue;
await runStatement(
sql`INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES (${user.id}, ${PLUGIN_ID}, ${FACTOR_ID})`,
);
enrolled.add(user.id);
result.factors++;
}
const moved = await tryRows<{ user_id: string }>(
sql`SELECT user_id FROM ${sql.identifier(ENROLLMENTS_TABLE)}`,
);
if (moved) {
const done = new Set(moved.map((row) => row.user_id));
for (const user of users) {
if (done.has(user.id) || !user.totp_secret) continue;
try {
const dek = DataCrypto.getUserDataKey(user.id);
if (!dek) continue;
const secret = LazyFieldEncryption.safeGetFieldValue(
user.totp_secret,
dek,
user.id,
"totpSecret",
);
if (!secret) continue;
const codes = readBackupCodes(user.totp_backup_codes, dek, user.id);
await runStatement(
sql`INSERT INTO ${sql.identifier(ENROLLMENTS_TABLE)} (user_id, secret, backup_codes) VALUES (${user.id}, ${await encryptSystemSecret(secret)}, ${await encryptSystemSecret(JSON.stringify(codes))})`,
);
await runStatement(
sql`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_backup_codes = NULL WHERE id = ${user.id}`,
);
result.secrets++;
} catch (error) {
databaseLogger.warn("TOTP migration failed for a user", {
operation: "totp_migration",
userId: user.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
} catch (error) {
databaseLogger.error("TOTP migration failed", error, {
operation: "totp_migration",
});
}
if (result.factors > 0 || result.secrets > 0) {
databaseLogger.info("Moved TOTP enrolment into the totp plugin", {
operation: "totp_migration",
...result,
});
}
return result;
}
@@ -0,0 +1,101 @@
/**
* Moves the two tunnel host columns into the tunnels plugin's host-scope
* settings, before those columns are dropped from ssh_data.
*
* Idempotent: skips a host that already has a tunnels settings row, so it is
* safe to run on every boot. Lossless: reads straight off the still-present
* ssh_data columns, so it must run and finish before those columns are ever
* dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface TunnelsSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyTunnelRow {
id: number;
enable_tunnel: number | boolean | null;
tunnel_connections: string | null;
}
function parseConnections(raw: string | null): unknown[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export async function runTunnelsSettingsMigration(): Promise<TunnelsSettingsMigrationResult> {
const result: TunnelsSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring these columns. The plugin's enable switch defaults to
// off, so every host that had tunnels on (the old column default) or
// any saved tunnel has to be copied, not just the non-default ones.
const rows = await selectLegacyRows<LegacyTunnelRow>(sql`
SELECT id, enable_tunnel, tunnel_connections
FROM ssh_data
WHERE enable_tunnel = true OR tunnel_connections IS NOT NULL
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"tunnels",
"host",
scopeId,
"enableTunnel",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"tunnels",
"host",
scopeId,
"enableTunnel",
JSON.stringify(!!row.enable_tunnel),
);
await pluginSettingsRepository.set(
"tunnels",
"host",
scopeId,
"tunnelConnections",
JSON.stringify(parseConnections(row.tunnel_connections)),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved tunnel settings for ${result.moved} host(s) into plugin settings`,
{ operation: "tunnels_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep
// whatever tunnel settings they already had (none, on a fresh run).
databaseLogger.warn("Tunnel settings migration failed", {
operation: "tunnels_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -0,0 +1,149 @@
/**
* Moves each host's Vault signer profile out of ssh_data and into the vault
* plugin's profileId host setting.
*
* An install that used Vault keeps sending the pre-2.9 redirect URI
* (/vault/oidc/callback), because that is the one its Vault OIDC roles
* allow: the plugin's legacyCallback setting is turned on.
*
* The column stays in the database, unused: core's drizzle migrations run
* before this does, so dropping it there would lose the data first.
*
* Idempotent: a host or setting that already has a value is skipped, so this
* is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
import { selectRows } from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "vault";
const SETTING_KEY = "profileId";
interface LegacyHostRow {
id: number;
vault_profile_id: number | string | null;
}
export interface VaultSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
legacyCallback: boolean;
}
async function legacyRows(): Promise<LegacyHostRow[]> {
try {
// Raw SQL: schema.ts no longer declares vault_profile_id.
return await selectRows<LegacyHostRow>(
sql`SELECT id, vault_profile_id FROM ssh_data WHERE vault_profile_id IS NOT NULL`,
);
} catch {
// A fresh install never had the column.
return [];
}
}
/** Whether 2.8 had any Vault profile, before or after the plugin adopted them. */
async function hadProfiles(): Promise<boolean> {
for (const table of ["vault_profiles", "p_vault_profiles"]) {
try {
const rows = await selectRows<unknown>(
sql`SELECT 1 FROM ${sql.identifier(table)} LIMIT 1`,
);
if (rows.length > 0) return true;
} catch {
// table not there
}
}
return false;
}
export async function runVaultSettingsMigration(): Promise<VaultSettingsMigrationResult> {
const result: VaultSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
legacyCallback: false,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
const rows = await legacyRows();
for (const row of rows) {
const profileId = Number(row.vault_profile_id);
if (!Number.isInteger(profileId) || profileId <= 0) continue;
const scopeId = String(row.id);
try {
const existing = await pluginSettings.get(
PLUGIN_ID,
"host",
scopeId,
SETTING_KEY,
);
if (existing) {
result.hostsSkipped++;
continue;
}
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
SETTING_KEY,
JSON.stringify(profileId),
);
result.hostsMoved++;
} catch (error) {
databaseLogger.warn("Vault host settings migration failed for a host", {
operation: "vault_settings_migration",
hostId: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Decided once, on the first boot with the plugin: written either way, so
// profiles created on a fresh install later never turn it on.
try {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
"legacyCallback",
);
if (!existing || existing.value === null) {
result.legacyCallback = rows.length > 0 || (await hadProfiles());
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
"legacyCallback",
JSON.stringify(result.legacyCallback),
);
}
} catch (error) {
databaseLogger.warn("Vault legacy callback migration failed", {
operation: "vault_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
if (result.hostsMoved > 0) {
databaseLogger.info(
`Moved the Vault profile for ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "vault_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,113 @@
/**
* Moves each host's Wake-on-LAN settings out of ssh_data and into the
* wake-on-lan plugin's host settings: mac_address becomes macAddress and
* wol_broadcast_address becomes broadcastAddress.
*
* The columns stay in the database, unused: core's drizzle migrations run
* before this does, so dropping them there would lose the data first.
*
* Idempotent: a host that already has any wake-on-lan row is skipped, so
* this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
import { selectRows } from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "wake-on-lan";
interface LegacyHostRow {
id: number;
mac_address: string | null;
wol_broadcast_address: string | null;
}
/** The plugin's host settings for one legacy row, defaults left out. */
export function wakeOnLanSettingsFromRow(
row: LegacyHostRow,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
if (row.mac_address) values.macAddress = row.mac_address;
if (row.wol_broadcast_address) {
values.broadcastAddress = row.wol_broadcast_address;
}
return values;
}
export interface WakeOnLanSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
}
export async function runWakeOnLanSettingsMigration(): Promise<WakeOnLanSettingsMigrationResult> {
const result: WakeOnLanSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
let rows: LegacyHostRow[];
try {
// Raw SQL: schema.ts no longer declares these columns.
rows = await selectRows<LegacyHostRow>(
sql`SELECT id, mac_address, wol_broadcast_address FROM ssh_data`,
);
} catch {
// A fresh install never had the columns.
return result;
}
for (const row of rows) {
const values = wakeOnLanSettingsFromRow(row);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
try {
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
key,
JSON.stringify(value),
);
}
result.hostsMoved++;
} catch (error) {
databaseLogger.warn(
"Wake-on-LAN host settings migration failed for a host",
{
operation: "wake_on_lan_settings_migration",
hostId: row.id,
error: error instanceof Error ? error.message : String(error),
},
);
}
}
if (result.hostsMoved > 0) {
databaseLogger.info(
`Moved Wake-on-LAN settings for ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "wake_on_lan_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,134 @@
/**
* Moves each host's Warpgate flag out of ssh_data and into the warpgate
* plugin's useWarpgate host setting. Hosts from before 2.7 that still carry
* auth_type "warpgate" get the setting too, and auth type "none", which is
* what that auth type meant.
*
* The column stays in the database, unused: core's drizzle migrations run
* before this does, so dropping it there would lose the data first.
*
* Idempotent: a host that already has the setting is skipped, so this is
* safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "warpgate";
const SETTING_KEY = "useWarpgate";
interface LegacyHostRow {
id: number;
use_warpgate?: number | boolean | string | null;
auth_type: string | null;
}
export function usesWarpgate(row: LegacyHostRow): boolean {
const flag = row.use_warpgate;
return (
flag === true ||
flag === 1 ||
flag === "1" ||
flag === "true" ||
row.auth_type === "warpgate"
);
}
export interface WarpgateSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
authTypesFixed: number;
}
async function legacyRows(): Promise<LegacyHostRow[]> {
try {
// Raw SQL: schema.ts no longer declares use_warpgate.
return await selectRows<LegacyHostRow>(
sql`SELECT id, use_warpgate, auth_type FROM ssh_data`,
);
} catch {
// A fresh install never had the column.
try {
return await selectRows<LegacyHostRow>(
sql`SELECT id, auth_type FROM ssh_data WHERE auth_type = 'warpgate'`,
);
} catch {
return [];
}
}
}
export async function runWarpgateSettingsMigration(): Promise<WarpgateSettingsMigrationResult> {
const result: WarpgateSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
authTypesFixed: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
for (const row of await legacyRows()) {
if (!usesWarpgate(row)) continue;
const scopeId = String(row.id);
try {
const existing = await pluginSettings.get(
PLUGIN_ID,
"host",
scopeId,
SETTING_KEY,
);
if (existing) {
result.hostsSkipped++;
} else {
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
SETTING_KEY,
JSON.stringify(true),
);
result.hostsMoved++;
}
if (row.auth_type === "warpgate") {
await runStatement(
sql`UPDATE ssh_data SET auth_type = 'none' WHERE id = ${row.id}`,
);
result.authTypesFixed++;
}
} catch (error) {
databaseLogger.warn(
"Warpgate host settings migration failed for a host",
{
operation: "warpgate_settings_migration",
hostId: row.id,
error: error instanceof Error ? error.message : String(error),
},
);
}
}
if (result.hostsMoved > 0) {
databaseLogger.info(
`Moved the Warpgate flag for ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "warpgate_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,98 @@
/**
* Moves the two web endpoint host columns into the web-endpoint plugin's
* host-scope settings, before those columns are dropped from ssh_data.
*
* Idempotent: skips a host that already has a web-endpoint settings row, so
* it is safe to run on every boot. Lossless: reads straight off the
* still-present ssh_data columns, so it must run and finish before those
* columns are ever dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface WebEndpointSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyWebEndpointRow {
id: number;
enable_web_ui: number | boolean | null;
web_ui_config: string | null;
}
function parseConfig(raw: string | null): unknown {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
export async function runWebEndpointSettingsMigration(): Promise<WebEndpointSettingsMigrationResult> {
const result: WebEndpointSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring these columns.
const rows = await selectLegacyRows<LegacyWebEndpointRow>(sql`
SELECT id, enable_web_ui, web_ui_config
FROM ssh_data
WHERE enable_web_ui = true OR web_ui_config IS NOT NULL
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"web-endpoint",
"host",
scopeId,
"enableWebUi",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"web-endpoint",
"host",
scopeId,
"enableWebUi",
JSON.stringify(!!row.enable_web_ui),
);
await pluginSettingsRepository.set(
"web-endpoint",
"host",
scopeId,
"webUiConfig",
JSON.stringify(parseConfig(row.web_ui_config)),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved web endpoint settings for ${result.moved} host(s) into plugin settings`,
{ operation: "web_endpoint_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep
// whatever web endpoint settings they already had (none, on a fresh run).
databaseLogger.warn("Web endpoint settings migration failed", {
operation: "web_endpoint_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}