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
3 changed files with 44 additions and 24 deletions
Showing only changes of commit 24c0fa1e7c - Show all commits
+1 -1
View File
@@ -233,7 +233,7 @@ async function firstDeploy(
try { try {
await appendLog(previewId, `[PP] SSH connected. Waiting for bootstrap to complete...\n`); await appendLog(previewId, `[PP] SSH connected. Waiting for bootstrap to complete...\n`);
await waitForBootstrap(sshSession, 600_000); await waitForBootstrap(sshSession, 900_000);
await appendLog(previewId, `[PP] Bootstrap complete. Starting setup...\n`); await appendLog(previewId, `[PP] Bootstrap complete. Starting setup...\n`);
if (repoConfig.aptPackages.length > 0) { if (repoConfig.aptPackages.length > 0) {
+25 -19
View File
@@ -115,7 +115,9 @@ export async function createPreviewSecurityGroup(ec2: EC2Client, groupName: stri
} }
const BOOTSTRAP_SCRIPT = `#!/bin/bash const BOOTSTRAP_SCRIPT = `#!/bin/bash
set -e set -euo pipefail
exec > >(tee -a /var/log/pp-bootstrap.log) 2>&1
trap 'touch /var/lib/pp-bootstrap-failed' ERR
apt-get update -y apt-get update -y
apt-get install -y curl git unzip build-essential apt-get install -y curl git unzip build-essential
@@ -125,17 +127,6 @@ systemctl enable docker
systemctl start docker systemctl start docker
apt-get install -y docker-compose-plugin 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) # NVM + Node LTS (installed as root, available to root SSH sessions)
export HOME=/root export HOME=/root
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
@@ -144,8 +135,18 @@ source "$NVM_DIR/nvm.sh"
nvm install --lts nvm install --lts
nvm alias default lts/* nvm alias default lts/*
# Signal that bootstrap is complete # Do not permit PP's root SSH session until every bootstrap step succeeded.
# That prevents PP from seeing SSH as ready while cloud-init is still mutating
# the host and turns the completion marker into a reliable readiness signal.
touch /var/lib/pp-bootstrap-done touch /var/lib/pp-bootstrap-done
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin without-password/' /etc/ssh/sshd_config
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
`; `;
export async function launchInstance(opts: { export async function launchInstance(opts: {
@@ -179,12 +180,17 @@ export async function launchInstance(opts: {
export async function waitForInstanceRunning(ec2: EC2Client, instanceId: string, maxWaitMs = 300_000): Promise<string> { export async function waitForInstanceRunning(ec2: EC2Client, instanceId: string, maxWaitMs = 300_000): Promise<string> {
const start = Date.now(); const start = Date.now();
while (Date.now() - start < maxWaitMs) { while (Date.now() - start < maxWaitMs) {
const res = await ec2.send(new DescribeInstancesCommand({ try {
InstanceIds: [instanceId], const res = await ec2.send(new DescribeInstancesCommand({
})); InstanceIds: [instanceId],
const inst = res.Reservations?.[0]?.Instances?.[0]; }));
if (inst?.State?.Name === "running" && inst.PublicIpAddress) { const inst = res.Reservations?.[0]?.Instances?.[0];
return inst.PublicIpAddress; if (inst?.State?.Name === "running" && inst.PublicIpAddress) {
return inst.PublicIpAddress;
}
} catch (e: any) {
// AWS eventual consistency: instance may not be visible immediately after RunInstances
if (e.name !== "InvalidInstanceID.NotFound") throw e;
} }
await sleep(5000); await sleep(5000);
} }
+18 -4
View File
@@ -63,22 +63,32 @@ function tryConnect(host: string, privateKey: string, timeoutMs: number): Promis
username: "root", username: "root",
privateKey, privateKey,
readyTimeout: timeoutMs, readyTimeout: timeoutMs,
keepaliveInterval: 10000,
keepaliveCountMax: 3,
algorithms: { algorithms: {
serverHostKey: ["ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521"], serverHostKey: ["ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "ssh-ed25519"],
}, },
}); });
}); });
} }
const EXEC_TIMEOUT_MS = 30_000;
function execOnConn(conn: Client, command: string): Promise<{ stdout: string; stderr: string; code: number }> { function execOnConn(conn: Client, command: string): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`SSH exec timed out: ${command.slice(0, 60)}`)), EXEC_TIMEOUT_MS);
conn.exec(command, (err, stream) => { conn.exec(command, (err, stream) => {
if (err) return reject(err); if (err) {
clearTimeout(timer);
return reject(err);
}
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
stream.on("data", (d: Buffer) => { stdout += d.toString(); }); stream.on("data", (d: Buffer) => { stdout += d.toString(); });
stream.stderr.on("data", (d: Buffer) => { stderr += d.toString(); }); stream.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
stream.on("close", (code: number) => { stream.on("close", (code: number) => {
clearTimeout(timer);
resolve({ stdout, stderr, code: code ?? 0 }); resolve({ stdout, stderr, code: code ?? 0 });
}); });
}); });
@@ -88,8 +98,12 @@ function execOnConn(conn: Client, command: string): Promise<{ stdout: string; st
export async function waitForBootstrap(session: SshSession, maxWaitMs = 600_000): Promise<void> { export async function waitForBootstrap(session: SshSession, maxWaitMs = 600_000): Promise<void> {
const start = Date.now(); const start = Date.now();
while (Date.now() - start < maxWaitMs) { while (Date.now() - start < maxWaitMs) {
const res = await session.exec("test -f /var/lib/pp-bootstrap-done && echo done || echo waiting"); try {
if (res.stdout.trim() === "done") return; const res = await session.exec("test -f /var/lib/pp-bootstrap-done && echo done || echo waiting");
if (res.stdout.trim() === "done") return;
} catch {
// transient SSH exec error (e.g. timeout, channel reset) — retry
}
await sleep(10_000); await sleep(10_000);
} }
throw new Error("EC2 bootstrap did not complete within timeout"); throw new Error("EC2 bootstrap did not complete within timeout");