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:
Vendored
+2
@@ -36,6 +36,7 @@ var import_errors = require("./lib/errors");
|
|||||||
var import_jobWorker = require("./workers/jobWorker");
|
var import_jobWorker = require("./workers/jobWorker");
|
||||||
var import_cronWorker = require("./workers/cronWorker");
|
var import_cronWorker = require("./workers/cronWorker");
|
||||||
var import_adminSettings = require("./lib/adminSettings");
|
var import_adminSettings = require("./lib/adminSettings");
|
||||||
|
var import_orphanCleanup = require("./services/orphanCleanup");
|
||||||
var import_auth2 = require("./routes/auth");
|
var import_auth2 = require("./routes/auth");
|
||||||
var import_webhook = require("./routes/webhook");
|
var import_webhook = require("./routes/webhook");
|
||||||
var import_user = require("./routes/api/user");
|
var import_user = require("./routes/api/user");
|
||||||
@@ -110,6 +111,7 @@ server.start().then(async (port) => {
|
|||||||
await (0, import_adminSettings.getAdminSettings)();
|
await (0, import_adminSettings.getAdminSettings)();
|
||||||
(0, import_jobWorker.startJobWorker)();
|
(0, import_jobWorker.startJobWorker)();
|
||||||
(0, import_cronWorker.startCronWorkers)();
|
(0, import_cronWorker.startCronWorkers)();
|
||||||
|
(0, import_orphanCleanup.runOrphanCleanup)().catch((e) => import_logger.logger.warn(e, "Orphan cleanup error"));
|
||||||
import_logger.logger.info("All workers started");
|
import_logger.logger.info("All workers started");
|
||||||
}).catch((err) => import_logger.logger.error(err, "Server failed to start"));
|
}).catch((err) => import_logger.logger.error(err, "Server failed to start"));
|
||||||
// Annotate the CommonJS export names for ESM import in node:
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+29
-34
@@ -84,41 +84,29 @@ async function validateAwsCredentials(user) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function generateSshKeyPair() {
|
function generateSshKeyPair() {
|
||||||
const { privateKey, publicKey } = (0, import_crypto.generateKeyPairSync)("rsa", {
|
const { privateKey: privKeyPem, publicKey: pubKeyPem } = (0, import_crypto.generateKeyPairSync)("rsa", {
|
||||||
modulusLength: 2048,
|
modulusLength: 2048,
|
||||||
publicKeyEncoding: { type: "pkcs1", format: "pem" },
|
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||||
privateKeyEncoding: { type: "pkcs1", format: "pem" }
|
privateKeyEncoding: { type: "pkcs1", format: "pem" }
|
||||||
});
|
});
|
||||||
const pubKeyOpenSsh = rsaPemToOpenSsh(publicKey);
|
const pubKeyObj = (0, import_crypto.createPublicKey)(pubKeyPem);
|
||||||
return { privateKey, publicKey: pubKeyOpenSsh };
|
const pubKeyDer = pubKeyObj.export({ type: "spki", format: "der" });
|
||||||
|
function sshEncodeBuffer(buf) {
|
||||||
|
const lenBuf = Buffer.allocUnsafe(4);
|
||||||
|
lenBuf.writeUInt32BE(buf.length, 0);
|
||||||
|
return Buffer.concat([lenBuf, buf]);
|
||||||
}
|
}
|
||||||
function rsaPemToOpenSsh(pem) {
|
const keyTypeStr = Buffer.from("ssh-rsa");
|
||||||
const { publicKeyEncoding } = (0, import_crypto.generateKeyPairSync)("rsa", {
|
const keyTypeEncoded = sshEncodeBuffer(keyTypeStr);
|
||||||
modulusLength: 2048,
|
const base64Encoded = pubKeyDer.toString("base64");
|
||||||
publicKeyEncoding: { type: "pkcs8", format: "pem" },
|
const openSshKey = `ssh-rsa ${Buffer.concat([keyTypeEncoded]).toString("base64")} pp-generated`;
|
||||||
privateKeyEncoding: { type: "pkcs8", format: "pem" }
|
return { privateKey: privKeyPem, publicKeyOpenssh: `ssh-rsa ${base64Encoded} pp-generated` };
|
||||||
});
|
|
||||||
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) {
|
|
||||||
const len = Buffer.allocUnsafe(4);
|
|
||||||
len.writeUInt32BE(buf.length, 0);
|
|
||||||
return Buffer.concat([len, buf]);
|
|
||||||
}
|
|
||||||
const typeEncoded = encodeBuffer(type);
|
|
||||||
const rsaKeyData = der;
|
|
||||||
const base64Key = Buffer.concat([typeEncoded, rsaKeyData]).toString("base64");
|
|
||||||
return `ssh-rsa ${base64Key} pp-generated`;
|
|
||||||
}
|
}
|
||||||
async function generateAndImportKeyPair(ec2, keyName) {
|
async function generateAndImportKeyPair(ec2, keyName) {
|
||||||
const { privateKey, publicKey } = generateSshKeyPair();
|
const { privateKey, publicKeyOpenssh } = generateSshKeyPair();
|
||||||
await ec2.send(new import_client_ec2.ImportKeyPairCommand({
|
await ec2.send(new import_client_ec2.ImportKeyPairCommand({
|
||||||
KeyName: keyName,
|
KeyName: keyName,
|
||||||
PublicKeyMaterial: Buffer.from(publicKey)
|
PublicKeyMaterial: Buffer.from(publicKeyOpenssh)
|
||||||
}));
|
}));
|
||||||
return { privateKey };
|
return { privateKey };
|
||||||
}
|
}
|
||||||
@@ -134,22 +122,25 @@ async function createPreviewSecurityGroup(ec2, groupName, port) {
|
|||||||
Description: `PP Preview security group: ${groupName}`
|
Description: `PP Preview security group: ${groupName}`
|
||||||
}));
|
}));
|
||||||
const groupId = res.GroupId;
|
const groupId = res.GroupId;
|
||||||
await ec2.send(new import_client_ec2.AuthorizeSecurityGroupIngressCommand({
|
const ingress = [
|
||||||
GroupId: groupId,
|
|
||||||
IpPermissions: [
|
|
||||||
{
|
{
|
||||||
IpProtocol: "tcp",
|
IpProtocol: "tcp",
|
||||||
FromPort: 22,
|
FromPort: 22,
|
||||||
ToPort: 22,
|
ToPort: 22,
|
||||||
IpRanges: [{ CidrIp: "0.0.0.0/0" }]
|
IpRanges: [{ CidrIp: "0.0.0.0/0" }]
|
||||||
},
|
}
|
||||||
{
|
];
|
||||||
|
if (port !== 22) {
|
||||||
|
ingress.push({
|
||||||
IpProtocol: "tcp",
|
IpProtocol: "tcp",
|
||||||
FromPort: port,
|
FromPort: port,
|
||||||
ToPort: port,
|
ToPort: port,
|
||||||
IpRanges: [{ CidrIp: "0.0.0.0/0" }]
|
IpRanges: [{ CidrIp: "0.0.0.0/0" }]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
]
|
await ec2.send(new import_client_ec2.AuthorizeSecurityGroupIngressCommand({
|
||||||
|
GroupId: groupId,
|
||||||
|
IpPermissions: ingress
|
||||||
}));
|
}));
|
||||||
return groupId;
|
return groupId;
|
||||||
}
|
}
|
||||||
@@ -216,6 +207,7 @@ async function deleteSecurityGroupAws(ec2, groupName) {
|
|||||||
}));
|
}));
|
||||||
const groupId = describe.SecurityGroups?.[0]?.GroupId;
|
const groupId = describe.SecurityGroups?.[0]?.GroupId;
|
||||||
if (groupId) {
|
if (groupId) {
|
||||||
|
await sleep(5e3);
|
||||||
await ec2.send(new import_client_ec2.DeleteSecurityGroupCommand({ GroupId: groupId }));
|
await ec2.send(new import_client_ec2.DeleteSecurityGroupCommand({ GroupId: groupId }));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -224,7 +216,10 @@ async function deleteSecurityGroupAws(ec2, groupName) {
|
|||||||
}
|
}
|
||||||
async function describeAllManagedInstances(ec2) {
|
async function describeAllManagedInstances(ec2) {
|
||||||
const res = await ec2.send(new import_client_ec2.DescribeInstancesCommand({
|
const res = await ec2.send(new import_client_ec2.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 ?? []);
|
return (res.Reservations ?? []).flatMap((r) => r.Instances ?? []);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
+74
@@ -0,0 +1,74 @@
|
|||||||
|
"use strict";
|
||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||||
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
|
var __export = (target, all) => {
|
||||||
|
for (var name in all)
|
||||||
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||||||
|
};
|
||||||
|
var __copyProps = (to, from, except, desc) => {
|
||||||
|
if (from && typeof from === "object" || typeof from === "function") {
|
||||||
|
for (let key of __getOwnPropNames(from))
|
||||||
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||||
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
};
|
||||||
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||||
|
var orphanCleanup_exports = {};
|
||||||
|
__export(orphanCleanup_exports, {
|
||||||
|
runOrphanCleanup: () => runOrphanCleanup
|
||||||
|
});
|
||||||
|
module.exports = __toCommonJS(orphanCleanup_exports);
|
||||||
|
var import_db = require("../lib/db");
|
||||||
|
var import_logger = require("../lib/logger");
|
||||||
|
var import_ec2 = require("./ec2");
|
||||||
|
const log = (0, import_logger.createLogger)("ORPHAN_CLEANUP");
|
||||||
|
async function runOrphanCleanup() {
|
||||||
|
log.info("Running orphan EC2 instance cleanup");
|
||||||
|
const users = await import_db.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
awsAccessKeyId: { not: null },
|
||||||
|
awsSecretAccessKey: { not: null },
|
||||||
|
awsRegion: { not: null }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const activePreviews = await import_db.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 = (0, import_ec2.makeEc2Client)(user);
|
||||||
|
const instances = await (0, import_ec2.describeAllManagedInstances)(ec2);
|
||||||
|
for (const inst of instances) {
|
||||||
|
const instanceId = inst.InstanceId;
|
||||||
|
if (!instanceId) continue;
|
||||||
|
const tags = Object.fromEntries((inst.Tags || []).map((t) => [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 (0, import_ec2.deleteKeyPairAws)(ec2, keyName);
|
||||||
|
await (0, import_ec2.terminateInstance)(ec2, instanceId);
|
||||||
|
setTimeout(() => (0, import_ec2.deleteSecurityGroupAws)(ec2, sgName), 3e4);
|
||||||
|
} 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");
|
||||||
|
}
|
||||||
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
|
0 && (module.exports = {
|
||||||
|
runOrphanCleanup
|
||||||
|
});
|
||||||
|
//# sourceMappingURL=orphanCleanup.js.map
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"sources": ["../../src/services/orphanCleanup.ts"],
|
||||||
|
"sourcesContent": ["import { prisma } from \"../lib/db\";\nimport { createLogger } from \"../lib/logger\";\nimport { makeEc2Client, describeAllManagedInstances, terminateInstance, deleteKeyPairAws, deleteSecurityGroupAws } from \"./ec2\";\n\nconst log = createLogger(\"ORPHAN_CLEANUP\");\n\nexport async function runOrphanCleanup() {\n log.info(\"Running orphan EC2 instance cleanup\");\n\n const users = await prisma.user.findMany({\n where: {\n awsAccessKeyId: { not: null },\n awsSecretAccessKey: { not: null },\n awsRegion: { not: null },\n },\n });\n\n const activePreviews = await prisma.preview.findMany({\n where: { status: { notIn: [\"STOPPED\", \"IGNORED\"] }, instanceId: { not: null } },\n select: { id: true, instanceId: true },\n });\n const activeInstanceIds = new Set(activePreviews.map(p => p.instanceId!));\n\n for (const user of users) {\n try {\n const ec2 = makeEc2Client(user as any);\n const instances = await describeAllManagedInstances(ec2);\n\n for (const inst of instances) {\n const instanceId = inst.InstanceId;\n if (!instanceId) continue;\n\n const tags = Object.fromEntries((inst.Tags || []).map((t: any) => [t.Key, t.Value]));\n const previewId = parseInt(tags[\"pp:previewId\"] || \"0\", 10);\n\n if (!activeInstanceIds.has(instanceId)) {\n log.info({ instanceId, previewId }, \"Terminating orphan instance\");\n try {\n const keyName = `pp-preview-${previewId}`;\n const sgName = `pp-preview-${previewId}`;\n await deleteKeyPairAws(ec2, keyName);\n await terminateInstance(ec2, instanceId);\n setTimeout(() => deleteSecurityGroupAws(ec2, sgName), 30_000);\n } catch (e) {\n log.warn({ e, instanceId }, \"Failed to terminate orphan\");\n }\n }\n }\n } catch (e) {\n log.warn({ e, userId: user.id }, \"Failed to check orphans for user\");\n }\n }\n\n log.info(\"Orphan cleanup complete\");\n}\n"],
|
||||||
|
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gBAAuB;AACvB,oBAA6B;AAC7B,iBAAwH;AAExH,MAAM,UAAM,4BAAa,gBAAgB;AAEzC,eAAsB,mBAAmB;AACvC,MAAI,KAAK,qCAAqC;AAE9C,QAAM,QAAQ,MAAM,iBAAO,KAAK,SAAS;AAAA,IACvC,OAAO;AAAA,MACL,gBAAgB,EAAE,KAAK,KAAK;AAAA,MAC5B,oBAAoB,EAAE,KAAK,KAAK;AAAA,MAChC,WAAW,EAAE,KAAK,KAAK;AAAA,IACzB;AAAA,EACF,CAAC;AAED,QAAM,iBAAiB,MAAM,iBAAO,QAAQ,SAAS;AAAA,IACnD,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC,WAAW,SAAS,EAAE,GAAG,YAAY,EAAE,KAAK,KAAK,EAAE;AAAA,IAC9E,QAAQ,EAAE,IAAI,MAAM,YAAY,KAAK;AAAA,EACvC,CAAC;AACD,QAAM,oBAAoB,IAAI,IAAI,eAAe,IAAI,OAAK,EAAE,UAAW,CAAC;AAExE,aAAW,QAAQ,OAAO;AACxB,QAAI;AACF,YAAM,UAAM,0BAAc,IAAW;AACrC,YAAM,YAAY,UAAM,wCAA4B,GAAG;AAEvD,iBAAW,QAAQ,WAAW;AAC5B,cAAM,aAAa,KAAK;AACxB,YAAI,CAAC,WAAY;AAEjB,cAAM,OAAO,OAAO,aAAa,KAAK,QAAQ,CAAC,GAAG,IAAI,CAAC,MAAW,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;AACnF,cAAM,YAAY,SAAS,KAAK,cAAc,KAAK,KAAK,EAAE;AAE1D,YAAI,CAAC,kBAAkB,IAAI,UAAU,GAAG;AACtC,cAAI,KAAK,EAAE,YAAY,UAAU,GAAG,6BAA6B;AACjE,cAAI;AACF,kBAAM,UAAU,cAAc,SAAS;AACvC,kBAAM,SAAS,cAAc,SAAS;AACtC,sBAAM,6BAAiB,KAAK,OAAO;AACnC,sBAAM,8BAAkB,KAAK,UAAU;AACvC,uBAAW,UAAM,mCAAuB,KAAK,MAAM,GAAG,GAAM;AAAA,UAC9D,SAAS,GAAG;AACV,gBAAI,KAAK,EAAE,GAAG,WAAW,GAAG,4BAA4B;AAAA,UAC1D;AAAA,QACF;AAAA,MACF;AAAA,IACF,SAAS,GAAG;AACV,UAAI,KAAK,EAAE,GAAG,QAAQ,KAAK,GAAG,GAAG,kCAAkC;AAAA,IACrE;AAAA,EACF;AAEA,MAAI,KAAK,yBAAyB;AACpC;",
|
||||||
|
"names": []
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { ERROR_MESSAGES } from "./lib/errors";
|
|||||||
import { startJobWorker } from "./workers/jobWorker";
|
import { startJobWorker } from "./workers/jobWorker";
|
||||||
import { startCronWorkers } from "./workers/cronWorker";
|
import { startCronWorkers } from "./workers/cronWorker";
|
||||||
import { getAdminSettings } from "./lib/adminSettings";
|
import { getAdminSettings } from "./lib/adminSettings";
|
||||||
|
import { runOrphanCleanup } from "./services/orphanCleanup";
|
||||||
|
|
||||||
import { loginHandler, logoutHandler, meHandler, setupStatusHandler, firstUserHandler } from "./routes/auth";
|
import { loginHandler, logoutHandler, meHandler, setupStatusHandler, firstUserHandler } from "./routes/auth";
|
||||||
import { webhookHandler } from "./routes/webhook";
|
import { webhookHandler } from "./routes/webhook";
|
||||||
@@ -125,6 +126,7 @@ server
|
|||||||
await getAdminSettings();
|
await getAdminSettings();
|
||||||
startJobWorker();
|
startJobWorker();
|
||||||
startCronWorkers();
|
startCronWorkers();
|
||||||
|
runOrphanCleanup().catch(e => logger.warn(e, "Orphan cleanup error"));
|
||||||
logger.info("All workers started");
|
logger.info("All workers started");
|
||||||
})
|
})
|
||||||
.catch((err) => logger.error(err, "Server failed to start"));
|
.catch((err) => logger.error(err, "Server failed to start"));
|
||||||
|
|||||||
+36
-38
@@ -12,7 +12,7 @@ import {
|
|||||||
CreateTagsCommand,
|
CreateTagsCommand,
|
||||||
} from "@aws-sdk/client-ec2";
|
} from "@aws-sdk/client-ec2";
|
||||||
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
|
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
|
||||||
import { generateKeyPairSync } from "crypto";
|
import { generateKeyPairSync, createPublicKey } from "crypto";
|
||||||
import { createLogger } from "../lib/logger";
|
import { createLogger } from "../lib/logger";
|
||||||
import { decrypt } from "../lib/encryption";
|
import { decrypt } from "../lib/encryption";
|
||||||
import type { User } from "@prisma/client";
|
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 } {
|
export function generateSshKeyPair(): { privateKey: string; publicKeyOpenssh: string } {
|
||||||
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
|
const { privateKey: privKeyPem, publicKey: pubKeyPem } = generateKeyPairSync("rsa", {
|
||||||
modulusLength: 2048,
|
modulusLength: 2048,
|
||||||
publicKeyEncoding: { type: "pkcs1", format: "pem" },
|
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||||
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
||||||
});
|
});
|
||||||
const pubKeyOpenSsh = rsaPemToOpenSsh(publicKey);
|
|
||||||
return { privateKey, publicKey: pubKeyOpenSsh };
|
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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rsaPemToOpenSsh(pem: string): string {
|
const keyTypeStr = Buffer.from("ssh-rsa");
|
||||||
const { publicKeyEncoding } = generateKeyPairSync("rsa", {
|
const keyTypeEncoded = sshEncodeBuffer(keyTypeStr);
|
||||||
modulusLength: 2048,
|
|
||||||
publicKeyEncoding: { type: "pkcs8", format: "pem" },
|
const base64Encoded = pubKeyDer.toString("base64");
|
||||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
const openSshKey = `ssh-rsa ${Buffer.concat([keyTypeEncoded]).toString("base64")} pp-generated`;
|
||||||
});
|
|
||||||
void publicKeyEncoding;
|
return { privateKey: privKeyPem, publicKeyOpenssh: `ssh-rsa ${base64Encoded} pp-generated` };
|
||||||
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 typeEncoded = encodeBuffer(type);
|
|
||||||
const rsaKeyData = der;
|
|
||||||
const base64Key = Buffer.concat([typeEncoded, rsaKeyData]).toString("base64");
|
|
||||||
return `ssh-rsa ${base64Key} pp-generated`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateAndImportKeyPair(ec2: EC2Client, keyName: string): Promise<{ privateKey: string }> {
|
export async function generateAndImportKeyPair(ec2: EC2Client, keyName: string): Promise<{ privateKey: string }> {
|
||||||
const { privateKey, publicKey } = generateSshKeyPair();
|
const { privateKey, publicKeyOpenssh } = generateSshKeyPair();
|
||||||
await ec2.send(new ImportKeyPairCommand({
|
await ec2.send(new ImportKeyPairCommand({
|
||||||
KeyName: keyName,
|
KeyName: keyName,
|
||||||
PublicKeyMaterial: Buffer.from(publicKey),
|
PublicKeyMaterial: Buffer.from(publicKeyOpenssh),
|
||||||
}));
|
}));
|
||||||
return { privateKey };
|
return { privateKey };
|
||||||
}
|
}
|
||||||
@@ -126,22 +116,26 @@ export async function createPreviewSecurityGroup(ec2: EC2Client, groupName: stri
|
|||||||
}));
|
}));
|
||||||
const groupId = res.GroupId!;
|
const groupId = res.GroupId!;
|
||||||
|
|
||||||
await ec2.send(new AuthorizeSecurityGroupIngressCommand({
|
const ingress: any[] = [
|
||||||
GroupId: groupId,
|
|
||||||
IpPermissions: [
|
|
||||||
{
|
{
|
||||||
IpProtocol: "tcp",
|
IpProtocol: "tcp",
|
||||||
FromPort: 22,
|
FromPort: 22,
|
||||||
ToPort: 22,
|
ToPort: 22,
|
||||||
IpRanges: [{ CidrIp: "0.0.0.0/0" }],
|
IpRanges: [{ CidrIp: "0.0.0.0/0" }],
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
|
if (port !== 22) {
|
||||||
|
ingress.push({
|
||||||
IpProtocol: "tcp",
|
IpProtocol: "tcp",
|
||||||
FromPort: port,
|
FromPort: port,
|
||||||
ToPort: port,
|
ToPort: port,
|
||||||
IpRanges: [{ CidrIp: "0.0.0.0/0" }],
|
IpRanges: [{ CidrIp: "0.0.0.0/0" }],
|
||||||
},
|
});
|
||||||
],
|
}
|
||||||
|
|
||||||
|
await ec2.send(new AuthorizeSecurityGroupIngressCommand({
|
||||||
|
GroupId: groupId,
|
||||||
|
IpPermissions: ingress,
|
||||||
}));
|
}));
|
||||||
return groupId;
|
return groupId;
|
||||||
}
|
}
|
||||||
@@ -223,6 +217,7 @@ export async function deleteSecurityGroupAws(ec2: EC2Client, groupName: string):
|
|||||||
}));
|
}));
|
||||||
const groupId = describe.SecurityGroups?.[0]?.GroupId;
|
const groupId = describe.SecurityGroups?.[0]?.GroupId;
|
||||||
if (groupId) {
|
if (groupId) {
|
||||||
|
await sleep(5000);
|
||||||
await ec2.send(new DeleteSecurityGroupCommand({ GroupId: groupId }));
|
await ec2.send(new DeleteSecurityGroupCommand({ GroupId: groupId }));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -232,7 +227,10 @@ export async function deleteSecurityGroupAws(ec2: EC2Client, groupName: string):
|
|||||||
|
|
||||||
export async function describeAllManagedInstances(ec2: EC2Client): Promise<any[]> {
|
export async function describeAllManagedInstances(ec2: EC2Client): Promise<any[]> {
|
||||||
const res = await ec2.send(new DescribeInstancesCommand({
|
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 ?? []);
|
return (res.Reservations ?? []).flatMap(r => r.Instances ?? []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
+905
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>PR Previews</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<script type="module" crossorigin src="/assets/index-BhM-WgUO.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-CA1FHrdL.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -68,6 +68,12 @@ export default function App() {
|
|||||||
} />
|
} />
|
||||||
<Route path="/privacy" element={<Layout><Privacy /></Layout>} />
|
<Route path="/privacy" element={<Layout><Privacy /></Layout>} />
|
||||||
|
|
||||||
|
<Route path="/setup" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SetupWizard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
<Route path="/*" element={
|
<Route path="/*" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<SetupCheck>
|
<SetupCheck>
|
||||||
@@ -78,7 +84,6 @@ export default function App() {
|
|||||||
<Route path="/repos" element={<Repos />} />
|
<Route path="/repos" element={<Repos />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/admin" element={<Admin />} />
|
<Route path="/admin" element={<Admin />} />
|
||||||
<Route path="/setup" element={<SetupWizard />} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
Reference in New Issue
Block a user