feat: add orphan EC2 cleanup on startup, fix SetupWizard routing, fix SSH key gen

- On startup, scan for EC2 instances with pp:managed=true and terminate any orphans
- SetupWizard now renders as full-page (no Layout wrapper)
- Fixed SSH RSA key generation (generateKeyPairSync with spki format)
- Removed unused import in orphanCleanup.ts
- Frontend rebuild with App.tsx routing fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 00:20:36 +02:00
parent 40d484bede
commit 6434be8bed
13 changed files with 1151 additions and 93 deletions
+2
View File
@@ -13,6 +13,7 @@ import { ERROR_MESSAGES } from "./lib/errors";
import { startJobWorker } from "./workers/jobWorker";
import { startCronWorkers } from "./workers/cronWorker";
import { getAdminSettings } from "./lib/adminSettings";
import { runOrphanCleanup } from "./services/orphanCleanup";
import { loginHandler, logoutHandler, meHandler, setupStatusHandler, firstUserHandler } from "./routes/auth";
import { webhookHandler } from "./routes/webhook";
@@ -125,6 +126,7 @@ server
await getAdminSettings();
startJobWorker();
startCronWorkers();
runOrphanCleanup().catch(e => logger.warn(e, "Orphan cleanup error"));
logger.info("All workers started");
})
.catch((err) => logger.error(err, "Server failed to start"));
+44 -46
View File
@@ -12,7 +12,7 @@ import {
CreateTagsCommand,
} from "@aws-sdk/client-ec2";
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
import { generateKeyPairSync } from "crypto";
import { generateKeyPairSync, createPublicKey } from "crypto";
import { createLogger } from "../lib/logger";
import { decrypt } from "../lib/encryption";
import type { User } from "@prisma/client";
@@ -68,46 +68,36 @@ export async function validateAwsCredentials(user: User): Promise<{ success: boo
}
}
export function generateSshKeyPair(): { privateKey: string; publicKey: string } {
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
export function generateSshKeyPair(): { privateKey: string; publicKeyOpenssh: string } {
const { privateKey: privKeyPem, publicKey: pubKeyPem } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "pkcs1", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs1", format: "pem" },
});
const pubKeyOpenSsh = rsaPemToOpenSsh(publicKey);
return { privateKey, publicKey: pubKeyOpenSsh };
}
function rsaPemToOpenSsh(pem: string): string {
const { publicKeyEncoding } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "pkcs8", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
void publicKeyEncoding;
const der = Buffer.from(
pem.replace(/-----BEGIN RSA PUBLIC KEY-----/, "")
.replace(/-----END RSA PUBLIC KEY-----/, "")
.replace(/\n/g, ""),
"base64"
);
const type = Buffer.from("ssh-rsa");
function encodeBuffer(buf: Buffer): Buffer {
const len = Buffer.allocUnsafe(4);
len.writeUInt32BE(buf.length, 0);
return Buffer.concat([len, buf]);
const pubKeyObj = createPublicKey(pubKeyPem);
const pubKeyDer = pubKeyObj.export({ type: "spki", format: "der" });
function sshEncodeBuffer(buf: Buffer): Buffer {
const lenBuf = Buffer.allocUnsafe(4);
lenBuf.writeUInt32BE(buf.length, 0);
return Buffer.concat([lenBuf, buf]);
}
const typeEncoded = encodeBuffer(type);
const rsaKeyData = der;
const base64Key = Buffer.concat([typeEncoded, rsaKeyData]).toString("base64");
return `ssh-rsa ${base64Key} pp-generated`;
const keyTypeStr = Buffer.from("ssh-rsa");
const keyTypeEncoded = sshEncodeBuffer(keyTypeStr);
const base64Encoded = pubKeyDer.toString("base64");
const openSshKey = `ssh-rsa ${Buffer.concat([keyTypeEncoded]).toString("base64")} pp-generated`;
return { privateKey: privKeyPem, publicKeyOpenssh: `ssh-rsa ${base64Encoded} pp-generated` };
}
export async function generateAndImportKeyPair(ec2: EC2Client, keyName: string): Promise<{ privateKey: string }> {
const { privateKey, publicKey } = generateSshKeyPair();
const { privateKey, publicKeyOpenssh } = generateSshKeyPair();
await ec2.send(new ImportKeyPairCommand({
KeyName: keyName,
PublicKeyMaterial: Buffer.from(publicKey),
PublicKeyMaterial: Buffer.from(publicKeyOpenssh),
}));
return { privateKey };
}
@@ -126,22 +116,26 @@ export async function createPreviewSecurityGroup(ec2: EC2Client, groupName: stri
}));
const groupId = res.GroupId!;
const ingress: any[] = [
{
IpProtocol: "tcp",
FromPort: 22,
ToPort: 22,
IpRanges: [{ CidrIp: "0.0.0.0/0" }],
},
];
if (port !== 22) {
ingress.push({
IpProtocol: "tcp",
FromPort: port,
ToPort: port,
IpRanges: [{ CidrIp: "0.0.0.0/0" }],
});
}
await ec2.send(new AuthorizeSecurityGroupIngressCommand({
GroupId: groupId,
IpPermissions: [
{
IpProtocol: "tcp",
FromPort: 22,
ToPort: 22,
IpRanges: [{ CidrIp: "0.0.0.0/0" }],
},
{
IpProtocol: "tcp",
FromPort: port,
ToPort: port,
IpRanges: [{ CidrIp: "0.0.0.0/0" }],
},
],
IpPermissions: ingress,
}));
return groupId;
}
@@ -223,6 +217,7 @@ export async function deleteSecurityGroupAws(ec2: EC2Client, groupName: string):
}));
const groupId = describe.SecurityGroups?.[0]?.GroupId;
if (groupId) {
await sleep(5000);
await ec2.send(new DeleteSecurityGroupCommand({ GroupId: groupId }));
}
} catch (e) {
@@ -232,7 +227,10 @@ export async function deleteSecurityGroupAws(ec2: EC2Client, groupName: string):
export async function describeAllManagedInstances(ec2: EC2Client): Promise<any[]> {
const res = await ec2.send(new DescribeInstancesCommand({
Filters: [{ Name: "tag:pp:managed", Values: ["true"] }, { Name: "instance-state-name", Values: ["running", "pending", "stopping", "stopped"] }],
Filters: [
{ Name: "tag:pp:managed", Values: ["true"] },
{ Name: "instance-state-name", Values: ["running", "pending", "stopping"] },
],
}));
return (res.Reservations ?? []).flatMap(r => r.Instances ?? []);
}
+55
View File
@@ -0,0 +1,55 @@
import { prisma } from "../lib/db";
import { createLogger } from "../lib/logger";
import { makeEc2Client, describeAllManagedInstances, terminateInstance, deleteKeyPairAws, deleteSecurityGroupAws } from "./ec2";
const log = createLogger("ORPHAN_CLEANUP");
export async function runOrphanCleanup() {
log.info("Running orphan EC2 instance cleanup");
const users = await prisma.user.findMany({
where: {
awsAccessKeyId: { not: null },
awsSecretAccessKey: { not: null },
awsRegion: { not: null },
},
});
const activePreviews = await prisma.preview.findMany({
where: { status: { notIn: ["STOPPED", "IGNORED"] }, instanceId: { not: null } },
select: { id: true, instanceId: true },
});
const activeInstanceIds = new Set(activePreviews.map(p => p.instanceId!));
for (const user of users) {
try {
const ec2 = makeEc2Client(user as any);
const instances = await describeAllManagedInstances(ec2);
for (const inst of instances) {
const instanceId = inst.InstanceId;
if (!instanceId) continue;
const tags = Object.fromEntries((inst.Tags || []).map((t: any) => [t.Key, t.Value]));
const previewId = parseInt(tags["pp:previewId"] || "0", 10);
if (!activeInstanceIds.has(instanceId)) {
log.info({ instanceId, previewId }, "Terminating orphan instance");
try {
const keyName = `pp-preview-${previewId}`;
const sgName = `pp-preview-${previewId}`;
await deleteKeyPairAws(ec2, keyName);
await terminateInstance(ec2, instanceId);
setTimeout(() => deleteSecurityGroupAws(ec2, sgName), 30_000);
} catch (e) {
log.warn({ e, instanceId }, "Failed to terminate orphan");
}
}
}
} catch (e) {
log.warn({ e, userId: user.id }, "Failed to check orphans for user");
}
}
log.info("Orphan cleanup complete");
}