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 < /opt/timmy-node/docker-compose.yml < /root/node-credentials.txt <(endpoint: string, token: string, body: unknown): Promise { 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; } async function doGet(endpoint: string, token: string): Promise { 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; } async function pollDropletIp( dropletId: number, token: string, maxMs = 120_000, ): Promise { 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 { 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 { 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 { 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 { 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();