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

@@ -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();

View 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();