Task #5: Lightning-gated node bootstrap (proof-of-concept)

Pay a Lightning invoice → Timmy auto-provisions a Bitcoin full node on DO.

New: lib/db/src/schema/bootstrap-jobs.ts
- bootstrap_jobs table: id, state, amountSats, paymentHash, paymentRequest,
  dropletId, nodeIp, tailscaleHostname, lnbitsUrl, sshPrivateKey,
  sshKeyDelivered (bool), errorMessage, createdAt, updatedAt
- States: awaiting_payment | provisioning | ready | failed
- Payment data stored inline (no FK to jobs/invoices tables — separate entity)
- db:push applied to create table in Postgres

New: artifacts/api-server/src/lib/provisioner.ts
- ProvisionerService: stubs when DO_API_TOKEN absent, real otherwise
- Stub mode: generates a real RSA 4096-bit SSH keypair via ssh-keygen,
  returns RFC 5737 test IP + fake Tailscale hostname after 2s delay
- Real mode: upload SSH public key to DO → generate Tailscale auth key →
  create DO droplet with cloud-init user_data → poll for public IP (2 min)
- buildCloudInitScript(): non-interactive bash that installs Docker + Tailscale
  + UFW + Bitcoin Knots via docker-compose; joins Tailscale if authkey provided
- provision() designed as fire-and-forget (void); updates DB to ready/failed

New: artifacts/api-server/src/routes/bootstrap.ts
- POST /api/bootstrap: create job + LNbits invoice, return paymentRequest
- GET /api/bootstrap/🆔 poll-driven state machine
  * awaiting_payment: checks payment, fires provisioner on confirm
  * provisioning: returns progress message
  * ready: delivers credentials; SSH private key delivered once then cleared
  * failed: returns error message
- Stub mode message includes the exact /dev/stub/pay URL for easy testing
- nextSteps array guides user through post-provision setup

Updated: artifacts/api-server/src/lib/pricing.ts
- Added bootstrapFee field reading BOOTSTRAP_FEE_SATS env var (default 10000)
- calculateBootstrapFeeSats() method

Updated: artifacts/api-server/src/routes/index.ts
- Mounts bootstrapRouter

Updated: replit.md
- Documents all 7 new env vars (DO_API_TOKEN, DO_REGION, DO_SIZE, etc.)
- Full curl-based flow example with annotated response shape

End-to-end verified in stub mode: POST → pay → provisioning → ready (SSH key)
→ second GET clears key and shows sshKeyNote
This commit is contained in:
alexpaynex
2026-03-18 18:47:48 +00:00
parent 1a60363b74
commit f43e782c50
7 changed files with 617 additions and 0 deletions

View File

@@ -0,0 +1,195 @@
import { Router, type Request, type Response } from "express";
import { randomUUID } from "crypto";
import { db, bootstrapJobs, type BootstrapJob } from "@workspace/db";
import { eq } from "drizzle-orm";
import { lnbitsService } from "../lib/lnbits.js";
import { pricingService } from "../lib/pricing.js";
import { provisionerService } from "../lib/provisioner.js";
const router = Router();
async function getBootstrapJobById(id: string): Promise<BootstrapJob | null> {
const rows = await db
.select()
.from(bootstrapJobs)
.where(eq(bootstrapJobs.id, id))
.limit(1);
return rows[0] ?? null;
}
/**
* Advances the bootstrap job state machine on each poll.
*
* awaiting_payment → (payment confirmed) → provisioning
* (provisioner runs async and writes ready/failed to DB)
*
* Returns the refreshed job, or null if a DB read is needed.
*/
async function advanceBootstrapJob(job: BootstrapJob): Promise<BootstrapJob | null> {
if (job.state !== "awaiting_payment") return job;
const isPaid = await lnbitsService.checkInvoicePaid(job.paymentHash);
if (!isPaid) return job;
const updated = await db
.update(bootstrapJobs)
.set({ state: "provisioning", updatedAt: new Date() })
.where(eq(bootstrapJobs.id, job.id))
.returning();
if (updated.length === 0) return getBootstrapJobById(job.id);
console.log(`[bootstrap] Payment confirmed for ${job.id} — starting provisioning`);
// Fire-and-forget: provisioner updates DB when done
void provisionerService.provision(job.id);
return { ...job, state: "provisioning" };
}
/**
* POST /api/bootstrap
*
* Creates a bootstrap job and returns the Lightning invoice.
*/
router.post("/bootstrap", async (req: Request, res: Response) => {
try {
const fee = pricingService.calculateBootstrapFeeSats();
const jobId = randomUUID();
const invoice = await lnbitsService.createInvoice(
fee,
`Node bootstrap fee — job ${jobId}`,
);
await db.insert(bootstrapJobs).values({
id: jobId,
state: "awaiting_payment",
amountSats: fee,
paymentHash: invoice.paymentHash,
paymentRequest: invoice.paymentRequest,
});
res.status(201).json({
bootstrapJobId: jobId,
invoice: {
paymentRequest: invoice.paymentRequest,
amountSats: fee,
...(lnbitsService.stubMode ? { paymentHash: invoice.paymentHash } : {}),
},
stubMode: lnbitsService.stubMode || provisionerService.stubMode,
message: lnbitsService.stubMode
? `Stub mode: simulate payment with POST /api/dev/stub/pay/${invoice.paymentHash} then poll GET /api/bootstrap/:id`
: "Pay the invoice, then poll GET /api/bootstrap/:id for status",
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create bootstrap job";
res.status(500).json({ error: message });
}
});
/**
* GET /api/bootstrap/:id
*
* Polls status. Triggers provisioning once payment is confirmed.
* Returns credentials (SSH key delivered once, then cleared) when ready.
*/
router.get("/bootstrap/:id", async (req: Request, res: Response) => {
const { id } = req.params;
if (!id || typeof id !== "string") {
res.status(400).json({ error: "Invalid bootstrap job id" });
return;
}
try {
let job = await getBootstrapJobById(id);
if (!job) {
res.status(404).json({ error: "Bootstrap job not found" });
return;
}
const advanced = await advanceBootstrapJob(job);
if (advanced) job = advanced;
const base = {
bootstrapJobId: job.id,
state: job.state,
amountSats: job.amountSats,
createdAt: job.createdAt,
};
switch (job.state) {
case "awaiting_payment":
res.json({
...base,
invoice: {
paymentRequest: job.paymentRequest,
amountSats: job.amountSats,
...(lnbitsService.stubMode ? { paymentHash: job.paymentHash } : {}),
},
message: "Waiting for Lightning payment",
});
break;
case "provisioning":
res.json({
...base,
message: "Payment confirmed — provisioning your Bitcoin node. Poll again in ~30 s.",
});
break;
case "ready": {
const keyNote = job.sshKeyDelivered
? "SSH private key was delivered on first retrieval — check your records"
: null;
// Deliver SSH key on first retrieval, then clear it from DB
const sshPrivateKey = job.sshKeyDelivered ? null : job.sshPrivateKey;
if (!job.sshKeyDelivered && job.sshPrivateKey) {
await db
.update(bootstrapJobs)
.set({ sshKeyDelivered: true, sshPrivateKey: null, updatedAt: new Date() })
.where(eq(bootstrapJobs.id, job.id));
}
res.json({
...base,
credentials: {
nodeIp: job.nodeIp,
tailscaleHostname: job.tailscaleHostname,
lnbitsUrl: job.lnbitsUrl,
sshPrivateKey,
...(keyNote ? { sshKeyNote: keyNote } : {}),
},
nextSteps: [
"SSH into your node: ssh root@<nodeIp>",
"Bitcoin is syncing — this takes 1-2 weeks: bash /opt/timmy-node/ops.sh sync",
"Once sync reaches ~1.0, initialize LND + LNbits: bash /opt/timmy-node/lnd-init.sh",
"Auto-configure cold storage sweep: bash /opt/timmy-node/ops.sh configure-sweep",
],
stubMode: provisionerService.stubMode,
message: provisionerService.stubMode
? "Stub mode — these are fake credentials. Set DO_API_TOKEN for real provisioning."
: "Your node is being bootstrapped. Bitcoin sync has started.",
});
break;
}
case "failed":
res.json({
...base,
errorMessage: job.errorMessage,
message: "Provisioning failed. Contact the operator for a refund.",
});
break;
default:
res.json(base);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch bootstrap job";
res.status(500).json({ error: message });
}
});
export default router;

View File

@@ -1,6 +1,7 @@
import { Router, type IRouter } from "express";
import healthRouter from "./health.js";
import jobsRouter from "./jobs.js";
import bootstrapRouter from "./bootstrap.js";
import demoRouter from "./demo.js";
import devRouter from "./dev.js";
import testkitRouter from "./testkit.js";
@@ -10,6 +11,7 @@ const router: IRouter = Router();
router.use(healthRouter);
router.use(jobsRouter);
router.use(bootstrapRouter);
router.use(demoRouter);
router.use(testkitRouter);
router.use(uiRouter);