From a9012a661338676718882bd3da46cc7b85ebaf3e Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 25 Dec 2025 13:24:23 -0800 Subject: [PATCH] Move cloudflare tunnel creation back into startup.sh (#2694) ## Description: Reverts #49b01d8 Move cloudflare tunnel creation back into startup.sh. This keeps the infra outside of the main app. ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/core/configuration/Config.ts | 4 - src/core/configuration/DefaultConfig.ts | 12 - src/server/Cloudflare.ts | 289 ------------------------ src/server/Server.ts | 41 ---- startup.sh | 84 +++++++ supervisord.conf | 10 +- tests/util/TestServerConfig.ts | 12 - 7 files changed, 93 insertions(+), 359 deletions(-) delete mode 100644 src/server/Cloudflare.ts diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index a03d85e5a..d9172fc87 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -59,10 +59,6 @@ export interface ServerConfig { jwkPublicKey(): Promise; domain(): string; subdomain(): string; - cloudflareAccountId(): string; - cloudflareApiToken(): string; - cloudflareConfigPath(): string; - cloudflareCredsPath(): string; stripePublishableKey(): string; allowedFlares(): string[] | undefined; enableMatchmaking(): boolean; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 04589128d..965da5cdc 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -102,18 +102,6 @@ export abstract class DefaultServerConfig implements ServerConfig { subdomain(): string { return process.env.SUBDOMAIN ?? ""; } - cloudflareAccountId(): string { - return process.env.CF_ACCOUNT_ID ?? ""; - } - cloudflareApiToken(): string { - return process.env.CF_API_TOKEN ?? ""; - } - cloudflareConfigPath(): string { - return process.env.CF_CONFIG_PATH ?? ""; - } - cloudflareCredsPath(): string { - return process.env.CF_CREDS_PATH ?? ""; - } private publicKey: JWK; abstract jwtAudience(): string; diff --git a/src/server/Cloudflare.ts b/src/server/Cloudflare.ts deleted file mode 100644 index a9bc132e4..000000000 --- a/src/server/Cloudflare.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { spawn } from "child_process"; -import { promises as fs } from "fs"; -import yaml from "js-yaml"; -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 configPath: string, - private credsPath: string, - ) { - log.info(`Using config: ${this.configPath}`); - log.info(`Using credentials: ${this.credsPath}`); - } - - 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 configAlreadyExists(): Promise { - try { - await fs.access(this.configPath); - return true; - } catch { - return false; - } - } - - public async createTunnel(config: TunnelConfig): Promise<{ - tunnelId: string; - tunnelToken: string; - tunnelUrl: 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 - 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 }; - } - - 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 tokenData = JSON.parse( - Buffer.from(tunnelToken, "base64").toString("utf8"), - ); - - const credentials = { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - AccountTag: tokenData.a || this.accountId, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - TunnelID: tokenData.t || tunnelId, - TunnelName: tunnelName, - TunnelSecret: tokenData.s, - }; - - await fs.writeFile( - this.credsPath, - JSON.stringify(credentials, null, 2), - "utf8", - ); - log.info(`Created credentials file at: ${this.credsPath}`); - - const tunnelConfig: CloudflaredConfig = { - tunnel: tunnelId, - "credentials-file": this.credsPath, - ingress: [ - ...Array.from(subdomainToService.entries()).map( - ([subdomain, service]) => ({ - hostname: `${subdomain}.${domain}`, - service: service, - }), - ), - { - service: "http_status:404", - }, - ], - }; - - // Write config file - await fs.writeFile(this.configPath, yaml.dump(tunnelConfig), "utf8"); - log.info(`Created config file at: ${this.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() { - const cloudflared = spawn( - "cloudflared", - [ - "tunnel", - "--config", - this.configPath, - "--loglevel", - "error", - "--protocol", - "http2", - "--retries", - "15", - "--no-autoupdate", - "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(); - } -} diff --git a/src/server/Server.ts b/src/server/Server.ts index c3d91d295..e83205acf 100644 --- a/src/server/Server.ts +++ b/src/server/Server.ts @@ -1,22 +1,15 @@ import cluster from "cluster"; import * as dotenv from "dotenv"; -import { GameEnv } from "../core/configuration/Config"; -import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; -import { Cloudflare, TunnelConfig } from "./Cloudflare"; import { startMaster } from "./Master"; import { startWorker } from "./Worker"; // Load environment variables before we read configuration values derived from them. dotenv.config(); -const config = getServerConfigFromServer(); // Main entry point of the application async function main() { // Check if this is the primary (master) process if (cluster.isPrimary) { - if (config.env() !== GameEnv.Dev) { - await setupTunnels(); - } console.log("Starting master process..."); await startMaster(); } else { @@ -31,37 +24,3 @@ main().catch((error) => { console.error("Failed to start server:", error); process.exit(1); }); - -async function setupTunnels() { - const cloudflare = new Cloudflare( - config.cloudflareAccountId(), - config.cloudflareApiToken(), - config.cloudflareConfigPath(), - config.cloudflareCredsPath(), - ); - - const domainToService = new Map().set( - config.subdomain(), - // TODO: change to 3000 when we have a proper tunnel setup. - `http://localhost:80`, - ); - - for (let i = 0; i < config.numWorkers(); i++) { - domainToService.set( - `w${i}-${config.subdomain()}`, - `http://localhost:${3000 + i + 1}`, - ); - } - - if (!(await cloudflare.configAlreadyExists())) { - await cloudflare.createTunnel({ - subdomain: config.subdomain(), - domain: config.domain(), - subdomainToService: domainToService, - } as TunnelConfig); - } else { - console.log("Config already exists, skipping tunnel creation"); - } - - await cloudflare.startCloudflared(); -} diff --git a/startup.sh b/startup.sh index 28903d067..05b122a23 100644 --- a/startup.sh +++ b/startup.sh @@ -1,5 +1,89 @@ #!/bin/bash set -e + +# Check if required environment variables are set +if [ -z "$CF_API_TOKEN" ] || [ -z "$CF_ACCOUNT_ID" ] || [ -z "$SUBDOMAIN" ] || [ -z "$DOMAIN" ]; then + echo "Error: Required environment variables not set" + echo "Please set CF_API_TOKEN, CF_ACCOUNT_ID, SUBDOMAIN, and DOMAIN" + exit 1 +fi + +# Generate a unique tunnel name using timestamp +TIMESTAMP=$(date +%Y%m%d%H%M%S) +TUNNEL_NAME="${SUBDOMAIN}-tunnel-${TIMESTAMP}" +echo "Using unique tunnel name: ${TUNNEL_NAME}" + +# Create a new tunnel +echo "Creating Cloudflare tunnel for subdomain ${SUBDOMAIN}..." +TUNNEL_RESPONSE=$(curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "{\"name\":\"${TUNNEL_NAME}\"}") + +# Extract tunnel ID and token +TUNNEL_ID=$(echo $TUNNEL_RESPONSE | jq -r '.result.id') +TUNNEL_TOKEN=$(echo $TUNNEL_RESPONSE | jq -r '.result.token') + +if [ -z "$TUNNEL_ID" ] || [ "$TUNNEL_ID" == "null" ]; then + echo "Failed to create tunnel" + echo $TUNNEL_RESPONSE + exit 1 +fi + +echo "Tunnel created with ID: ${TUNNEL_ID}" + +# Configure the tunnel with hostname +echo "Configuring tunnel to point to ${SUBDOMAIN}.${DOMAIN}..." +curl -s -X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}/configurations" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "{\"config\":{\"ingress\":[{\"hostname\":\"${SUBDOMAIN}.${DOMAIN}\",\"service\":\"http://localhost:80\"},{\"service\":\"http_status:404\"}]}}" + +# Update DNS record to point to the new tunnel +echo "Updating DNS record to point to the new tunnel..." + +# First check if DNS record exists +DNS_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=${DOMAIN}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json") + +ZONE_ID=$(echo $DNS_RECORDS | jq -r '.result[0].id') + +if [ -z "$ZONE_ID" ] || [ "$ZONE_ID" == "null" ]; then + echo "Could not find zone ID for domain ${DOMAIN}" + exit 1 +fi + +# Check for existing record +EXISTING_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${SUBDOMAIN}.${DOMAIN}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json") + +RECORD_ID=$(echo $EXISTING_RECORDS | jq -r '.result[0].id') + +# Create or update the DNS record +if [ -z "$RECORD_ID" ] || [ "$RECORD_ID" == "null" ]; then + # Create new record + echo "Creating new DNS record..." + DNS_RESPONSE=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "{\"type\":\"CNAME\",\"name\":\"${SUBDOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}") +else + # Update existing record + echo "Updating existing DNS record..." + DNS_RESPONSE=$(curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "{\"type\":\"CNAME\",\"name\":\"${SUBDOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}") +fi + +# Log the tunnel information +echo "Tunnel configuration is set up! Site will be available at: https://${SUBDOMAIN}.${DOMAIN}" + +# Export the tunnel token for supervisord +export CLOUDFLARE_TUNNEL_TOKEN=${TUNNEL_TOKEN} + # Start supervisord if [ "$DOMAIN" = openfront.dev ] && [ "$SUBDOMAIN" != main ]; then exec timeout 18h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/supervisord.conf b/supervisord.conf index c31d0429c..953ef3b64 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -22,4 +22,12 @@ user=node stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 \ No newline at end of file +stderr_logfile_maxbytes=0 + +[program:cloudflared] +command=cloudflared tunnel run --token %(ENV_CLOUDFLARE_TUNNEL_TOKEN)s +autostart=true +autorestart=true +user=node +stdout_logfile=/var/log/cloudflared.log +stderr_logfile=/var/log/cloudflared-err.log \ No newline at end of file diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index a7cb2def6..36b4c91d2 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -22,24 +22,12 @@ export class TestServerConfig implements ServerConfig { stripePublishableKey(): string { throw new Error("Method not implemented."); } - cloudflareConfigPath(): string { - throw new Error("Method not implemented."); - } - cloudflareCredsPath(): string { - throw new Error("Method not implemented."); - } domain(): string { throw new Error("Method not implemented."); } subdomain(): string { throw new Error("Method not implemented."); } - cloudflareAccountId(): string { - throw new Error("Method not implemented."); - } - cloudflareApiToken(): string { - throw new Error("Method not implemented."); - } jwtAudience(): string { throw new Error("Method not implemented."); }