From 9b0af076074d77c62652129a685e8b0537dff5dc Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 14 Oct 2026 08:59:57 +0800 Subject: [PATCH] fix: drop ssh_data.vault_profile_id so removing the vault plugin keeps hosts writable (#1633) The vault plugin renames vault_profiles to p_vault_profiles, and SQLite rewrites ssh_data's foreign key to follow it. Uninstalling the plugin then dropped p_vault_profiles and left ssh_data pointing at a missing table, so creating, cloning and deleting hosts failed with "no such table: main.p_vault_profiles". Postgres and MySQL refused the uninstall instead. The column was already copied into the vault plugin's host settings by 2.9, so retire it on every engine. The SQLite boot drop also repairs databases already in the broken state; MySQL drops the column's foreign key first. --- src/backend/database/db/index.ts | 21 +++++ src/backend/database/db/retired-columns.ts | 8 +- .../db/vault-profile-foreign-key.test.ts | 89 +++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/backend/tests/database/db/vault-profile-foreign-key.test.ts diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 8210a72c7..e72930e99 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -1926,6 +1926,23 @@ async function moveRemoteLegacySudoPasswords(remote: { } } +async function dropMysqlForeignKeys( + remote: { execute: (query: SQL) => Promise }, + table: string, + column: string, +): Promise { + const keys = resultRows<{ name: string }>( + await remote.execute( + sql`SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ${table} AND COLUMN_NAME = ${column} AND REFERENCED_TABLE_NAME IS NOT NULL`, + ), + ); + for (const key of keys) { + await remote.execute( + sql`ALTER TABLE ${sql.identifier(table)} DROP FOREIGN KEY ${sql.identifier(key.name)}`, + ); + } +} + /** * The columns 2.9.0 left in place without a drizzle migration, so drizzle no * longer knows them. Dropped by hand, only where they still exist. @@ -1961,6 +1978,10 @@ async function dropRemoteRetired( for (const column of columns) { if (!existing.has(`${table}.${column}`)) continue; try { + // Postgres drops a column's foreign keys with it; MySQL refuses. + if (dialect === "mysql") { + await dropMysqlForeignKeys(remote, table, column); + } await remote.execute( sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`, ); diff --git a/src/backend/database/db/retired-columns.ts b/src/backend/database/db/retired-columns.ts index 2f431e3ac..b2784205d 100644 --- a/src/backend/database/db/retired-columns.ts +++ b/src/backend/database/db/retired-columns.ts @@ -5,8 +5,11 @@ * * Kept on purpose: columns a move only reads once the owner's data key opens * at a password login (users.totp_*, the rdp, vnc and telnet logins and - * domain), so a user who never signed in to 2.9 still gets them, and - * ssh_data.vault_profile_id, whose foreign key SQLite cannot drop in place. + * domain), so a user who never signed in to 2.9 still gets them. + * + * ssh_data.vault_profile_id goes because its foreign key follows the vault + * plugin's rename of vault_profiles: once the plugin is uninstalled, SQLite + * refuses every write to ssh_data and the other engines refuse the uninstall. */ /** Columns drizzle never knew were removed, dropped by hand on every engine. */ @@ -52,6 +55,7 @@ export const REMOTE_RETIRED_COLUMNS: Record = { "telnet_port", "tunnel_connections", "use_warpgate", + "vault_profile_id", "vnc_port", "web_ui_config", "wol_broadcast_address", diff --git a/src/backend/tests/database/db/vault-profile-foreign-key.test.ts b/src/backend/tests/database/db/vault-profile-foreign-key.test.ts new file mode 100644 index 000000000..7828b0a97 --- /dev/null +++ b/src/backend/tests/database/db/vault-profile-foreign-key.test.ts @@ -0,0 +1,89 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ssh_data.vault_profile_id points at vault_profiles. The vault plugin renames +// that table to p_vault_profiles, which SQLite follows into ssh_data's foreign +// key, so uninstalling the plugin used to leave every host write failing with +// "no such table: main.p_vault_profiles". +describe("ssh_data and the vault plugin's tables", () => { + let dataDir: string; + + beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-vault-fk-")); + vi.resetModules(); + vi.stubEnv("DATA_DIR", dataDir); + vi.stubEnv("DB_FILE_ENCRYPTION", "false"); + vi.stubEnv("ALLOW_EMPTY_DATA_DIR", "true"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + function seed(afterAdoption: string): void { + const seed = new Database(":memory:"); + seed.exec( + fs.readFileSync( + new URL("../../fixtures/sqlite-2.8-schema.sql", import.meta.url), + "utf8", + ), + ); + seed.exec(` + CREATE TABLE plugin_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + migration_id TEXT NOT NULL, + checksum TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (plugin_id, migration_id), + FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE + ); + INSERT INTO users (id, username, password_hash) + VALUES ('owner', 'alice', 'hash'); + INSERT INTO ssh_data (id, user_id, ip, port, username, auth_type) + VALUES (1, 'owner', '192.0.2.1', 22, 'root', 'password'); + ALTER TABLE vault_profiles RENAME TO p_vault_profiles; + ALTER TABLE vault_tokens RENAME TO p_vault_tokens; + ${afterAdoption} + `); + fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize()); + seed.close(); + } + + async function boot() { + const db = await import("../../../database/db/index.js"); + await db.initializeDatabase(); + return db.getSqlite(); + } + + function writeHosts(sqlite: Database.Database): void { + sqlite + .prepare( + "INSERT INTO ssh_data (user_id, ip, port, username, auth_type) VALUES ('owner', '192.0.2.2', 22, 'root', 'password')", + ) + .run(); + sqlite.prepare("DELETE FROM ssh_data WHERE id = 1").run(); + } + + it("repairs a database the plugin was already uninstalled from", async () => { + seed("DROP TABLE p_vault_tokens; DROP TABLE p_vault_profiles;"); + const sqlite = await boot(); + + expect(() => writeHosts(sqlite)).not.toThrow(); + }); + + it("leaves nothing in ssh_data pointing at the plugin", async () => { + seed(""); + const sqlite = await boot(); + + expect( + sqlite.prepare("PRAGMA foreign_key_list(ssh_data)").all(), + ).not.toContainEqual(expect.objectContaining({ table: "p_vault_profiles" })); + sqlite.exec("DROP TABLE p_vault_tokens; DROP TABLE p_vault_profiles;"); + expect(() => writeHosts(sqlite)).not.toThrow(); + }); +});