feat(v2): full spec implementation — root SSH, bootstrap sentinel, HMAC webhook, all parametric routes fixed #1

Merged
space merged 9 commits from v2/full-spec-implementation into main 2026-07-26 14:26:39 +02:00
9 changed files with 211 additions and 81 deletions
Showing only changes of commit 25f3612561 - Show all commits
+56 -53
View File
@@ -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<string, string>;
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 .env> | 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({
+19
View File
@@ -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: {
+11 -1
View File
@@ -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<void> {
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));
}
+17 -14
View File
@@ -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: {},
},
});
}
}
}
}
+69 -3
View File
@@ -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<any>(null);
const [editUser, setEditUser] = useState<EditUserForm | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
const [stopConfirm, setStopConfirm] = useState<any>(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 && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 w-full max-w-sm">
<h2 className="font-semibold text-lg mb-4">Edit User: {editUser.username}</h2>
<form onSubmit={handleSaveUser} className="space-y-3">
<div>
<label className={labelCls}>Username</label>
<input type="text" value={editUser.newUsername}
onChange={e => setEditUser(u => u ? { ...u, newUsername: e.target.value } : null)}
className={inputCls} />
</div>
<div>
<label className={labelCls}>New Password (leave blank to keep current)</label>
<input type="password" value={editUser.newPassword} placeholder="New password"
onChange={e => setEditUser(u => u ? { ...u, newPassword: e.target.value } : null)}
className={inputCls} />
</div>
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving} className={`${btnCls} flex-1`}>
{saving ? "Saving..." : "Save Changes"}
</button>
<button type="button" onClick={() => setEditUser(null)}
className="flex-1 px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-slate-600 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
Cancel
</button>
</div>
</form>
</div>
</div>
)}
<h1 className="text-2xl font-bold mb-6">Admin Panel</h1>
<div className="flex gap-2 mb-6 border-b border-gray-200 dark:border-slate-700">
@@ -123,7 +177,7 @@ export function Admin() {
<input type="text" value={newUsername} onChange={e => setNewUsername(e.target.value)}
placeholder="Username" className={inputCls} required />
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)}
placeholder="Password" className={inputCls} required />
placeholder="Password (min 8 chars)" className={inputCls} required minLength={8} />
<button type="submit" disabled={saving} className={btnCls}>Create</button>
</form>
</div>
@@ -153,6 +207,12 @@ export function Admin() {
<td className="px-4 py-3 text-gray-500 dark:text-slate-400">{new Date(u.createdAt).toLocaleDateString()}</td>
<td className="px-4 py-3 text-right">
<div className="flex gap-2 justify-end">
<button
onClick={() => setEditUser({ id: u.id, username: u.username, newUsername: u.username, newPassword: "", isAdmin: u.isAdmin, isFounder: u.isFounder })}
className="text-xs text-gray-600 dark:text-slate-300 hover:underline"
>
Edit
</button>
{!u.isFounder && u.id !== user.id && (
<>
<button
@@ -208,7 +268,7 @@ export function Admin() {
onChange={e => setSettings((s: any) => ({ ...s, previewRetentionDays: Number(e.target.value) }))}
className={inputCls} min={1} />
</Field>
<Field label="Contact Email">
<Field label="Contact Email (shown in privacy policy)">
<input type="email" value={settings.contactEmail}
onChange={e => setSettings((s: any) => ({ ...s, contactEmail: e.target.value }))}
className={inputCls} placeholder="admin@example.com" />
@@ -264,6 +324,11 @@ export function Admin() {
</td>
</tr>
))}
{previews.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-gray-500 dark:text-slate-400">No previews yet.</td>
</tr>
)}
</tbody>
</table>
</div>
@@ -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";
+21 -8
View File
@@ -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<string | null>(null);
useEffect(() => {
api.admin.getSettings().then(res => {
if (res.ok && res.data?.contactEmail) setContactEmail(res.data.contactEmail);
}).catch(() => {});
}, []);
return (
<div className="max-w-2xl prose dark:prose-invert">
<Link to="/" className="text-sm text-blue-600 dark:text-blue-400 hover:underline mb-4 inline-block"> Back</Link>
@@ -10,9 +19,9 @@ export function Privacy() {
<h2>What We Store</h2>
<ul>
<li>Your username and hashed password.</li>
<li>Your Gitea Personal Access Token (PAT), encrypted at rest with AES-256.</li>
<li>Your AWS Access Key ID and Secret Access Key, encrypted at rest with AES-256.</li>
<li>Your username and hashed password (bcrypt).</li>
<li>Your Gitea Personal Access Token (PAT), encrypted at rest with AES-256-GCM.</li>
<li>Your AWS Access Key ID and Secret Access Key, encrypted at rest with AES-256-GCM.</li>
<li>Preview logs, PR metadata (PR number, title, commit SHA), and EC2 instance details.</li>
<li>SSH private keys (ephemeral per launch, encrypted at rest, deleted on instance termination).</li>
</ul>
@@ -26,16 +35,20 @@ export function Privacy() {
</ul>
<h2>Data Retention</h2>
<p>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.</p>
<p>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.</p>
<h2>EC2 Instances</h2>
<p>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.</p>
<p>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.</p>
<h2>Analytics & Tracking</h2>
<h2>Analytics &amp; Tracking</h2>
<p>No analytics, no tracking, no external data sharing. PP is fully self-contained.</p>
<h2>Contact</h2>
<p>For questions or concerns, contact the instance administrator.</p>
{contactEmail ? (
<p>For questions or concerns, contact the instance administrator at <a href={`mailto:${contactEmail}`} className="text-blue-600 dark:text-blue-400 underline">{contactEmail}</a>.</p>
) : (
<p>For questions or concerns, contact the instance administrator.</p>
)}
</div>
);
}
+1 -1
View File
@@ -280,7 +280,7 @@ const IAM_POLICY = `{
"ec2:DeleteSecurityGroup",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:DescribeSecurityGroups",
"ec2:ImportKeyPair",
"ec2:CreateKeyPair",
"ec2:DeleteKeyPair",
"ec2:CreateTags",
"sts:GetCallerIdentity"
+1 -1
View File
@@ -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": "*"
}]
+16
View File
@@ -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