diff --git a/shell.nix b/shell.nix index d62080389..72f75e107 100644 --- a/shell.nix +++ b/shell.nix @@ -55,6 +55,10 @@ pkgs.mkShell { pkgs.xz pkgs.libxml2 pkgs.pkg-config + + # Terraform + pkgs.terraform + pkgs.wrangler ] ++ pkgs.lib.optional with-blackfire pkgs.blackfire ; diff --git a/terraform/cloudflare/.gitignore b/terraform/cloudflare/.gitignore new file mode 100644 index 000000000..341cc4423 --- /dev/null +++ b/terraform/cloudflare/.gitignore @@ -0,0 +1,31 @@ +# Terraform state files +*.tfstate +*.tfstate.* + +# Terraform variable files with sensitive data +*.auto.tfvars + +# Terraform CLI configuration files +.terraformrc +terraform.rc + +# Terraform directory +.terraform/ + +# Wrangler / Workers local development +workers/.dev.vars +workers/.wrangler/ +.terraform.lock.hcl + +# Crash log files +crash.log +crash.*.log + +# Override files +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Plans +*.tfplan diff --git a/terraform/cloudflare/README.md b/terraform/cloudflare/README.md new file mode 100644 index 000000000..9b319a055 --- /dev/null +++ b/terraform/cloudflare/README.md @@ -0,0 +1,15 @@ +# Cloudflare Terraform + +Required Variables: + +``` +export CLOUDFLARE_API_TOKEN="xxx" +export TF_VAR_account_id="xxx" +export TF_VAR_zone_id="xxx" +``` + +# Initialization + +``` +terraform init +``` diff --git a/terraform/cloudflare/dns.tf b/terraform/cloudflare/dns.tf new file mode 100644 index 000000000..286bf98d2 --- /dev/null +++ b/terraform/cloudflare/dns.tf @@ -0,0 +1,7 @@ +# DNS records for flow-php.com zone + +# This file is intentionally minimal - the R2 custom domain resource +# automatically creates the necessary DNS records for playground-snippets.flow-php.com +# via cloudflare_r2_custom_domain resource in r2.tf + +# Add additional DNS records below as needed diff --git a/terraform/cloudflare/main.tf b/terraform/cloudflare/main.tf new file mode 100644 index 000000000..f7a913c82 --- /dev/null +++ b/terraform/cloudflare/main.tf @@ -0,0 +1,21 @@ +resource "cloudflare_account" "account" { + name = "norbert@orzechowicz.pl" + type = "standard" +} + +import { + id = var.account_id + to = cloudflare_account.account +} + +resource "cloudflare_zone" "flow_php" { + account = cloudflare_account.account + name = "flow-php.com" + + paused = false +} + +import { + id = var.zone_id + to = cloudflare_zone.flow_php +} \ No newline at end of file diff --git a/terraform/cloudflare/notifications.tf b/terraform/cloudflare/notifications.tf new file mode 100644 index 000000000..f6a88e023 --- /dev/null +++ b/terraform/cloudflare/notifications.tf @@ -0,0 +1,62 @@ +locals { + k = 1000 + m = 1000 * local.k + mb = 1024 * 1024 + gb = 1024 * 1024 * 1024 + + r2_storage_free_tier = 10 * local.gb # 10 GB + r2_class_a_operations_free_tier = 1 * local.m # 1 million requests + r2_class_b_operations_free_tier = 10 * local.m # 10 million requests + + thresholds = [10, 20, 30, 40, 50, 60, 70, 80, 90, 95] + + notification_configs = merge( + { + for threshold in local.thresholds : + "r2_storage_${threshold}" => { + name = "R2 Storage - ${threshold}% of Free Tier" + product = "r2_storage" + limit = local.r2_storage_free_tier * (threshold / 100) + description = "Alert when R2 storage usage reaches ${threshold}% of free tier (${threshold / 10} GB)" + } + }, + { + for threshold in local.thresholds : + "r2_class_a_${threshold}" => { + name = "R2 Class A Operations - ${threshold}% of Free Tier" + product = "r2_class_a_operations" + limit = local.r2_class_a_operations_free_tier * (threshold / 100) + description = "Alert when R2 Class A operations reach ${threshold}% of free tier (${threshold * 10000} requests)" + } + }, + { + for threshold in local.thresholds : + "r2_class_b_${threshold}" => { + name = "R2 Class B Operations - ${threshold}% of Free Tier" + product = "r2_class_b_operations" + limit = local.r2_class_b_operations_free_tier * (threshold / 100) + description = "Alert when R2 Class B operations reach ${threshold}% of free tier (${threshold * 100000} requests)" + } + } + ) +} + +resource "cloudflare_notification_policy" "r2_notifications" { + for_each = local.notification_configs + + account_id = cloudflare_account.account.id + name = each.value.name + enabled = true + alert_type = "billing_usage_alert" + + filters = { + limit = [each.value.limit] + product = [each.value.product] + } + + mechanisms = { + email = [{ + id = var.notification_email + }] + } +} diff --git a/terraform/cloudflare/outputs.tf b/terraform/cloudflare/outputs.tf new file mode 100644 index 000000000..826f7c30b --- /dev/null +++ b/terraform/cloudflare/outputs.tf @@ -0,0 +1,25 @@ +output "turnstile_site_key" { + description = "Turnstile site key for Flow PHP Playground (public, use in frontend)" + value = cloudflare_turnstile_widget.flow_php.id +} + +output "turnstile_secret_key" { + description = "Turnstile secret key for Flow PHP Playground (private, use in backend)" + value = cloudflare_turnstile_widget.flow_php.secret + sensitive = true +} + +output "r2_playground_snippets_bucket_name" { + description = "Name of the R2 bucket for playground snippets" + value = cloudflare_r2_bucket.playground_snippets.name +} + +output "r2_playground_snippets_bucket_id" { + description = "ID of the R2 bucket for playground snippets" + value = cloudflare_r2_bucket.playground_snippets.id +} + +output "r2_playground_snippets_custom_domain" { + description = "Custom domain URL for R2 playground snippets bucket" + value = "https://playground-snippets.flow-php.com" +} diff --git a/terraform/cloudflare/provider.tf b/terraform/cloudflare/provider.tf new file mode 100644 index 000000000..f60d273e6 --- /dev/null +++ b/terraform/cloudflare/provider.tf @@ -0,0 +1,2 @@ +provider "cloudflare" { +} diff --git a/terraform/cloudflare/r2.tf b/terraform/cloudflare/r2.tf new file mode 100644 index 000000000..949b218df --- /dev/null +++ b/terraform/cloudflare/r2.tf @@ -0,0 +1,29 @@ +resource "cloudflare_r2_bucket" "playground_snippets" { + account_id = cloudflare_account.account.id + name = "flow-php-playground-snippets" + location = "eeur" +} + +resource "cloudflare_r2_bucket_cors" "playground_snippets_cors" { + account_id = cloudflare_account.account.id + bucket_name = cloudflare_r2_bucket.playground_snippets.name + + rules = [{ + allowed = { + methods = ["GET", "PUT", "POST", "DELETE", "HEAD"] + origins = ["https://flow-php.com"] + headers = ["*"] + } + id = "playground-snippets-cors" + expose_headers = ["ETag"] + max_age_seconds = 3600 + }] +} + +resource "cloudflare_r2_custom_domain" "playground_snippets" { + account_id = cloudflare_account.account.id + bucket_name = cloudflare_r2_bucket.playground_snippets.name + domain = "playground-snippets.flow-php.com" + zone_id = cloudflare_zone.flow_php.id + enabled = true +} diff --git a/terraform/cloudflare/turnstile.tf b/terraform/cloudflare/turnstile.tf new file mode 100644 index 000000000..7ad88bc36 --- /dev/null +++ b/terraform/cloudflare/turnstile.tf @@ -0,0 +1,7 @@ +resource "cloudflare_turnstile_widget" "flow_php" { + account_id = cloudflare_account.account.id + name = "Flow PHP" + domains = ["flow-php.com", "www.flow-php.com"] + mode = "managed" + region = "world" +} diff --git a/terraform/cloudflare/variables.tf b/terraform/cloudflare/variables.tf new file mode 100644 index 000000000..b9bad6807 --- /dev/null +++ b/terraform/cloudflare/variables.tf @@ -0,0 +1,16 @@ +variable "account_id" { + description = "Cloudflare Account ID" + type = string +} + +variable "zone_id" { + description = "Cloudflare Zone ID" + type = string +} + + +variable "notification_email" { + description = "Email address for Cloudflare notifications" + type = string + default = "norbert@orzechowicz.pl" +} diff --git a/terraform/cloudflare/versions.tf b/terraform/cloudflare/versions.tf new file mode 100644 index 000000000..495f38004 --- /dev/null +++ b/terraform/cloudflare/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + version = "5.12.0" + } + } +} diff --git a/terraform/cloudflare/waf.tf b/terraform/cloudflare/waf.tf new file mode 100644 index 000000000..189ae2714 --- /dev/null +++ b/terraform/cloudflare/waf.tf @@ -0,0 +1,28 @@ +resource "cloudflare_ruleset" "playground_bot_protection" { + zone_id = cloudflare_zone.flow_php.id + name = "Playground Bot Protection" + description = "Bot protection for playground route" + kind = "zone" + phase = "http_request_firewall_custom" + + rules = [ + { + action = "block" + expression = "(http.request.uri.path contains \"/playground\" and ip.geoip.country in {\"CN\" \"RU\" \"VN\" \"IN\" \"BR\" \"ID\"})" + description = "Block playground access from CN, RU, VN, IN, BR, ID" + enabled = true + }, + { + action = "managed_challenge" + expression = "(http.request.uri.path contains \"/playground\")" + description = "Managed challenge for playground access" + enabled = true + }, + { + action = "challenge" + expression = "(http.request.uri.path contains \"/playground\" and cf.threat_score gt 14)" + description = "Challenge IPs with bad reputation accessing playground" + enabled = true + } + ] +} diff --git a/terraform/cloudflare/workers.tf b/terraform/cloudflare/workers.tf new file mode 100644 index 000000000..a4634263a --- /dev/null +++ b/terraform/cloudflare/workers.tf @@ -0,0 +1,17 @@ +resource "cloudflare_workers_script" "turnstile_verify" { + account_id = cloudflare_account.account.id + script_name = "turnstile-verify" + content = file("${path.module}/workers/turnstile-verify.js") + + bindings = [{ + name = "TURNSTILE_SECRET_KEY" + type = "secret_text" + text = cloudflare_turnstile_widget.flow_php.secret + }] +} + +resource "cloudflare_workers_route" "turnstile_verify" { + zone_id = cloudflare_zone.flow_php.id + pattern = "flow-php.com/api/verify-turnstile" + script = cloudflare_workers_script.turnstile_verify.script_name +} diff --git a/terraform/cloudflare/workers/.dev.vars.example b/terraform/cloudflare/workers/.dev.vars.example new file mode 100644 index 000000000..3b29bd4ba --- /dev/null +++ b/terraform/cloudflare/workers/.dev.vars.example @@ -0,0 +1,4 @@ +# Copy this file to .dev.vars and fill in your actual Turnstile secret key +# .dev.vars is gitignored and used for local development with wrangler + +TURNSTILE_SECRET_KEY=your-turnstile-secret-key-here diff --git a/terraform/cloudflare/workers/.gitignore b/terraform/cloudflare/workers/.gitignore new file mode 100644 index 000000000..f25034b51 --- /dev/null +++ b/terraform/cloudflare/workers/.gitignore @@ -0,0 +1,2 @@ +node_modules +.wrangler \ No newline at end of file diff --git a/terraform/cloudflare/workers/README.md b/terraform/cloudflare/workers/README.md new file mode 100644 index 000000000..cfdffa868 --- /dev/null +++ b/terraform/cloudflare/workers/README.md @@ -0,0 +1,111 @@ +# Cloudflare Workers - Local Development + +## Prerequisites + +- Nix shell environment (includes Wrangler CLI) + +## Setup + +1. Enter the nix-shell (if not already in it): +```bash +nix-shell +``` + +2. Create local environment file: +```bash +cp .dev.vars.example .dev.vars +``` + +3. Edit `.dev.vars` and add your Turnstile secret key: +``` +TURNSTILE_SECRET_KEY=your-actual-secret-key +``` + +You can get the Turnstile secret key after running `terraform apply`: +```bash +cd .. +terraform output -raw turnstile_secret_key +``` + +## Local Development + +Start the local development server: +```bash +cd terraform/cloudflare/workers +wrangler dev +``` + +This will start Wrangler's local development server at `http://localhost:8787` + +## Testing the Worker Locally + +### Using curl: + +```bash +# Test with a valid token (you'll need to get a real token from the Turnstile widget) +curl -X POST http://localhost:8787/api/verify-turnstile \ + -H "Content-Type: application/json" \ + -d '{"token": "your-turnstile-token-here"}' +``` + +### Using a test HTML page: + +Create a `test.html` file: + +```html + + + + Turnstile Test + + + +

Turnstile Test

+
+ + + + +``` + +Replace `YOUR_SITE_KEY` with your actual Turnstile site key from: +```bash +cd .. +terraform output turnstile_site_key +``` + +## Deployment + +The worker is deployed via Terraform, not directly via Wrangler. To deploy changes: + +1. Edit `turnstile-verify.js` +2. Run `terraform apply` from the `terraform/cloudflare` directory + +## Debugging + +View logs in the Wrangler dev session by checking the terminal output. All console.log statements will appear there. + +## Notes + +- The `.dev.vars` file contains secrets and is gitignored +- Local development uses Wrangler's local runtime, which closely mimics the production Cloudflare Workers environment +- CORS is configured to allow requests from `https://flow-php.com` - you may need to adjust for local testing diff --git a/terraform/cloudflare/workers/turnstile-verify.js b/terraform/cloudflare/workers/turnstile-verify.js new file mode 100644 index 000000000..a118c38ed --- /dev/null +++ b/terraform/cloudflare/workers/turnstile-verify.js @@ -0,0 +1,53 @@ +addEventListener('fetch', event => { + event.respondWith(handleRequest(event.request)) +}) + +async function handleRequest(request) { + if (request.method !== 'POST') { + return new Response('Method not allowed', { status: 405 }) + } + + const corsHeaders = { + 'Access-Control-Allow-Origin': 'https://flow-php.com', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + } + + if (request.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }) + } + + try { + const { token } = await request.json() + + if (!token) { + return new Response(JSON.stringify({ success: false, error: 'Token is required' }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } + + const formData = new FormData() + formData.append('secret', TURNSTILE_SECRET_KEY) + formData.append('response', token) + formData.append('remoteip', request.headers.get('CF-Connecting-IP')) + + const verifyResponse = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + method: 'POST', + body: formData, + }) + + const outcome = await verifyResponse.json() + + return new Response(JSON.stringify(outcome), { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + + } catch (error) { + return new Response(JSON.stringify({ success: false, error: error.message }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } +} diff --git a/terraform/cloudflare/workers/wrangler.toml b/terraform/cloudflare/workers/wrangler.toml new file mode 100644 index 000000000..d870699b3 --- /dev/null +++ b/terraform/cloudflare/workers/wrangler.toml @@ -0,0 +1,9 @@ +name = "turnstile-verify" +main = "turnstile-verify.js" +compatibility_date = "2024-01-01" + +[vars] +# Non-sensitive environment variables go here + +# Secrets should be added via: wrangler secret put TURNSTILE_SECRET_KEY +# Or for local dev, create a .dev.vars file (which is in .gitignore)