327 lines
9.7 KiB
TypeScript
327 lines
9.7 KiB
TypeScript
|
|
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();
|