Skill: lucassynnott/cloudflare-api (version 1.0.0)

--- name: cloudflare description: Connect to Cloudflare API for DNS management, tunnels, and zone administration. Use when user needs to manage domains, DNS records, or create tunnels. read_when: - User asks about Cloudflare DNS or domains - User wants to create or manage DNS records - User needs to set up Cloudflare tunnels - User wants to list their Cloudflare zones metadata: clawdbot: emoji: "☁️" requires: bins: ["curl", "jq"] --- # Cloudflare Skill Connect to [Cloudflare](https://cloudflare.com) API for DNS management, tunnels, and zone administration. ## Setup ### 1. Get Your API Token 1. Go to [dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens) 2. Create a token with required permissions: - **Zone:Read** - List domains - **DNS:Edit** - Manage DNS records - **Account:Cloudflare Tunnel:Edit** - Manage tunnels 3. Copy the token ### 2. Configure ```bash # Option A: Store in file (recommended) echo "YOUR_API_TOKEN" > ~/.cloudflare_token chmod 600 ~/.cloudflare_token # Option B: Environment variable export CLOUDFLARE_API_TOKEN="YOUR_API_TOKEN" ``` ### 3. Test Connection ```bash ./scripts/setup.sh ``` --- ## Commands ### Zones (Domains) ```bash ./scripts/zones/list.sh # List all zones ./scripts/zones/list.sh --json # JSON output ./scripts/zones/get.sh example.com # Get zone details ``` ### DNS Records ```bash # List records ./scripts/dns/list.sh example.com ./scripts/dns/list.sh example.com --type A ./scripts/dns/list.sh example.com --name api # Create record ./scripts/dns/create.sh example.com \ --type A \ --name api \ --content 1.2.3.4 \ --proxied # Create CNAME ./scripts/dns/create.sh example.com \ --type CNAME \ --name www \ --content example.com \ --proxied # Update record ./scripts/dns/update.sh example.com \ --name api \ --type A \ --content 5.6.7.8 # Delete record ./scripts/dns/delete.sh example.com --name api --type A ``` ### Tunnels ```bash # List tunnels ./scripts/tunnels/list.sh # Create tunnel ./scripts/tunnels/create.sh my-tunnel # Configure tunnel ingress ./scripts/tunnels/configure.sh my-tunnel \ --hostname app.example.com \ --service http://localhost:3000 # Get run token ./scripts/tunnels/token.sh my-tunnel # Delete tunnel ./scripts/tunnels/delete.sh my-tunnel ``` --- ## Token Permissions | Feature | Required Permission | |---------|-------------------| | List zones | Zone:Read | | Manage DNS | DNS:Edit | | Manage tunnels | Account:Cloudflare Tunnel:Edit | Create token at: [dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens) --- ## Common Workflows ### Point subdomain to server ```bash ./scripts/dns/create.sh mysite.com --type A --name api --content 1.2.3.4 --proxied ``` ### Set up tunnel for local service ```bash # 1. Create tunnel ./scripts/tunnels/create.sh webhook-tunnel # 2. Configure ingress ./scripts/tunnels/configure.sh webhook-tunnel \ --hostname hook.mysite.com \ --service http://localhost:8080 # 3. Add DNS record TUNNEL_ID=$(./scripts/tunnels/list.sh --name webhook-tunnel --quiet) ./scripts/dns/create.sh mysite.com \ --type CNAME \ --name hook \ --content ${TUNNEL_ID}.cfargotunnel.com \ --proxied # 4. Run tunnel [REDACTED_SECRET] webhook-tunnel) cloudflared tunnel run --token $TOKEN ``` --- ## Output Formats | Flag | Description | |------|-------------| | `--json` | Raw JSON from API | | `--table` | Formatted table (default) | | `--quiet` | Minimal output (IDs only) | --- ## Troubleshooting | Error | Solution | |-------|----------| | "No API token found" | Run setup or set CLOUDFLARE_API_TOKEN | | "401 Unauthorized" | Check token is valid | | "403 Forbidden" | Token missing required permission | | "Zone not found" | Verify domain is in your account |

path: examples/config-snippet.json
content: {
  "_comment": "Store your Cloudflare API token",
  
  "_option_1": "Save to file (recommended):",
  "_command": "echo 'YOUR_TOKEN' > ~/.cloudflare_token && chmod 600 ~/.cloudflare_token",
  
  "_option_2": "Or set environment variable:",
  "_env": "export CLOUDFLARE_API_TOKEN='YOUR_TOKEN'",
  
  "_get_token": "https://dash.cloudflare.com/profile/api-tokens",
  
  "_permissions_needed": {
    "zones": "Zone:Read",
    "dns": "DNS:Edit", 
    "tunnels": "Account:Cloudflare Tunnel:Edit"
  }
}


path: scripts/_lib.sh
content: #!/bin/bash
# Cloudflare API helper functions

CF_API="https://api.cloudflare.com/client/v4"

# Get API token
get_token() {
    if [ -n "$CLOUDFLARE_API_TOKEN" ]; then
        echo "$CLOUDFLARE_API_TOKEN"
    elif [ -f ~/.cloudflare_token ]; then
        cat ~/.cloudflare_token | tr -d '\n'
    else
        echo ""
    fi
}

# API GET request
cf_get() {
    local endpoint="$1"
    local [REDACTED_SECRET])
    curl -s -H "Authorization: Bearer $token" "${CF_API}${endpoint}"
}

# API POST request
cf_post() {
    local endpoint="$1"
    local data="$2"
    local [REDACTED_SECRET])
    curl -s -X POST \
        -H "Authorization: Bearer $token" \
        -H "Content-Type: application/json" \
        -d "$data" \
        "${CF_API}${endpoint}"
}

# API PUT request
cf_put() {
    local endpoint="$1"
    local data="$2"
    local [REDACTED_SECRET])
    curl -s -X PUT \
        -H "Authorization: Bearer $token" \
        -H "Content-Type: application/json" \
        -d "$data" \
        "${CF_API}${endpoint}"
}

# API DELETE request
cf_delete() {
    local endpoint="$1"
    local [REDACTED_SECRET])
    curl -s -X DELETE \
        -H "Authorization: Bearer $token" \
        "${CF_API}${endpoint}"
}

# Get zone ID from domain name
get_zone_id() {
    local domain="$1"
    local response=$(cf_get "/zones?name=$domain")
    echo "$response" | jq -r '.result[0].id // empty'
}

# Get account ID
get_account_id() {
    local response=$(cf_get "/accounts?per_page=1")
    echo "$response" | jq -r '.result[0].id // empty'
}

# Check for API errors
check_error() {
    local response="$1"
    if echo "$response" | jq -e '.success == false' >/dev/null 2>&1; then
        local error=$(echo "$response" | jq -r '.errors[0].message // "Unknown error"')
        local code=$(echo "$response" | jq -r '.errors[0].code // ""')
        echo "❌ Error: $error" >&2
        [ -n "$code" ] && echo "   Code: $code" >&2
        return 1
    fi
    return 0
}


path: scripts/dns/create.sh
content: #!/bin/bash
# Create DNS record

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../_lib.sh"

DOMAIN=""
TYPE=""
NAME=""
CONTENT=""
PROXIED=false
TTL=1

while [[ $# -gt 0 ]]; do
    case $1 in
        --type) TYPE="$2"; shift 2 ;;
        --name) NAME="$2"; shift 2 ;;
        --content) CONTENT="$2"; shift 2 ;;
        --proxied) PROXIED=true; shift ;;
        --ttl) TTL="$2"; shift 2 ;;
        -h|--help)
            echo "Usage: dns/create.sh <domain> --type TYPE --name NAME --content CONTENT [options]"
            echo ""
            echo "Required:"
            echo "  --type TYPE       Record type (A, AAAA, CNAME, TXT, MX, etc.)"
            echo "  --name NAME       Record name (subdomain, or @ for root)"
            echo "  --content CONTENT Record value"
            echo ""
            echo "Options:"
            echo "  --proxied         Enable Cloudflare proxy (orange cloud)"
            echo "  --ttl SECONDS     TTL (default: 1 = auto)"
            exit 0
            ;;
        *)
            if [ -z "$DOMAIN" ]; then
                DOMAIN="$1"
            fi
            shift
            ;;
    esac
done

if [ -z "$DOMAIN" ] || [ -z "$TYPE" ] || [ -z "$NAME" ] || [ -z "$CONTENT" ]; then
    echo "Usage: dns/create.sh <domain> --type TYPE --name NAME --content CONTENT"
    echo "Run with -h for help"
    exit 1
fi

[REDACTED_SECRET])
if [ -z "$TOKEN" ]; then
    echo "❌ No API token. Run ./scripts/setup.sh first." >&2
    exit 1
fi

ZONE_ID=$(get_zone_id "$DOMAIN")
if [ -z "$ZONE_ID" ]; then
    echo "❌ Zone '$DOMAIN' not found"
    exit 1
fi

# Build full name
if [ "$NAME" = "@" ]; then
    FULL_NAME="$DOMAIN"
else
    FULL_NAME="$NAME.$DOMAIN"
fi

echo "Creating $TYPE record: $FULL_NAME → $CONTENT"

DATA=$(jq -n \
    --arg type "$TYPE" \
    --arg name "$FULL_NAME" \
    --arg content "$CONTENT" \
    --argjson proxied "$PROXIED" \
    --argjson ttl "$TTL" \
    '{type: $type, name: $name, content: $content, proxied: $proxied, ttl: $ttl}')

RESPONSE=$(cf_post "/zones/$ZONE_ID/dns_records" "$DATA")

if check_error "$RESPONSE"; then
    echo "✅ Record created!"
    RECORD_ID=$(echo "$RESPONSE" | jq -r '.result.id')
    echo "   ID: $RECORD_ID"
else
    exit 1
fi


path: scripts/dns/delete.sh
content: #!/bin/bash
# Delete DNS record

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../_lib.sh"

DOMAIN=""
TYPE=""
NAME=""
FORCE=false

while [[ $# -gt 0 ]]; do
    case $1 in
        --type) TYPE="$2"; shift 2 ;;
        --name) NAME="$2"; shift 2 ;;
        --force|-f) FORCE=true; shift ;;
        -h|--help)
            echo "Usage: dns/delete.sh <domain> --name NAME --type TYPE [options]"
            echo ""
            echo "Required:"
            echo "  --name NAME  Record name to delete"
            echo "  --type TYPE  Record type"
            echo ""
            echo "Options:"
            echo "  --force, -f  Skip confirmation"
            exit 0
            ;;
        *)
            if [ -z "$DOMAIN" ]; then
                DOMAIN="$1"
            fi
            shift
            ;;
    esac
done

if [ -z "$DOMAIN" ] || [ -z "$NAME" ] || [ -z "$TYPE" ]; then
    echo "Usage: dns/delete.sh <domain> --name NAME --type TYPE"
    exit 1
fi

[REDACTED_SECRET])
if [ -z "$TOKEN" ]; then
    echo "❌ No API token. Run ./scripts/setup.sh first." >&2
    exit 1
fi

ZONE_ID=$(get_zone_id "$DOMAIN")
if [ -z "$ZONE_ID" ]; then
    echo "❌ Zone '$DOMAIN' not found"
    exit 1
fi

# Find the record
FULL_NAME="$NAME.$DOMAIN"
[ "$NAME" = "@" ] && FULL_NAME="$DOMAIN"

RECORDS=$(cf_get "/zones/$ZONE_ID/dns_records?name=$FULL_NAME&type=$TYPE")
RECORD_ID=$(echo "$RECORDS" | jq -r '.result[0].id // empty')
CONTENT=$(echo "$RECORDS" | jq -r '.result[0].content // empty')

if [ -z "$RECORD_ID" ]; then
    echo "❌ Record not found: $FULL_NAME ($TYPE)"
    exit 1
fi

if [ "$FORCE" != "true" ]; then
    echo "Delete $TYPE record?"
    echo "  Name: $FULL_NAME"
    echo "  Content: $CONTENT"
    read -p "Confirm (y/N): " confirm
    if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
        echo "Cancelled."
        exit 0
    fi
fi

RESPONSE=$(cf_delete "/zones/$ZONE_ID/dns_records/$RECORD_ID")

if check_error "$RESPONSE"; then
    echo "✅ Record deleted!"
else
    exit 1
fi


path: scripts/dns/list.sh
content: #!/bin/bash
# List DNS records for a zone

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../_lib.sh"

DOMAIN=""
TYPE=""
NAME=""
OUTPUT="table"

while [[ $# -gt 0 ]]; do
    case $1 in
        --type) TYPE="$2"; shift 2 ;;
        --name) NAME="$2"; shift 2 ;;
        --json) OUTPUT="json"; shift ;;
        -h|--help)
            echo "Usage: dns/list.sh <domain> [options]"
            echo ""
            echo "Options:"
            echo "  --type TYPE  Filter by record type (A, CNAME, TXT, etc.)"
            echo "  --name NAME  Filter by record name"
            echo "  --json       Raw JSON output"
            exit 0
            ;;
        *)
            if [ -z "$DOMAIN" ]; then
                DOMAIN="$1"
            fi
            shift
            ;;
    esac
done

if [ -z "$DOMAIN" ]; then
    echo "Usage: dns/list.sh <domain>"
    exit 1
fi

[REDACTED_SECRET])
if [ -z "$TOKEN" ]; then
    echo "❌ No API token. Run ./scripts/setup.sh first." >&2
    exit 1
fi

ZONE_ID=$(get_zone_id "$DOMAIN")
if [ -z "$ZONE_ID" ]; then
    echo "❌ Zone '$DOMAIN' not found"
    exit 1
fi

# Build query
QUERY="per_page=100"
[ -n "$TYPE" ] && QUERY="$QUERY&type=$TYPE"
[ -n "$NAME" ] && QUERY="$QUERY&name=$NAME.$DOMAIN"

RESPONSE=$(cf_get "/zones/$ZONE_ID/dns_records?$QUERY")
check_error "$RESPONSE" || exit 1

if [ "$OUTPUT" = "json" ]; then
    echo "$RESPONSE" | jq '.result'
else
    echo ""
    printf "%-8s | %-35s | %-40s | %s\n" "TYPE" "NAME" "CONTENT" "PROXIED"
    printf "%s\n" "---------|-------------------------------------|------------------------------------------|--------"
    
    echo "$RESPONSE" | jq -r '.result[] | [.type, .name, (.content | .[0:38]), (if .proxied then "✓" else "✗" end)] | @tsv' | \
    while IFS=$'\t' read -r type name content proxied; do
        printf "%-8s | %-35s | %-40s | %s\n" "$type" "$name" "$content" "$proxied"
    done
    
    COUNT=$(echo "$RESPONSE" | jq '.result | length')
    echo ""
    echo "Total: $COUNT record(s)"
fi


path: scripts/dns/update.sh
content: #!/bin/bash
# Update DNS record

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../_lib.sh"

DOMAIN=""
TYPE=""
NAME=""
CONTENT=""
PROXIED=""

while [[ $# -gt 0 ]]; do
    case $1 in
        --type) TYPE="$2"; shift 2 ;;
        --name) NAME="$2"; shift 2 ;;
        --content) CONTENT="$2"; shift 2 ;;
        --proxied) PROXIED=true; shift ;;
        --no-proxy) PROXIED=false; shift ;;
        -h|--help)
            echo "Usage: dns/update.sh <domain> --name NAME --type TYPE [options]"
            echo ""
            echo "Required:"
            echo "  --name NAME       Record name to update"
            echo "  --type TYPE       Record type"
            echo ""
            echo "Options:"
            echo "  --content VALUE   New content value"
            echo "  --proxied         Enable proxy"
            echo "  --no-proxy        Disable proxy"
            exit 0
            ;;
        *)
            if [ -z "$DOMAIN" ]; then
                DOMAIN="$1"
            fi
            shift
            ;;
    esac
done

if [ -z "$DOMAIN" ] || [ -z "$NAME" ] || [ -z "$TYPE" ]; then
    echo "Usage: dns/update.sh <domain> --name NAME --type TYPE --content VALUE"
    exit 1
fi

[REDACTED_SECRET])
if [ -z "$TOKEN" ]; then
    echo "❌ No API token. Run ./scripts/setup.sh first." >&2
    exit 1
fi

ZONE_ID=$(get_zone_id "$DOMAIN")
if [ -z "$ZONE_ID" ]; then
    echo "❌ Zone '$DOMAIN' not found"
    exit 1
fi

# Find the record
FULL_NAME="$NAME.$DOMAIN"
[ "$NAME" = "@" ] && FULL_NAME="$DOMAIN"

RECORDS=$(cf_get "/zones/$ZONE_ID/dns_records?name=$FULL_NAME&type=$TYPE")
RECORD_ID=$(echo "$RECORDS" | jq -r '.result[0].id // empty')

if [ -z "$RECORD_ID" ]; then
    echo "❌ Record not found: $FULL_NAME ($TYPE)"
    exit 1
fi

# Get current values
CURRENT=$(echo "$RECORDS" | jq '.result[0]')
CURRENT_CONTENT=$(echo "$CURRENT" | jq -r '.content')
CURRENT_PROXIED=$(echo "$CURRENT" | jq -r '.proxied')

# Use new values or keep current
[ -z "$CONTENT" ] && CONTENT="$CURRENT_CONTENT"
[ -z "$PROXIED" ] && PROXIED="$CURRENT_PROXIED"

echo "Updating $TYPE record: $FULL_NAME"

DATA=$(jq -n \
    --arg type "$TYPE" \
    --arg name "$FULL_NAME" \
    --arg content "$CONTENT" \
    --argjson proxied "$PROXIED" \
    '{type: $type, name: $name, content: $content, proxied: $proxied, ttl: 1}')

RESPONSE=$(cf_put "/zones/$ZONE_ID/dns_records/$RECORD_ID" "$DATA")

if check_error "$RESPONSE"; then
    echo "✅ Record updated!"
else
    exit 1
fi


path: scripts/setup.sh
content: #!/bin/bash
# Cloudflare API Setup - Validate and test connection

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/_lib.sh"

[REDACTED_SECRET])

if [ -z "$TOKEN" ]; then
    echo "❌ No API token found!"
    echo ""
    echo "Setup instructions:"
    echo "  1. Get your API token from https://dash.cloudflare.com/profile/api-tokens"
    echo "  2. Save it:"
    echo "     echo 'YOUR_TOKEN' > ~/.cloudflare_token"
    echo "     chmod 600 ~/.cloudflare_token"
    echo ""
    echo "  Or set environment variable:"
    echo "     export CLOUDFLARE_API_TOKEN='YOUR_TOKEN'"
    exit 1
fi

echo "🔑 API token found"
echo "🔗 Testing connection..."

# Verify token
RESPONSE=$(cf_get "/user/tokens/verify")

if echo "$RESPONSE" | jq -e '.success == true' >/dev/null 2>&1; then
    echo "✅ Token valid!"
elif echo "$RESPONSE" | jq -e '.result.id' >/dev/null 2>&1; then
    echo "✅ Token valid!"
else
    # Try listing accounts as fallback verification
    ACCOUNTS=$(cf_get "/accounts")
    if echo "$ACCOUNTS" | jq -e '.success == true' >/dev/null 2>&1; then
        echo "✅ Token valid!"
    else
        echo "❌ Token invalid or expired"
        echo "Get a new token at https://dash.cloudflare.com/profile/api-tokens"
        exit 1
    fi
fi

# Show account info
ACCOUNTS=$(cf_get "/accounts")
ACCOUNT_NAME=$(echo "$ACCOUNTS" | jq -r '.result[0].name // "Unknown"')
echo "📧 Account: $ACCOUNT_NAME"

# Show zones
ZONES=$(cf_get "/zones?per_page=5")
ZONE_COUNT=$(echo "$ZONES" | jq -r '.result_info.total_count // 0')
echo "🌐 Zones: $ZONE_COUNT domain(s)"

if [ "$ZONE_COUNT" -gt 0 ]; then
    echo ""
    echo "Recent domains:"
    echo "$ZONES" | jq -r '.result[].name' | head -5 | while read domain; do
        echo "  - $domain"
    done
fi

echo ""
echo "You're all set! Try:"
echo "  ./scripts/zones/list.sh"
echo "  ./scripts/dns/list.sh yourdomain.com"


path: scripts/tunnels/configure.sh
content: #!/bin/bash
# Configure tunnel ingress

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../_lib.sh"

TUNNEL_NAME=""
HOSTNAME=""
SERVICE=""

while [[ $# -gt 0 ]]; do
    case $1 in
        --hostname) HOSTNAME="$2"; shift 2 ;;
        --service) SERVICE="$2"; shift 2 ;;
        -h|--help)
            echo "Usage: tunnels/configure.sh <tunnel-name> --hostname HOST --service URL"
            echo ""
            echo "Required:"
            echo "  --hostname HOST  Public hostname (e.g., app.example.com)"
            echo "  --service URL    Local service URL (e.g., http://localhost:3000)"
            exit 0
            ;;
        *)
            if [ -z "$TUNNEL_NAME" ]; then
                TUNNEL_NAME="$1"
            fi
            shift
            ;;
    esac
done

if [ -z "$TUNNEL_NAME" ] || [ -z "$HOSTNAME" ] || [ -z "$SERVICE" ]; then
    echo "Usage: tunnels/configure.sh <tunnel-name> --hostname HOST --service URL"
    exit 1
fi

[REDACTED_SECRET])
if [ -z "$TOKEN" ]; then
    echo "❌ No API token. Run ./scripts/setup.sh first." >&2
    exit 1
fi

ACCOUNT_ID=$(get_account_id)
if [ -z "$ACCOUNT_ID" ]; then
    echo "❌ Could not get account ID"
    exit 1
fi

# Get tunnel ID from name
TUNNELS=$(cf_get "/accounts/$ACCOUNT_ID/cfd_tunnel?name=$TUNNEL_NAME")
TUNNEL_ID=$(echo "$TUNNELS" | jq -r '.result[0].id // empty')

if [ -z "$TUNNEL_ID" ]; then
    echo "❌ Tunnel '$TUNNEL_NAME' not found"
    exit 1
fi

echo "Configuring tunnel: $TUNNEL_NAME ($TUNNEL_ID)"
echo "  Hostname: $HOSTNAME"
echo "  Service: $SERVICE"

DATA=$(jq -n \
    --arg hostname "$HOSTNAME" \
    --arg service "$SERVICE" \
    '{config: {ingress: [{hostname: $hostname, service: $service}, {service: "http_status:404"}]}}')

RESPONSE=$(cf_put "/accounts/$ACCOUNT_ID/cfd_tunnel/$TUNNEL_ID/configurations" "$DATA")

if check_error "$RESPONSE"; then
    echo "✅ Tunnel configured!"
    echo ""
    echo "Don't forget to add DNS record:"
    echo "  Type: CNAME"
    echo "  Name: $(echo $HOSTNAME | cut -d. -f1)"
    echo "  Content: ${TUNNEL_ID}.cfargotunnel.com"
    echo "  Proxied: ON"
else
    exit 1
fi


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)