mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
chore: lock the plugin boundary and remove 2.9.0 leftovers
This commit is contained in:
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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user