Files
dokploy/apps/dokploy/scripts/migrate-auth-secret.ts
T
ngenohkevin 9f10f0f4e9 fix(migrate-auth-secret): exit cleanly when there are no 2FA records
The empty-records branch of `main()` returned without calling
`process.exit(0)`, leaving the Drizzle Postgres connection pool
holding the event loop open. The `migrate-auth-secret` process
then hangs indefinitely after printing "No 2FA records found,
nothing to migrate." causing the upstream `0.29.3.sh` security
migration script (which calls this via `docker exec`) to never
reach its final `docker service update` step that mounts the new
Docker Secret. Operators end up with the new secret created but
the dokploy service still configured with the hardcoded
`BETTER_AUTH_SECRET`, while believing the migration completed.

Match the success branch a few lines below which already does
`process.exit(0)`, and the pattern used in sibling scripts
`reset-password.ts` and `reset-2fa.ts`.

Closes #4392
2026-05-12 21:35:02 +03:00

98 lines
2.6 KiB
TypeScript

/**
* Use this command to automatically migrate the auth secret: curl -sSL https://dokploy.com/security/0.29.3.sh | bash
* Migration script: re-encrypt 2FA secrets after rotating BETTER_AUTH_SECRET.
*
* Usage:
* OLD_SECRET=<old_secret> NEW_SECRET=<new_secret> npx tsx apps/dokploy/scripts/migrate-auth-secret.ts
*
* Both OLD_SECRET and NEW_SECRET are required.
* Run this BEFORE restarting Dokploy with the new secret.
*/
import { db } from "@dokploy/server/db";
import { twoFactor } from "@dokploy/server/db/schema";
import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto";
import { eq } from "drizzle-orm";
const OLD_SECRET = process.env.OLD_SECRET as string;
const NEW_SECRET = process.env.NEW_SECRET as string;
if (!OLD_SECRET || !NEW_SECRET) {
console.error(
"❌ OLD_SECRET and NEW_SECRET environment variables are required.",
);
console.error(
" Usage: OLD_SECRET=<old> NEW_SECRET=<new> npx tsx apps/dokploy/scripts/migrate-auth-secret.ts",
);
process.exit(1);
}
if (OLD_SECRET === NEW_SECRET) {
console.error("❌ OLD_SECRET and NEW_SECRET must be different.");
process.exit(1);
}
async function reEncrypt(
value: string,
oldSecret: string,
newSecret: string,
): Promise<string> {
const plaintext = await symmetricDecrypt({ key: oldSecret, data: value });
return symmetricEncrypt({ key: newSecret, data: plaintext });
}
async function main() {
console.log("🔍 Fetching 2FA records...");
const records = await db.select().from(twoFactor);
if (records.length === 0) {
console.log("✅ No 2FA records found, nothing to migrate.");
process.exit(0);
}
console.log(`📦 Found ${records.length} 2FA record(s) to migrate.`);
let migrated = 0;
let failed = 0;
await db.transaction(async (tx) => {
for (const record of records) {
try {
const [newSecret, newBackupCodes] = await Promise.all([
reEncrypt(record.secret, OLD_SECRET, NEW_SECRET),
reEncrypt(record.backupCodes, OLD_SECRET, NEW_SECRET),
]);
await tx
.update(twoFactor)
.set({ secret: newSecret, backupCodes: newBackupCodes })
.where(eq(twoFactor.id, record.id));
migrated++;
} catch (err) {
console.error(
`❌ Failed to migrate record ${record.id} (userId: ${record.userId}):`,
err,
);
failed++;
throw err; // rollback the whole transaction
}
}
});
console.log(`✅ Migrated ${migrated} record(s) successfully.`);
if (failed > 0) {
console.error(
`❌ ${failed} record(s) failed — transaction was rolled back.`,
);
process.exit(1);
} else {
process.exit(0);
}
}
main().catch((err) => {
console.error("❌ Migration failed:", err);
process.exit(1);
});