diff --git a/backend/src/services/deploy.ts b/backend/src/services/deploy.ts index 1ed99f22..94e04099 100644 --- a/backend/src/services/deploy.ts +++ b/backend/src/services/deploy.ts @@ -13,7 +13,7 @@ import { deleteKeyPairAws, deleteSecurityGroupAws, } from "./ec2"; -import { connectSsh, type SshSession } from "./ssh"; +import { connectSsh, waitForBootstrap, type SshSession } from "./ssh"; import { buildPrCommentBody, postComment, @@ -70,29 +70,6 @@ async function updateStatus(previewId: number, status: Preview["status"], extra: await prisma.preview.update({ where: { id: previewId }, data: { status, ...extra } }); } -async function updateGiteaComment(user: User, preview: Preview, repoConfig: RepoConfig, statusLine: string, lastLogLines?: string) { - const body = buildPrCommentBody({ - owner: repoConfig.repoOwner, - repo: repoConfig.repoName, - prNumber: preview.prNumber, - status: statusLine, - commitSha: preview.commitSha, - updatedAt: new Date(), - ppBaseUrl: env.PP_BASE_URL, - lastLogLines, - instanceIp: preview.instanceIp ?? undefined, - port: preview.port, - }); - - if (preview.giteaCommentId) { - try { - await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, body); - } catch (e) { - log.warn({ e }, "Failed to update Gitea comment"); - } - } -} - export async function runDeploy(jobId: number) { const job = await prisma.job.findUnique({ where: { id: jobId }, include: { preview: { include: { repoConfig: { include: { user: true } } } } } }); if (!job || !job.preview) { @@ -194,7 +171,7 @@ async function firstDeploy( status: "🟡 Provisioning EC2 instance...", commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL, }); - let commentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody); + const commentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody); await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } }); checkAbort(previewId); @@ -227,8 +204,7 @@ async function firstDeploy( }); await prisma.preview.update({ where: { id: previewId }, data: { instanceId } }); - - await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for it to be running...\n`); + await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for running state...\n`); checkAbort(previewId); @@ -243,16 +219,21 @@ async function firstDeploy( }) ); - await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH...\n`); + await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH (as root)...\n`); checkAbort(previewId); + // Connect as root. Ubuntu user-data copies authorized_keys to root and enables root SSH. const sshSession = await connectSsh(instanceIp, privateKey, 300_000); activeSshSessions.set(previewId, sshSession); try { + await appendLog(previewId, `[PP] SSH connected. Waiting for bootstrap to complete...\n`); + await waitForBootstrap(sshSession, 600_000); + await appendLog(previewId, `[PP] Bootstrap complete. Starting setup...\n`); + if (repoConfig.aptPackages.length > 0) { - await runSshStep(previewId, sshSession, `sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ${repoConfig.aptPackages.join(" ")}`); + await runSshStep(previewId, sshSession, `DEBIAN_FRONTEND=noninteractive apt-get install -y ${repoConfig.aptPackages.join(" ")}`); } const giteaPat = user.giteaPAT ? decrypt(user.giteaPAT) : ""; @@ -260,14 +241,25 @@ async function firstDeploy( parsedUrl.username = encodeURIComponent(user.giteaUsername || ""); parsedUrl.password = encodeURIComponent(giteaPat); const authCloneUrl = parsedUrl.toString(); - await runSshStep(previewId, sshSession, `git clone '${authCloneUrl}' /opt/app`); + + // Log a masked version so PAT is not exposed in preview logs + const maskedUrl = `${parsedUrl.protocol}//${parsedUrl.username}:****@${parsedUrl.hostname}${parsedUrl.port ? ":" + parsedUrl.port : ""}${parsedUrl.pathname}`; + await appendLog(previewId, `$ git clone '${maskedUrl}' /opt/app\n`); + const cloneResult = await sshSession.exec(`git clone '${authCloneUrl}' /opt/app`); + if (cloneResult.stdout) await appendLog(previewId, cloneResult.stdout); + if (cloneResult.stderr) { + // Mask PAT in stderr output too + const maskedStderr = cloneResult.stderr.replace(encodeURIComponent(giteaPat), "****").replace(giteaPat, "****"); + await appendLog(previewId, maskedStderr); + } + if (cloneResult.code !== 0) throw new Error(`git clone failed with exit code ${cloneResult.code}`); + await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`); - await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, true); + await setupAndBuild(previewId, sshSession, repoConfig, commitSha, true); await updateStatus(previewId, "RUNNING", { commitSha, instanceIp, port: repoConfig.port, lastActivityAt: new Date() }); - const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } }); await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId, buildPrCommentBody({ owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber, @@ -291,16 +283,20 @@ async function redeploy( prTitle: string, ) { const previewId = preview.id; - const instanceIp = preview.instanceIp!; - const privateKey = decrypt(preview.sshPrivateKey!); - const sshSession = await connectSsh(instanceIp, privateKey, 30_000); + // Always use fresh data from DB for connection details + const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } }); + if (!freshPreview?.instanceIp || !freshPreview?.sshPrivateKey) { + throw new Error("No active instance found for redeploy — cannot SSH in"); + } + const instanceIp = freshPreview.instanceIp; + const privateKey = decrypt(freshPreview.sshPrivateKey); + + const sshSession = await connectSsh(instanceIp, privateKey, 60_000); activeSshSessions.set(previewId, sshSession); await appendLog(previewId, `\n--- Redeploy: ${commitSha} ---\n`); - const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } }); - const commentBody = buildPrCommentBody({ owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber, status: `🟡 Building... (EC2 at ${instanceIp})`, @@ -313,14 +309,14 @@ async function redeploy( if (repoConfig.useDockerCompose) { const composePath = repoConfig.composeFilePath || "docker-compose.yml"; await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} down 2>&1 || true`); - } else if (freshPreview?.pid) { + } else if (freshPreview.pid) { await runSshStep(previewId, sshSession, `kill ${freshPreview.pid} 2>/dev/null || true; sleep 5; kill -9 ${freshPreview.pid} 2>/dev/null || true`); } await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD`); await updateStatus(previewId, "BUILDING"); - await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, false); + await setupAndBuild(previewId, sshSession, repoConfig, commitSha, false); await updateStatus(previewId, "RUNNING", { commitSha, lastActivityAt: new Date() }); @@ -347,7 +343,6 @@ async function setupAndBuild( previewId: number, sshSession: SshSession, repoConfig: RepoConfig, - preview: Preview, commitSha: string, isFirstProvision: boolean, ) { @@ -355,14 +350,13 @@ async function setupAndBuild( const nvmCmd = `${NVM_PREFIX} if [ -f /opt/app/.nvmrc ]; then nvm install && nvm use; else nvm use default; fi`; await runSshStep(previewId, sshSession, `bash -c '${nvmCmd}'`, false); - // Write .env file + // Write .env file safely using base64 to handle special chars and newlines const envVars = repoConfig.envVars as Record; - const envLines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\\n"); - if (envLines) { - await runSshStep(previewId, sshSession, `printf '${envLines}\\n' > /opt/app/.env`, false); - } else { - await runSshStep(previewId, sshSession, `touch /opt/app/.env`, false); - } + const envContent = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\n") + "\n"; + const envB64 = Buffer.from(envContent).toString("base64"); + await appendLog(previewId, `$ echo ' | base64 -d > /opt/app/.env'\n`); + const envResult = await sshSession.exec(`echo '${envB64}' | base64 -d > /opt/app/.env`); + if (envResult.code !== 0) throw new Error(".env write failed"); if (isFirstProvision) { for (const cmd of repoConfig.setupCommands) { @@ -436,15 +430,24 @@ export async function stopPreview(previewId: number, reason: "STOPPED" | "FAILED if (preview.instanceId) { const ec2 = makeEc2Client(user); + // Terminate instance first, then clean up key pair and security group + try { + await terminateInstance(ec2, preview.instanceId); + } catch (e) { + log.warn({ e, instanceId: preview.instanceId }, "Failed to terminate instance"); + } + // Delete key pair immediately (doesn't depend on instance state) try { await deleteKeyPairAws(ec2, `pp-preview-${previewId}`); } catch {} - try { - await deleteSecurityGroupAws(ec2, `pp-preview-${previewId}`); - } catch {} - try { - await terminateInstance(ec2, preview.instanceId); - } catch {} + // Delete security group after a delay to allow instance ENI detachment + setTimeout(async () => { + try { + await deleteSecurityGroupAws(ec2, `pp-preview-${previewId}`); + } catch (e) { + log.warn({ e, previewId }, "Failed to delete security group after termination"); + } + }, 30_000); } await prisma.preview.update({ diff --git a/backend/src/services/ec2.ts b/backend/src/services/ec2.ts index 70b72ba5..9b174e78 100644 --- a/backend/src/services/ec2.ts +++ b/backend/src/services/ec2.ts @@ -118,15 +118,34 @@ const BOOTSTRAP_SCRIPT = `#!/bin/bash set -e apt-get update -y apt-get install -y curl git unzip build-essential + +# Docker curl -fsSL https://get.docker.com | sh systemctl enable docker systemctl start docker apt-get install -y docker-compose-plugin + +# Allow root SSH with key auth (sshd_config may default to prohibit-password or no) +sed -i 's/^#*PermitRootLogin.*/PermitRootLogin without-password/' /etc/ssh/sshd_config +# Copy ubuntu's authorized_keys to root so PP can SSH as root +mkdir -p /root/.ssh +chmod 700 /root/.ssh +if [ -f /home/ubuntu/.ssh/authorized_keys ]; then + cp /home/ubuntu/.ssh/authorized_keys /root/.ssh/authorized_keys + chmod 600 /root/.ssh/authorized_keys +fi +systemctl reload sshd || service ssh reload + +# NVM + Node LTS (installed as root, available to root SSH sessions) +export HOME=/root curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash export NVM_DIR="/root/.nvm" source "$NVM_DIR/nvm.sh" nvm install --lts nvm alias default lts/* + +# Signal that bootstrap is complete +touch /var/lib/pp-bootstrap-done `; export async function launchInstance(opts: { diff --git a/backend/src/services/ssh.ts b/backend/src/services/ssh.ts index 8aa1b767..c2cb4fc6 100644 --- a/backend/src/services/ssh.ts +++ b/backend/src/services/ssh.ts @@ -60,7 +60,7 @@ function tryConnect(host: string, privateKey: string, timeoutMs: number): Promis conn.connect({ host, port: 22, - username: "ubuntu", + username: "root", privateKey, readyTimeout: timeoutMs, algorithms: { @@ -85,6 +85,16 @@ function execOnConn(conn: Client, command: string): Promise<{ stdout: string; st }); } +export async function waitForBootstrap(session: SshSession, maxWaitMs = 600_000): Promise { + const start = Date.now(); + while (Date.now() - start < maxWaitMs) { + const res = await session.exec("test -f /var/lib/pp-bootstrap-done && echo done || echo waiting"); + if (res.stdout.trim() === "done") return; + await sleep(10_000); + } + throw new Error("EC2 bootstrap did not complete within timeout"); +} + function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); } diff --git a/backend/src/workers/cronWorker.ts b/backend/src/workers/cronWorker.ts index 1dffb013..db1859a8 100644 --- a/backend/src/workers/cronWorker.ts +++ b/backend/src/workers/cronWorker.ts @@ -28,29 +28,32 @@ export function startCronWorkers() { } async function checkInactivity() { - const settings = await getAdminSettings(); const now = new Date(); const running = await prisma.preview.findMany({ where: { status: "RUNNING" }, + include: { repoConfig: { select: { inactivityHours: true } } }, }); for (const preview of running) { - const inactivityMs = settings.maxConcurrentInstancesPerUser; // will use actual inactivityHours from repoConfig - const repoConfig = await prisma.repoConfig.findUnique({ where: { id: preview.repoConfigId } }); - if (!repoConfig) continue; - - const deadline = new Date(preview.lastActivityAt.getTime() + repoConfig.inactivityHours * 3600 * 1000); + const inactivityHours = preview.repoConfig.inactivityHours; + const deadline = new Date(preview.lastActivityAt.getTime() + inactivityHours * 3600 * 1000); if (now >= deadline) { - log.info({ previewId: preview.id }, "Preview inactive, enqueuing INACTIVITY_STOP"); - await prisma.job.create({ - data: { - previewId: preview.id, - type: "INACTIVITY_STOP", - status: "PENDING", - payload: {}, - }, + log.info({ previewId: preview.id, inactivityHours }, "Preview inactive, enqueuing INACTIVITY_STOP"); + // Only create one pending INACTIVITY_STOP per preview + const existingStop = await prisma.job.findFirst({ + where: { previewId: preview.id, type: "INACTIVITY_STOP", status: { in: ["PENDING", "RUNNING"] } }, }); + if (!existingStop) { + await prisma.job.create({ + data: { + previewId: preview.id, + type: "INACTIVITY_STOP", + status: "PENDING", + payload: {}, + }, + }); + } } } } diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 1e3d1f62..9ac75b33 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -6,6 +6,15 @@ import { ConfirmDialog } from "../components/ConfirmDialog"; import { StatusBadge } from "../components/StatusBadge"; import { Link } from "react-router-dom"; +interface EditUserForm { + id: number; + username: string; + newUsername: string; + newPassword: string; + isAdmin: boolean; + isFounder: boolean; +} + export function Admin() { const { user } = useAuth(); const [tab, setTab] = useState<"users" | "settings" | "previews">("users"); @@ -16,7 +25,7 @@ export function Admin() { const [newUsername, setNewUsername] = useState(""); const [newPassword, setNewPassword] = useState(""); - const [editUser, setEditUser] = useState(null); + const [editUser, setEditUser] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); const [stopConfirm, setStopConfirm] = useState(null); const [saving, setSaving] = useState(false); @@ -64,6 +73,19 @@ export function Admin() { else toast.error(res.message || "Failed to delete user"); }; + const handleSaveUser = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editUser) return; + setSaving(true); + const data: any = {}; + if (editUser.newUsername && editUser.newUsername !== editUser.username) data.username = editUser.newUsername; + if (editUser.newPassword) data.password = editUser.newPassword; + const res = await api.admin.updateUser(editUser.id, data); + setSaving(false); + if (res.ok) { toast.success("User updated"); setEditUser(null); loadUsers(); } + else toast.error(res.message || "Failed to update user"); + }; + const handleSaveSettings = async (e: React.FormEvent) => { e.preventDefault(); setSaving(true); @@ -102,6 +124,38 @@ export function Admin() { danger /> + {/* Edit user modal */} + {editUser && ( +
+
+

Edit User: {editUser.username}

+
+
+ + setEditUser(u => u ? { ...u, newUsername: e.target.value } : null)} + className={inputCls} /> +
+
+ + setEditUser(u => u ? { ...u, newPassword: e.target.value } : null)} + className={inputCls} /> +
+
+ + +
+
+
+
+ )} +

Admin Panel

@@ -123,7 +177,7 @@ export function Admin() { setNewUsername(e.target.value)} placeholder="Username" className={inputCls} required /> setNewPassword(e.target.value)} - placeholder="Password" className={inputCls} required /> + placeholder="Password (min 8 chars)" className={inputCls} required minLength={8} />
@@ -153,6 +207,12 @@ export function Admin() { {new Date(u.createdAt).toLocaleDateString()}
+ {!u.isFounder && u.id !== user.id && ( <>
@@ -282,5 +347,6 @@ function Field({ label, children }: { label: string; children: React.ReactNode } ); } +const labelCls = "block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1"; const inputCls = "w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500"; const btnCls = "px-4 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors disabled:opacity-50"; diff --git a/frontend/src/pages/Privacy.tsx b/frontend/src/pages/Privacy.tsx index 8e25b3ba..37d84e79 100644 --- a/frontend/src/pages/Privacy.tsx +++ b/frontend/src/pages/Privacy.tsx @@ -1,7 +1,16 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { Link } from "react-router-dom"; +import { api } from "../services/api"; export function Privacy() { + const [contactEmail, setContactEmail] = useState(null); + + useEffect(() => { + api.admin.getSettings().then(res => { + if (res.ok && res.data?.contactEmail) setContactEmail(res.data.contactEmail); + }).catch(() => {}); + }, []); + return (
← Back @@ -10,9 +19,9 @@ export function Privacy() {

What We Store

    -
  • Your username and hashed password.
  • -
  • Your Gitea Personal Access Token (PAT), encrypted at rest with AES-256.
  • -
  • Your AWS Access Key ID and Secret Access Key, encrypted at rest with AES-256.
  • +
  • Your username and hashed password (bcrypt).
  • +
  • Your Gitea Personal Access Token (PAT), encrypted at rest with AES-256-GCM.
  • +
  • Your AWS Access Key ID and Secret Access Key, encrypted at rest with AES-256-GCM.
  • Preview logs, PR metadata (PR number, title, commit SHA), and EC2 instance details.
  • SSH private keys (ephemeral per launch, encrypted at rest, deleted on instance termination).
@@ -26,16 +35,20 @@ export function Privacy() {

Data Retention

-

Preview records are retained for the number of days configured by the administrator (default: 30 days after a preview is stopped or failed). You can view this setting in the admin panel.

+

Preview records (logs, metadata) are retained for the number of days configured by the administrator (default: 30 days after a preview is stopped or failed). You can view this setting in the admin panel.

EC2 Instances

-

Preview instances are launched in your own AWS account. PP terminates them on PR close, inactivity timeout, or manual stop. PP does not retain any data from inside EC2 instances.

+

Preview instances are launched in your own AWS account using your credentials. PP terminates them on PR close, inactivity timeout, or manual stop. PP does not retain any data from inside EC2 instances beyond what is captured in preview logs.

-

Analytics & Tracking

+

Analytics & Tracking

No analytics, no tracking, no external data sharing. PP is fully self-contained.

Contact

-

For questions or concerns, contact the instance administrator.

+ {contactEmail ? ( +

For questions or concerns, contact the instance administrator at {contactEmail}.

+ ) : ( +

For questions or concerns, contact the instance administrator.

+ )}
); } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index e882dcf6..c7348daa 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -280,7 +280,7 @@ const IAM_POLICY = `{ "ec2:DeleteSecurityGroup", "ec2:AuthorizeSecurityGroupIngress", "ec2:DescribeSecurityGroups", - "ec2:ImportKeyPair", + "ec2:CreateKeyPair", "ec2:DeleteKeyPair", "ec2:CreateTags", "sts:GetCallerIdentity" diff --git a/frontend/src/pages/SetupWizard.tsx b/frontend/src/pages/SetupWizard.tsx index ffb24fc1..215640f5 100644 --- a/frontend/src/pages/SetupWizard.tsx +++ b/frontend/src/pages/SetupWizard.tsx @@ -148,7 +148,7 @@ export function SetupWizard() { "Action": ["ec2:RunInstances","ec2:TerminateInstances", "ec2:DescribeInstances","ec2:CreateSecurityGroup", "ec2:DeleteSecurityGroup","ec2:AuthorizeSecurityGroupIngress", - "ec2:DescribeSecurityGroups","ec2:ImportKeyPair", + "ec2:DescribeSecurityGroups","ec2:CreateKeyPair", "ec2:DeleteKeyPair","ec2:CreateTags","sts:GetCallerIdentity"], "Resource": "*" }] diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 507e28e1..11e1af53 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,19 @@ packages: - backend - frontend +allowBuilds: + '@prisma/client': set this to true or false + '@prisma/engines': set this to true or false + bufferutil: set this to true or false + cpu-features: set this to true or false + esbuild: set this to true or false + prisma: set this to true or false + ssh2: set this to true or false +onlyBuiltDependencies: + - '@prisma/client' + - '@prisma/engines' + - bufferutil + - cpu-features + - esbuild + - prisma + - ssh2