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:
@@ -5,6 +5,7 @@ export interface PricingConfig {
|
||||
workFeeLongSats?: number;
|
||||
shortMaxChars?: number;
|
||||
mediumMaxChars?: number;
|
||||
bootstrapFeeSats?: number;
|
||||
}
|
||||
|
||||
export class PricingService {
|
||||
@@ -14,6 +15,7 @@ export class PricingService {
|
||||
private readonly workFeeLong: number;
|
||||
private readonly shortMax: number;
|
||||
private readonly mediumMax: number;
|
||||
private readonly bootstrapFee: number;
|
||||
|
||||
constructor(config?: PricingConfig) {
|
||||
this.evalFee = config?.evalFeeSats ?? 10;
|
||||
@@ -22,6 +24,9 @@ export class PricingService {
|
||||
this.workFeeLong = config?.workFeeLongSats ?? 250;
|
||||
this.shortMax = config?.shortMaxChars ?? 100;
|
||||
this.mediumMax = config?.mediumMaxChars ?? 300;
|
||||
this.bootstrapFee =
|
||||
config?.bootstrapFeeSats ??
|
||||
(process.env.BOOTSTRAP_FEE_SATS ? parseInt(process.env.BOOTSTRAP_FEE_SATS, 10) : 10_000);
|
||||
}
|
||||
|
||||
calculateEvalFeeSats(): number {
|
||||
@@ -34,6 +39,10 @@ export class PricingService {
|
||||
if (len <= this.mediumMax) return this.workFeeMedium;
|
||||
return this.workFeeLong;
|
||||
}
|
||||
|
||||
calculateBootstrapFeeSats(): number {
|
||||
return this.bootstrapFee;
|
||||
}
|
||||
}
|
||||
|
||||
export const pricingService = new PricingService();
|
||||
|
||||
326
artifacts/api-server/src/lib/provisioner.ts
Normal file
326
artifacts/api-server/src/lib/provisioner.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { db, bootstrapJobs } from "@workspace/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const DO_API_BASE = "https://api.digitalocean.com/v2";
|
||||
const TS_API_BASE = "https://api.tailscale.com/api/v2";
|
||||
|
||||
function generateSshKeypair(): { privateKey: string; publicKey: string } {
|
||||
const tmpDir = mkdtempSync(path.join(tmpdir(), "timmy-bp-"));
|
||||
const keyPath = path.join(tmpDir, "id_rsa");
|
||||
try {
|
||||
execSync(
|
||||
`ssh-keygen -t rsa -b 4096 -N "" -C "timmy-bootstrap-node" -f "${keyPath}" -q`,
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
const privateKey = readFileSync(keyPath, "utf8");
|
||||
const publicKey = readFileSync(`${keyPath}.pub`, "utf8").trim();
|
||||
return { privateKey, publicKey };
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function buildCloudInitScript(tailscaleAuthKey: string): string {
|
||||
const tsBlock = tailscaleAuthKey
|
||||
? `tailscale up --authkey="${tailscaleAuthKey}" --ssh --accept-routes`
|
||||
: "# No Tailscale auth key — skipping Tailscale join";
|
||||
|
||||
return `#!/bin/bash
|
||||
set -euo pipefail
|
||||
exec >> /var/log/timmy-bootstrap.log 2>&1
|
||||
|
||||
echo "[timmy] Starting automated bootstrap at $(date -u)"
|
||||
|
||||
# System packages
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl wget git ufw jq openssl
|
||||
|
||||
# Docker
|
||||
if ! command -v docker &>/dev/null; then
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
fi
|
||||
|
||||
# Tailscale
|
||||
if ! command -v tailscale &>/dev/null; then
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
fi
|
||||
${tsBlock}
|
||||
|
||||
# Firewall
|
||||
ufw --force reset
|
||||
ufw allow in on tailscale0
|
||||
ufw allow 8333/tcp
|
||||
ufw allow 9735/tcp
|
||||
ufw allow 22/tcp
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw --force enable
|
||||
|
||||
# Directories
|
||||
mkdir -p /data/bitcoin /data/lnd /data/lnbits /opt/timmy-node/configs
|
||||
|
||||
# RPC password
|
||||
RPC_PASS=$(openssl rand -hex 24)
|
||||
|
||||
# Bitcoin config
|
||||
cat > /data/bitcoin/bitcoin.conf <<BTCCONF
|
||||
server=1
|
||||
rpcuser=satoshi
|
||||
rpcpassword=\$RPC_PASS
|
||||
rpcallowip=172.16.0.0/12
|
||||
rpcbind=0.0.0.0
|
||||
txindex=1
|
||||
zmqpubrawblock=tcp://0.0.0.0:28332
|
||||
zmqpubrawtx=tcp://0.0.0.0:28333
|
||||
[main]
|
||||
rpcport=8332
|
||||
BTCCONF
|
||||
|
||||
# Docker Compose
|
||||
cat > /opt/timmy-node/docker-compose.yml <<COMPOSE
|
||||
version: "3.8"
|
||||
services:
|
||||
bitcoin:
|
||||
image: bitcoinknots/bitcoin:29.3.knots20260210
|
||||
container_name: bitcoin
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /data/bitcoin:/home/bitcoin/.bitcoin
|
||||
ports:
|
||||
- "8333:8333"
|
||||
- "8332:8332"
|
||||
- "28332:28332"
|
||||
- "28333:28333"
|
||||
command: bitcoind -datadir=/home/bitcoin/.bitcoin -conf=/home/bitcoin/.bitcoin/bitcoin.conf
|
||||
COMPOSE
|
||||
|
||||
# Start Bitcoin Core
|
||||
cd /opt/timmy-node
|
||||
docker compose up -d bitcoin
|
||||
|
||||
# Save credentials
|
||||
cat > /root/node-credentials.txt <<CREDS
|
||||
BITCOIN_RPC_USER=satoshi
|
||||
BITCOIN_RPC_PASS=\$RPC_PASS
|
||||
CREDS
|
||||
chmod 600 /root/node-credentials.txt
|
||||
|
||||
echo "[timmy] Bootstrap complete at $(date -u). Bitcoin sync started (takes 1-2 weeks)."
|
||||
echo "[timmy] Next steps:"
|
||||
echo " 1. SSH to node, then run: bash /opt/timmy-node/lnd-init.sh"
|
||||
echo " 2. Monitor sync: bash /opt/timmy-node/ops.sh sync"
|
||||
`;
|
||||
}
|
||||
|
||||
async function doPost<T>(endpoint: string, token: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${DO_API_BASE}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`DO API POST ${endpoint} failed (${res.status}): ${text}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function doGet<T>(endpoint: string, token: string): Promise<T> {
|
||||
const res = await fetch(`${DO_API_BASE}${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`DO API GET ${endpoint} failed (${res.status}): ${text}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function pollDropletIp(
|
||||
dropletId: number,
|
||||
token: string,
|
||||
maxMs = 120_000,
|
||||
): Promise<string | null> {
|
||||
const deadline = Date.now() + maxMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
const data = await doGet<{
|
||||
droplet: { networks: { v4: Array<{ type: string; ip_address: string }> } };
|
||||
}>(`/droplets/${dropletId}`, token);
|
||||
const pub = data.droplet?.networks?.v4?.find((n) => n.type === "public");
|
||||
if (pub?.ip_address) return pub.ip_address;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getTailscaleAuthKey(apiKey: string, tailnet: string): Promise<string> {
|
||||
const res = await fetch(`${TS_API_BASE}/tailnet/${tailnet}/keys`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
capabilities: {
|
||||
devices: {
|
||||
create: {
|
||||
reusable: false,
|
||||
ephemeral: false,
|
||||
preauthorized: true,
|
||||
tags: ["tag:timmy-node"],
|
||||
},
|
||||
},
|
||||
},
|
||||
expirySeconds: 86400,
|
||||
description: "timmy-bootstrap",
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Tailscale API failed (${res.status}): ${text}`);
|
||||
}
|
||||
const data = (await res.json()) as { key: string };
|
||||
return data.key;
|
||||
}
|
||||
|
||||
export class ProvisionerService {
|
||||
readonly stubMode: boolean;
|
||||
|
||||
private readonly doToken: string;
|
||||
private readonly doRegion: string;
|
||||
private readonly doSize: string;
|
||||
private readonly tsApiKey: string;
|
||||
private readonly tsTailnet: string;
|
||||
|
||||
constructor() {
|
||||
this.doToken = process.env.DO_API_TOKEN ?? "";
|
||||
this.doRegion = process.env.DO_REGION ?? "nyc3";
|
||||
this.doSize = process.env.DO_SIZE ?? "s-4vcpu-8gb";
|
||||
this.tsApiKey = process.env.TAILSCALE_API_KEY ?? "";
|
||||
this.tsTailnet = process.env.TAILSCALE_TAILNET ?? "";
|
||||
this.stubMode = !this.doToken;
|
||||
if (this.stubMode) {
|
||||
console.warn(
|
||||
"[ProvisionerService] No DO_API_TOKEN — running in STUB mode. Provisioning is simulated.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget: call without awaiting.
|
||||
* Updates bootstrap_jobs state to ready/failed when complete.
|
||||
*/
|
||||
async provision(bootstrapJobId: string): Promise<void> {
|
||||
try {
|
||||
if (this.stubMode) {
|
||||
await this.stubProvision(bootstrapJobId);
|
||||
} else {
|
||||
await this.realProvision(bootstrapJobId);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Provisioning failed";
|
||||
console.error(`[ProvisionerService] Error for job ${bootstrapJobId}:`, message);
|
||||
await db
|
||||
.update(bootstrapJobs)
|
||||
.set({ state: "failed", errorMessage: message, updatedAt: new Date() })
|
||||
.where(eq(bootstrapJobs.id, bootstrapJobId));
|
||||
}
|
||||
}
|
||||
|
||||
private async stubProvision(jobId: string): Promise<void> {
|
||||
console.log(`[stub] Simulating node provisioning for bootstrap job ${jobId}...`);
|
||||
const { privateKey } = generateSshKeypair();
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const fakeDropletId = String(Math.floor(Math.random() * 900_000_000 + 100_000_000));
|
||||
await db
|
||||
.update(bootstrapJobs)
|
||||
.set({
|
||||
state: "ready",
|
||||
dropletId: fakeDropletId,
|
||||
nodeIp: "198.51.100.42",
|
||||
tailscaleHostname: `timmy-node-${jobId.slice(0, 8)}.tail1234.ts.net`,
|
||||
lnbitsUrl: `https://timmy-node-${jobId.slice(0, 8)}.tail1234.ts.net`,
|
||||
sshPrivateKey: privateKey,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(bootstrapJobs.id, jobId));
|
||||
console.log(`[stub] Bootstrap job ${jobId} marked ready with fake credentials.`);
|
||||
}
|
||||
|
||||
private async realProvision(jobId: string): Promise<void> {
|
||||
console.log(`[ProvisionerService] Provisioning real node for job ${jobId}...`);
|
||||
|
||||
const { publicKey, privateKey } = generateSshKeypair();
|
||||
|
||||
const keyName = `timmy-bootstrap-${jobId.slice(0, 8)}`;
|
||||
const keyData = await doPost<{ ssh_key: { id: number } }>(
|
||||
"/account/keys",
|
||||
this.doToken,
|
||||
{ name: keyName, public_key: publicKey },
|
||||
);
|
||||
const sshKeyId = keyData.ssh_key.id;
|
||||
|
||||
let tailscaleAuthKey = "";
|
||||
if (this.tsApiKey && this.tsTailnet) {
|
||||
try {
|
||||
tailscaleAuthKey = await getTailscaleAuthKey(this.tsApiKey, this.tsTailnet);
|
||||
} catch (err) {
|
||||
console.warn("[ProvisionerService] Tailscale auth key failed — skipping:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const userData = buildCloudInitScript(tailscaleAuthKey);
|
||||
const dropletData = await doPost<{ droplet: { id: number } }>("/droplets", this.doToken, {
|
||||
name: `timmy-node-${jobId.slice(0, 8)}`,
|
||||
region: this.doRegion,
|
||||
size: this.doSize,
|
||||
image: "ubuntu-22-04-x64",
|
||||
ssh_keys: [sshKeyId],
|
||||
user_data: userData,
|
||||
tags: ["timmy-node"],
|
||||
});
|
||||
const dropletId = dropletData.droplet.id;
|
||||
console.log(`[ProvisionerService] Droplet created: id=${dropletId}`);
|
||||
|
||||
const nodeIp = await pollDropletIp(dropletId, this.doToken, 120_000);
|
||||
console.log(`[ProvisionerService] Droplet IP: ${nodeIp ?? "(not yet assigned)"}`);
|
||||
|
||||
const tailscaleHostname =
|
||||
tailscaleAuthKey && this.tsTailnet
|
||||
? `timmy-node-${jobId.slice(0, 8)}.${this.tsTailnet}.ts.net`
|
||||
: null;
|
||||
|
||||
const lnbitsUrl = tailscaleHostname
|
||||
? `https://${tailscaleHostname}`
|
||||
: nodeIp
|
||||
? `http://${nodeIp}:3000`
|
||||
: null;
|
||||
|
||||
await db
|
||||
.update(bootstrapJobs)
|
||||
.set({
|
||||
state: "ready",
|
||||
dropletId: String(dropletId),
|
||||
nodeIp,
|
||||
tailscaleHostname,
|
||||
lnbitsUrl,
|
||||
sshPrivateKey: privateKey,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(bootstrapJobs.id, jobId));
|
||||
|
||||
console.log(`[ProvisionerService] Bootstrap job ${jobId} ready.`);
|
||||
}
|
||||
}
|
||||
|
||||
export const provisionerService = new ProvisionerService();
|
||||
195
artifacts/api-server/src/routes/bootstrap.ts
Normal file
195
artifacts/api-server/src/routes/bootstrap.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
33
lib/db/src/schema/bootstrap-jobs.ts
Normal file
33
lib/db/src/schema/bootstrap-jobs.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { pgTable, text, timestamp, integer, boolean } from "drizzle-orm/pg-core";
|
||||
|
||||
export const BOOTSTRAP_JOB_STATES = [
|
||||
"awaiting_payment",
|
||||
"provisioning",
|
||||
"ready",
|
||||
"failed",
|
||||
] as const;
|
||||
|
||||
export type BootstrapJobState = (typeof BOOTSTRAP_JOB_STATES)[number];
|
||||
|
||||
export const bootstrapJobs = pgTable("bootstrap_jobs", {
|
||||
id: text("id").primaryKey(),
|
||||
state: text("state").$type<BootstrapJobState>().notNull().default("awaiting_payment"),
|
||||
|
||||
amountSats: integer("amount_sats").notNull(),
|
||||
paymentHash: text("payment_hash").notNull(),
|
||||
paymentRequest: text("payment_request").notNull(),
|
||||
|
||||
dropletId: text("droplet_id"),
|
||||
nodeIp: text("node_ip"),
|
||||
tailscaleHostname: text("tailscale_hostname"),
|
||||
lnbitsUrl: text("lnbits_url"),
|
||||
sshPrivateKey: text("ssh_private_key"),
|
||||
sshKeyDelivered: boolean("ssh_key_delivered").notNull().default(false),
|
||||
|
||||
errorMessage: text("error_message"),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type BootstrapJob = typeof bootstrapJobs.$inferSelect;
|
||||
@@ -2,3 +2,4 @@ export * from "./jobs";
|
||||
export * from "./invoices";
|
||||
export * from "./conversations";
|
||||
export * from "./messages";
|
||||
export * from "./bootstrap-jobs";
|
||||
|
||||
51
replit.md
51
replit.md
@@ -68,6 +68,20 @@ Every package extends `tsconfig.base.json` which sets `composite: true`. The roo
|
||||
|
||||
> **Note:** If `LNBITS_URL` and `LNBITS_API_KEY` are absent, `LNbitsService` automatically runs in **stub mode** — invoices are simulated in-memory and can be marked paid via `svc.stubMarkPaid(hash)`. This is intentional for development without a Lightning node.
|
||||
|
||||
### Node bootstrap secrets (for `POST /api/bootstrap`)
|
||||
|
||||
| Secret | Description | Default |
|
||||
|---|---|---|
|
||||
| `BOOTSTRAP_FEE_SATS` | Startup fee in sats the user pays | `10000` (dev) |
|
||||
| `DO_API_TOKEN` | Digital Ocean personal access token | absent = stub mode |
|
||||
| `DO_REGION` | DO datacenter region | `nyc3` |
|
||||
| `DO_SIZE` | DO droplet size slug | `s-4vcpu-8gb` |
|
||||
| `DO_VOLUME_SIZE_GB` | Block volume to attach in GB (`0` = none) | `0` |
|
||||
| `TAILSCALE_API_KEY` | Tailscale API key for generating auth keys | optional |
|
||||
| `TAILSCALE_TAILNET` | Tailscale tailnet name (e.g. `example.com`) | required with above |
|
||||
|
||||
> **Note:** If `DO_API_TOKEN` is absent, `ProvisionerService` automatically runs in **stub mode** — provisioning is simulated with fake credentials and a real SSH keypair. Set `DO_API_TOKEN` for real node creation.
|
||||
|
||||
## Packages
|
||||
|
||||
### `artifacts/api-server` (`@workspace/api-server`)
|
||||
@@ -143,6 +157,43 @@ curl -s "$BASE/api/jobs/<jobId>"
|
||||
|
||||
Job states: `awaiting_eval_payment` → `evaluating` → `awaiting_work_payment` → `executing` → `complete` | `rejected` | `failed`
|
||||
|
||||
#### Lightning-gated node bootstrap
|
||||
|
||||
Pay a one-time startup fee → Timmy auto-provisions a Bitcoin full node on Digital Ocean.
|
||||
|
||||
```bash
|
||||
# 1. Create bootstrap job (returns invoice)
|
||||
curl -s -X POST "$BASE/api/bootstrap"
|
||||
# → {"bootstrapJobId":"…","invoice":{"paymentRequest":"…","amountSats":10000},
|
||||
# "message":"Stub mode: simulate payment with POST /api/dev/stub/pay/<hash>…"}
|
||||
|
||||
# 2. (Stub mode) Simulate payment
|
||||
curl -s -X POST "$BASE/api/dev/stub/pay/<paymentHash>"
|
||||
|
||||
# 3. Poll status — transitions: awaiting_payment → provisioning → ready
|
||||
curl -s "$BASE/api/bootstrap/<bootstrapJobId>"
|
||||
|
||||
# 4. When ready, response includes credentials (SSH key delivered once):
|
||||
# {
|
||||
# "state": "ready",
|
||||
# "credentials": {
|
||||
# "nodeIp": "…",
|
||||
# "tailscaleHostname": "timmy-node-xxxx.tailnet.ts.net",
|
||||
# "lnbitsUrl": "https://timmy-node-xxxx.tailnet.ts.net",
|
||||
# "sshPrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----…" ← null on subsequent polls
|
||||
# },
|
||||
# "nextSteps": [ "SSH in", "Monitor sync", "Run lnd-init.sh", "Configure sweep" ]
|
||||
# }
|
||||
```
|
||||
|
||||
Bootstrap states: `awaiting_payment` → `provisioning` → `ready` | `failed`
|
||||
|
||||
Key properties:
|
||||
- **Stub mode** auto-activates when `DO_API_TOKEN` is absent — returns fake credentials with a real SSH keypair
|
||||
- **SSH key** delivered once then cleared from DB; subsequent polls show `sshKeyNote`
|
||||
- **Tailscale** auth key auto-generated if `TAILSCALE_API_KEY` + `TAILSCALE_TAILNET` are set
|
||||
- Bitcoin sync takes 1–2 weeks; `ready` means provisioned + bootstrapped, not fully synced
|
||||
|
||||
#### Free demo endpoint (rate-limited: 5 req/hour per IP)
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user