WIP: Claude Code progress on #65

Automated salvage commit — agent session ended (exit 124).
Work in progress, may need continuation.
This commit is contained in:
Alexander Whitestone
2026-03-23 22:26:20 -04:00
parent 94d2e48455
commit ad2a5e23fa
8 changed files with 185 additions and 260 deletions

View File

@@ -1,20 +1,23 @@
import { randomBytes } from "crypto";
import { exec } from "child_process";
import { promisify } from "util";
import { makeLogger } from "./logger.js";
const logger = makeLogger("provisioner");
const execAsync = promisify(exec);
export interface ProvisionerConfig {
doApiToken: string;
doRegion: string;
doSize: string;
doVolumeSizeGb: number;
doVpcUuid: string; // New: Digital Ocean VPC UUID
doSshKeyFingerprint: string; // New: Digital Ocean SSH Key Fingerprint
doVpcUuid: string;
doSshKeyFingerprint: string;
tailscaleApiKey: string;
tailscaleTailnet: string;
}
const stubProvisioningResults = new Map<string, any>(); // To store fake results for stub mode
const stubProvisioningResults = new Map<string, unknown>(); // To store fake results for stub mode
export class ProvisionerService {
private readonly config: ProvisionerConfig;
@@ -26,8 +29,8 @@ export class ProvisionerService {
doRegion: config?.doRegion ?? process.env.DO_REGION ?? "nyc3",
doSize: config?.doSize ?? process.env.DO_SIZE ?? "s-2vcpu-4gb",
doVolumeSizeGb: config?.doVolumeSizeGb ?? parseInt(process.env.DO_VOLUME_SIZE_GB ?? "100", 10),
doVpcUuid: config?.doVpcUuid ?? process.env.DO_VPC_UUID ?? "", // New
doSshKeyFingerprint: config?.doSshKeyFingerprint ?? process.env.DO_SSH_KEY_FINGERPRINT ?? "", // New
doVpcUuid: config?.doVpcUuid ?? process.env.DO_VPC_UUID ?? "",
doSshKeyFingerprint: config?.doSshKeyFingerprint ?? process.env.DO_SSH_KEY_FINGERPRINT ?? "",
tailscaleApiKey: config?.tailscaleApiKey ?? process.env.TAILSCALE_API_KEY ?? "",
tailscaleTailnet: config?.tailscaleTailnet ?? process.env.TAILSCALE_TAILNET ?? "",
};
@@ -73,36 +76,22 @@ FakeKeyForJob${jobId}
logger.info("creating Digital Ocean droplet", { jobId });
// Use doctl or DigitalOcean API client to create droplet
// For now, I'll use doctl via runShellCommand, assuming it's available in the environment
const dropletName = `timmy-node-${jobId.slice(0, 8)}`;
const createDropletCommand = `doctl compute droplet create ${dropletName} \
--region ${this.config.doRegion} \
--size ${this.config.doSize} \
--image ubuntu-22-04-x64 \
--enable-private-networking \
--vpc-uuid <YOUR_VPC_UUID> \
--user-data '${cloudConfig}' \
--ssh-keys <YOUR_SSH_KEY_FINGERPRINT> \
--format ID --no-header`; // Simplistic command, needs refinement for real use
const createDropletCmd = [
`doctl compute droplet create ${dropletName}`,
`--region ${this.config.doRegion}`,
`--size ${this.config.doSize}`,
`--image ubuntu-22-04-x64`,
`--enable-private-networking`,
`--vpc-uuid ${this.config.doVpcUuid}`,
`--user-data '${cloudConfig}'`,
`--ssh-keys ${this.config.doSshKeyFingerprint}`,
`--format ID --no-header`,
].join(" \\\n ");
const createDropletOutput = await default_api.run_shell_command(
command: `doctl compute droplet create ${dropletName} \
--region ${this.config.doRegion} \
--size ${this.config.doSize} \
--image ubuntu-22-04-x64 \
--enable-private-networking \
--vpc-uuid ${this.config.doVpcUuid} \
--user-data '${cloudConfig}' \
--ssh-keys ${this.config.doSshKeyFingerprint} \
--format ID --no-header`,
description: `Creating Digital Ocean droplet ${dropletName} for job ${jobId}`,
);
const dropletId = createDropletOutput.output.trim();
const { stdout } = await execAsync(createDropletCmd);
const dropletId = stdout.trim();
// In a real scenario, we would poll the DigitalOcean API to wait for the droplet
// to become active and retrieve its public IP and Tailscale IP.
// For now, we'll simulate this and retrieve dummy IPs.
logger.info("simulating droplet creation and IP assignment", { jobId, dropletId });
await new Promise(resolve => setTimeout(resolve, 10000)); // Simulate droplet creation time
@@ -111,11 +100,11 @@ FakeKeyForJob${jobId}
const lnbitsUrl = `http://${nodeIp}:3000/lnbits`; // Dummy LNbits URL
return {
dropletId: dropletId,
nodeIp: nodeIp,
tailscaleHostname: tailscaleHostname,
lnbitsUrl: lnbitsUrl,
sshPrivateKey: sshPrivateKey,
dropletId,
nodeIp,
tailscaleHostname,
lnbitsUrl,
sshPrivateKey,
};
}
@@ -123,23 +112,16 @@ FakeKeyForJob${jobId}
private async generateSshKeyPair(): Promise<{ sshPrivateKey: string; sshPublicKey: string }> {
logger.info("generating SSH keypair");
const keyPath = `/tmp/id_rsa_${randomBytes(4).toString("hex")}`;
// Generate an unencrypted SSH keypair for programmatic use (careful with security)
await default_api.run_shell_command(
command: `ssh-keygen -t rsa -b 4096 -f ${keyPath} -N ""`,
description: "Generating SSH keypair",
);
const sshPrivateKey = (await default_api.run_shell_command(command: `cat ${keyPath}`)).output.trim();
const sshPublicKey = (await default_api.run_shell_command(command: `cat ${keyPath}.pub`)).output.trim();
await default_api.run_shell_command(command: `rm ${keyPath} ${keyPath}.pub`, description: "Cleaning up temporary SSH keys");
return { sshPrivateKey, sshPublicKey };
await execAsync(`ssh-keygen -t rsa -b 4096 -f ${keyPath} -N ""`);
const { stdout: privOut } = await execAsync(`cat ${keyPath}`);
const { stdout: pubOut } = await execAsync(`cat ${keyPath}.pub`);
await execAsync(`rm ${keyPath} ${keyPath}.pub`);
return { sshPrivateKey: privOut.trim(), sshPublicKey: pubOut.trim() };
}
// Helper to create Tailscale auth key (simplified stub)
private async createTailscaleAuthKey(): Promise<string> {
logger.info("creating Tailscale auth key (stub)");
// In a real scenario, this would involve calling the Tailscale API
// e.g., curl -X POST -H "Authorization: Bearer ${TAILSCALE_API_KEY}"
// "https://api.tailscale.com/api/v2/tailnet/${TAILSCALE_TAILNET}/keys"
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call
return `tskey-test-${randomBytes(16).toString("hex")}`;
}
@@ -147,14 +129,7 @@ FakeKeyForJob${jobId}
// Helper to build cloud-init script
private buildCloudInitScript(sshPublicKey: string, tailscaleAuthKey: string): string {
logger.info("building cloud-init script");
const setupScriptUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/setup.sh`;
const bitcoinConfUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/configs/bitcoin.conf`;
const lndConfUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/configs/lnd.conf`;
const dockerComposeUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/docker-compose.yml`;
const lndInitUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/lnd-init.sh`;
const sweepUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/sweep.sh`;
const sweepConfExampleUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/sweep.conf.example`;
const opsUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/ops.sh`;
const baseUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure`;
return `
#cloud-config
@@ -169,39 +144,17 @@ write_files:
permissions: '0755'
content: |
#!/usr/bin/env bash
curl -s ${setupScriptUrl} > /root/setup.sh
- path: /root/configs/bitcoin.conf
content: |
curl -s ${bitcoinConfUrl} > /root/configs/bitcoin.conf
- path: /root/configs/lnd.conf
content: |
curl -s ${lndConfUrl} > /root/configs/lnd.conf
- path: /root/docker-compose.yml
content: |
curl -s ${dockerComposeUrl} > /root/docker-compose.yml
- path: /root/lnd-init.sh
permissions: '0755'
content: |
curl -s ${lndInitUrl} > /root/lnd-init.sh
- path: /root/sweep.sh
permissions: '0755'
content: |
curl -s ${sweepUrl} > /root/sweep.sh
- path: /root/sweep.conf.example
content: |
curl -s ${sweepConfExampleUrl} > /root/sweep.conf.example
- path: /root/ops.sh
permissions: '0755'
content: |
curl -s ${opsUrl} > /root/ops.sh
curl -s ${baseUrl}/setup.sh > /root/setup.sh
runcmd:
- mkdir -p /root/configs
- curl -s ${setupScriptUrl} > /tmp/setup.sh
- curl -s ${baseUrl}/setup.sh > /tmp/setup.sh
- chmod +x /tmp/setup.sh
- export TAILSCALE_AUTH_KEY="${tailscaleAuthKey}"
- export TAILSCALE_TAILNET="${this.config.tailscaleTailnet}"
- /tmp/setup.sh
`;
}
}
export const provisionerService = new ProvisionerService();
export const provisionerService = new ProvisionerService();

View File

@@ -138,7 +138,7 @@ router.post("/bootstrap", async (req: Request, res: Response) => {
// ── GET /api/bootstrap/:id ───────────────────────────────────────────────────
router.get("/bootstrap/:id", async (req: Request, res: Response) => {
const { id } = req.params; // Assuming ID is always valid, add Zod validation later
const id = String(req.params["id"] ?? ""); // cast: Express 5 params are string
try {
let job = await getBootstrapJobById(id);

View File

@@ -1,10 +1,8 @@
import { type Express, Router } from "express";
import { z } from "zod";
import { Status } from "../lib/http.js";
import { rootLogger } from "../lib/logger.js";
import { type Request, Router } from "express";
import { makeLogger } from "../lib/logger.js";
const router = Router();
const log = rootLogger.child({ service: "relay-policy" });
const log = makeLogger("relay-policy");
// ── Auth ──────────────────────────────────────────────────────────────────────
@@ -14,7 +12,7 @@ if (!RELAY_POLICY_SECRET) {
log.warn("RELAY_POLICY_SECRET is not set — /api/relay/policy will be unauthenticated!");
}
function isAuthenticated(req: Express.Request): boolean {
function isAuthenticated(req: Request): boolean {
if (!RELAY_POLICY_SECRET) {
return true; // No secret configured, so no auth.
}
@@ -29,43 +27,54 @@ function isAuthenticated(req: Express.Request): boolean {
return true;
}
// ── POST /api/relay/policy ────────────────────────────────────────────────────
// ── Request body shape (manual validation — zod not in deps) ──────────────────
const relayPolicyRequestSchema = z.object({
event: z.object({
id: z.string(),
pubkey: z.string(),
kind: z.number(),
created_at: z.number(),
tags: z.array(z.array(z.string())),
content: z.string(),
sig: z.string(),
}),
receivedAt: z.number(),
sourceType: z.string(),
sourceInfo: z.string(),
});
interface StrfryEventBody {
event?: {
id?: unknown;
pubkey?: unknown;
kind?: unknown;
created_at?: unknown;
tags?: unknown;
content?: unknown;
sig?: unknown;
};
receivedAt?: unknown;
sourceType?: unknown;
sourceInfo?: unknown;
}
function parseRelayPolicyBody(body: unknown): { ok: true; eventId: string } | { ok: false } {
if (!body || typeof body !== "object") return { ok: false };
const b = body as StrfryEventBody;
if (!b.event || typeof b.event !== "object") return { ok: false };
const id = b.event.id;
if (typeof id !== "string" || !id) return { ok: false };
return { ok: true, eventId: id };
}
type StrfryAction = "accept" | "reject" | "shadowReject";
router.post("/relay/policy", (req, res) => {
if (!isAuthenticated(req)) {
return res.status(Status.UNAUTHORIZED).json({
res.status(401).json({
action: "reject",
msg: "unauthorized",
});
return;
}
const parse = relayPolicyRequestSchema.safeParse(req.body);
if (!parse.success) {
log.warn("invalid /relay/policy request", { error: parse.error.format() });
return res.status(Status.BAD_REQUEST).json({
const parsed = parseRelayPolicyBody(req.body);
if (!parsed.ok) {
log.warn("invalid /relay/policy request");
res.status(400).json({
action: "reject",
msg: "invalid request",
});
return;
}
const eventId = parse.data.event.id;
const { eventId } = parsed;
// Bootstrap state: reject everything.
// This will be extended by whitelist + moderation tasks.