From 37f7d8f1fb9bc133a27921610069b7d2078e453d Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Fri, 11 Sep 2026 11:29:45 +0200 Subject: [PATCH] Add Cloudflare redirect rules script and GitHub Action - scripts/cloudflare/setup-redirects.sh: CLI tool to manage redirects - scripts/cloudflare/redirect-rules.json: rule definitions - .github/workflows/cloudflare-redirects.yml: manual trigger workflow - Supports: apply, status, dry-run, delete - Requires secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID --- .github/workflows/cloudflare-redirects.yml | 58 +++++++ scripts/cloudflare/redirect-rules.json | 72 +++++++++ scripts/cloudflare/setup-redirects.sh | 172 +++++++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 .github/workflows/cloudflare-redirects.yml create mode 100644 scripts/cloudflare/redirect-rules.json create mode 100755 scripts/cloudflare/setup-redirects.sh diff --git a/.github/workflows/cloudflare-redirects.yml b/.github/workflows/cloudflare-redirects.yml new file mode 100644 index 0000000..f3df3fb --- /dev/null +++ b/.github/workflows/cloudflare-redirects.yml @@ -0,0 +1,58 @@ +name: Setup Cloudflare Redirects + +on: + workflow_dispatch: + inputs: + action: + description: "Action to perform" + required: true + default: "apply" + type: choice + options: + - apply + - status + - dry-run + - delete + +env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + +jobs: + redirects: + name: Cloudflare Redirects + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Validate secrets + run: | + if [ -z "$CLOUDFLARE_API_TOKEN" ]; then + echo "Error: CLOUDFLARE_API_TOKEN secret is not set" + exit 1 + fi + if [ -z "$CLOUDFLARE_ZONE_ID" ]; then + echo "Error: CLOUDFLARE_ZONE_ID secret is not set" + exit 1 + fi + + - name: Run ${{ inputs.action }} + run: | + case "${{ inputs.action }}" in + status) + ./scripts/cloudflare/setup-redirects.sh --status + ;; + dry-run) + ./scripts/cloudflare/setup-redirects.sh --dry-run + ;; + delete) + ./scripts/cloudflare/setup-redirects.sh --delete + ;; + *) + ./scripts/cloudflare/setup-redirects.sh + ;; + esac + +# Required repo secrets: +# CLOUDFLARE_API_TOKEN — API token with Dynamic Redirects Write permission +# CLOUDFLARE_ZONE_ID — Zone ID for moonweb.org (found in Cloudflare dashboard) diff --git a/scripts/cloudflare/redirect-rules.json b/scripts/cloudflare/redirect-rules.json new file mode 100644 index 0000000..1a6f4ac --- /dev/null +++ b/scripts/cloudflare/redirect-rules.json @@ -0,0 +1,72 @@ +[ + { + "expression": "http.host eq \"hub.moonweb.org\"", + "description": "hub.moonweb.org -> www.moonweb.org", + "action": "redirect", + "action_parameters": { + "from_value": { + "target_url": { + "expression": "concat(\"https://www.moonweb.org\", http.request.uri.path)" + }, + "status_code": 301, + "preserve_query_string": true + } + } + }, + { + "expression": "http.host eq \"infra.moonweb.org\"", + "description": "infra.moonweb.org -> www.moonweb.org/infra/", + "action": "redirect", + "action_parameters": { + "from_value": { + "target_url": { + "expression": "concat(\"https://www.moonweb.org/infra\", http.request.uri.path)" + }, + "status_code": 301, + "preserve_query_string": true + } + } + }, + { + "expression": "http.host eq \"smarthome.moonweb.org\"", + "description": "smarthome.moonweb.org -> www.moonweb.org/smarthome/", + "action": "redirect", + "action_parameters": { + "from_value": { + "target_url": { + "expression": "concat(\"https://www.moonweb.org/smarthome\", http.request.uri.path)" + }, + "status_code": 301, + "preserve_query_string": true + } + } + }, + { + "expression": "http.host eq \"code.moonweb.org\"", + "description": "code.moonweb.org -> www.moonweb.org/code/", + "action": "redirect", + "action_parameters": { + "from_value": { + "target_url": { + "expression": "concat(\"https://www.moonweb.org/code\", http.request.uri.path)" + }, + "status_code": 301, + "preserve_query_string": true + } + } + }, + { + "expression": "http.host eq \"retro.moonweb.org\"", + "description": "retro.moonweb.org -> www.moonweb.org/retro/", + "action": "redirect", + "action_parameters": { + "from_value": { + "target_url": { + "expression": "concat(\"https://www.moonweb.org/retro\", http.request.uri.path)" + }, + "status_code": 301, + "preserve_query_string": true + } + } + } +] diff --git a/scripts/cloudflare/setup-redirects.sh b/scripts/cloudflare/setup-redirects.sh new file mode 100755 index 0000000..f7fa5ee --- /dev/null +++ b/scripts/cloudflare/setup-redirects.sh @@ -0,0 +1,172 @@ +#!/bin/bash +# Setup Cloudflare redirect rules for moonweb.org +# Redirects old subdomains to www.moonweb.org subdirectories +# +# Required env vars: +# CLOUDFLARE_API_TOKEN - API token with Dynamic Redirects Write permission +# CLOUDFLARE_ZONE_ID - Zone ID for moonweb.org +# +# Usage: +# ./setup-redirects.sh # Apply redirects +# ./setup-redirects.sh --dry-run # Show what would be created +# ./setup-redirects.sh --status # Show current redirect rules +# ./setup-redirects.sh --delete # Delete all redirect rules + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RULES_FILE="$SCRIPT_DIR/redirect-rules.json" +API="https://api.cloudflare.com/client/v4" +PHASE="http_request_dynamic_redirect" +RULESET_NAME="moonweb-subdomain-redirects" + +DRY_RUN=false +STATUS=false +DELETE=false + +for arg in "$@"; do + case $arg in + --dry-run) DRY_RUN=true ;; + --status) STATUS=true ;; + --delete) DELETE=true ;; + esac +done + +if [ -z "${CLOUDFLARE_API_TOKEN:-}" ] || [ -z "${CLOUDFLARE_ZONE_ID:-}" ]; then + echo "Error: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID must be set" + exit 1 +fi + +cf_api() { + local method=$1 + local url=$2 + shift 2 + curl -s -X "$method" "$url" \ + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + -H "Content-Type: application/json" \ + "$@" +} + +# Get existing ruleset ID for this phase +get_ruleset_id() { + cf_api GET "$API/zones/$CLOUDFLARE_ZONE_ID/rulesets" | \ + python3 -c " +import sys, json +data = json.load(sys.stdin) +for r in data.get('result', []): + if r.get('phase') == '$PHASE': + print(r['id']) + sys.exit(0) +print('') +" 2>/dev/null +} + +# Get rules in a ruleset +get_rules() { + local ruleset_id=$1 + cf_api GET "$API/zones/$CLOUDFLARE_ZONE_ID/rulesets/$ruleset_id" | \ + python3 -c " +import sys, json +data = json.load(sys.stdin) +for r in data.get('result', {}).get('rules', []): + desc = r.get('description', 'unnamed') + expr = r.get('expression', '') + print(f' {desc}') + print(f' expression: {expr}') +" 2>/dev/null +} + +# Show current status +if [ "$STATUS" = true ]; then + echo "=== Current redirect rules ===" + ruleset_id=$(get_ruleset_id) + if [ -z "$ruleset_id" ]; then + echo "No redirect ruleset found for phase $PHASE" + exit 0 + fi + echo "Ruleset ID: $ruleset_id" + get_rules "$ruleset_id" + exit 0 +fi + +# Delete all redirect rules +if [ "$DELETE" = true ]; then + echo "=== Deleting redirect rules ===" + ruleset_id=$(get_ruleset_id) + if [ -z "$ruleset_id" ]; then + echo "No redirect ruleset found, nothing to delete" + exit 0 + fi + echo "Deleting ruleset $ruleset_id..." + RESULT=$(cf_api DELETE "$API/zones/$CLOUDFLARE_ZONE_ID/rulesets/$ruleset_id") + echo "$RESULT" | python3 -c " +import sys, json +data = json.load(sys.stdin) +if data.get('success'): + print('Deleted successfully') +else: + for e in data.get('errors', []): + print(f\"Error: {e.get('message', '')}\") +" 2>/dev/null + exit 0 +fi + +# Build payload from JSON file +PAYLOAD=$(python3 -c " +import json +with open('$RULES_FILE') as f: + rules = json.load(f) +payload = { + 'name': '$RULESET_NAME', + 'kind': 'zone', + 'phase': '$PHASE', + 'rules': rules +} +print(json.dumps(payload)) +") + +RULE_COUNT=$(echo "$PAYLOAD" | python3 -c "import sys, json; print(len(json.load(sys.stdin)['rules']))" 2>/dev/null) + +if [ "$DRY_RUN" = true ]; then + echo "=== DRY RUN ===" + echo "Would create/update ruleset '$RULESET_NAME' with $RULE_COUNT rules:" + python3 -c " +import json +with open('$RULES_FILE') as f: + rules = json.load(f) +for r in rules: + print(f\" - {r['description']}\") +" 2>/dev/null + exit 0 +fi + +# Check if ruleset already exists +echo "=== Checking existing ruleset ===" +EXISTING_ID=$(get_ruleset_id) + +if [ -n "$EXISTING_ID" ]; then + echo "Updating existing ruleset $EXISTING_ID..." + RESULT=$(cf_api PUT "$API/zones/$CLOUDFLARE_ZONE_ID/rulesets/$EXISTING_ID" \ + -d "$PAYLOAD") +else + echo "Creating new ruleset..." + RESULT=$(cf_api POST "$API/zones/$CLOUDFLARE_ZONE_ID/rulesets" \ + -d "$PAYLOAD") +fi + +# Check result +echo "$RESULT" | python3 -c " +import sys, json +data = json.load(sys.stdin) +if data.get('success'): + ruleset = data['result'] + print(f\"Success! Ruleset: {ruleset['name']} (ID: {ruleset['id']})\") + print(f\"Rules: {len(ruleset.get('rules', []))}\") + for r in ruleset.get('rules', []): + print(f\" - {r.get('description', 'unnamed')}\") +else: + print('Error:') + for e in data.get('errors', []): + print(f\" {e.get('code', '')}: {e.get('message', '')}\") + sys.exit(1) +" 2>/dev/null