From 388f710379b81d2de9875c1e90e8e66cc3fd1be1 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 25 Feb 2026 11:37:31 -0800 Subject: [PATCH] add pg migration --- server/setup/migrationsPg.ts | 6 +- server/setup/scriptsPg/1.16.0.ts | 179 +++++++++++++++++++++++++++ server/setup/scriptsSqlite/1.16.0.ts | 7 +- 3 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 server/setup/scriptsPg/1.16.0.ts diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 8d27435a..1ace7347 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -5,6 +5,7 @@ import semver from "semver"; import { versionMigrations } from "../db/pg"; import { __DIRNAME, APP_VERSION } from "@server/lib/consts"; import path from "path"; +import { build } from "@server/build"; import m1 from "./scriptsPg/1.6.0"; import m2 from "./scriptsPg/1.7.0"; import m3 from "./scriptsPg/1.8.0"; @@ -19,7 +20,7 @@ import m11 from "./scriptsPg/1.14.0"; import m12 from "./scriptsPg/1.15.0"; import m13 from "./scriptsPg/1.15.3"; import m14 from "./scriptsPg/1.15.4"; -import { build } from "@server/build"; +import m15 from "./scriptsPg/1.16.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -39,7 +40,8 @@ const migrations = [ { version: "1.14.0", run: m11 }, { version: "1.15.0", run: m12 }, { version: "1.15.3", run: m13 }, - { version: "1.15.4", run: m14 } + { version: "1.15.4", run: m14 }, + { version: "1.16.0", run: m15 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/scriptsPg/1.16.0.ts b/server/setup/scriptsPg/1.16.0.ts new file mode 100644 index 00000000..f87bd7f2 --- /dev/null +++ b/server/setup/scriptsPg/1.16.0.ts @@ -0,0 +1,179 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; +import { configFilePath1, configFilePath2 } from "@server/lib/consts"; +import { encrypt } from "@server/lib/crypto"; +import { generateCA } from "@server/private/lib/sshCA"; +import fs from "fs"; +import yaml from "js-yaml"; + +const version = "1.16.0"; + +function getServerSecret(): string { + const envSecret = process.env.SERVER_SECRET; + + const configPath = fs.existsSync(configFilePath1) + ? configFilePath1 + : fs.existsSync(configFilePath2) + ? configFilePath2 + : null; + + // If no config file but an env secret is set, use the env secret directly + if (!configPath) { + if (envSecret && envSecret.length > 0) { + return envSecret; + } + + throw new Error( + "Cannot generate org CA keys: no config file found and SERVER_SECRET env var is not set. " + + "Expected config.yml or config.yaml in the config directory, or set SERVER_SECRET." + ); + } + + const configContent = fs.readFileSync(configPath, "utf8"); + const config = yaml.load(configContent) as { + server?: { secret?: string }; + }; + + let secret = config?.server?.secret; + if (!secret || secret.length === 0) { + // Fall back to SERVER_SECRET env var if config does not contain server.secret + if (envSecret && envSecret.length > 0) { + secret = envSecret; + } + } + + if (!secret || secret.length === 0) { + throw new Error( + "Cannot generate org CA keys: no server.secret in config and SERVER_SECRET env var is not set. " + + "Set server.secret in config.yml/config.yaml or set SERVER_SECRET." + ); + } + + return secret; +} + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + // Ensure server secret exists before running migration (required for org CA key generation) + getServerSecret(); + + try { + await db.execute(sql`BEGIN`); + + // Schema changes + await db.execute(sql` + CREATE TABLE "roundTripMessageTracker" ( + "messageId" serial PRIMARY KEY NOT NULL, + "clientId" varchar, + "messageType" varchar, + "sentAt" bigint NOT NULL, + "receivedAt" bigint, + "error" text, + "complete" boolean DEFAULT false NOT NULL + ); + `); + + await db.execute( + sql`ALTER TABLE "orgs" ADD COLUMN "sshCaPrivateKey" text;` + ); + await db.execute( + sql`ALTER TABLE "orgs" ADD COLUMN "sshCaPublicKey" text;` + ); + await db.execute( + sql`ALTER TABLE "orgs" ADD COLUMN "isBillingOrg" boolean;` + ); + await db.execute( + sql`ALTER TABLE "orgs" ADD COLUMN "billingOrgId" varchar;` + ); + + await db.execute( + sql`ALTER TABLE "roles" ADD COLUMN "sshSudoMode" varchar(32) DEFAULT 'none';` + ); + await db.execute( + sql`ALTER TABLE "roles" ADD COLUMN "sshSudoCommands" text DEFAULT '[]';` + ); + await db.execute( + sql`ALTER TABLE "roles" ADD COLUMN "sshCreateHomeDir" boolean DEFAULT true;` + ); + await db.execute( + sql`ALTER TABLE "roles" ADD COLUMN "sshUnixGroups" text DEFAULT '[]';` + ); + + await db.execute( + sql`ALTER TABLE "siteResources" ADD COLUMN "authDaemonPort" integer DEFAULT 22123;` + ); + await db.execute( + sql`ALTER TABLE "siteResources" ADD COLUMN "authDaemonMode" varchar(32) DEFAULT 'site';` + ); + + await db.execute( + sql`ALTER TABLE "userOrgs" ADD COLUMN "pamUsername" varchar;` + ); + + // Set all admin role sudo to "full"; other roles keep default "none" + await db.execute( + sql`UPDATE "roles" SET "sshSudoMode" = 'full' WHERE "isAdmin" = true;` + ); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + // Generate and store encrypted SSH CA keys for all orgs + try { + const secret = getServerSecret(); + + const orgQuery = await db.execute(sql`SELECT "orgId" FROM "orgs"`); + const orgRows = orgQuery.rows as { orgId: string }[]; + + const failedOrgIds: string[] = []; + + for (const row of orgRows) { + try { + const ca = generateCA(`pangolin-ssh-ca-${row.orgId}`); + const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret); + + await db.execute(sql` + UPDATE "orgs" + SET "sshCaPrivateKey" = ${encryptedPrivateKey}, + "sshCaPublicKey" = ${ca.publicKeyOpenSSH} + WHERE "orgId" = ${row.orgId}; + `); + } catch (err) { + failedOrgIds.push(row.orgId); + console.error( + `Error: No CA was generated for organization "${row.orgId}".`, + err instanceof Error ? err.message : err + ); + } + } + + if (orgRows.length > 0) { + const succeeded = orgRows.length - failedOrgIds.length; + console.log( + `Generated and stored SSH CA keys for ${succeeded} org(s).` + ); + } + + if (failedOrgIds.length > 0) { + console.error( + `No CA was generated for ${failedOrgIds.length} organization(s): ${failedOrgIds.join( + ", " + )}` + ); + } + } catch (e) { + console.error( + "Error while generating SSH CA keys for orgs after migration:", + e + ); + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.16.0.ts b/server/setup/scriptsSqlite/1.16.0.ts index cf128e48..0abc9e0c 100644 --- a/server/setup/scriptsSqlite/1.16.0.ts +++ b/server/setup/scriptsSqlite/1.16.0.ts @@ -55,12 +55,13 @@ function getServerSecret(): string { export default async function migration() { console.log(`Running setup script ${version}...`); + // Ensure server secret exists before running migration (required for org CA key generation) + getServerSecret(); + const location = path.join(APP_PATH, "db", "db.sqlite"); const db = new Database(location); try { - const secret = getServerSecret(); - db.pragma("foreign_keys = OFF"); db.transaction(() => { @@ -123,6 +124,8 @@ export default async function migration() { }[]; // Generate and store encrypted SSH CA keys for all orgs + const secret = getServerSecret(); + const updateOrgCaKeys = db.prepare( "UPDATE orgs SET sshCaPrivateKey = ?, sshCaPublicKey = ? WHERE orgId = ?" );