mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
This commit is contained in:
1 parent
2ee9b3592c
commit
f779d6a08e
37 files changed
+5184
-377
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
|
||||
Executable
+62
@@ -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"
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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");
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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", {
|
||||
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
d4 ssh recording
|
||||
+1
@@ -0,0 +1 @@
|
||||
d4 rdp recording
|
||||
@@ -0,0 +1,4 @@
|
||||
JWT_SECRET=059e69f1248271031d958a76bd60280f09b8c99fa3414a03de84d0af886c1e4cef0ccbb4d5f1ef2d05c3607b89805134aa9cf985ebaddd3a01d7847a0772c2d7
|
||||
DATABASE_KEY=a0e3e3b679a351d0daeb0f790ecfce906c3169d966e81d2e6be89873ab4b191a
|
||||
ENCRYPTION_KEY=1c06c6a7605c71dc4724d4d39d17ea45afd44e3325115ac4e1e21e9d55f73030
|
||||
INTERNAL_AUTH_TOKEN=d1ae0f2e46a0913996148702cf5a4decb1da7b68a39098ac3a75a00f0d1278e2
|
||||
+844
@@ -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",
|
||||
]);
|
||||
+1136
File diff suppressed because it is too large.
Load diff
+1208
File diff suppressed because it is too large.
Load diff
@@ -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 }>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user