import { spawn } from "child_process"; import { promises as fs } from "fs"; import yaml from "js-yaml"; import { join } from "path"; import { logger } from "./Logger"; const log = logger.child({ module: "cloudflare", }); export interface TunnelConfig { domain: string; subdomain: string; subdomainToService: Map; } interface TunnelResponse { result: { id: string; token: string; }; } interface ZoneResponse { result: Array<{ id: string; }>; } interface DNSRecordResponse { result: Array<{ id: string; }>; } interface CloudflaredConfig { tunnel: string; "credentials-file": string; ingress: Array<{ hostname?: string; service: string; }>; } export class Cloudflare { private baseUrl = "https://api.cloudflare.com/client/v4"; constructor( private accountId: string, private apiToken: string, private configDir: string, ) { log.info(`Using config directory: ${this.configDir}`); } private async makeRequest( url: string, method: string = "GET", data?: any, ): Promise { const response = await fetch(url, { method, headers: { Authorization: `Bearer ${this.apiToken}`, "Content-Type": "application/json", }, body: data ? JSON.stringify(data) : undefined, }); if (!response.ok) { const errorText = await response.text(); throw new Error( `Cloudflare API error: url ${url} ${response.status} - ${errorText}`, ); } return response.json() as Promise; } public async createTunnel(config: TunnelConfig): Promise<{ tunnelId: string; tunnelToken: string; tunnelUrl: string; configPath: string; }> { const { domain, subdomain, subdomainToService } = config; // Generate unique tunnel name const timestamp = new Date().toISOString().replace(/[-:.]/g, ""); const tunnelName = `${subdomain}-tunnel-${timestamp}`; log.info(`Creating tunnel with name: ${tunnelName}`); // Create tunnel via API to get official tunnel ID and token const tunnelResponse = await this.makeRequest( `${this.baseUrl}/accounts/${this.accountId}/cfd_tunnel`, "POST", { name: tunnelName }, ); const tunnelId = tunnelResponse.result.id; const tunnelToken = tunnelResponse.result.token; if (!tunnelId) { throw new Error("Failed to create tunnel"); } log.info(`Tunnel created with ID: ${tunnelId}`); // Create local config file instead of using API configuration const configPath = await this.writeTunnelConfig( tunnelId, tunnelToken, subdomain, domain, subdomainToService, tunnelName, ); // Get zone ID const zoneResponse = await this.makeRequest( `${this.baseUrl}/zones?name=${domain}`, ); const zoneId = zoneResponse.result[0]?.id; if (!zoneId) { throw new Error(`Could not find zone ID for domain ${domain}`); } await Promise.all( Array.from(subdomainToService.entries()).map(([subdomain, _]) => this.updateDNSRecord(zoneId, tunnelId, subdomain, domain), ), ); const tunnelUrl = `https://${subdomain}.${domain}`; log.info(`Tunnel is set up! Site will be available at: ${tunnelUrl}`); return { tunnelId, tunnelToken, tunnelUrl, configPath }; } private async writeTunnelConfig( tunnelId: string, tunnelToken: string, subdomain: string, domain: string, subdomainToService: Map, tunnelName: string, ): Promise { log.info(`Creating local config for tunnel ${subdomain}.${domain}...`); const configPath = join(this.configDir, `${tunnelName}.yml`); const credentialsFile = join(this.configDir, `${tunnelId}.json`); const tokenData = JSON.parse( Buffer.from(tunnelToken, "base64").toString("utf8"), ); const credentials = { AccountTag: tokenData.a || this.accountId, TunnelID: tokenData.t || tunnelId, TunnelName: tunnelName, TunnelSecret: tokenData.s, }; await fs.writeFile( credentialsFile, JSON.stringify(credentials, null, 2), "utf8", ); log.info(`Created credentials file at: ${credentialsFile}`); const tunnelConfig: CloudflaredConfig = { tunnel: tunnelId, "credentials-file": credentialsFile, ingress: [ ...Array.from(subdomainToService.entries()).map( ([subdomain, service]) => ({ hostname: `${subdomain}.${domain}`, service: service, }), ), { service: "http_status:404", }, ], }; // Write config file await fs.writeFile(configPath, yaml.dump(tunnelConfig), "utf8"); log.info(`Created config file at: ${configPath}`); return configPath; } private async updateDNSRecord( zoneId: string, tunnelId: string, subdomain: string, domain: string, ): Promise { const existingRecords = await this.makeRequest( `${this.baseUrl}/zones/${zoneId}/dns_records?name=${subdomain}.${domain}`, ); const recordId = existingRecords.result[0]?.id; const dnsData = { type: "CNAME", name: subdomain, content: `${tunnelId}.cfargotunnel.com`, ttl: 1, proxied: true, }; if (recordId) { log.info(`Updating existing DNS record for ${subdomain}.${domain}...`); await this.makeRequest( `${this.baseUrl}/zones/${zoneId}/dns_records/${recordId}`, "PUT", dnsData, ); } else { log.info(`Creating new DNS record for ${subdomain}.${domain}...`); await this.makeRequest( `${this.baseUrl}/zones/${zoneId}/dns_records`, "POST", dnsData, ); } } public async startCloudflared(configPath: string) { const cloudflared = spawn( "cloudflared", ["tunnel", "--config", configPath, "--loglevel", "error", "run"], { detached: true, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, // Set this to bypass origin cert requirement for named tunnels TUNNEL_ORIGIN_CERT: "/dev/null", }, }, ); cloudflared.stdout?.on("data", (data) => { log.info(data.toString().trim()); }); cloudflared.stderr?.on("data", (data) => { log.error(data.toString().trim()); }); cloudflared.on("error", (error) => { log.error("Failed to start cloudflared", { error: error.message, }); }); cloudflared.on("exit", (code, signal) => { if (code !== null) { log.error(`Cloudflared exited with code ${code}`, { exitCode: code, signal, }); } }); cloudflared.unref(); } }