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
This commit is contained in:
2026-09-11 11:29:45 +02:00
parent c76de3ec36
commit 37f7d8f1fb
3 changed files with 302 additions and 0 deletions
@@ -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)
+72
View File
@@ -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
}
}
}
]
+172
View File
@@ -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