#!/usr/bin/env bash # Configure Cloudflare Tunnel Routes and DNS Records via API # Usage: ./configure-cloudflare-api.sh # Requires: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID environment variables set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color info() { echo -e "${GREEN}[INFO]${NC} $1"; } warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1"; } debug() { echo -e "${BLUE}[DEBUG]${NC} $1"; } # Check for required tools if ! command -v curl >/dev/null 2>&1; then error "curl is required but not installed" exit 1 fi if ! command -v jq >/dev/null 2>&1; then error "jq is required but not installed. Install with: apt-get install jq" exit 1 fi # Load environment variables if [[ -f "$SCRIPT_DIR/../.env" ]]; then source "$SCRIPT_DIR/../.env" fi # Cloudflare API configuration (support multiple naming conventions) CLOUDFLARE_API_TOKEN="${CLOUDFLARE_API_TOKEN:-}" CLOUDFLARE_ZONE_ID="${CLOUDFLARE_ZONE_ID:-}" CLOUDFLARE_ACCOUNT_ID="${CLOUDFLARE_ACCOUNT_ID:-}" CLOUDFLARE_EMAIL="${CLOUDFLARE_EMAIL:-}" CLOUDFLARE_API_KEY="${CLOUDFLARE_API_KEY:-}" DOMAIN="${DOMAIN:-${CLOUDFLARE_DOMAIN:-d-bis.org}}" # Tunnel configuration (support multiple naming conventions) # Prefer JWT token from installed service, then env vars INSTALLED_TOKEN="" if command -v ssh >/dev/null 2>&1; then INSTALLED_TOKEN=$(ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@${PROXMOX_HOST:-192.168.11.10} \ "pct exec 102 -- cat /etc/systemd/system/cloudflared.service 2>/dev/null | grep -o 'tunnel run --token [^ ]*' | cut -d' ' -f3" 2>/dev/null || echo "") fi TUNNEL_TOKEN="${INSTALLED_TOKEN:-${TUNNEL_TOKEN:-${CLOUDFLARE_TUNNEL_TOKEN:-eyJhIjoiNTJhZDU3YTcxNjcxYzVmYzAwOWVkZjA3NDQ2NTgxOTYiLCJ0IjoiMTBhYjIyZGEtOGVhMy00ZTJlLWE4OTYtMjdlY2UyMjExYTA1IiwicyI6IlptRXlOMkkyTVRrdE1EZzFNeTAwTkRBNExXSXhaalF0Wm1KaE5XVmpaVEEzTVdGbCJ9}}}" # RPC endpoint configuration # Public endpoints route to VMID 2502 (NO JWT authentication) # Private endpoints route to VMID 2501 (JWT authentication required) declare -A RPC_ENDPOINTS=( [rpc-http-pub]="https://192.168.11.252:443" [rpc-ws-pub]="https://192.168.11.252:443" [rpc-http-prv]="https://192.168.11.251:443" [rpc-ws-prv]="https://192.168.11.251:443" ) # API base URLs CF_API_BASE="https://api.cloudflare.com/client/v4" CF_ZERO_TRUST_API="https://api.cloudflare.com/client/v4/accounts" # Function to make Cloudflare API request cf_api_request() { local method="$1" local endpoint="$2" local data="${3:-}" local url="${CF_API_BASE}${endpoint}" local headers=() if [[ -n "$CLOUDFLARE_API_TOKEN" ]]; then headers+=("-H" "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}") elif [[ -n "$CLOUDFLARE_API_KEY" ]]; then # Global API Keys are typically 40 chars, API Tokens are longer # If no email provided, assume it's an API Token if [[ -z "$CLOUDFLARE_EMAIL" ]] || [[ ${#CLOUDFLARE_API_KEY} -gt 50 ]]; then headers+=("-H" "Authorization: Bearer ${CLOUDFLARE_API_KEY}") else headers+=("-H" "X-Auth-Email: ${CLOUDFLARE_EMAIL}") headers+=("-H" "X-Auth-Key: ${CLOUDFLARE_API_KEY}") fi else error "Cloudflare API credentials not found!" error "Set CLOUDFLARE_API_TOKEN or CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY" exit 1 fi headers+=("-H" "Content-Type: application/json") local response if [[ -n "$data" ]]; then response=$(curl -s -X "$method" "$url" "${headers[@]}" -d "$data") else response=$(curl -s -X "$method" "$url" "${headers[@]}") fi # Check if response is valid JSON if ! echo "$response" | jq -e . >/dev/null 2>&1; then error "Invalid JSON response from API" debug "Response: $response" return 1 fi # Check for API errors local success=$(echo "$response" | jq -r '.success // false' 2>/dev/null) if [[ "$success" != "true" ]]; then local errors=$(echo "$response" | jq -r '.errors[]?.message // .error // "Unknown error"' 2>/dev/null | head -3) if [[ -z "$errors" ]]; then errors="API request failed (check response)" fi error "API request failed: $errors" debug "Response: $response" return 1 fi echo "$response" } # Function to get zone ID from domain get_zone_id() { if [[ -n "$CLOUDFLARE_ZONE_ID" ]]; then echo "$CLOUDFLARE_ZONE_ID" return 0 fi info "Getting zone ID for domain: $DOMAIN" local response=$(cf_api_request "GET" "/zones?name=${DOMAIN}") local zone_id=$(echo "$response" | jq -r '.result[0].id // empty') if [[ -z "$zone_id" ]]; then error "Zone not found for domain: $DOMAIN" exit 1 fi info "Zone ID: $zone_id" echo "$zone_id" } # Function to get account ID (needed for Zero Trust API) get_account_id() { info "Getting account ID..." # Try to get from token verification local response=$(cf_api_request "GET" "/user/tokens/verify") local account_id=$(echo "$response" | jq -r '.result.id // empty') if [[ -z "$account_id" ]]; then # Try alternative: get from accounts list response=$(cf_api_request "GET" "/accounts") account_id=$(echo "$response" | jq -r '.result[0].id // empty') fi if [[ -z "$account_id" ]]; then # Last resort: try to get from zone local zone_id=$(get_zone_id) response=$(cf_api_request "GET" "/zones/${zone_id}") account_id=$(echo "$response" | jq -r '.result.account.id // empty') fi if [[ -z "$account_id" ]]; then error "Could not determine account ID" error "You may need to specify CLOUDFLARE_ACCOUNT_ID in .env file" exit 1 fi info "Account ID: $account_id" echo "$account_id" } # Function to extract tunnel ID from token get_tunnel_id_from_token() { local token="$1" # Check if it's a JWT token (has dots) if [[ "$token" == *.*.* ]]; then # Decode JWT token (basic base64 decode of payload) local payload=$(echo "$token" | cut -d'.' -f2) # Add padding if needed local padding=$((4 - ${#payload} % 4)) if [[ $padding -ne 4 ]]; then payload="${payload}$(printf '%*s' $padding | tr ' ' '=')" fi # Decode and extract tunnel ID (field 't' contains tunnel ID) if command -v python3 >/dev/null 2>&1; then echo "$payload" | python3 -c "import sys, base64, json; payload=sys.stdin.read().strip(); padding=4-len(payload)%4; payload+=('='*padding if padding<4 else ''); data=json.loads(base64.b64decode(payload)); print(data.get('t', ''))" 2>/dev/null || echo "" else echo "$payload" | base64 -d 2>/dev/null | jq -r '.t // empty' 2>/dev/null || echo "" fi else # Not a JWT token, return empty echo "" fi } # Function to get tunnel ID get_tunnel_id() { local account_id="$1" local token="$2" # Try to extract from JWT token first local tunnel_id=$(get_tunnel_id_from_token "$token") if [[ -n "$tunnel_id" ]]; then info "Tunnel ID from token: $tunnel_id" echo "$tunnel_id" return 0 fi # Fallback: list tunnels and find the one warn "Could not extract tunnel ID from token, listing tunnels..." local response=$(cf_api_request "GET" "/accounts/${account_id}/cfd_tunnel" 2>/dev/null) if [[ -z "$response" ]]; then error "Failed to list tunnels. Check API credentials." exit 1 fi local tunnel_id=$(echo "$response" | jq -r '.result[0].id // empty' 2>/dev/null) if [[ -z "$tunnel_id" ]]; then error "Could not find tunnel ID" debug "Response: $response" exit 1 fi info "Tunnel ID: $tunnel_id" echo "$tunnel_id" } # Function to get tunnel name get_tunnel_name() { local account_id="$1" local tunnel_id="$2" local response=$(cf_api_request "GET" "/accounts/${account_id}/cfd_tunnel/${tunnel_id}") local tunnel_name=$(echo "$response" | jq -r '.result.name // empty') echo "$tunnel_name" } # Function to configure tunnel routes configure_tunnel_routes() { local account_id="$1" local tunnel_id="$2" local tunnel_name="$3" info "Configuring tunnel routes for: $tunnel_name" # Build ingress rules array local ingress_array="[" local first=true for subdomain in "${!RPC_ENDPOINTS[@]}"; do local service="${RPC_ENDPOINTS[$subdomain]}" local hostname="${subdomain}.${DOMAIN}" if [[ "$first" == "true" ]]; then first=false else ingress_array+="," fi # Determine if WebSocket local is_ws=false if [[ "$subdomain" == *"ws"* ]]; then is_ws=true fi # Build ingress rule # Add noTLSVerify to skip certificate validation (certificates don't have IP SANs) if [[ "$is_ws" == "true" ]]; then ingress_array+="{\"hostname\":\"${hostname}\",\"service\":\"${service}\",\"originRequest\":{\"httpHostHeader\":\"${hostname}\",\"noTLSVerify\":true}}" else ingress_array+="{\"hostname\":\"${hostname}\",\"service\":\"${service}\",\"originRequest\":{\"noTLSVerify\":true}}" fi info " Adding route: ${hostname} → ${service}" done # Add catch-all (must be last) ingress_array+=",{\"service\":\"http_status:404\"}]" # Create config JSON local config_data=$(echo "$ingress_array" | jq -c '{ config: { ingress: . } }') info "Updating tunnel configuration..." local response=$(cf_api_request "PUT" "/accounts/${account_id}/cfd_tunnel/${tunnel_id}/configurations" "$config_data") if echo "$response" | jq -e '.success' >/dev/null 2>&1; then info "✓ Tunnel routes configured successfully" else local errors=$(echo "$response" | jq -r '.errors[]?.message // "Unknown error"' | head -3) error "Failed to configure tunnel routes: $errors" debug "Response: $response" return 1 fi } # Function to create or update DNS record create_or_update_dns_record() { local zone_id="$1" local name="$2" local target="$3" local proxied="${4:-true}" # Check if record exists local response=$(cf_api_request "GET" "/zones/${zone_id}/dns_records?name=${name}.${DOMAIN}&type=CNAME") local record_id=$(echo "$response" | jq -r '.result[0].id // empty') local data=$(jq -n \ --arg name "${name}.${DOMAIN}" \ --arg target "$target" \ --argjson proxied "$proxied" \ '{ type: "CNAME", name: $name, content: $target, proxied: $proxied, ttl: 1 }') if [[ -n "$record_id" ]]; then info " Updating existing DNS record: ${name}.${DOMAIN}" response=$(cf_api_request "PUT" "/zones/${zone_id}/dns_records/${record_id}" "$data") else info " Creating DNS record: ${name}.${DOMAIN}" response=$(cf_api_request "POST" "/zones/${zone_id}/dns_records" "$data") fi if echo "$response" | jq -e '.success' >/dev/null 2>&1; then info " ✓ DNS record configured" else error " ✗ Failed to configure DNS record" return 1 fi } # Function to configure DNS records configure_dns_records() { local zone_id="$1" local tunnel_id="$2" local tunnel_target="${tunnel_id}.cfargotunnel.com" info "Configuring DNS records..." info "Tunnel target: $tunnel_target" for subdomain in "${!RPC_ENDPOINTS[@]}"; do create_or_update_dns_record "$zone_id" "$subdomain" "$tunnel_target" "true" done } # Main execution main() { info "Cloudflare API Configuration Script" info "====================================" echo "" # Validate credentials if [[ -z "$CLOUDFLARE_API_TOKEN" ]] && [[ -z "$CLOUDFLARE_EMAIL" ]] && [[ -z "$CLOUDFLARE_API_KEY" ]]; then error "Cloudflare API credentials required!" echo "" echo "Set one of:" echo " export CLOUDFLARE_API_TOKEN='your-api-token'" echo " OR" echo " export CLOUDFLARE_EMAIL='your-email@example.com'" echo " export CLOUDFLARE_API_KEY='your-api-key'" echo "" echo "You can also create a .env file in the project root with these variables." exit 1 fi # If API_KEY is provided but no email, we need email for Global API Key if [[ -n "$CLOUDFLARE_API_KEY" ]] && [[ -z "$CLOUDFLARE_EMAIL" ]] && [[ -z "$CLOUDFLARE_API_TOKEN" ]]; then error "CLOUDFLARE_API_KEY requires CLOUDFLARE_EMAIL" error "Please add CLOUDFLARE_EMAIL to your .env file" error "" error "OR create an API Token instead:" error " 1. Go to: https://dash.cloudflare.com/profile/api-tokens" error " 2. Create token with: Zone:DNS:Edit, Account:Cloudflare Tunnel:Edit" error " 3. Set CLOUDFLARE_API_TOKEN in .env" exit 1 fi # Get zone ID local zone_id=$(get_zone_id) # Get account ID local account_id="${CLOUDFLARE_ACCOUNT_ID:-}" if [[ -z "$account_id" ]]; then account_id=$(get_account_id) else info "Using provided Account ID: $account_id" fi # Get tunnel ID - try from .env first, then extraction, then API local tunnel_id="${CLOUDFLARE_TUNNEL_ID:-}" # If not in .env, try to extract from JWT token if [[ -z "$tunnel_id" ]] && [[ "$TUNNEL_TOKEN" == *.*.* ]]; then local payload=$(echo "$TUNNEL_TOKEN" | cut -d'.' -f2) local padding=$((4 - ${#payload} % 4)) if [[ $padding -ne 4 ]]; then payload="${payload}$(printf '%*s' $padding | tr ' ' '=')" fi if command -v python3 >/dev/null 2>&1; then tunnel_id=$(echo "$payload" | python3 -c "import sys, base64, json; payload=sys.stdin.read().strip(); padding=4-len(payload)%4; payload+=('='*padding if padding<4 else ''); data=json.loads(base64.b64decode(payload)); print(data.get('t', ''))" 2>/dev/null || echo "") fi fi # If extraction failed, try API (but don't fail if API doesn't work) if [[ -z "$tunnel_id" ]]; then tunnel_id=$(get_tunnel_id "$account_id" "$TUNNEL_TOKEN" 2>/dev/null || echo "") fi if [[ -z "$tunnel_id" ]]; then error "Could not determine tunnel ID" error "Please set CLOUDFLARE_TUNNEL_ID in .env file" error "Or ensure API credentials are valid to fetch it automatically" exit 1 fi info "Using Tunnel ID: $tunnel_id" local tunnel_name=$(get_tunnel_name "$account_id" "$tunnel_id" 2>/dev/null || echo "tunnel-${tunnel_id:0:8}") echo "" info "Configuration Summary:" echo " Domain: $DOMAIN" echo " Zone ID: $zone_id" echo " Account ID: $account_id" echo " Tunnel: $tunnel_name (ID: $tunnel_id)" echo "" # Configure tunnel routes echo "==========================================" info "Step 1: Configuring Tunnel Routes" echo "==========================================" configure_tunnel_routes "$account_id" "$tunnel_id" "$tunnel_name" echo "" echo "==========================================" info "Step 2: Configuring DNS Records" echo "==========================================" configure_dns_records "$zone_id" "$tunnel_id" echo "" echo "==========================================" info "Configuration Complete!" echo "==========================================" echo "" info "Next steps:" echo " 1. Wait 1-2 minutes for DNS propagation" echo " 2. Test endpoints:" echo " curl https://rpc-http-pub.d-bis.org/health" echo " 3. Verify in Cloudflare Dashboard:" echo " - Zero Trust → Networks → Tunnels → Check routes" echo " - DNS → Records → Verify CNAME records" } # Run main function main