mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-24 06:58:15 +00:00
refuse to start with an empty database when data exists elsewhere (#1118)
When the data directory holds no database, startup treats it as a first run and silently creates an empty one. A deployment that loses DATA_DIR — an .env file the service no longer loads, a volume that did not mount — lands in exactly that state, so the user is asked to register an admin account again while the real database sits untouched one directory over. It is indistinguishable from the upgrade having deleted everything. Check the known data locations before creating a new database and refuse to start when one of them already holds a database, naming both directories. ALLOW_EMPTY_DATA_DIR=true starts anyway for anyone deliberately starting over. This matches how a failed decryption already behaves: it throws rather than falling back to an empty database. Closes Termix-SSH/Support#1006
This commit is contained in:
@@ -8,6 +8,10 @@ import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js"
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { DatabaseMigration } from "../../utils/database-migration.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import {
|
||||
assertDataDirIsNotMisconfigured,
|
||||
DataDirMisconfiguredError,
|
||||
} from "../../utils/data-dir-guard.js";
|
||||
import { getDefaultGuacdUrl } from "../../utils/guacd-config.js";
|
||||
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
@@ -104,11 +108,16 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assertDataDirIsNotMisconfigured(dataDir);
|
||||
memoryDatabase = new Database(":memory:");
|
||||
isNewDatabase = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Not a decryption problem: the database is fine, we are pointed at the
|
||||
// wrong directory. Surface that message as-is.
|
||||
if (error instanceof DataDirMisconfiguredError) throw error;
|
||||
|
||||
databaseLogger.error("Failed to initialize memory database", error, {
|
||||
operation: "db_memory_init_failed",
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
@@ -145,6 +154,7 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assertDataDirIsNotMisconfigured(dataDir);
|
||||
memoryDatabase = new Database(":memory:");
|
||||
isNewDatabase = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import {
|
||||
ALLOW_EMPTY_DATA_DIR_ENV,
|
||||
assertDataDirIsNotMisconfigured,
|
||||
DataDirMisconfiguredError,
|
||||
findDatabaseOutsideDataDir,
|
||||
} from "../../utils/data-dir-guard.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-datadir-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Writes a plain (unencrypted) database file into `dir`. */
|
||||
function writePlainDatabase(dir: string, size = 4096): string {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const dbPath = path.join(dir, "db.sqlite");
|
||||
fs.writeFileSync(dbPath, Buffer.alloc(size, 1));
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("findDatabaseOutsideDataDir", () => {
|
||||
it("returns null on a genuinely fresh install", () => {
|
||||
const cwd = makeTempDir();
|
||||
const dataDir = path.join(cwd, "db", "data");
|
||||
|
||||
expect(findDatabaseOutsideDataDir(dataDir, cwd)).toBeNull();
|
||||
});
|
||||
|
||||
it("finds a database left in the legacy data directory", () => {
|
||||
const cwd = makeTempDir();
|
||||
const legacyDir = path.join(cwd, "data");
|
||||
writePlainDatabase(legacyDir);
|
||||
|
||||
expect(findDatabaseOutsideDataDir(path.join(cwd, "db", "data"), cwd)).toBe(
|
||||
legacyDir,
|
||||
);
|
||||
});
|
||||
|
||||
it("finds a database under the default directory when DATA_DIR points elsewhere", () => {
|
||||
const cwd = makeTempDir();
|
||||
const defaultDir = path.join(cwd, "db", "data");
|
||||
writePlainDatabase(defaultDir);
|
||||
|
||||
expect(findDatabaseOutsideDataDir("/mnt/unmounted-volume", cwd)).toBe(
|
||||
defaultDir,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores the configured data directory itself", () => {
|
||||
const cwd = makeTempDir();
|
||||
const dataDir = path.join(cwd, "data");
|
||||
writePlainDatabase(dataDir);
|
||||
|
||||
expect(findDatabaseOutsideDataDir(dataDir, cwd)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a zero-length database file", () => {
|
||||
const cwd = makeTempDir();
|
||||
writePlainDatabase(path.join(cwd, "data"), 0);
|
||||
|
||||
expect(findDatabaseOutsideDataDir(path.join(cwd, "db", "data"), cwd)).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertDataDirIsNotMisconfigured", () => {
|
||||
it("passes when no database exists anywhere else", () => {
|
||||
const cwd = makeTempDir();
|
||||
|
||||
expect(() =>
|
||||
assertDataDirIsNotMisconfigured(path.join(cwd, "db", "data"), {}, cwd),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("refuses to start and names both directories", () => {
|
||||
const cwd = makeTempDir();
|
||||
const legacyDir = path.join(cwd, "data");
|
||||
writePlainDatabase(legacyDir);
|
||||
const dataDir = path.join(cwd, "db", "data");
|
||||
|
||||
expect(() => assertDataDirIsNotMisconfigured(dataDir, {}, cwd)).toThrow(
|
||||
DataDirMisconfiguredError,
|
||||
);
|
||||
expect(() => assertDataDirIsNotMisconfigured(dataDir, {}, cwd)).toThrow(
|
||||
new RegExp(`${legacyDir}`),
|
||||
);
|
||||
expect(() => assertDataDirIsNotMisconfigured(dataDir, {}, cwd)).toThrow(
|
||||
new RegExp(`${dataDir}`),
|
||||
);
|
||||
});
|
||||
|
||||
it("can be overridden to start with a new database", () => {
|
||||
const cwd = makeTempDir();
|
||||
writePlainDatabase(path.join(cwd, "data"));
|
||||
|
||||
for (const value of ["true", "1", "YES", "on"]) {
|
||||
expect(() =>
|
||||
assertDataDirIsNotMisconfigured(
|
||||
path.join(cwd, "db", "data"),
|
||||
{ [ALLOW_EMPTY_DATA_DIR_ENV]: value },
|
||||
cwd,
|
||||
),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("still refuses when the override is not a truthy value", () => {
|
||||
const cwd = makeTempDir();
|
||||
writePlainDatabase(path.join(cwd, "data"));
|
||||
|
||||
expect(() =>
|
||||
assertDataDirIsNotMisconfigured(
|
||||
path.join(cwd, "db", "data"),
|
||||
{ [ALLOW_EMPTY_DATA_DIR_ENV]: "false" },
|
||||
cwd,
|
||||
),
|
||||
).toThrow(DataDirMisconfiguredError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { DatabaseFileEncryption } from "./database-file-encryption.js";
|
||||
|
||||
export const ALLOW_EMPTY_DATA_DIR_ENV = "ALLOW_EMPTY_DATA_DIR";
|
||||
|
||||
/** Thrown when the data directory looks misconfigured rather than empty. */
|
||||
export class DataDirMisconfiguredError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "DataDirMisconfiguredError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Directories Termix has shipped or documented as a data location. A deployment
|
||||
* that loses DATA_DIR — an unloaded .env file, an unmounted volume — falls back
|
||||
* to the default and finds an empty directory, which is indistinguishable from a
|
||||
* first run. Checking these tells the two apart.
|
||||
*/
|
||||
const KNOWN_DATA_DIRS = ["db/data", "data", "/app/data"];
|
||||
|
||||
const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
|
||||
|
||||
function hasDatabaseFile(dir: string): boolean {
|
||||
const dbPath = path.join(dir, "db.sqlite");
|
||||
|
||||
if (DatabaseFileEncryption.isEncryptedDatabaseFile(`${dbPath}.encrypted`)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.statSync(dbPath).size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks for a database outside the configured data directory. Returns the
|
||||
* directory holding it, or null when this really is a fresh install.
|
||||
*/
|
||||
export function findDatabaseOutsideDataDir(
|
||||
dataDir: string,
|
||||
cwd: string = process.cwd(),
|
||||
): string | null {
|
||||
const resolvedDataDir = path.resolve(dataDir);
|
||||
|
||||
for (const candidate of KNOWN_DATA_DIRS) {
|
||||
const dir = path.resolve(cwd, candidate);
|
||||
if (dir === resolvedDataDir) continue;
|
||||
if (hasDatabaseFile(dir)) return dir;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isEmptyDataDirAllowed(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return TRUE_VALUES.has(
|
||||
env[ALLOW_EMPTY_DATA_DIR_ENV]?.trim().toLowerCase() ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses to start with a blank database when an existing one sits elsewhere.
|
||||
* Creating a fresh database in that state looks exactly like data loss: the user
|
||||
* is asked to register an admin account again while their real data is intact
|
||||
* one directory over.
|
||||
*/
|
||||
export function assertDataDirIsNotMisconfigured(
|
||||
dataDir: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
cwd: string = process.cwd(),
|
||||
): void {
|
||||
if (isEmptyDataDirAllowed(env)) return;
|
||||
|
||||
const existing = findDatabaseOutsideDataDir(dataDir, cwd);
|
||||
if (!existing) return;
|
||||
|
||||
throw new DataDirMisconfiguredError(
|
||||
`No database found in DATA_DIR (${path.resolve(dataDir)}), but an existing database is present in ${existing}. ` +
|
||||
`Starting here would create an empty database and hide your data. ` +
|
||||
`Set DATA_DIR=${existing} (check that your .env file is loaded and any volume is mounted), ` +
|
||||
`or set ${ALLOW_EMPTY_DATA_DIR_ENV}=true to start with a new database anyway.`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user