test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught

This commit is contained in:
LukeGus committed 2026-09-25 09:58:45 -05:00
1 parent 2ee9b3592c
commit f779d6a08e
37 files changed
+5184 -377

No files matched your search

+48 -7
View File
@@ -128,9 +128,10 @@ Plugins own their tables. A plugin declares them with `defineTable()` from
`mysql` under `migrations/<dialect>/NNNN_name.sql`. Table names are prefixed
`p_<id with - as _>_`, which `defineTable` adds. `ctx.kv` is for small
key/value state, and `ctx.files.dataDir()` (**B13**, needs `files:own`) is a
per-plugin folder under `DATA_DIR`, created on first call, for state too
large for `ctx.kv` (session recordings, uploaded files); a plugin lays out
its own subdirectories underneath it. Core `schema.ts` ends up holding only
per-plugin folder, `<DATA_DIR>/plugin-data/<id>`, created on first call, for
state too large for `ctx.kv` (session recordings, uploaded files); a plugin
lays out its own subdirectories underneath it. It is kept apart from
`<DATA_DIR>/plugins/`, where the loader looks for user plugins (**D4**). Core `schema.ts` ends up holding only
core tables.
**One table object, three DDL emitters.** The sqlite-core definitions already
@@ -897,7 +898,10 @@ importing the old repository directly. **B14** added `enabledFor(hostId)`
exists: remote desktop records an RDP or Telnet session only when the plugin
is running and the host's switch is on, and writes the row as the session's
user. Recording files live
under `ctx.files.dataDir()/session_logs/<user>/<session>.cast`. Retention
under `ctx.files.dataDir()/session_logs/<user>/<session>.cast`. **D4**:
`createFinished` moves a file written elsewhere (guacd's, in remote desktop's
recordings folder) to `ctx.files.dataDir()/session_recordings/<folder>/`,
since playback and retention only read inside the data folder. Retention
(`retentionDays`, an admin setting) runs a sweep at boot and every 24 hours.
**B15**'s docker plugin provides `docker.containers` (`listContainers`,
@@ -1303,7 +1307,7 @@ base types and branches on no plugin type name.
matches, else the one in the plugin's data folder, else a download pinned to
the release's SHA-256. `opkssh login` runs through `ctx.process.run` and is
killed on cancel, close and deactivate. The config lives at
`<DATA_DIR>/plugins/opkssh/config.yml`; `redirect_uris` there are still the
`<DATA_DIR>/plugin-data/opkssh/config.yml`; `redirect_uris` there are still the
localhost listener candidates only, and the public callback still goes to
OPKSSH as `--remote-redirect-uri`.
- The chooser and callback pages are public plugin routes
@@ -1743,6 +1747,43 @@ A host switch whose 2.8 column defaulted to on declares `enableDefault: true`
(file manager, terminal), because a copy only writes the hosts that changed
it, and every other host has to keep reading on.
**Before activate, not after.** A copy that sets how long a plugin keeps
history goes in `src/backend/upgrade/plugin-data-moves.ts`
(`runPluginDataMoves`), which the loader runs once a plugin's migrations
applied and before its `activate`. A plugin that prunes on activate would
otherwise prune by its own default first: session recording's retention
(and its 2.8 files) and host metrics' history retention move there.
#### Proving it (D4)
- **The fixture.** `src/backend/tests/fixtures/upgrade/` is a 2.8 install:
`db.sqlite` in 2.8's exact schema (`sqlite-2.8-schema.sql`) with rows in
every table `LEGACY_TABLE_OWNERS` lists, every host flag and 2.8 auth type,
TOTP, passkey, SSO and LDAP users, custom roles and the moved settings keys;
`fixture.env` with the keys it was encrypted with; and `files/`, the
recording files its rows point at. `rows.ts` is the source, and
`npx tsx scripts/build-upgrade-fixture.ts` rewrites the rest, encrypting
the columns 2.8 encrypted the way 2.8 did. `upgrade-fixture-data.test.ts`
fails when a legacy table has no rows.
- **The test.** `src/backend/tests/plugins/upgrade-fixture.test.ts` boots it
through `bootCore` (the starter's order: backup, database, core migrations,
plugins, data copies) against `dist/plugins`, checks every row, setting,
secret, role, login and sync type through plugin routes, settings and ctx,
then boots the same data directory again and checks that nothing changed.
- **Postgres and MySQL.** `scripts/lib/upgrade-sql.mjs` prints every statement
the upgrade runs on each engine, snapshotted in
`fixtures/upgrade/upgrade-{postgres,mysql}.sql` by `scripts/upgrade-sql.test.ts`,
which also refuses a `DROP TABLE` of a legacy table or any `DROP COLUMN`.
`bash scripts/upgrade-check.sh` starts both servers in Docker, builds the
2.8 schema from the tag's own drizzle migrations, copies the fixture in and
runs the same test with `TEST_DIALECT` set.
- **The backup.** `backupBeforeUpgrade` (`src/backend/boot.ts`) copies the
SQLite file before anything opens it. Each upgrade that rewrites the
database has its own marker (`ensurePreupgradeBackup({ reason })`); 2.9.0's
is `pre-plugin-runtime`, so an install that already has the 2.5 refactor's
marker still gets a copy. The test checks the copy is the 2.8 file byte for
byte and that a second boot makes no new one.
**Left for 3.0.0.** These stay in 2.9.0 so a downgrade still works, and go
once every install has booted it:
@@ -2050,7 +2091,7 @@ authority for signatures.
| `log.debug/info/warn/error` | none | Lines in core's log, tagged with the plugin |
| `events.emit/on` | `events:core` for core topics | Topics under `plugin.<id>.*`, or core's (`user.deleted`, `user.data_wiped`, host events) |
| `kv.get/set/delete/list` | `kv:own` | Small key/value state |
| `files.dataDir()` | `files:own` | A per-plugin folder under `DATA_DIR` |
| `files.dataDir()` | `files:own` | A per-plugin folder, `<DATA_DIR>/plugin-data/<id>` |
| `db.define/client/refs/persist/dialect` | `db:own` (not `dialect`) | The plugin's own tables through Drizzle; `persist` flushes SQLite to disk |
| `sync.registerEntity/recordTombstone` | none | Adds a table to remote sync, and records a delete for it |
| `registry.provide/consume/revoke` | none | The low-level named registry services sit on |
@@ -2294,7 +2335,7 @@ keeps its account key sealed in `ctx.kv`, and checks every 12 hours.
- `ensureBinary({ name, version, url, sha256, prebuilt })` returns the path of
a verified executable: the first `prebuilt` path whose SHA-256 matches (a
copy baked into the Docker image, used in place), else
`<DATA_DIR>/plugins/<id>/bin/<name>` when it matches, else a download over
`<DATA_DIR>/plugin-data/<id>/bin/<name>` when it matches, else a download over
https (redirects followed, `network:outbound` checked only then), written
atomically with mode 755. A mismatch throws and keeps nothing.
+3 -5
View File
@@ -117,11 +117,9 @@ with a real install, a real device or a repo outside this one.
URL (`/plugin-api/termix-identity/u/<handle>`, the old one still 308s) and
the HTTPS certificate page (`docs.termix.site/features/networking/ssl`,
which still describes certbot and the webroot challenge).
- Upgrade a real 2.8 install on Postgres and on MySQL (SQLite is covered by
the upgrade test): proxmox host settings, tunnels, web endpoints, the file
manager's default path and SCP switch, and the terminal, tmux, sharing and
recording switches all survive. Postgres was checked by hand against a
scratch database; MySQL only runs in CI.
- Run `bash scripts/upgrade-check.sh` once with Docker running. It upgrades
the D4 fixture on real Postgres 16 and MySQL 8. The Postgres half passed
against an in-process PGlite server during D4; MySQL has not run yet.
- Host settings round trip: filter the Hosts panel by a plugin feature, bulk
enable and disable a plugin on a few hosts, export hosts to JSON and import
them on another install (plugin settings come across, secrets do not), the
@@ -0,0 +1,39 @@
import fs from "node:fs/promises";
import path from "node:path";
export function isInside(file: string, dir: string): boolean {
return path.resolve(file).startsWith(`${path.resolve(dir)}${path.sep}`);
}
/**
* Moves a finished recording someone else wrote (guacd's, in remote
* desktop's recordings folder) under this plugin's data folder, which is the
* only place playback and retention read from. Returns where it ended up;
* a file that is not there is left alone.
*/
export async function adoptRecordingFile(
source: string,
dataDir: string,
): Promise<string> {
if (isInside(source, dataDir)) return source;
const target = path.join(
dataDir,
"session_recordings",
path.basename(path.dirname(source)),
path.basename(source),
);
try {
await fs.access(source);
} catch {
return source;
}
await fs.mkdir(path.dirname(target), { recursive: true });
try {
await fs.rename(source, target);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
await fs.copyFile(source, target);
await fs.unlink(source);
}
return target;
}
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { SessionRecordingRepository } from "./repository.js";
import { adoptRecordingFile } from "./files.js";
/**
* recordings.writer v1, the service ssh-terminal calls to record sessions.
@@ -40,8 +41,8 @@ export interface RecordingsWriterV1 {
/**
* Inserts a row for a recording a caller already wrote to disk itself
* (remote desktop's guacd recordings), rather than streaming through
* open()/append(). The caller already decided to record; this only writes
* the row.
* open()/append(). The caller already decided to record. The file is
* moved under this plugin's data folder, where playback reads it.
*/
createFinished: (input: {
hostId: number;
@@ -117,7 +118,15 @@ export function createRecordingsWriter(
async createFinished(input) {
const username = await repository.usernameFor(input.userId);
const row = await repository.create({ ...input, username });
const recordingPath = await adoptRecordingFile(
input.recordingPath,
await ctx.files.dataDir(),
).catch(() => input.recordingPath);
const row = await repository.create({
...input,
recordingPath,
username,
});
return { id: row.id };
},
};
@@ -172,6 +172,41 @@ describe("recordings.writer", () => {
expect(row.id).toBeGreaterThan(0);
});
it("createFinished moves guacd's file under the data folder, where playback reads it", async () => {
const { mock, writer } = await setup();
const guacdDir = await fs.mkdtemp(path.join(os.tmpdir(), "guacd-"));
const source = path.join(guacdDir, "guacamole", "rdp-1.guac");
await fs.mkdir(path.dirname(source), { recursive: true });
await fs.writeFile(source, "4.size,1.0;");
const row = await writer.createFinished({
hostId: 7,
userId: "user-1",
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
duration: 10,
recordingPath: source,
protocol: "rdp",
format: "guacamole",
});
const target = path.join(
await mock.ctx.files.dataDir(),
"session_recordings",
"guacamole",
"rdp-1.guac",
);
await expect(fs.readFile(target, "utf8")).resolves.toBe("4.size,1.0;");
await expect(fs.access(source)).rejects.toThrow();
const stored = db!.sqlite
.prepare(
"SELECT recording_path FROM p_session_recording_session_recordings WHERE id = ?",
)
.get(row.id) as { recording_path: string };
expect(stored.recording_path).toBe(target);
await fs.rm(guacdDir, { recursive: true, force: true });
});
it("enabledFor follows the host's recording switch", async () => {
const { mock, writer } = await setup();
await mock.ctx.settings.setHost(7, "enableSessionRecording", true);
+159
View File
@@ -0,0 +1,159 @@
/**
* Writes src/backend/tests/fixtures/upgrade/{db.sqlite,fixture.env,files/}: a 2.8 install's
* data directory with every feature table filled. The schema is 2.8's own
* dump, the rows come from rows.ts, and the columns 2.8 encrypted are
* encrypted the way 2.8 did it (a v3 system-wrapped data key per user, then
* FieldCrypto per field), so the upgrade test has to really decrypt them.
*
* Usage: npx tsx scripts/build-upgrade-fixture.ts
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import Database from "better-sqlite3";
import bcrypt from "bcryptjs";
import { FieldCrypto } from "../src/backend/utils/field-crypto.js";
import {
ENCRYPTED_COLUMNS,
FIXTURE_PASSWORD,
RECORDING_FILES,
ROWS,
type Row,
} from "../src/backend/tests/fixtures/upgrade/rows.js";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const FIXTURES = path.join(ROOT, "src/backend/tests/fixtures");
const OUT = path.join(FIXTURES, "upgrade");
const hex = (label: string) =>
crypto
.createHash("sha256")
.update(`termix-d4-fixture:${label}`)
.digest("hex");
const ENV = {
JWT_SECRET: hex("jwt") + hex("jwt-2"),
DATABASE_KEY: hex("database"),
ENCRYPTION_KEY: hex("encryption"),
INTERNAL_AUTH_TOKEN: hex("internal"),
};
const masterKey = Buffer.from(ENV.ENCRYPTION_KEY, "hex");
/** UserKeyManager's v3 wrap, unchanged since 2.8. */
function wrapDek(userId: string, dek: Buffer): string {
const wrapKey = Buffer.from(
crypto.hkdfSync(
"sha256",
masterKey,
Buffer.alloc(0),
`termix:dek-wrap:v3:${userId}`,
32,
),
);
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", wrapKey, iv);
cipher.setAAD(Buffer.from(userId, "utf8"));
const ct = Buffer.concat([cipher.update(dek), cipher.final()]);
return JSON.stringify({
v: 3,
alg: "aes-256-gcm",
iv: iv.toString("base64"),
ct: ct.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
createdAt: "2026-05-01T10:00:00.000Z",
});
}
const deks = new Map<string, Buffer>();
const dekFor = (userId: string) => {
let dek = deks.get(userId);
if (!dek) {
dek = Buffer.from(hex(`dek:${userId}`), "hex");
deks.set(userId, dek);
}
return dek;
};
function prepareRow(table: string, row: Row): Row {
const out: Row = { ...row };
if (table === "users" && out.password_hash === undefined) {
out.password_hash = bcrypt.hashSync(FIXTURE_PASSWORD, 10);
}
for (const [encTable, column, field, userColumn] of ENCRYPTED_COLUMNS) {
if (encTable !== table || typeof out[column] !== "string" || !out[column])
continue;
const userId = String(out[userColumn]);
const recordId = table === "users" ? userId : String(out.id);
out[column] = FieldCrypto.encryptField(
out[column] as string,
dekFor(userId),
recordId,
field,
);
}
return out;
}
function insert(sqlite: Database.Database, table: string, row: Row): void {
const keys = Object.keys(row);
sqlite
.prepare(
`INSERT INTO "${table}" (${keys.map((key) => `"${key}"`).join(", ")}) VALUES (${keys.map(() => "?").join(", ")})`,
)
.run(...keys.map((key) => row[key]));
}
fs.mkdirSync(OUT, { recursive: true });
const dbPath = path.join(OUT, "db.sqlite");
fs.rmSync(dbPath, { force: true });
const sqlite = new Database(dbPath);
sqlite.pragma("journal_mode = DELETE");
sqlite.exec(
fs.readFileSync(path.join(FIXTURES, "sqlite-2.8-schema.sql"), "utf8"),
);
sqlite.pragma("foreign_keys = ON");
sqlite.transaction(() => {
for (const [table, rows] of Object.entries(ROWS)) {
for (const row of rows) insert(sqlite, table, prepareRow(table, row));
}
for (const user of ROWS.users) {
const userId = String(user.id);
insert(sqlite, "settings", {
key: `user_dek_v3_${userId}`,
value: wrapDek(userId, dekFor(userId)),
});
}
})();
const violations = sqlite.pragma("foreign_key_check") as unknown[];
if (violations.length > 0) {
throw new Error(`Foreign key violations: ${JSON.stringify(violations)}`);
}
sqlite.exec("VACUUM");
sqlite.close();
fs.writeFileSync(
path.join(OUT, "fixture.env"),
Object.entries(ENV)
.map(([key, value]) => `${key}=${value}`)
.join("\n") + "\n",
);
// The recording files 2.8 left in DATA_DIR, which the rows point at.
const files = path.join(OUT, "files");
fs.rmSync(files, { recursive: true, force: true });
for (const [kind, relative] of Object.entries(RECORDING_FILES)) {
const file = path.join(files, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
`d4 ${kind} recording
`,
);
}
console.log(`Wrote ${path.relative(ROOT, dbPath)} and fixture.env`);
+124
View File
@@ -0,0 +1,124 @@
/**
* Every SQL statement a 2.8 install's Postgres or MySQL database runs on its
* way to 2.9.0, in the order it runs them: core's drizzle migrations added
* since release-2.8.0-tag, then each bundled plugin's own migrations in the
* order the loader activates them. The data copies that run after that
* (src/backend/upgrade/) are TypeScript, not SQL, and are covered by the
* upgrade test and scripts/upgrade-check.sh.
*
* Snapshot-tested by scripts/upgrade-sql.test.ts, so any change to the
* upgrade path shows up as a diff in review.
*
* Usage: node scripts/lib/upgrade-sql.mjs postgres|mysql
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../..",
);
/** The last core migration release-2.8.0-tag shipped, per dialect. */
export const LAST_28_MIGRATION = {
postgres: "0024_flimsy_forge",
mysql: "0023_even_beast",
};
/** Plugin ids in the order the loader activates them: dependencies first. */
export function pluginActivationOrder(root = ROOT) {
const dir = path.join(root, "plugins");
const manifests = new Map(
fs
.readdirSync(dir, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(dir, entry.name, "manifest.json")),
)
.map((entry) => [
entry.name,
JSON.parse(
fs.readFileSync(path.join(dir, entry.name, "manifest.json"), "utf8"),
),
])
.sort(([a], [b]) => a.localeCompare(b)),
);
const order = [];
const done = new Set();
const visit = (id) => {
if (done.has(id) || !manifests.has(id)) return;
done.add(id);
const manifest = manifests.get(id);
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
visit(dependency);
}
for (const dependency of Object.keys(manifest.optionalDependencies ?? {})) {
visit(dependency);
}
order.push(id);
};
for (const id of manifests.keys()) visit(id);
return order;
}
function section(title, sql) {
return `-- ==== ${title} ====\n${sql.trim()}\n`;
}
export function buildUpgradeSql(dialect, root = ROOT) {
if (!(dialect in LAST_28_MIGRATION)) {
throw new Error(`unsupported dialect "${dialect}"`);
}
const parts = [
`-- Termix 2.8.0 to 2.9.0 on ${dialect}, generated by scripts/lib/upgrade-sql.mjs\n`,
];
const coreDir = path.join(root, "drizzle", dialect);
const journal = JSON.parse(
fs.readFileSync(path.join(coreDir, "meta", "_journal.json"), "utf8"),
);
const tags = journal.entries.map((entry) => entry.tag);
const last = tags.indexOf(LAST_28_MIGRATION[dialect]);
if (last === -1) {
throw new Error(
`${LAST_28_MIGRATION[dialect]} is not in the ${dialect} journal`,
);
}
for (const tag of tags.slice(last + 1)) {
parts.push(
section(
`core ${tag}`,
fs.readFileSync(path.join(coreDir, `${tag}.sql`), "utf8"),
),
);
}
for (const id of pluginActivationOrder(root)) {
const dir = path.join(root, "plugins", id, "migrations", dialect);
if (!fs.existsSync(dir)) continue;
for (const file of fs
.readdirSync(dir)
.filter((name) => name.endsWith(".sql"))
.sort()) {
parts.push(
section(
`plugin ${id} ${file}`,
fs.readFileSync(path.join(dir, file), "utf8"),
),
);
}
}
return parts.join("\n").replace(/\r\n/g, "\n");
}
if (
process.argv[1] &&
fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
) {
process.stdout.write(buildUpgradeSql(process.argv[2] ?? "postgres"));
}
+35
View File
@@ -0,0 +1,35 @@
# Throwaway Postgres and MySQL servers for scripts/upgrade-check.sh. Data
# lives in tmpfs, so every run starts from an empty server.
name: termix-upgrade-check
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: termix
POSTGRES_PASSWORD: termix
POSTGRES_DB: termix
ports:
- "55433:5432"
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U termix -d termix"]
interval: 2s
timeout: 5s
retries: 30
mysql:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: termix
MYSQL_DATABASE: termix
ports:
- "33307:3306"
tmpfs:
- /var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-ptermix"]
interval: 2s
timeout: 5s
retries: 60
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Upgrades the 2.8 fixture install to 2.9.0 on real Postgres and MySQL
# servers and runs the same checks the SQLite upgrade test runs.
#
# Needs Docker and a built tree (npm run build). Run from anywhere:
# bash scripts/upgrade-check.sh # both engines
# bash scripts/upgrade-check.sh postgres # one engine
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
COMPOSE=(docker compose -f "$ROOT/scripts/upgrade-check.compose.yml")
TAG="release-2.8.0-tag"
if [ "$#" -gt 0 ]; then
DIALECTS=("$@")
else
DIALECTS=(postgres mysql)
fi
cd "$ROOT"
if [ ! -f dist/plugins/snippets/dist/backend.js ]; then
echo "dist/plugins is missing. Run npm run build first." >&2
exit 1
fi
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "Tag $TAG is missing. Run git fetch --tags first." >&2
exit 1
fi
LEGACY="$(mktemp -d)"
cleanup() {
"${COMPOSE[@]}" down -v >/dev/null 2>&1 || true
rm -rf "$LEGACY"
}
trap cleanup EXIT
# 2.8's own drizzle migrations build the 2.8 schema on each server.
git archive "$TAG" drizzle | tar -x -C "$LEGACY"
"${COMPOSE[@]}" up -d --wait
status=0
for dialect in "${DIALECTS[@]}"; do
case "$dialect" in
postgres) url="postgres://termix:termix@127.0.0.1:55433/termix" ;;
mysql) url="mysql://root:termix@127.0.0.1:33307/termix" ;;
*)
echo "Unknown dialect $dialect (postgres or mysql)" >&2
exit 2
;;
esac
echo
echo "== 2.8 to 2.9.0 on $dialect"
if ! TEST_DIALECT="$dialect" TEST_DATABASE_URL="$url" \
UPGRADE_LEGACY_MIGRATIONS_DIR="$LEGACY/drizzle" \
npx vitest run src/backend/tests/plugins/upgrade-fixture.test.ts \
--project backend; then
status=1
fi
done
exit "$status"
+44
View File
@@ -0,0 +1,44 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { LEGACY_TABLE_OWNERS } from "../packages/plugin-sdk/src/db";
import { buildUpgradeSql } from "./lib/upgrade-sql.mjs";
const SNAPSHOTS = path.resolve(
__dirname,
"../src/backend/tests/fixtures/upgrade",
);
const RENAME = {
postgres: /^ALTER TABLE "([a-z0-9_]+)" RENAME TO "(p_[a-z0-9_]+)";$/gm,
mysql: /^RENAME TABLE `([a-z0-9_]+)` TO `(p_[a-z0-9_]+)`;$/gm,
};
describe.each(["postgres", "mysql"] as const)(
"the 2.8 to 2.9.0 upgrade SQL on %s",
(dialect) => {
const sql = buildUpgradeSql(dialect);
const statements = sql.replace(/^--.*$/gm, "");
it("matches the reviewed snapshot", async () => {
await expect(sql).toMatchFileSnapshot(
path.join(SNAPSHOTS, `upgrade-${dialect}.sql`),
);
});
it("adopts every legacy table by renaming it, never by dropping it", () => {
const renamed = new Set(
[...sql.matchAll(RENAME[dialect])].map((match) => match[1]),
);
for (const table of Object.keys(LEGACY_TABLE_OWNERS)) {
expect(renamed, table).toContain(table);
expect(statements).not.toMatch(
new RegExp(`DROP TABLE[^;]*[\`"]${table}[\`"]`, "i"),
);
}
});
it("never drops a column", () => {
expect(statements).not.toMatch(/DROP COLUMN/i);
});
},
);
+25
View File
@@ -6,6 +6,31 @@
import { AuthManager } from "./utils/auth-manager.js";
import { DataCrypto } from "./utils/data-crypto.js";
import { ensurePreupgradeBackup } from "./utils/database-layer-preupgrade-backup.js";
import { systemLogger } from "./utils/logger.js";
/**
* Copies the SQLite file before anything opens it, so the copy is the database
* exactly as the previous version left it: before core's schema patches and
* before any plugin adopts a table. Client-server engines are the operator's
* to back up.
*/
export async function backupBeforeUpgrade(options: {
dataDir: string;
version: string;
}): Promise<void> {
const { needsExplicitPersist, resolveDatabaseDialect } =
await import("./database/db/dialect.js");
const dialect = resolveDatabaseDialect();
if (needsExplicitPersist(dialect)) {
ensurePreupgradeBackup(options);
return;
}
systemLogger.info(
`Skipping pre-upgrade backup on ${dialect} - back up the database yourself`,
{ operation: "backend_init_db_backup_skipped", dialect },
);
}
export async function runCoreBootMigrations(): Promise<void> {
const { UserKeyManager } = await import("./utils/user-keys.js");
+3 -9
View File
@@ -19,6 +19,7 @@ import type {
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import { getPluginDataDir } from "./paths.js";
import type { DisposableBag } from "./disposables.js";
type AuditFn = (
@@ -31,7 +32,7 @@ interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
/** Where downloaded binaries go. Defaults to <DATA_DIR>/plugins/<id>/bin. */
/** Where downloaded binaries go. Defaults to <DATA_DIR>/plugin-data/<id>/bin. */
binDir?: () => string;
/** Test seam for the download. */
fetch?: typeof fetch;
@@ -58,14 +59,7 @@ export function createPluginProcess(deps: Deps): PluginProcess {
const pluginId = deps.manifest.id;
const declared = deps.manifest.capabilities;
const binDir =
deps.binDir ??
(() =>
path.join(
process.env.DATA_DIR ?? "./db/data",
"plugins",
pluginId,
"bin",
));
deps.binDir ?? (() => path.join(getPluginDataDir(pluginId), "bin"));
const doFetch = deps.fetch ?? fetch;
const audited = async <T>(
+2 -3
View File
@@ -300,9 +300,8 @@ export function createPluginContext(
"files:own",
async () => {
const fs = await import("node:fs/promises");
const path = await import("node:path");
const base = process.env.DATA_DIR ?? "./db/data";
const dir = path.join(base, "plugins", pluginId);
const { getPluginDataDir } = await import("./paths.js");
const dir = getPluginDataDir(pluginId);
await fs.mkdir(dir, { recursive: true });
return dir;
},
+4 -3
View File
@@ -78,10 +78,11 @@ export function resetPluginData(): void {
}
async function execute(statements: string[]): Promise<void> {
const { getDb } = await import("../database/db/index.js");
const db = getDb();
// getDb() has run() only on SQLite; runStatement picks execute() elsewhere.
const { runStatement } =
await import("../utils/crypto-migration/raw-rows.js");
for (const statement of statements) {
await db.run(sql.raw(statement));
await runStatement(sql.raw(statement));
}
}
+13
View File
@@ -15,6 +15,19 @@ export function getPluginsDir(): string {
return path.join(process.env.DATA_DIR || "./db/data", "plugins");
}
/**
* A plugin's own files (ctx.files.dataDir(), downloaded binaries). Kept out of
* getPluginsDir(), where the loader would read every data folder as a user
* plugin that clashes with the bundled one of the same name.
*/
export function getPluginDataDir(pluginId: string): string {
return path.join(
process.env.DATA_DIR || "./db/data",
"plugin-data",
pluginId,
);
}
/**
* Where user .tmxplug files are unpacked. A dot directory inside the user
* plugins dir, which the loader's directory scan skips.
+7 -6
View File
@@ -10,7 +10,10 @@ import { sql } from "drizzle-orm";
import { columnName, createTableSql } from "@termix/plugin-sdk/ddl";
import { prefixedTableName } from "@termix/plugin-sdk/db";
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
import { getDb } from "../database/db/index.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
import { listAllTables } from "./data.js";
export interface UserOwnedTable {
@@ -56,11 +59,10 @@ type Row = Record<string, unknown>;
export async function readUserPluginRows(
userId: string,
): Promise<Record<string, Row[]>> {
const db = getDb();
const result: Record<string, Row[]> = {};
for (const owned of listUserOwnedTables()) {
try {
const rows = await db.all<Row>(
const rows = await selectRows<Row>(
sql`SELECT ${sql.join(
owned.columns.map((column) => sql.identifier(column)),
sql`, `,
@@ -137,7 +139,6 @@ export async function importUserPluginRows(
importDb: SqliteLike,
userId: string,
): Promise<PluginRowsImport> {
const db = getDb();
const summary: PluginRowsImport = { imported: 0, skipped: 0, errors: [] };
for (const owned of listUserOwnedTables()) {
@@ -160,7 +161,7 @@ export async function importUserPluginRows(
column === owned.userColumn ? userId : source[column],
);
try {
const existing = await db.all(
const existing = await selectRows(
sql`SELECT 1 FROM ${sql.identifier(owned.table)} WHERE ${sql.join(
columns.map((column, index) =>
values[index] === null
@@ -174,7 +175,7 @@ export async function importUserPluginRows(
summary.skipped++;
continue;
}
await db.run(
await runStatement(
sql`INSERT INTO ${sql.identifier(owned.table)} (${sql.join(
columns.map((column) => sql.identifier(column)),
sql`, `,
+2 -15
View File
@@ -4,7 +4,6 @@ import { promises as fs, readFileSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { AutoSSLSetup } from "./tls/self-signed.js";
import { ensureDatabaseLayerPreupgradeBackup } from "./utils/database-layer-preupgrade-backup.js";
import { DatabaseSaveTrigger } from "./utils/database-save-trigger.js";
import { SystemCrypto } from "./utils/system-crypto.js";
import {
@@ -160,20 +159,8 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
await import("./database/db/dialect.js");
const databaseDialect = resolveDatabaseDialect();
// The pre-upgrade backup copies the SQLite file, so there is nothing for it
// to do on a client-server engine. Say so rather than no-op silently:
// backups are the operator's own responsibility there.
if (needsExplicitPersist(databaseDialect)) {
ensureDatabaseLayerPreupgradeBackup({ dataDir, version });
} else {
systemLogger.info(
`Skipping pre-upgrade backup on ${databaseDialect} - back up the database yourself`,
{
operation: "backend_init_db_backup_skipped",
dialect: databaseDialect,
},
);
}
const { backupBeforeUpgrade } = await import("./boot.js");
await backupBeforeUpgrade({ dataDir, version });
await AutoSSLSetup.initialize();
systemLogger.success("SSL setup completed", {
Binary file not shown.
@@ -0,0 +1 @@
d4 ssh recording
@@ -0,0 +1 @@
d4 rdp recording
+4
View File
@@ -0,0 +1,4 @@
JWT_SECRET=059e69f1248271031d958a76bd60280f09b8c99fa3414a03de84d0af886c1e4cef0ccbb4d5f1ef2d05c3607b89805134aa9cf985ebaddd3a01d7847a0772c2d7
DATABASE_KEY=a0e3e3b679a351d0daeb0f790ecfce906c3169d966e81d2e6be89873ab4b191a
ENCRYPTION_KEY=1c06c6a7605c71dc4724d4d39d17ea45afd44e3325115ac4e1e21e9d55f73030
INTERNAL_AUTH_TOKEN=d1ae0f2e46a0913996148702cf5a4decb1da7b68a39098ac3a75a00f0d1278e2
+844
View File
@@ -0,0 +1,844 @@
/**
* The rows in the committed 2.8 fixture (db.sqlite next to this file), as
* plain data. scripts/build-upgrade-fixture.ts writes them into the real 2.8
* schema, encrypting the columns 2.8 kept encrypted with each user's data
* key, and the upgrade test reads them back through 2.9.0's plugins.
*
* Every value a test looks for carries "d4" so it is easy to find.
*/
export type Row = Record<string, unknown>;
/** Every password user signs in with this. */
export const FIXTURE_PASSWORD = "d4-upgrade-password";
/** The TOTP user's secret (base32) and 2.8 backup codes. */
export const TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP";
export const TOTP_BACKUP_CODES = ["D4BACKUP1", "D4BACKUP2", "D4BACKUP3"];
export const USERS = {
admin: "d4-user-admin",
alice: "d4-user-alice",
totp: "d4-user-totp",
passkey: "d4-user-passkey",
sso: "d4-user-sso",
ldap: "d4-user-ldap",
} as const;
export const HOSTS = {
password: 1,
key: 2,
credential: 3,
none: 4,
agent: 5,
opkssh: 6,
stepca: 7,
tailscale: 8,
vault: 9,
warpgate: 10,
rdpOnly: 11,
plain: 12,
desktop: 13,
} as const;
/** The 2.8 auth_type each host was saved with. */
export const HOST_AUTH_TYPES: Record<number, string> = {
[HOSTS.password]: "password",
[HOSTS.key]: "key",
[HOSTS.credential]: "credential",
[HOSTS.none]: "none",
[HOSTS.agent]: "agent",
[HOSTS.opkssh]: "opkssh",
[HOSTS.stepca]: "stepca",
[HOSTS.tailscale]: "tailscale",
[HOSTS.vault]: "vault",
[HOSTS.warpgate]: "warpgate",
[HOSTS.rdpOnly]: "password",
[HOSTS.plain]: "password",
[HOSTS.desktop]: "password",
};
/**
* 2.8 stored recording paths absolute. The fixture writes this token instead,
* and the harness swaps in the DATA_DIR it boots from, where it also copies
* the files under upgrade/files.
*/
export const DATA_DIR_TOKEN = "{{DATA_DIR}}";
export const RECORDING_FILES = {
ssh: "session_logs/d4-user-admin/d4-session.cast",
rdp: "session_recordings/guacamole/d4-rdp.guac",
};
export const SSO_PROVIDER_ID = 1;
export const LDAP_PROVIDER_ID = 2;
/** 2.8's seeded user role, which the upgrade must leave as it was. */
export const USER_ROLE_PERMISSIONS_28 = [
"hosts.*",
"snippets.*",
"automations.*",
"credentials.*",
"ai.*",
];
export const OPERATORS_ROLE_ID = 10;
export const OPERATORS_PERMISSIONS = [
"hosts.view",
"credentials.view",
"snippets.*",
"automations.run",
"ai.use",
];
/** 2.8's electron/remote-sync-entities.cjs SYNCED_ENTITY_TYPES. */
export const SYNCED_ENTITY_TYPES_28 = [
"sshCredentials",
"vaultProfiles",
"sshFolders",
"snippetFolders",
"hosts",
"snippets",
"dashboardServiceLinks",
"homepageItems",
"userPreferences",
];
const T = "2026-05-01T10:00:00.000Z";
const LATER = "2099-01-01T00:00:00.000Z";
/**
* Columns 2.8 stored encrypted with the owning user's data key, as
* [table, column, field name, column holding the user id]. The record id is
* the row id, except on users where it is the user id.
*/
export const ENCRYPTED_COLUMNS: Array<[string, string, string, string]> = [
["users", "totp_secret", "totpSecret", "id"],
["users", "totp_backup_codes", "totpBackupCodes", "id"],
["ssh_data", "password", "password", "user_id"],
["ssh_data", "key", "key", "user_id"],
["ssh_data", "key_password", "keyPassword", "user_id"],
["ssh_data", "sudo_password", "sudoPassword", "user_id"],
["ssh_data", "rdp_password", "rdpPassword", "user_id"],
["ssh_credentials", "password", "password", "user_id"],
["ssh_credentials", "private_key", "privateKey", "user_id"],
["ssh_credentials", "key", "key", "user_id"],
["ssh_credentials", "public_key", "publicKey", "user_id"],
["notification_channels", "config", "config", "user_id"],
["opkssh_tokens", "ssh_cert", "sshCert", "user_id"],
["opkssh_tokens", "private_key", "privateKey", "user_id"],
["vault_tokens", "ssh_cert", "sshCert", "user_id"],
["vault_tokens", "private_key", "privateKey", "user_id"],
["termix_identity_ca", "private_key", "privateKey", "user_id"],
["ai_providers", "api_key", "apiKey", "user_id"],
["secret_sources", "token", "token", "user_id"],
];
const PUBLIC_KEY =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGd4ZDRkNGQ0ZDRkNGQ0ZDRkNGQ0ZDRkNGQ0ZDRkNGQ0 d4";
const PRIVATE_KEY =
"-----BEGIN OPENSSH PRIVATE KEY-----\nd4-private-key\n-----END OPENSSH PRIVATE KEY-----";
function host(id: number, name: string, extra: Row = {}): Row {
return {
id,
user_id: USERS.admin,
name,
ip: `10.4.0.${id}`,
port: 22,
username: "root",
folder: "d4-folder",
tags: JSON.stringify(["d4"]),
auth_type: HOST_AUTH_TYPES[id],
sync_id: `d4-sync-host-${id}`,
created_at: T,
updated_at: T,
...extra,
};
}
const HOST_ROWS: Row[] = [
// Every plugin switch changed away from its 2.8 default.
host(HOSTS.password, "d4-password", {
password: "d4-host-password",
sudo_password: "d4-sudo",
enable_terminal: 1,
enable_command_history: 0,
enable_terminal_toolbar: 0,
enable_session_logging: 0,
allow_session_sharing: 0,
enable_file_manager: 0,
default_path: "/srv/d4",
scp_legacy: 1,
enable_tunnel: 1,
tunnel_connections: JSON.stringify([
{
sourcePort: 8080,
endpointPort: 80,
endpointHost: "d4-key",
autoStart: false,
},
]),
enable_web_ui: 1,
web_ui_config: JSON.stringify({
endpoints: [
{ id: "grafana", label: "d4-grafana", port: 3000, protocol: "http" },
],
}),
enable_tmux_monitor: 1,
enable_docker: 1,
docker_config: JSON.stringify({ runtime: "podman" }),
enable_ai_assistant: 1,
mac_address: "AA:BB:CC:DD:EE:04",
wol_broadcast_address: "10.4.0.255",
stats_config: JSON.stringify({
statusCheckEnabled: false,
statusCheckInterval: 90,
metricsEnabled: false,
useGlobalMetricsInterval: false,
metricsInterval: 45,
enabledWidgets: ["cpu", "memory"],
}),
}),
host(HOSTS.key, "d4-key", {
key: PRIVATE_KEY,
key_password: "d4-key-pass",
key_type: "ssh-ed25519",
enable_proxmox: 1,
proxmox_config: JSON.stringify({
apiUrl: "https://pve.d4.example:8006",
tokenId: "root@pam!d4",
}),
enable_proxmox_stats: 1,
proxmox_stats_config: JSON.stringify({ node: "d4-node" }),
}),
host(HOSTS.credential, "d4-credential", { credential_id: 1 }),
host(HOSTS.none, "d4-none"),
host(HOSTS.agent, "d4-agent"),
host(HOSTS.opkssh, "d4-opkssh"),
host(HOSTS.stepca, "d4-stepca"),
host(HOSTS.tailscale, "d4-tailscale"),
host(HOSTS.vault, "d4-vault", { vault_profile_id: 1 }),
host(HOSTS.warpgate, "d4-warpgate"),
// A host from before the per-protocol switches: connection_type alone made
// it an RDP host with SSH off.
host(HOSTS.rdpOnly, "d4-rdp", {
connection_type: "rdp",
rdp_user: "d4-rdp-user",
rdp_password: "d4-rdp-password",
}),
host(HOSTS.desktop, "d4-desktop", {
enable_rdp: 1,
enable_vnc: 1,
enable_telnet: 1,
rdp_port: 3390,
vnc_port: 5901,
telnet_port: 2323,
rdp_user: "d4-rdp-user",
rdp_password: "d4-rdp-password",
rdp_security: "nla",
rdp_ignore_cert: 1,
guacamole_config: JSON.stringify({ colorDepth: 24 }),
}),
// Touches nothing, so it has to keep reading 2.8's defaults.
host(HOSTS.plain, "d4-plain"),
];
export const ROWS: Record<string, Row[]> = {
users: [
{ id: USERS.admin, username: "d4-admin", is_admin: 1 },
{ id: USERS.alice, username: "d4-alice" },
{
id: USERS.totp,
username: "d4-totp",
totp_enabled: 1,
totp_secret: TOTP_SECRET,
totp_backup_codes: JSON.stringify(TOTP_BACKUP_CODES),
},
{ id: USERS.passkey, username: "d4-passkey" },
{
id: USERS.sso,
username: "d4-sso",
password_hash: "",
is_oidc: 1,
oidc_identifier: "d4-oidc-subject",
sso_provider_id: SSO_PROVIDER_ID,
},
{
id: USERS.ldap,
username: "d4-ldap",
password_hash: "",
is_oidc: 1,
oidc_identifier: `ldap:${LDAP_PROVIDER_ID}:d4-ldap-dn`,
sso_provider_id: LDAP_PROVIDER_ID,
},
].map((user) => ({ registered_at: T, ...user })),
roles: [
{
id: 1,
name: "admin",
display_name: "rbac.roles.admin",
description: "Administrator with full access",
is_system: 1,
permissions: JSON.stringify(["*"]),
},
{
id: 2,
name: "user",
display_name: "rbac.roles.user",
description: "Regular user",
is_system: 1,
permissions: JSON.stringify(USER_ROLE_PERMISSIONS_28),
},
{
id: OPERATORS_ROLE_ID,
name: "d4-operators",
display_name: "D4 operators",
is_system: 0,
permissions: JSON.stringify(OPERATORS_PERMISSIONS),
},
],
user_roles: [
{ user_id: USERS.admin, role_id: 1 },
...[USERS.alice, USERS.totp, USERS.passkey, USERS.sso, USERS.ldap].map(
(userId) => ({ user_id: userId, role_id: 2 }),
),
{
user_id: USERS.alice,
role_id: OPERATORS_ROLE_ID,
granted_by: USERS.admin,
},
],
ssh_credentials: [
{
id: 1,
user_id: USERS.admin,
name: "d4-credential",
auth_type: "key",
username: "deploy",
private_key: PRIVATE_KEY,
public_key: PUBLIC_KEY,
key_type: "ssh-ed25519",
sync_id: "d4-sync-credential",
},
],
ssh_folders: [
{
id: 1,
user_id: USERS.admin,
name: "d4-folder",
sync_id: "d4-sync-folder",
},
],
vault_profiles: [
{
id: 1,
user_id: USERS.admin,
name: "d4-vault",
vault_addr: "https://vault.d4.example",
ssh_mount: "ssh",
ssh_role: "admin",
shared: 0,
sync_id: "d4-sync-vault",
},
],
ssh_data: HOST_ROWS,
host_access: [
{
id: 1,
host_id: HOSTS.password,
user_id: USERS.alice,
granted_by: USERS.admin,
permission_level: "connect",
},
],
user_preferences: [
{
user_id: USERS.admin,
theme: "dark",
ai_assistant_enabled: 1,
ai_read_only_commands: 1,
rdp_defaults: JSON.stringify({
colorDepth: 16,
resizeMethod: "reconnect",
enableDrive: true,
}),
},
],
sso_providers: [
{
id: SSO_PROVIDER_ID,
name: "d4-sso",
type: "oidc",
enabled: 1,
display_order: 0,
config: JSON.stringify({
issuerUrl: "https://id.d4.example",
clientId: "termix",
clientSecret: "d4-client-secret",
}),
},
{
id: LDAP_PROVIDER_ID,
name: "d4-ldap",
type: "ldap",
enabled: 1,
display_order: 1,
config: JSON.stringify({
url: "ldaps://ldap.d4.example",
bindDn: "cn=termix,dc=d4",
bindPassword: "d4-bind",
searchBase: "dc=d4",
}),
},
],
webauthn_credentials: [
{
id: "d4-passkey-id",
user_id: USERS.passkey,
name: "d4-passkey",
credential_id: "ZDQtY3JlZGVudGlhbA",
public_key: "ZDQtcHVibGljLWtleQ",
counter: 3,
transports: JSON.stringify(["internal"]),
},
],
notification_channels: [
{
id: 1,
user_id: USERS.admin,
name: "d4-channel",
type: "ntfy",
config: JSON.stringify({ url: "https://ntfy.d4.example/d4" }),
enabled: 1,
},
],
snippet_folders: [
{
id: 1,
user_id: USERS.admin,
name: "d4-snippet-folder",
sync_id: "d4-sync-snippet-folder",
},
],
snippets: [
{
id: 1,
user_id: USERS.admin,
name: "d4-snippet",
content: "uptime",
folder: "d4-snippet-folder",
sync_id: "d4-sync-snippet",
},
{
id: 2,
user_id: USERS.admin,
name: "d4-shared-snippet",
content: "df -h",
sync_id: "d4-sync-snippet-shared",
},
],
snippet_access: [
{
id: 1,
snippet_id: 2,
user_id: USERS.alice,
granted_by: USERS.admin,
permission_level: "view",
},
],
fleets: [{ id: 1, user_id: USERS.admin, name: "d4-fleet" }],
fleet_members: [
{ id: 1, fleet_id: 1, host_id: HOSTS.password },
{ id: 2, fleet_id: 1, host_id: HOSTS.key },
],
fleet_inventory: [
{
id: 1,
host_id: HOSTS.password,
user_id: USERS.admin,
os_pretty_name: "d4 Linux",
hostname: "d4-password",
},
],
automations: [
{
id: 1,
user_id: USERS.admin,
name: "d4-automation",
enabled: 0,
definition: JSON.stringify({
version: 1,
trigger: { type: "manual" },
steps: [],
}),
},
],
automation_trigger_state: [
{ id: 1, automation_id: 1, state_key: "d4-state", last_value: 1 },
],
automation_schedules: [
{ id: 1, automation_id: 1, cron: "0 4 * * *", timezone: "UTC" },
],
automation_runs: [
{
id: 1,
automation_id: 1,
user_id: USERS.admin,
trigger_type: "manual",
status: "success",
started_at: T,
finished_at: T,
},
],
automation_run_steps: [
{
id: 1,
run_id: 1,
step_index: 0,
step_id: "d4-step",
step_type: "command",
status: "success",
output: "d4-output",
},
],
automation_channels: [{ id: 1, automation_id: 1, channel_id: 1 }],
user_workspaces: [
{
id: 1,
user_id: USERS.admin,
name: "d4-workspace",
payload: JSON.stringify({}),
sync_id: "d4-sync-workspace",
},
],
network_topology: [
{
id: 1,
user_id: USERS.admin,
topology: JSON.stringify({ nodes: [{ id: "d4-node" }], edges: [] }),
},
],
homepage_items: [
{
id: 1,
user_id: USERS.admin,
type_id: "clock",
title: "d4-widget",
config: JSON.stringify({}),
sync_id: "d4-sync-homepage-item",
},
],
homepage_layouts: [
{
id: 1,
user_id: USERS.admin,
layout: JSON.stringify([{ i: "1", x: 0, y: 0, w: 2, h: 2 }]),
},
],
dashboard_service_links: [
{
id: 1,
user_id: USERS.admin,
label: "d4-link",
url: "https://d4.example",
sync_id: "d4-sync-service-link",
},
],
vault_tokens: [
{
id: 1,
user_id: USERS.admin,
profile_id: 1,
ssh_cert: "d4-vault-cert",
private_key: PRIVATE_KEY,
expires_at: LATER,
},
],
opkssh_tokens: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.opkssh,
ssh_cert: "d4-opkssh-cert",
private_key: PRIVATE_KEY,
email: "d4@example.com",
expires_at: LATER,
},
],
session_recordings: [
{
id: 1,
host_id: HOSTS.password,
user_id: USERS.admin,
username: "d4-admin",
started_at: T,
ended_at: T,
duration: 12,
recording_path: `${DATA_DIR_TOKEN}/${RECORDING_FILES.ssh}`,
protocol: "ssh",
format: "asciicast",
},
{
id: 2,
host_id: HOSTS.desktop,
user_id: USERS.admin,
username: "d4-admin",
started_at: T,
ended_at: T,
duration: 30,
recording_path: `${DATA_DIR_TOKEN}/${RECORDING_FILES.rdp}`,
protocol: "rdp",
format: "guacamole",
},
],
command_history: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
command: "d4-history-command",
executed_at: T,
},
],
c2s_tunnel_presets: [
{
id: 1,
user_id: USERS.admin,
name: "d4-preset",
config: JSON.stringify({ tunnels: [] }),
},
],
ai_providers: [
{
id: 1,
user_id: USERS.admin,
provider_type: "openai",
label: "d4-provider",
api_key: "sk-d4-provider-key",
api_key_prefix: "sk-d4",
default_model: "gpt-d4",
enabled: 1,
},
],
ai_conversations: [
{ id: 1, user_id: USERS.admin, title: "d4-conversation", provider_id: 1 },
],
ai_messages: [
{ id: 1, conversation_id: 1, role: "user", content: "d4-message" },
],
ai_proposals: [
{
id: 1,
conversation_id: 1,
user_id: USERS.admin,
kind: "run_command",
summary: "d4-proposal",
payload: JSON.stringify({ command: "uptime" }),
},
],
secret_sources: [
{
id: "d4-source",
user_id: USERS.admin,
name: "d4-source",
kind: "vaultwarden",
base_url: "https://vw.d4.example",
token: "d4-source-token",
},
],
termix_identities: [{ id: 1, user_id: USERS.admin, handle: "d4-handle" }],
termix_identity_keys: [
{
id: 1,
identity_id: 1,
user_id: USERS.admin,
public_key: PUBLIC_KEY,
key_type: "ssh-ed25519",
algorithm: "ed25519",
label: "d4-identity-key",
},
],
termix_identity_ca: [
{
id: 1,
identity_id: 1,
user_id: USERS.admin,
public_key: PUBLIC_KEY,
private_key: PRIVATE_KEY,
},
],
host_metrics_preferences: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
layout: JSON.stringify({ d4: true }),
},
],
host_health_checks: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
checks: JSON.stringify([{ id: "d4-check", type: "tcp", port: 22 }]),
},
],
host_health_history: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
check_id: "d4-check",
ts: T,
ok: 1,
latency_ms: 4,
},
],
host_metrics_history: [
{ id: 1, host_id: HOSTS.password, ts: T, cpu_percent: 44 },
],
proxmox_stats_preferences: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.key,
layout: JSON.stringify({ d4: true }),
},
],
proxmox_node_history: [{ id: 1, host_id: HOSTS.key, ts: T, cpu_percent: 4 }],
session_shares: [
{
id: "d4-share",
host_id: HOSTS.password,
owner_user_id: USERS.admin,
protocol: "ssh",
session_id: "d4-session",
share_type: "user",
target_user_id: USERS.alice,
permission_level: "read",
expires_at: LATER,
},
],
session_share_participants: [
{ id: 1, share_id: "d4-share", user_id: USERS.alice, joined_at: T },
],
collab_rooms: [
{
id: "d4-room",
name: "d4-room",
owner_user_id: USERS.admin,
persistent: 1,
},
],
collab_room_members: [
{ id: 1, room_id: "d4-room", user_id: USERS.admin, room_role: "owner" },
{ id: 2, room_id: "d4-room", user_id: USERS.alice, added_by: USERS.admin },
],
file_manager_recent: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
name: "d4-recent",
path: "/srv/d4/recent",
},
],
file_manager_pinned: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
name: "d4-pinned",
path: "/srv/d4/pinned",
},
],
file_manager_shortcuts: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
name: "d4-shortcut",
path: "/srv/d4/shortcut",
},
],
transfer_recent: [
{
id: 1,
user_id: USERS.admin,
source_host_id: HOSTS.password,
dest_host_id: HOSTS.key,
dest_path: "/srv/d4/transfer",
dest_path_label: "d4-transfer",
},
],
tmux_session_tags: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
session_name: "d4-tmux",
tag: "d4-tag",
},
],
settings: [
["tailscale_api_key", "tskey-d4"],
["tailscale_api_base_url", "https://headscale.d4.example"],
["guac_enabled", "true"],
["guac_url", "guacd.d4.example:4822"],
["global_metrics_interval", "40"],
["metrics_history_retention_days", "14"],
[
"host_defaults",
JSON.stringify({ metricsEnabled: false, enableCommandHistory: false }),
],
["ai_globally_enabled", "true"],
["ai_private_endpoint_allowlist", JSON.stringify(["10.4.0.0/24"])],
[
"acme_ssl_settings",
JSON.stringify({
enabled: true,
domain: "termix.d4.example",
email: "d4@example.com",
challengeType: "cloudflare",
cloudflareToken: "d4-cloudflare-token",
}),
],
["step_ca_url", "https://ca.d4.example"],
["step_ca_fingerprint", "d4fingerprint"],
["step_ca_provisioner", "d4-provisioner"],
["terminal_session_timeout_minutes", "45"],
["terminal_session_persistence_enabled", "false"],
["terminal_image_max_count", "7"],
["session_sharing_globally_enabled", "false"],
// Long enough that the fixture's recordings never age out.
["session_recording_retention_days", "3650"],
].map(([key, value]) => ({ key, value })),
};
/** The 2.8 core tables the fixture fills that stay core in 2.9.0. */
export const CORE_TABLES = new Set([
"users",
"roles",
"user_roles",
"ssh_credentials",
"ssh_folders",
"ssh_data",
"host_access",
"user_preferences",
"notification_channels",
"settings",
]);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+95 -11
View File
@@ -10,10 +10,14 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
import { DATA_DIR_TOKEN } from "../fixtures/upgrade/rows.js";
import { remoteTestDialect, seedRemote28 } from "./remote-seed.js";
export const REPO_ROOT = path.resolve(__dirname, "../../../..");
export const BUILT_PLUGINS_DIR = path.join(REPO_ROOT, "dist", "plugins");
const SCHEMA_28 = path.join(__dirname, "../fixtures/sqlite-2.8-schema.sql");
/** The committed 2.8 install: db.sqlite, the fixture.env it was keyed with and its files. */
export const UPGRADE_FIXTURE_DIR = path.join(__dirname, "../fixtures/upgrade");
/** Every plugin in the repo, by folder. */
export function sourcePluginIds(): string[] {
@@ -87,23 +91,90 @@ export function create28Database() {
export interface BootedCore {
dataDir: string;
/** The live database core and the plugins write to. */
sqlite: Database.Database;
shutdown: () => Promise<void>;
/** The live database core and the plugins write to; null on Postgres and MySQL. */
sqlite: Database.Database | null;
/** The 2.8 database file exactly as it was written to DATA_DIR, if one was. */
seed: Buffer | null;
/** keepDataDir writes the database to disk and leaves DATA_DIR for another boot. */
shutdown: (options?: { keepDataDir?: boolean }) => Promise<void>;
}
/** A 2.8 data directory to boot: a seeded database, or a folder holding one. */
export type BootSource =
Database.Database | { fixtureDir: string } | { reuseDataDir: string };
/**
* Lays a committed 2.8 install (fixtures/upgrade) into DATA_DIR: its key file
* and the files its rows point at, with the fixture's DATA_DIR token in those
* rows swapped for the real path, as 2.8 stored them absolute.
*/
function placeFixture(fixtureDir: string, dataDir: string): Buffer {
fs.copyFileSync(
path.join(fixtureDir, "fixture.env"),
path.join(dataDir, ".env"),
);
fs.cpSync(path.join(fixtureDir, "files"), dataDir, { recursive: true });
const sqlite = new Database(
fs.readFileSync(path.join(fixtureDir, "db.sqlite")),
);
try {
sqlite
.prepare(
"UPDATE session_recordings SET recording_path = replace(recording_path, ?, ?)",
)
.run(DATA_DIR_TOKEN, dataDir.split(path.sep).join("/"));
return sqlite.serialize();
} finally {
sqlite.close();
}
}
const SYSTEM_SECRETS = [
"JWT_SECRET",
"DATABASE_KEY",
"ENCRYPTION_KEY",
"INTERNAL_AUTH_TOKEN",
];
/**
* Writes the seeded database into a fresh DATA_DIR and runs starter.ts's boot
* sequence up to, not including, opening the HTTP server. Call
* vi.resetModules() before, so module state from other tests is gone.
* sequence up to, not including, opening the HTTP server: the pre-upgrade
* backup, the database, core's migrations, then the plugins and their data
* moves. Call vi.resetModules() before, so module state from other tests is
* gone.
*/
export async function bootCore(seed: Database.Database): Promise<BootedCore> {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-boot-"));
export async function bootCore(source: BootSource): Promise<BootedCore> {
const dataDir =
"reuseDataDir" in source
? source.reuseDataDir
: fs.mkdtempSync(path.join(os.tmpdir(), "termix-boot-"));
process.env.DATA_DIR = dataDir;
process.env.DB_FILE_ENCRYPTION = "false";
process.env.ALLOW_EMPTY_DATA_DIR = "true";
process.env.TERMIX_BUNDLED_PLUGINS_DIR = BUILT_PLUGINS_DIR;
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
// SystemCrypto prefers these over DATA_DIR/.env, and a previous boot in
// this worker set them.
for (const key of SYSTEM_SECRETS) delete process.env[key];
let seed: Buffer | null = null;
if (source instanceof Database) {
seed = source.serialize();
} else if ("fixtureDir" in source) {
seed = placeFixture(source.fixtureDir, dataDir);
}
// TEST_DIALECT=postgres|mysql puts the same 2.8 data on a real server.
const remote = remoteTestDialect();
if (remote) {
const url = process.env.TEST_DATABASE_URL;
if (!url) throw new Error(`TEST_DIALECT=${remote} needs TEST_DATABASE_URL`);
process.env.DATABASE_DIALECT = remote;
process.env.DATABASE_URL = url;
if (seed) await seedRemote28(remote, url, seed);
} else if (seed) {
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed);
}
const { SystemCrypto } = await import("../../utils/system-crypto.js");
const systemCrypto = SystemCrypto.getInstance();
@@ -112,10 +183,13 @@ export async function bootCore(seed: Database.Database): Promise<BootedCore> {
await systemCrypto.initializeEncryptionKey();
await systemCrypto.initializeInternalAuthToken();
const { backupBeforeUpgrade, runCoreBootMigrations } =
await import("../../boot.js");
await backupBeforeUpgrade({ dataDir, version: "2.9.0-test" });
const db = await import("../../database/db/index.js");
await db.initializeDatabase();
const { runCoreBootMigrations } = await import("../../boot.js");
await runCoreBootMigrations();
const { primeKnownPermissions } =
@@ -132,9 +206,19 @@ export async function bootCore(seed: Database.Database): Promise<BootedCore> {
return {
dataDir,
sqlite: db.getSqlite(),
shutdown: async () => {
sqlite: remote ? null : db.getSqlite(),
seed,
shutdown: async ({ keepDataDir = false } = {}) => {
await shutdownPlugins();
if (keepDataDir) {
if (!remote) {
fs.writeFileSync(
path.join(dataDir, "db.sqlite"),
db.getSqlite().serialize(),
);
}
return;
}
fs.rmSync(dataDir, { recursive: true, force: true });
},
};
@@ -129,8 +129,8 @@ describe("the built plugins on a 2.8 database", () => {
});
it("records every plugin migration it ran", async () => {
const recorded = core.sqlite
.prepare(
const recorded = core
.sqlite!.prepare(
"SELECT plugin_id, COUNT(*) AS n FROM plugin_migrations GROUP BY plugin_id",
)
.all() as Array<{ plugin_id: string; n: number }>;
+146
View File
@@ -0,0 +1,146 @@
/**
* Puts a 2.8 install on a real Postgres or MySQL server for the upgrade test:
* wipes the database, applies release-2.8.0-tag's own drizzle migrations
* (UPGRADE_LEGACY_MIGRATIONS_DIR, which scripts/upgrade-check.sh extracts
* from git), then copies every row of the SQLite fixture across. After that
* the database is exactly what a 2.8 server left behind, and the normal boot
* takes it the rest of the way.
*/
import Database from "better-sqlite3";
import { sql, type SQL } from "drizzle-orm";
import { ROWS } from "../fixtures/upgrade/rows.js";
export type RemoteDialect = "postgres" | "mysql";
type Remote = { execute: (query: SQL) => Promise<unknown> };
/** TEST_DIALECT, the same switch the repository tests use. */
export function remoteTestDialect(
env: NodeJS.ProcessEnv = process.env,
): RemoteDialect | null {
const value = env.TEST_DIALECT?.trim().toLowerCase();
return value === "postgres" || value === "mysql" ? value : null;
}
async function rows<T>(db: Remote, query: SQL): Promise<T[]> {
const result = (await db.execute(query)) as { rows?: T[] } | T[];
if (Array.isArray(result)) {
return (Array.isArray(result[0]) ? result[0] : result) as T[];
}
return result.rows ?? [];
}
async function wipe(db: Remote, dialect: RemoteDialect): Promise<void> {
if (dialect === "postgres") {
await db.execute(sql.raw("DROP SCHEMA IF EXISTS drizzle CASCADE"));
await db.execute(sql.raw("DROP SCHEMA public CASCADE"));
await db.execute(sql.raw("CREATE SCHEMA public"));
return;
}
const [{ name }] = await rows<{ name: string }>(
db,
sql.raw("SELECT DATABASE() AS name"),
);
await db.execute(sql.raw(`DROP DATABASE \`${name}\``));
await db.execute(sql.raw(`CREATE DATABASE \`${name}\``));
await db.execute(sql.raw(`USE \`${name}\``));
}
async function columnTypes(
db: Remote,
dialect: RemoteDialect,
table: string,
): Promise<Map<string, string>> {
const schema =
dialect === "postgres" ? sql`current_schema()` : sql`DATABASE()`;
const found = await rows<{ name: string; type: string }>(
db,
sql`SELECT column_name AS name, data_type AS type FROM information_schema.columns WHERE table_schema = ${schema} AND table_name = ${table}`,
);
return new Map(found.map((column) => [column.name, column.type]));
}
async function resyncSequence(db: Remote, table: string): Promise<void> {
const [sequence] = await rows<{ name: string | null }>(
db,
sql.raw(
`SELECT pg_get_serial_sequence('"${table}"', 'id') AS name FROM information_schema.columns ` +
`WHERE table_schema = current_schema() AND table_name = '${table}' AND column_name = 'id'`,
),
);
if (!sequence?.name) return;
await db.execute(
sql.raw(
`SELECT setval('${sequence.name}', COALESCE((SELECT MAX(id) FROM "${table}"), 0) + 1, false)`,
),
);
}
export async function seedRemote28(
dialect: RemoteDialect,
url: string,
fixture: Buffer,
): Promise<void> {
const legacyMigrations = process.env.UPGRADE_LEGACY_MIGRATIONS_DIR;
if (!legacyMigrations) {
throw new Error(
"UPGRADE_LEGACY_MIGRATIONS_DIR must point at release-2.8.0-tag's drizzle folder. Run scripts/upgrade-check.sh.",
);
}
const { drizzle } =
dialect === "postgres"
? await import("drizzle-orm/node-postgres")
: await import("drizzle-orm/mysql2");
const db = (
drizzle as unknown as (
config: unknown,
) => Remote & { $client: { end: () => Promise<void> } }
)(
dialect === "postgres"
? { connection: url }
: { connection: { uri: url, connectionLimit: 1 } },
);
const source = new Database(fixture, { readonly: true });
try {
await wipe(db, dialect);
const { runRemoteMigrations } =
await import("../../database/db/migrate.js");
await runRemoteMigrations(dialect, db as never, {
DRIZZLE_MIGRATIONS_DIR: legacyMigrations,
});
for (const table of Object.keys(ROWS)) {
const types = await columnTypes(db, dialect, table);
if (types.size === 0) {
throw new Error(`2.8's ${dialect} schema has no ${table} table`);
}
for (const row of source
.prepare(`SELECT * FROM "${table}"`)
.all() as Array<Record<string, unknown>>) {
const columns = Object.keys(row).filter(
(column) => types.has(column) && row[column] !== null,
);
const values = columns.map((column) =>
types.get(column) === "boolean" ? Boolean(row[column]) : row[column],
);
await db.execute(
sql`INSERT INTO ${sql.identifier(table)} (${sql.join(
columns.map((column) => sql.identifier(column)),
sql`, `,
)}) VALUES (${sql.join(
values.map((value) => sql`${value}`),
sql`, `,
)})`,
);
}
if (dialect === "postgres") await resyncSequence(db, table);
}
} finally {
source.close();
await db.$client.end();
}
}
@@ -1,268 +0,0 @@
/**
* The quick upgrade check: a 2.8 database with rows in the tables plugins
* adopt, host columns plugins took over and settings keys they moved boots
* into 2.9.0, and the data reads back through the plugins' own APIs.
*/
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
assertPluginsBuilt,
bootCore,
create28Database,
type BootedCore,
} from "./built-harness.js";
const BOOT_TIMEOUT = 180_000;
const USER = "user-upgrade";
let core: BootedCore;
let app: express.Express;
let token: string;
const ids: Record<string, number> = {};
function seed() {
const { sqlite, insert } = create28Database();
insert("users", {
id: USER,
username: "admin",
password_hash: "not-a-real-hash",
is_admin: 1,
});
// A host that changed every plugin-owned switch away from its default...
ids.custom = insert("ssh_data", {
user_id: USER,
name: "custom",
ip: "10.0.0.1",
port: 22,
username: "root",
auth_type: "password",
enable_terminal: 1,
enable_command_history: 0,
enable_terminal_toolbar: 0,
enable_session_logging: 0,
allow_session_sharing: 0,
enable_file_manager: 0,
default_path: "/srv/data",
scp_legacy: 1,
enable_tunnel: 1,
tunnel_connections: [
{
sourcePort: 8080,
endpointPort: 80,
endpointHost: "custom",
autoStart: false,
},
],
enable_web_ui: 1,
web_ui_config: {
endpoints: [
{ id: "grafana", label: "Grafana", port: 3000, protocol: "http" },
],
},
enable_tmux_monitor: 1,
enable_docker: 1,
});
// ...and one that never touched them, which must keep 2.8's defaults.
ids.plain = insert("ssh_data", {
user_id: USER,
name: "plain",
ip: "10.0.0.2",
port: 22,
username: "root",
auth_type: "password",
});
insert("snippets", {
user_id: USER,
name: "upgrade-snippet",
content: "uptime",
});
insert("fleets", { user_id: USER, name: "upgrade-fleet" });
insert("user_workspaces", {
user_id: USER,
name: "upgrade-workspace",
payload: {},
});
insert("network_topology", {
user_id: USER,
topology: { nodes: [{ id: "upgrade-node" }], edges: [] },
});
insert("automations", {
user_id: USER,
name: "upgrade-automation",
enabled: 0,
definition: {
version: 1,
trigger: { type: "manual" },
steps: [],
},
});
insert("homepage_items", {
user_id: USER,
type_id: "clock",
title: "upgrade-widget",
config: {},
});
insert("vault_profiles", {
user_id: USER,
name: "upgrade-vault",
vault_addr: "https://vault.example.com",
ssh_role: "admin",
});
insert("termix_identities", { user_id: USER, handle: "upgrade-handle" });
insert("sso_providers", {
name: "upgrade-sso",
type: "oidc",
config: {
issuerUrl: "https://id.example.com",
clientId: "termix",
clientSecret: "secret",
},
});
insert("settings", { key: "terminal_session_timeout_minutes", value: "45" });
insert("settings", { key: "step_ca_url", value: "https://ca.example.com" });
return sqlite;
}
beforeAll(async () => {
assertPluginsBuilt();
vi.resetModules();
core = await bootCore(seed());
const { AuthManager } = await import("../../utils/auth-manager.js");
const auth = AuthManager.getInstance();
await auth.registerUser(USER);
await auth.unlockWithSystemKey(USER);
token = await auth.generateJWTToken(USER, {
deviceType: "web",
deviceInfo: "upgrade test",
});
const { mountPluginApi } =
await import("../../database/routes/plugin-api-routes.js");
const { default: pluginRoutes } =
await import("../../database/routes/plugins.js");
app = express();
app.use(express.json());
app.use("/plugins", pluginRoutes);
mountPluginApi(app);
}, BOOT_TIMEOUT);
afterAll(async () => {
await core?.shutdown();
}, BOOT_TIMEOUT);
function get(path: string) {
return request(app).get(path).set("Authorization", `Bearer ${token}`);
}
async function hostSettings(pluginId: string, hostId: number) {
const response = await get(`/plugins/${pluginId}/settings/host/${hostId}`);
expect(response.status, `${pluginId} host settings`).toBe(200);
return response.body.values as Record<string, unknown>;
}
describe("upgrading a 2.8 database", () => {
it("renames every adopted table the 2.8 schema had", () => {
const tables = new Set(
(
core.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all() as Array<{ name: string }>
).map((row) => row.name),
);
for (const legacy of [
"snippets",
"fleets",
"user_workspaces",
"network_topology",
"automations",
"homepage_items",
"vault_profiles",
"termix_identities",
"sso_providers",
]) {
expect(tables.has(legacy), `${legacy} was adopted`).toBe(false);
}
});
it("reads the adopted rows back through each plugin's routes", async () => {
const checks: Array<[string, string]> = [
["/plugin-api/snippets/", "upgrade-snippet"],
["/plugin-api/fleets/", "upgrade-fleet"],
["/plugin-api/workspaces/", "upgrade-workspace"],
["/plugin-api/network-topology/", "upgrade-node"],
["/plugin-api/automations/", "upgrade-automation"],
["/plugin-api/homepage/items", "upgrade-widget"],
["/plugin-api/vault/profiles", "upgrade-vault"],
["/plugin-api/termix-identity/me", "upgrade-handle"],
["/plugin-api/sso/providers", "upgrade-sso"],
];
for (const [path, marker] of checks) {
const response = await get(path);
expect(response.status, path).toBe(200);
expect(JSON.stringify(response.body), path).toContain(marker);
}
});
it("moves every changed host switch into its plugin's host settings", async () => {
expect(await hostSettings("ssh-terminal", ids.custom)).toMatchObject({
enableTerminal: true,
enableCommandHistory: false,
enableTerminalToolbar: false,
});
expect(await hostSettings("session-recording", ids.custom)).toMatchObject({
enableSessionRecording: false,
});
expect(await hostSettings("session-sharing", ids.custom)).toMatchObject({
allowSessionSharing: false,
});
expect(await hostSettings("file-manager", ids.custom)).toMatchObject({
enableFileManager: false,
defaultPath: "/srv/data",
scpLegacy: true,
});
const tunnels = await hostSettings("tunnels", ids.custom);
expect(tunnels.enableTunnel).toBe(true);
expect(JSON.stringify(tunnels.tunnelConnections)).toContain("8080");
const web = await hostSettings("web-endpoint", ids.custom);
expect(web.enableWebUi).toBe(true);
expect(JSON.stringify(web.webUiConfig)).toContain("Grafana");
expect(await hostSettings("tmux-monitor", ids.custom)).toMatchObject({
enableTmuxMonitor: true,
});
expect(await hostSettings("docker", ids.custom)).toMatchObject({
enableDocker: true,
});
});
it("keeps 2.8's defaults for a host that never changed them", async () => {
expect(await hostSettings("file-manager", ids.plain)).toMatchObject({
enableFileManager: true,
});
expect(await hostSettings("ssh-terminal", ids.plain)).toMatchObject({
enableTerminal: true,
enableCommandHistory: true,
});
expect(await hostSettings("session-recording", ids.plain)).toMatchObject({
enableSessionRecording: true,
});
expect(await hostSettings("session-sharing", ids.plain)).toMatchObject({
allowSessionSharing: true,
});
});
it("moves core settings keys into plugin settings", async () => {
const terminal = await get("/plugins/ssh-terminal/settings/admin");
expect(terminal.status).toBe(200);
expect(terminal.body.values.sessionTimeoutMinutes).toBe(45);
const stepCa = await get("/plugins/step-ca/settings/admin");
expect(stepCa.status).toBe(200);
expect(stepCa.body.values.caUrl).toBe("https://ca.example.com");
});
});
@@ -0,0 +1,72 @@
/**
* The committed 2.8 fixture still matches rows.ts and still fills every
* table a plugin adopts. A new legacy table in LEGACY_TABLE_OWNERS fails here
* until it gets fixture rows (npx tsx scripts/build-upgrade-fixture.ts).
*/
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { afterAll, describe, expect, it } from "vitest";
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
import { FieldCrypto } from "../../utils/field-crypto.js";
import { UPGRADE_FIXTURE_DIR } from "./built-harness.js";
import {
ENCRYPTED_COLUMNS,
RECORDING_FILES,
ROWS,
} from "../fixtures/upgrade/rows.js";
const sqlite = new Database(path.join(UPGRADE_FIXTURE_DIR, "db.sqlite"), {
readonly: true,
});
afterAll(() => sqlite.close());
const count = (table: string) =>
(
sqlite.prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get() as {
n: number;
}
).n;
describe("the committed 2.8 upgrade fixture", () => {
it("fills every table a plugin adopts", () => {
for (const table of Object.keys(LEGACY_TABLE_OWNERS)) {
expect(ROWS[table]?.length ?? 0, table).toBeGreaterThan(0);
expect(count(table), table).toBeGreaterThan(0);
}
});
it("holds exactly the rows in rows.ts", () => {
for (const [table, rows] of Object.entries(ROWS)) {
// Each user also has a wrapped data key in settings.
const extra = table === "settings" ? ROWS.users.length : 0;
expect(count(table), table).toBe(rows.length + extra);
}
});
it("stores the columns 2.8 encrypted as ciphertext", () => {
for (const [table, column] of ENCRYPTED_COLUMNS) {
const values = (
sqlite
.prepare(
`SELECT "${column}" AS value FROM "${table}" WHERE "${column}" IS NOT NULL AND "${column}" <> ''`,
)
.all() as Array<{ value: string }>
).map((row) => row.value);
for (const value of values) {
expect(FieldCrypto.isEncrypted(value), `${table}.${column}`).toBe(true);
}
}
});
it("ships the recording files its rows point at", () => {
for (const relative of Object.values(RECORDING_FILES)) {
expect(
fs.existsSync(path.join(UPGRADE_FIXTURE_DIR, "files", relative)),
relative,
).toBe(true);
}
});
});
@@ -0,0 +1,649 @@
/**
* The 2.8 to 2.9.0 upgrade, end to end. The committed 2.8 install in
* fixtures/upgrade boots through the pre-upgrade backup, core's migrations,
* every built plugin's migrations and the data moves, and everything it held
* reads back through the plugins' routes, their settings and their ctx. Then
* the same data directory boots again and nothing changes.
*
* TEST_DIALECT=postgres|mysql with TEST_DATABASE_URL runs it against a real
* server instead (scripts/upgrade-check.sh sets that up).
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import express from "express";
import request from "supertest";
import { sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
import {
assertPluginsBuilt,
bootCore,
BUILT_PLUGINS_DIR,
UPGRADE_FIXTURE_DIR,
type BootedCore,
} from "./built-harness.js";
import { remoteTestDialect } from "./remote-seed.js";
import {
FIXTURE_PASSWORD,
HOST_AUTH_TYPES,
HOSTS,
LDAP_PROVIDER_ID,
OPERATORS_PERMISSIONS,
ROWS,
SSO_PROVIDER_ID,
SYNCED_ENTITY_TYPES_28,
TOTP_BACKUP_CODES,
TOTP_SECRET,
USER_ROLE_PERMISSIONS_28,
USERS,
} from "../fixtures/upgrade/rows.js";
const BOOT_TIMEOUT = 240_000;
type UserKey = keyof typeof USERS;
interface Booted {
core: BootedCore;
app: express.Express;
tokens: Record<UserKey, string>;
errors: string[];
}
/** legacy table -> the p_ table its plugin renamed it to. */
function adoptedNames(): Map<string, string> {
const names = new Map<string, string>();
for (const id of new Set(Object.values(LEGACY_TABLE_OWNERS))) {
const dir = path.join(BUILT_PLUGINS_DIR, id, "migrations", "sqlite");
for (const file of fs.readdirSync(dir)) {
const text = fs.readFileSync(path.join(dir, file), "utf8");
for (const match of text.matchAll(
/ALTER TABLE "([a-z0-9_]+)" RENAME TO "(p_[a-z0-9_]+)"/g,
)) {
names.set(match[1], match[2]);
}
}
}
return names;
}
async function boot(dataDir?: string): Promise<Booted> {
vi.resetModules();
const errors: string[] = [];
const error = vi.spyOn(console, "error").mockImplementation((...args) => {
errors.push(args.map(String).join(" "));
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
let core: BootedCore;
try {
core = await bootCore(
dataDir ? { reuseDataDir: dataDir } : { fixtureDir: UPGRADE_FIXTURE_DIR },
);
} finally {
error.mockRestore();
warn.mockRestore();
}
const { AuthManager } = await import("../../utils/auth-manager.js");
const auth = AuthManager.getInstance();
const tokens = {} as Record<UserKey, string>;
for (const [key, userId] of Object.entries(USERS)) {
await auth.unlockWithSystemKey(userId);
tokens[key as UserKey] = await auth.generateJWTToken(userId, {
deviceType: "web",
deviceInfo: "upgrade test",
});
}
const { mountPluginApi } =
await import("../../database/routes/plugin-api-routes.js");
const { default: pluginRoutes } =
await import("../../database/routes/plugins.js");
const { default: userRoutes } =
await import("../../database/routes/users.js");
const app = express();
app.use(express.json());
app.use("/users", userRoutes);
app.use("/plugins", pluginRoutes);
mountPluginApi(app);
return { core, app, tokens, errors };
}
function get(booted: Booted, url: string, as: UserKey = "admin") {
return request(booted.app)
.get(url)
.set("Authorization", `Bearer ${booted.tokens[as]}`);
}
async function settings(
booted: Booted,
pluginId: string,
scope: string,
as: UserKey = "admin",
): Promise<Record<string, unknown>> {
const response = await get(
booted,
`/plugins/${pluginId}/settings/${scope}`,
as,
);
expect(response.status, `${pluginId} ${scope} settings`).toBe(200);
return response.body.values as Record<string, unknown>;
}
/** A ctx for a running plugin, the one its own code gets. */
async function pluginCtx(pluginId: string) {
const { getPluginRuntime } = await import("../../plugins/index.js");
const { createPluginContext } = await import("../../plugins/ctx.js");
const loaded = getPluginRuntime().loader.get(pluginId);
if (!loaded?.handle) throw new Error(`${pluginId} is not running`);
return createPluginContext(loaded.manifest, loaded.handle);
}
async function asUser<T>(userId: string, fn: () => Promise<T>): Promise<T> {
const { runAsActor } = await import("../../plugins/actor.js");
return runAsActor(userId, "asUser", fn);
}
/** Every row of a plugin table, read through that plugin's ctx.db. */
async function pluginRows(
pluginId: string,
table: string,
): Promise<Array<Record<string, unknown>>> {
const ctx = await pluginCtx(pluginId);
/* eslint-disable @typescript-eslint/no-explicit-any */
const client = await ctx.db.client<any>();
const query = sql`SELECT * FROM ${sql.identifier(table)}`;
if (typeof client.all === "function") return client.all(query);
const result = await client.execute(query);
return Array.isArray(result) ? result[0] : result.rows;
/* eslint-enable @typescript-eslint/no-explicit-any */
}
/** Bookkeeping counts, compared between the first and second boot. */
async function counts(): Promise<Record<string, number>> {
const { selectRows } =
await import("../../utils/crypto-migration/raw-rows.js");
const out: Record<string, number> = {};
const tables = [
"plugin_migrations",
"plugin_settings",
"user_second_factors",
"user_external_identities",
"roles",
"user_roles",
"ssh_data",
...adoptedNames().values(),
];
for (const table of tables) {
const [row] = await selectRows<{ n: number | string }>(
sql`SELECT COUNT(*) AS n FROM ${sql.identifier(table)}`,
);
out[table] = Number(row.n);
}
return out;
}
function totpCode(secret: string, at = Date.now()): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let bits = "";
for (const char of secret.replace(/=+$/, "")) {
bits += alphabet.indexOf(char).toString(2).padStart(5, "0");
}
const key = Buffer.from(
bits.match(/.{8}/g)!.map((byte) => parseInt(byte, 2)),
);
const counter = Buffer.alloc(8);
counter.writeBigUInt64BE(BigInt(Math.floor(at / 30_000)));
const hmac = crypto.createHmac("sha1", key).update(counter).digest();
const offset = hmac[hmac.length - 1] & 0xf;
const value = (hmac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
return String(value).padStart(6, "0");
}
/** Everything a 2.8 install had, checked against a booted 2.9.0. */
function upgradeChecks(current: () => Booted, bootIndex: number) {
it("boots without logging an error", () => {
expect(current().errors).toEqual([]);
});
it("activates every plugin", async () => {
const { getPluginRuntime } = await import("../../plugins/index.js");
const notActive = getPluginRuntime()
.loader.list()
.filter((plugin) => plugin.state !== "active")
.map((plugin) => `${plugin.id}: ${plugin.state} ${plugin.lastError}`);
expect(notActive).toEqual([]);
});
it("keeps every row of every adopted table, readable by its plugin", async () => {
const names = adoptedNames();
for (const [legacy, pluginId] of Object.entries(LEGACY_TABLE_OWNERS)) {
const seeded = ROWS[legacy] ?? [];
expect(seeded.length, `the fixture fills ${legacy}`).toBeGreaterThan(0);
const table = names.get(legacy);
expect(table, `${pluginId} adopts ${legacy}`).toBeDefined();
// 2.8 kept LDAP directories in sso_providers; the ldap plugin takes them.
const expected =
legacy === "sso_providers"
? seeded.filter((row) => row.type !== "ldap")
: seeded;
const rows = await pluginRows(pluginId, table!);
const ids = rows.map((row) => String(row.id)).sort();
expect(ids, `${legacy} -> ${table}`).toEqual(
expected.map((row) => String(row.id)).sort(),
);
}
const ldap = await pluginRows("ldap", "p_ldap_providers");
expect(ldap.map((row) => String(row.id))).toEqual([
String(LDAP_PROVIDER_ID),
]);
});
it("reads the adopted rows back through each plugin's routes", async () => {
const checks: Array<[string, string, UserKey?]> = [
["/plugin-api/snippets/", "d4-snippet"],
["/plugin-api/snippets/shared", "d4-shared-snippet", "alice"],
["/plugin-api/fleets/", "d4-fleet"],
["/plugin-api/workspaces/", "d4-workspace"],
["/plugin-api/network-topology/", "d4-node"],
["/plugin-api/automations/", "d4-automation"],
["/plugin-api/homepage/items", "d4-widget"],
["/plugin-api/homepage/service-links", "d4-link"],
["/plugin-api/vault/profiles", "d4-vault"],
["/plugin-api/termix-identity/me", "d4-handle"],
["/plugin-api/sso/providers", "d4-sso"],
["/plugin-api/ldap/providers", "d4-ldap"],
[
`/plugin-api/ssh-terminal/command-history/${HOSTS.password}`,
"d4-history-command",
],
["/plugin-api/tunnels/presets", "d4-preset"],
["/plugin-api/ai/providers", "d4-provider"],
["/plugin-api/ai/conversations/1", "d4-message"],
["/plugin-api/secret-sources", "d4-source"],
["/plugin-api/session-sharing/rooms", "d4-room"],
["/plugin-api/webauthn/credentials", "d4-passkey", "passkey"],
];
const failures: string[] = [];
for (const [url, marker, as] of checks) {
const response = await get(current(), url, as);
if (
response.status !== 200 ||
!JSON.stringify(response.body).includes(marker)
) {
failures.push(
`${url} -> ${response.status} ${JSON.stringify(response.body).slice(0, 200)}`,
);
}
}
expect(failures).toEqual([]);
});
it("moves every host flag into its plugin's host settings", async () => {
const booted = current();
const host = (id: string) => settings(booted, id, `host/${HOSTS.password}`);
expect(await host("ssh-terminal")).toMatchObject({
enableTerminal: true,
enableCommandHistory: false,
enableTerminalToolbar: false,
});
expect(await host("session-recording")).toMatchObject({
enableSessionRecording: false,
});
expect(await host("session-sharing")).toMatchObject({
allowSessionSharing: false,
});
expect(await host("file-manager")).toMatchObject({
enableFileManager: false,
defaultPath: "/srv/d4",
scpLegacy: true,
});
const tunnels = await host("tunnels");
expect(tunnels.enableTunnel).toBe(true);
expect(JSON.stringify(tunnels.tunnelConnections)).toContain("8080");
const web = await host("web-endpoint");
expect(web.enableWebUi).toBe(true);
expect(JSON.stringify(web.webUiConfig)).toContain("d4-grafana");
expect(await host("tmux-monitor")).toMatchObject({
enableTmuxMonitor: true,
});
expect(await host("docker")).toMatchObject({
enableDocker: true,
containerRuntime: "podman",
});
expect(await host("ai")).toMatchObject({ enableAiAssistant: true });
expect(await host("wake-on-lan")).toMatchObject({
macAddress: "AA:BB:CC:DD:EE:04",
broadcastAddress: "10.4.0.255",
});
expect(await host("host-metrics")).toMatchObject({
metricsEnabled: false,
metricsInterval: 45,
enabledWidgets: ["cpu", "memory"],
});
const proxmox = await settings(booted, "proxmox", `host/${HOSTS.key}`);
expect(proxmox.enableProxmox).toBe(true);
expect(JSON.stringify(proxmox)).toContain("pve.d4.example");
expect(
await settings(booted, "remote-desktop", `host/${HOSTS.desktop}`),
).toMatchObject({
enableRdp: true,
enableVnc: true,
enableTelnet: true,
rdpPort: 3390,
vncPort: 5901,
telnetPort: 2323,
});
expect(
await settings(booted, "remote-desktop", `host/${HOSTS.rdpOnly}`),
).toMatchObject({ enableRdp: true });
expect(
await settings(booted, "vault", `host/${HOSTS.vault}`),
).toMatchObject({ profileId: 1 });
expect(
await settings(booted, "warpgate", `host/${HOSTS.warpgate}`),
).toMatchObject({ useWarpgate: true });
});
it("keeps 2.8's defaults on a host that never changed them", async () => {
const booted = current();
const host = (id: string) => settings(booted, id, `host/${HOSTS.plain}`);
expect(await host("file-manager")).toMatchObject({
enableFileManager: true,
});
expect(await host("ssh-terminal")).toMatchObject({
enableTerminal: true,
enableCommandHistory: true,
enableTerminalToolbar: true,
});
expect(await host("session-recording")).toMatchObject({
enableSessionRecording: true,
});
expect(await host("session-sharing")).toMatchObject({
allowSessionSharing: true,
});
expect(await host("docker")).toMatchObject({ enableDocker: false });
expect(await host("tmux-monitor")).toMatchObject({
enableTmuxMonitor: false,
});
});
it("keeps each host's connection and secrets", async () => {
const { selectRows } =
await import("../../utils/crypto-migration/raw-rows.js");
const hosts = await selectRows<{
id: number;
auth_type: string;
enable_ssh: unknown;
}>(sql`SELECT id, auth_type, enable_ssh FROM ssh_data ORDER BY id`);
const byId = new Map(hosts.map((row) => [row.id, row]));
for (const [id, authType] of Object.entries(HOST_AUTH_TYPES)) {
// Warpgate became a plugin setting on a host with no SSH auth of its own.
const want = authType === "warpgate" ? "none" : authType;
expect(byId.get(Number(id))?.auth_type, `host ${id}`).toBe(want);
}
expect(Number(byId.get(HOSTS.rdpOnly)?.enable_ssh)).toBe(0);
const ctx = await pluginCtx("remote-desktop");
const target = await asUser(USERS.admin, () =>
ctx.credentials.resolveHostProtocol(HOSTS.rdpOnly, "rdp"),
);
expect(target?.auth.password).toBe("d4-rdp-password");
});
it("registers an SSH auth provider for every 2.8 auth type", async () => {
const { listSshAuthProviders } =
await import("../../hosts/connect/auth-provider-registry.js");
const types = new Set(listSshAuthProviders().map((p) => p.type));
for (const authType of new Set(Object.values(HOST_AUTH_TYPES))) {
if (authType === "warpgate") continue;
expect(types, authType).toContain(authType);
}
});
it("plays back every 2.8 recording", async () => {
const booted = current();
const list = await get(booted, "/plugin-api/session-recording/");
expect(
(list.body.logs as Array<{ id: number }>).map((log) => log.id).sort(),
).toEqual([1, 2]);
for (const [id, kind] of [
[1, "ssh"],
[2, "rdp"],
] as const) {
const content = await get(
booted,
`/plugin-api/session-recording/${id}/content`,
)
.buffer(true)
.parse((res, done) => {
let text = "";
res.on("data", (chunk: Buffer) => (text += chunk.toString()));
res.on("end", () => done(null, text));
});
expect(content.status, `recording ${id}`).toBe(200);
expect(content.body).toContain(`d4 ${kind} recording`);
}
});
it("puts every moved setting in its plugin's admin and user scope", async () => {
const booted = current();
expect(await settings(booted, "ssh-terminal", "admin")).toMatchObject({
sessionTimeoutMinutes: 45,
sessionPersistence: false,
commandHistoryForNewHosts: false,
imageMaxCount: 7,
});
expect(await settings(booted, "step-ca", "admin")).toMatchObject({
caUrl: "https://ca.d4.example",
fingerprint: "d4fingerprint",
provisioner: "d4-provisioner",
});
expect(await settings(booted, "remote-desktop", "admin")).toMatchObject({
enabled: true,
guacdUrl: "guacd.d4.example:4822",
});
expect(await settings(booted, "host-metrics", "admin")).toMatchObject({
metricsInterval: 40,
historyRetentionDays: 14,
enabledForNewHosts: false,
});
expect(await settings(booted, "ai", "admin")).toMatchObject({
globallyEnabled: true,
});
expect(await settings(booted, "session-recording", "admin")).toMatchObject({
retentionDays: 3650,
});
expect(
JSON.stringify((await settings(booted, "ai", "admin")).privateEndpoints),
).toContain("10.4.0.0/24");
expect(await settings(booted, "session-sharing", "admin")).toMatchObject({
globallyEnabled: false,
});
expect(await settings(booted, "acme-ssl", "admin")).toMatchObject({
domain: "termix.d4.example",
email: "d4@example.com",
});
expect(await settings(booted, "tailscale", "admin")).toMatchObject({
apiBaseUrl: "https://headscale.d4.example",
});
expect(await settings(booted, "ai", "user")).toMatchObject({
enabled: true,
allowReadOnlyCommands: true,
});
expect(await settings(booted, "remote-desktop", "user")).toMatchObject({
colorDepth: "16",
resizeMethod: "reconnect",
enableDrive: "on",
});
});
it("keeps every secret readable by the plugin that owns it", async () => {
const tailscale = await pluginCtx("tailscale");
expect(await tailscale.settings.get("apiKey")).toBe("tskey-d4");
const acme = await pluginCtx("acme-ssl");
expect(JSON.stringify(await acme.settings.getAll("admin"))).toContain(
"d4-cloudflare-token",
);
const ai = await pluginCtx("ai");
expect(await asUser(USERS.admin, () => ai.secrets.get("provider:1"))).toBe(
"sk-d4-provider-key",
);
const sources = await pluginCtx("secret-sources");
expect(
await asUser(USERS.admin, () => sources.secrets.get("source:d4-source")),
).toBe("d4-source-token");
});
it("keeps every role's permissions", async () => {
const { PermissionManager } =
await import("../../utils/permission-manager.js");
const permissions = PermissionManager.getInstance();
for (const permission of OPERATORS_PERMISSIONS) {
expect(
await permissions.hasPermission(USERS.alice, permission),
`operators: ${permission}`,
).toBe(true);
}
for (const permission of USER_ROLE_PERMISSIONS_28) {
expect(
await permissions.hasPermission(USERS.passkey, permission),
`user: ${permission}`,
).toBe(true);
}
expect(await permissions.hasPermission(USERS.admin, "sso.manage")).toBe(
true,
);
});
it("still asks a 2.8 TOTP user for a code, and accepts one", async () => {
const booted = current();
const login = await request(booted.app)
.post("/users/login")
.send({ username: "d4-totp", password: FIXTURE_PASSWORD });
expect(login.status).toBe(200);
expect(login.body.requires_totp).toBe(true);
expect(JSON.stringify(login.body.second_factors)).toContain("totp");
const wrong = await request(booted.app)
.post("/users/totp/verify-login")
.send({ temp_token: login.body.temp_token, totp_code: "000000" });
expect(wrong.status).not.toBe(200);
const verify = await request(booted.app)
.post("/users/totp/verify-login")
.send({
temp_token: login.body.temp_token,
totp_code: totpCode(TOTP_SECRET),
});
expect(verify.status).toBe(200);
const again = await request(booted.app)
.post("/users/login")
.send({ username: "d4-totp", password: FIXTURE_PASSWORD });
const backup = await request(booted.app)
.post("/users/totp/verify-login")
.send({
temp_token: again.body.temp_token,
totp_code: TOTP_BACKUP_CODES[bootIndex],
});
expect(backup.status).toBe(200);
});
it("signs a password user without a second factor straight in", async () => {
const login = await request(current().app)
.post("/users/login")
.send({ username: "d4-alice", password: FIXTURE_PASSWORD });
expect(login.status).toBe(200);
expect(login.body.requires_totp).toBeFalsy();
});
it("links SSO and LDAP users to their providers", async () => {
const { selectRows } =
await import("../../utils/crypto-migration/raw-rows.js");
const links = await selectRows<{
user_id: string;
provider_id: unknown;
}>(sql`SELECT user_id, provider_id FROM user_external_identities`);
const byUser = new Map(
links.map((row) => [row.user_id, String(row.provider_id)]),
);
expect(byUser.get(USERS.sso)).toBe(String(SSO_PROVIDER_ID));
// The ldap plugin signs in with "ldap:<provider>".
expect(byUser.get(USERS.ldap)).toBe(`ldap:${LDAP_PROVIDER_ID}`);
});
it("syncs every entity type 2.8 synced, under the same name", async () => {
const { listEntityTypes } = await import("../../plugins/sync-registry.js");
const { registerCoreSyncEntities } =
await import("../../database/routes/sync-entities.js");
registerCoreSyncEntities();
const known = new Set(listEntityTypes());
for (const type of SYNCED_ENTITY_TYPES_28) {
expect(known, type).toContain(type);
}
const snippets = await pluginRows("snippets", "p_snippets_snippets");
expect(snippets.map((row) => row.sync_id)).toContain("d4-sync-snippet");
const vault = await pluginRows("vault", "p_vault_profiles");
expect(vault.map((row) => row.sync_id)).toContain("d4-sync-vault");
});
}
let first: Booted;
let firstCounts: Record<string, number>;
let second: Booted;
beforeAll(async () => {
assertPluginsBuilt();
first = await boot();
}, BOOT_TIMEOUT);
afterAll(async () => {
await (second ?? first)?.core.shutdown();
}, BOOT_TIMEOUT);
describe("a 2.8 install upgraded to 2.9.0", () => {
// Only SQLite is backed up by Termix; a server database is the operator's.
it.skipIf(remoteTestDialect())(
"backed up the 2.8 database before anything touched it",
async () => {
const backups = path.join(first.core.dataDir, "backups");
const dirs = fs
.readdirSync(backups)
.filter((name) => name.startsWith("pre-plugin-runtime-"));
expect(dirs).toHaveLength(1);
const copy = fs.readFileSync(path.join(backups, dirs[0], "db.sqlite"));
expect(copy.equals(first.core.seed!)).toBe(true);
},
);
upgradeChecks(() => first, 0);
});
describe("booting the upgraded install again", () => {
beforeAll(async () => {
firstCounts = await counts();
const dataDir = first.core.dataDir;
await first.core.shutdown({ keepDataDir: true });
second = await boot(dataDir);
}, BOOT_TIMEOUT);
it("changes nothing", async () => {
expect(await counts()).toEqual(firstCounts);
});
it.skipIf(remoteTestDialect())("takes no second backup", () => {
const backups = fs.readdirSync(path.join(second.core.dataDir, "backups"));
expect(
backups.filter((name) => name.startsWith("pre-plugin-runtime-")),
).toHaveLength(1);
});
upgradeChecks(() => second, 1);
});
@@ -43,7 +43,7 @@ import { runOpksshConfigMigration } from "../../upgrade/opkssh-config-migration.
let dataDir: string;
const legacy = () => path.join(dataDir, ".opk", "config.yml");
const target = () => path.join(dataDir, "plugins", "opkssh", "config.yml");
const target = () => path.join(dataDir, "plugin-data", "opkssh", "config.yml");
beforeEach(async () => {
state.settings = new Map();
@@ -0,0 +1,157 @@
/**
* The 2.8 session recording move. The retention period survives, so the
* plugin's first sweep does not prune with its own default, and each 2.8
* recording file moves into the plugin's folder with its row following it.
* Running it twice changes nothing.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
db: null as unknown,
core: new Map<string, string>(),
plugin: new Map<string, string>(),
installed: true,
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) => (h.installed ? { id } : null),
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => h.core.get(key) ?? null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (_p: string, _s: string, _id: string | null, key: string) =>
h.plugin.has(key) ? { value: h.plugin.get(key) } : null,
set: async (
_p: string,
_s: string,
_id: string | null,
key: string,
value: string,
) => {
h.plugin.set(key, value);
},
}),
}));
const { runSessionRecordingDataMigration } =
await import("../../upgrade/session-recording-data-migration.js");
let dataDir: string;
let sqlite: Database.Database;
function write(relative: string, content: string): string {
const file = path.join(dataDir, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
return file;
}
function paths(): Record<number, string> {
return Object.fromEntries(
(
sqlite
.prepare(
"SELECT id, recording_path FROM p_session_recording_session_recordings",
)
.all() as Array<{ id: number; recording_path: string }>
).map((row) => [row.id, row.recording_path]),
);
}
beforeEach(() => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "recording-move-"));
vi.stubEnv("DATA_DIR", dataDir);
vi.stubEnv("SESSION_RECORDING_RETENTION_DAYS", "");
h.core.clear();
h.plugin.clear();
h.installed = true;
sqlite = new Database(":memory:");
sqlite.exec(`CREATE TABLE p_session_recording_session_recordings (
id INTEGER PRIMARY KEY, recording_path TEXT
)`);
h.db = drizzle(sqlite);
});
afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(dataDir, { recursive: true, force: true });
});
describe("runSessionRecordingDataMigration", () => {
it("moves each 2.8 recording into the plugin's folder and points its row there", async () => {
const ssh = write("session_logs/user-1/s1.cast", "ssh");
const rdp = write("session_recordings/guacamole/r1.guac", "rdp");
const insert = sqlite.prepare(
"INSERT INTO p_session_recording_session_recordings (id, recording_path) VALUES (?, ?)",
);
insert.run(1, ssh);
insert.run(2, rdp);
insert.run(3, path.join(dataDir, "session_logs/user-1/gone.cast"));
insert.run(4, "/somewhere/else.cast");
const result = await runSessionRecordingDataMigration();
const base = path.join(dataDir, "plugin-data", "session-recording");
const moved = paths();
expect(moved[1]).toBe(path.join(base, "session_logs/user-1/s1.cast"));
expect(moved[2]).toBe(
path.join(base, "session_recordings/guacamole/r1.guac"),
);
expect(fs.readFileSync(moved[1], "utf8")).toBe("ssh");
expect(fs.readFileSync(moved[2], "utf8")).toBe("rdp");
expect(fs.existsSync(ssh)).toBe(false);
// A file 2.8 had already lost, and a path outside 2.8's folders, stay put.
expect(moved[3]).toBe(path.join(dataDir, "session_logs/user-1/gone.cast"));
expect(moved[4]).toBe("/somewhere/else.cast");
expect(result.moved).toBe(2);
const again = await runSessionRecordingDataMigration();
expect(again.moved).toBe(0);
expect(paths()).toEqual(moved);
});
it("keeps the 2.8 retention period", async () => {
h.core.set("session_recording_retention_days", "365");
await runSessionRecordingDataMigration();
expect(h.plugin.get("retentionDays")).toBe("365");
h.core.set("session_recording_retention_days", "90");
await runSessionRecordingDataMigration();
expect(h.plugin.get("retentionDays")).toBe("365");
});
it("falls back to the environment variable 2.8 read", async () => {
vi.stubEnv("SESSION_RECORDING_RETENTION_DAYS", "120");
await runSessionRecordingDataMigration();
expect(h.plugin.get("retentionDays")).toBe("120");
});
it("does nothing until the plugin is installed", async () => {
h.installed = false;
h.core.set("session_recording_retention_days", "365");
const ssh = write("session_logs/user-1/s1.cast", "ssh");
sqlite
.prepare(
"INSERT INTO p_session_recording_session_recordings (id, recording_path) VALUES (1, ?)",
)
.run(ssh);
await runSessionRecordingDataMigration();
expect(h.plugin.size).toBe(0);
expect(paths()[1]).toBe(ssh);
});
});
@@ -3,12 +3,15 @@ import fs from "fs";
import os from "os";
import path from "path";
import {
CURRENT_PREUPGRADE_BACKUP_REASON,
DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER,
DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX,
DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV,
ensureDatabaseLayerPreupgradeBackup,
ensurePreupgradeBackup,
preupgradeBackupMarker,
} from "../../utils/database-layer-preupgrade-backup.js";
const MARKER = preupgradeBackupMarker(CURRENT_PREUPGRADE_BACKUP_REASON);
const tempDirs: string[] = [];
function makeTempDir(): string {
@@ -23,7 +26,7 @@ afterEach(() => {
}
});
describe("ensureDatabaseLayerPreupgradeBackup", () => {
describe("ensurePreupgradeBackup", () => {
it("copies existing database files and writes a marker", () => {
const dataDir = makeTempDir();
const encryptedDbPath = path.join(dataDir, "db.sqlite.encrypted");
@@ -33,7 +36,7 @@ describe("ensureDatabaseLayerPreupgradeBackup", () => {
fs.writeFileSync(metadataPath, '{"version":"v2"}');
fs.writeFileSync(envPath, "DATABASE_KEY=test-key");
const result = ensureDatabaseLayerPreupgradeBackup({
const result = ensurePreupgradeBackup({
dataDir,
version: "2.5.0-test",
now: new Date("2026-06-27T12:00:00.000Z"),
@@ -41,12 +44,8 @@ describe("ensureDatabaseLayerPreupgradeBackup", () => {
});
expect(result.status).toBe("created");
expect(result.backupDir).toContain(DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX);
expect(
fs.existsSync(
path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER),
),
).toBe(true);
expect(result.backupDir).toContain(CURRENT_PREUPGRADE_BACKUP_REASON);
expect(fs.existsSync(path.join(dataDir, MARKER))).toBe(true);
expect(
fs.readFileSync(
path.join(result.backupDir!, "db.sqlite.encrypted"),
@@ -72,12 +71,9 @@ describe("ensureDatabaseLayerPreupgradeBackup", () => {
it("skips when the marker already exists", () => {
const dataDir = makeTempDir();
fs.writeFileSync(path.join(dataDir, "db.sqlite.encrypted"), "encrypted-db");
fs.writeFileSync(
path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER),
"{}",
);
fs.writeFileSync(path.join(dataDir, MARKER), "{}");
const result = ensureDatabaseLayerPreupgradeBackup({
const result = ensurePreupgradeBackup({
dataDir,
env: {} as NodeJS.ProcessEnv,
});
@@ -89,10 +85,37 @@ describe("ensureDatabaseLayerPreupgradeBackup", () => {
expect(fs.existsSync(path.join(dataDir, "backups"))).toBe(false);
});
it("still backs up an install that has the 2.5 refactor's marker", () => {
const dataDir = makeTempDir();
fs.writeFileSync(path.join(dataDir, "db.sqlite"), "2.8-db");
fs.writeFileSync(
path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER),
"{}",
);
const first = ensurePreupgradeBackup({
dataDir,
env: {} as NodeJS.ProcessEnv,
});
expect(first.status).toBe("created");
expect(
fs.readFileSync(path.join(first.backupDir!, "db.sqlite"), "utf8"),
).toBe("2.8-db");
const second = ensurePreupgradeBackup({
dataDir,
env: {} as NodeJS.ProcessEnv,
});
expect(second).toMatchObject({
status: "skipped",
reason: "marker_exists",
});
});
it("skips new installs without a database file", () => {
const dataDir = makeTempDir();
const result = ensureDatabaseLayerPreupgradeBackup({
const result = ensurePreupgradeBackup({
dataDir,
env: {} as NodeJS.ProcessEnv,
});
@@ -101,18 +124,14 @@ describe("ensureDatabaseLayerPreupgradeBackup", () => {
status: "skipped",
reason: "no_database_file",
});
expect(
fs.existsSync(
path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER),
),
).toBe(false);
expect(fs.existsSync(path.join(dataDir, MARKER))).toBe(false);
});
it("honors the explicit skip environment variable", () => {
const dataDir = makeTempDir();
fs.writeFileSync(path.join(dataDir, "db.sqlite.encrypted"), "encrypted-db");
const result = ensureDatabaseLayerPreupgradeBackup({
const result = ensurePreupgradeBackup({
dataDir,
env: {
[DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV]: "1",
@@ -132,7 +151,7 @@ describe("ensureDatabaseLayerPreupgradeBackup", () => {
fs.writeFileSync(path.join(dataDir, "backups"), "not-a-directory");
expect(() =>
ensureDatabaseLayerPreupgradeBackup({
ensurePreupgradeBackup({
dataDir,
env: {} as NodeJS.ProcessEnv,
}),
@@ -2,7 +2,7 @@
* 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
* folder (DATA_DIR/plugin-data/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
@@ -55,7 +55,7 @@ export async function runOpksshConfigMigration(
}
try {
const target = path.join(dataDir, "plugins", PLUGIN_ID, "config.yml");
const target = path.join(dataDir, "plugin-data", PLUGIN_ID, "config.yml");
if (!(await exists(target))) {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.copyFile(legacyConfig, target);
+5
View File
@@ -8,9 +8,14 @@
import { runLdapProviderMigration } from "./ldap-provider-migration.js";
import { runOpksshConfigMigration } from "./opkssh-config-migration.js";
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
import { runSessionRecordingDataMigration } from "./session-recording-data-migration.js";
import { runHostMetricsSettingsMigration } from "./host-metrics-settings-migration.js";
export async function runPluginDataMoves(): Promise<void> {
await runLdapProviderMigration();
await runOpksshConfigMigration();
await runTermixIdentityCaMigration();
// Both set how long history is kept, which the plugin prunes by on activate.
await runSessionRecordingDataMigration();
await runHostMetricsSettingsMigration();
}
@@ -0,0 +1,167 @@
/**
* Moves what a 2.8 install kept for session recordings into the
* session-recording plugin, before the plugin activates. Its retention sweep
* runs on activate, so both have to be in place first:
*
* - The retention period: the `session_recording_retention_days` setting, or
* the SESSION_RECORDING_RETENTION_DAYS environment variable 2.8 fell back
* to, becomes the plugin's `retentionDays`. Without it the plugin's 30 day
* default would prune an install that kept a year.
* - The files: 2.8 wrote them under DATA_DIR/session_logs (SSH) and
* DATA_DIR/session_recordings (guacd). The plugin only plays back and
* prunes files inside its own data folder, so each file a row points at is
* moved there, keeping its path below DATA_DIR, and the row follows it.
* Moved rather than copied: recordings can be large, and 2.8 cannot read
* the adopted table after a downgrade anyway.
*
* Idempotent: an existing retention value is kept, and a row that already
* points inside the plugin's folder is skipped.
*/
import fs from "node:fs/promises";
import path from "node:path";
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";
import { getPluginDataDir } from "../plugins/paths.js";
const PLUGIN_ID = "session-recording";
const TABLE = "p_session_recording_session_recordings";
const LEGACY_DIRS = ["session_logs", "session_recordings"];
export interface SessionRecordingDataMigrationResult {
retentionDays: number | null;
moved: number;
}
function days(value: unknown): number | null {
const parsed = parseInt(String(value ?? ""), 10);
return parsed >= 1 && parsed <= 3650 ? parsed : null;
}
async function exists(file: string): Promise<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}
async function move(source: string, target: string): Promise<void> {
await fs.mkdir(path.dirname(target), { recursive: true });
try {
await fs.rename(source, target);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
await fs.copyFile(source, target);
await fs.unlink(source);
}
}
async function migrateRetention(): Promise<number | null> {
const settings = createCurrentPluginSettingsRepository();
const existing = await settings.get(
PLUGIN_ID,
"admin",
null,
"retentionDays",
);
if (existing && existing.value !== null) return null;
const legacy =
days(
await createCurrentSettingsRepository().get(
"session_recording_retention_days",
),
) ?? days(process.env.SESSION_RECORDING_RETENTION_DAYS);
if (legacy === null) return null;
await settings.set(
PLUGIN_ID,
"admin",
null,
"retentionDays",
JSON.stringify(legacy),
);
return legacy;
}
async function migrateFiles(): Promise<number> {
let rows: Array<{ id: number; recording_path: string | null }>;
try {
rows = await selectRows(
sql`SELECT id, recording_path FROM ${sql.identifier(TABLE)} WHERE recording_path IS NOT NULL`,
);
} catch {
// The plugin has not adopted the table yet.
return 0;
}
const dataDir = path.resolve(process.env.DATA_DIR || "./db/data");
const pluginDir = getPluginDataDir(PLUGIN_ID);
let moved = 0;
for (const row of rows) {
const source = path.resolve(row.recording_path!);
const relative = path.relative(dataDir, source);
const [top] = relative.split(path.sep);
if (relative.startsWith("..") || !LEGACY_DIRS.includes(top)) continue;
const target = path.join(pluginDir, relative);
try {
if (!(await exists(target))) {
if (!(await exists(source))) continue;
await move(source, target);
}
await runStatement(
sql`UPDATE ${sql.identifier(TABLE)} SET recording_path = ${target} WHERE id = ${row.id}`,
);
moved++;
} catch (error) {
databaseLogger.warn("Could not move a session recording file", {
operation: "session_recording_data_migration",
recordingId: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
return moved;
}
export async function runSessionRecordingDataMigration(): Promise<SessionRecordingDataMigrationResult> {
const result: SessionRecordingDataMigrationResult = {
retentionDays: null,
moved: 0,
};
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
result.retentionDays = await migrateRetention();
result.moved = await migrateFiles();
} catch (error) {
databaseLogger.warn("Session recording data migration failed", {
operation: "session_recording_data_migration",
error: error instanceof Error ? error.message : String(error),
});
return result;
}
if (result.retentionDays !== null || result.moved > 0) {
databaseLogger.info("Moved 2.8 session recordings into the plugin", {
operation: "session_recording_data_migration",
retentionDays: result.retentionDays,
moved: result.moved,
});
}
return result;
}
@@ -2,16 +2,33 @@ import fs from "fs";
import path from "path";
import { databaseLogger } from "./logger.js";
export const DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER =
".database-layer-preupgrade-backup.json";
export const DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX =
"pre-database-layer-refactor";
/**
* Each upgrade that rewrites the database takes its own backup, once. The 2.5
* database layer refactor wrote the first marker, so a 2.8 install already has
* it; 2.9.0 moves data into plugins and needs a fresh copy regardless.
*/
export type PreupgradeBackupReason =
"pre-database-layer-refactor" | "pre-plugin-runtime";
export const CURRENT_PREUPGRADE_BACKUP_REASON: PreupgradeBackupReason =
"pre-plugin-runtime";
export function preupgradeBackupMarker(reason: PreupgradeBackupReason): string {
return reason === "pre-database-layer-refactor"
? ".database-layer-preupgrade-backup.json"
: `.preupgrade-backup-${reason}.json`;
}
export const DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER = preupgradeBackupMarker(
"pre-database-layer-refactor",
);
export const DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV =
"DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP";
const DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP_ENV =
"DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP";
interface PreupgradeBackupOptions {
reason?: PreupgradeBackupReason;
dataDir?: string;
version?: string;
now?: Date;
@@ -25,7 +42,7 @@ interface BackupFileEntry {
}
export interface PreupgradeBackupManifest {
reason: "pre-database-layer-refactor";
reason: PreupgradeBackupReason;
createdAt: string;
sourceVersion: string;
dataDir: string;
@@ -71,15 +88,17 @@ function copyIfExists(
};
}
function cleanupOldBackups(backupsRoot: string, keepCount: number): void {
function cleanupOldBackups(
backupsRoot: string,
prefix: string,
keepCount: number,
): void {
if (!fs.existsSync(backupsRoot)) return;
const entries = fs
.readdirSync(backupsRoot, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
entry.name.startsWith(`${DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX}-`),
(entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}-`),
)
.map((entry) => {
const fullPath = path.join(backupsRoot, entry.name);
@@ -95,15 +114,13 @@ function cleanupOldBackups(backupsRoot: string, keepCount: number): void {
}
}
export function ensureDatabaseLayerPreupgradeBackup(
export function ensurePreupgradeBackup(
options: PreupgradeBackupOptions = {},
): PreupgradeBackupResult {
const reason = options.reason ?? CURRENT_PREUPGRADE_BACKUP_REASON;
const env = options.env ?? process.env;
const dataDir = path.resolve(options.dataDir ?? env.DATA_DIR ?? "./db/data");
const markerPath = path.join(
dataDir,
DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER,
);
const markerPath = path.join(dataDir, preupgradeBackupMarker(reason));
if (
TRUE_VALUES.has(
@@ -139,9 +156,7 @@ export function ensureDatabaseLayerPreupgradeBackup(
const backupsRoot = path.join(dataDir, "backups");
const backupDir = path.join(
backupsRoot,
`${DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX}-${timestampForPath(
options.now ?? new Date(),
)}`,
`${reason}-${timestampForPath(options.now ?? new Date())}`,
);
try {
@@ -152,7 +167,7 @@ export function ensureDatabaseLayerPreupgradeBackup(
.filter((entry): entry is BackupFileEntry => entry !== null);
const manifest: PreupgradeBackupManifest = {
reason: "pre-database-layer-refactor",
reason,
createdAt: (options.now ?? new Date()).toISOString(),
sourceVersion: options.version ?? env.VERSION ?? "unknown",
dataDir,
@@ -166,10 +181,11 @@ export function ensureDatabaseLayerPreupgradeBackup(
);
fs.writeFileSync(markerPath, JSON.stringify(manifest, null, 2));
cleanupOldBackups(backupsRoot, parseKeepCount(env));
cleanupOldBackups(backupsRoot, reason, parseKeepCount(env));
databaseLogger.info("Database layer pre-upgrade backup created", {
operation: "database_layer_preupgrade_backup_created",
reason,
backupDir,
files: files.map((file) => path.basename(file.backup)),
});