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();
|
||||
Reference in New Issue
Block a user