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.
This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-10-14 08:59:57 +08:00
1 parent c044dbde87
commit 9b0af07607
3 files changed
+116 -2

No files matched your search

+21
View File
@@ -1926,6 +1926,23 @@ async function moveRemoteLegacySudoPasswords(remote: {
}
}
async function dropMysqlForeignKeys(
remote: { execute: (query: SQL) => Promise<unknown> },
table: string,
column: string,
): Promise<void> {
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)}`,
);
+6 -2
View File
@@ -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<string, string[]> = {
"telnet_port",
"tunnel_connections",
"use_warpgate",
"vault_profile_id",
"vnc_port",
"web_ui_config",
"wol_broadcast_address",
@@ -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();
});
});