1eff254a66
When an existing Cloudflare tunnel was found, the script would overwrite the credentials file with an empty TunnelSecret, breaking the service. Now validates existing credentials and only recreates the tunnel if the credentials file is missing or invalid. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
322 lines
12 KiB
Django/Jinja
322 lines
12 KiB
Django/Jinja
#!/usr/bin/env bash
|
|
# Phantom — Cloudflare Tunnel Setup
|
|
# Deployed by phantom to {{ ansible_host | default('this server') }}
|
|
# Run this script to expose {{ _tls_domain }} via Cloudflare Tunnel.
|
|
# Requires: a Cloudflare account managing the domain's DNS zone.
|
|
#
|
|
# Usage:
|
|
# ./setup-tunnel.sh # Interactive — prompts for API token
|
|
# ./setup-tunnel.sh <CF_API_TOKEN> # Non-interactive — token as argument
|
|
set -euo pipefail
|
|
|
|
DOMAIN="{{ _tls_domain }}"
|
|
TUNNEL_NAME="phantom-${DOMAIN%%.*}"
|
|
|
|
echo "============================================"
|
|
echo " Phantom — Cloudflare Tunnel Setup"
|
|
echo "============================================"
|
|
echo " Domain: ${DOMAIN}"
|
|
echo " Tunnel Name: ${TUNNEL_NAME}"
|
|
echo ""
|
|
|
|
# ── Get Cloudflare API token ──────────────────────────────────────────
|
|
CF_TOKEN="${1:-}"
|
|
if [ -z "${CF_TOKEN}" ]; then
|
|
echo "[*] Cloudflare API token required."
|
|
echo " Create one at: https://dash.cloudflare.com/profile/api-tokens"
|
|
echo " Permissions needed: Zone:DNS:Edit + Account:Cloudflare Tunnel:Edit"
|
|
echo ""
|
|
read -rp " Cloudflare API token: " CF_TOKEN
|
|
echo ""
|
|
fi
|
|
|
|
if [ -z "${CF_TOKEN}" ]; then
|
|
echo "[-] No token provided."
|
|
exit 1
|
|
fi
|
|
|
|
# ── Install cloudflared ───────────────────────────────────────────────
|
|
if command -v cloudflared &>/dev/null; then
|
|
echo "[+] cloudflared already installed: $(cloudflared --version 2>&1 | head -1)"
|
|
else
|
|
echo "[*] Installing cloudflared..."
|
|
if [ -f /usr/share/keyrings/cloudflare-main.gpg ]; then
|
|
echo " Cloudflare apt repo already configured"
|
|
else
|
|
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
|
|
| tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
|
|
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" \
|
|
| tee /etc/apt/sources.list.d/cloudflared.list >/dev/null
|
|
apt-get update -qq
|
|
fi
|
|
apt-get install -y -qq cloudflared
|
|
echo "[+] cloudflared installed"
|
|
fi
|
|
|
|
# ── Resolve Cloudflare Account ID ─────────────────────────────────────
|
|
echo "[*] Verifying API token..."
|
|
ACCOUNT_INFO=$(curl -sf -X GET "https://api.cloudflare.com/client/v4/accounts?page=1&per_page=1" \
|
|
-H "Authorization: Bearer ${CF_TOKEN}" \
|
|
-H "Content-Type: application/json" 2>/dev/null || true)
|
|
|
|
ACCOUNT_ID=$(echo "${ACCOUNT_INFO}" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
data = json.load(sys.stdin)
|
|
if data.get('success') and data.get('result'):
|
|
print(data['result'][0]['id'])
|
|
except: pass
|
|
" 2>/dev/null || true)
|
|
|
|
if [ -z "${ACCOUNT_ID}" ]; then
|
|
echo "[-] Failed to verify API token. Check permissions:"
|
|
echo " - Account:Cloudflare Tunnel:Edit"
|
|
echo " - Zone:DNS:Edit"
|
|
exit 1
|
|
fi
|
|
echo "[+] Account verified: ${ACCOUNT_ID}"
|
|
|
|
# ── Create or find existing tunnel via API ────────────────────────────
|
|
echo "[*] Checking for existing tunnel '${TUNNEL_NAME}'..."
|
|
TUNNEL_LIST=$(curl -sf -X GET \
|
|
"https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel?name=${TUNNEL_NAME}&is_deleted=false" \
|
|
-H "Authorization: Bearer ${CF_TOKEN}" \
|
|
-H "Content-Type: application/json" 2>/dev/null || true)
|
|
|
|
TUNNEL_ID=$(echo "${TUNNEL_LIST}" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
data = json.load(sys.stdin)
|
|
if data.get('success') and data.get('result'):
|
|
print(data['result'][0]['id'])
|
|
except: pass
|
|
" 2>/dev/null || true)
|
|
|
|
CREATED_NEW_TUNNEL=false
|
|
if [ -n "${TUNNEL_ID}" ]; then
|
|
echo "[+] Tunnel '${TUNNEL_NAME}' already exists: ${TUNNEL_ID}"
|
|
# Check if we have valid credentials on disk
|
|
CRED_FILE="/etc/cloudflared/${TUNNEL_ID}.json"
|
|
if [ -f "${CRED_FILE}" ]; then
|
|
EXISTING_SECRET=$(python3 -c "
|
|
import json, sys
|
|
try:
|
|
with open('${CRED_FILE}') as f:
|
|
d = json.load(f)
|
|
s = d.get('TunnelSecret', '')
|
|
if s: print(s)
|
|
except: pass
|
|
" 2>/dev/null || true)
|
|
if [ -n "${EXISTING_SECRET}" ]; then
|
|
echo "[+] Existing credentials file is valid"
|
|
else
|
|
echo "[-] Credentials file exists but has empty secret — tunnel must be recreated"
|
|
echo "[*] Deleting tunnel '${TUNNEL_NAME}' to recreate with new credentials..."
|
|
curl -sf -X DELETE \
|
|
"https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}" \
|
|
-H "Authorization: Bearer ${CF_TOKEN}" \
|
|
-H "Content-Type: application/json" >/dev/null 2>&1 || true
|
|
TUNNEL_ID=""
|
|
fi
|
|
else
|
|
echo "[-] No credentials file found — tunnel must be recreated"
|
|
echo "[*] Deleting tunnel '${TUNNEL_NAME}' to recreate with new credentials..."
|
|
curl -sf -X DELETE \
|
|
"https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}" \
|
|
-H "Authorization: Bearer ${CF_TOKEN}" \
|
|
-H "Content-Type: application/json" >/dev/null 2>&1 || true
|
|
TUNNEL_ID=""
|
|
fi
|
|
fi
|
|
|
|
if [ -z "${TUNNEL_ID}" ]; then
|
|
echo "[*] Creating tunnel '${TUNNEL_NAME}'..."
|
|
TUNNEL_SECRET=$(python3 -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())")
|
|
|
|
CREATE_RESP=$(curl -sf -X POST \
|
|
"https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel" \
|
|
-H "Authorization: Bearer ${CF_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
--data "{\"name\": \"${TUNNEL_NAME}\", \"tunnel_secret\": \"${TUNNEL_SECRET}\", \"config_src\": \"local\"}" \
|
|
2>/dev/null || true)
|
|
|
|
TUNNEL_ID=$(echo "${CREATE_RESP}" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
data = json.load(sys.stdin)
|
|
if data.get('success') and data.get('result'):
|
|
print(data['result']['id'])
|
|
else:
|
|
errors = data.get('errors', [])
|
|
if errors:
|
|
print('ERROR:' + errors[0].get('message', 'unknown'), file=sys.stderr)
|
|
except: pass
|
|
" 2>/dev/null || true)
|
|
|
|
if [ -z "${TUNNEL_ID}" ] || [[ "${TUNNEL_ID}" == ERROR:* ]]; then
|
|
echo "[-] Failed to create tunnel."
|
|
echo "${CREATE_RESP}" | python3 -m json.tool 2>/dev/null || echo "${CREATE_RESP}"
|
|
exit 1
|
|
fi
|
|
echo "[+] Tunnel created: ${TUNNEL_ID}"
|
|
CREATED_NEW_TUNNEL=true
|
|
fi
|
|
|
|
# ── Write credentials + config ────────────────────────────────────────
|
|
echo "[*] Writing tunnel configuration..."
|
|
mkdir -p /etc/cloudflared
|
|
|
|
# Write credentials file only for newly created tunnels
|
|
if [ "${CREATED_NEW_TUNNEL}" = true ]; then
|
|
python3 -c "
|
|
import json
|
|
creds = {'AccountTag': '${ACCOUNT_ID}', 'TunnelID': '${TUNNEL_ID}', 'TunnelSecret': '${TUNNEL_SECRET}'}
|
|
with open('/etc/cloudflared/${TUNNEL_ID}.json', 'w') as f:
|
|
json.dump(creds, f)
|
|
"
|
|
chmod 600 "/etc/cloudflared/${TUNNEL_ID}.json"
|
|
echo "[+] Credentials file written"
|
|
fi
|
|
|
|
cat > /etc/cloudflared/config.yml << CFEOF
|
|
tunnel: ${TUNNEL_ID}
|
|
credentials-file: /etc/cloudflared/${TUNNEL_ID}.json
|
|
|
|
ingress:
|
|
- hostname: ${DOMAIN}
|
|
service: https://localhost:443
|
|
originRequest:
|
|
noTLSVerify: true
|
|
- service: http_status:404
|
|
CFEOF
|
|
chmod 600 /etc/cloudflared/config.yml
|
|
echo "[+] Config written"
|
|
|
|
# ── Route DNS ─────────────────────────────────────────────────────────
|
|
echo "[*] Setting up DNS route for ${DOMAIN}..."
|
|
|
|
# Get zone ID for the domain
|
|
BASE_DOMAIN=$(echo "${DOMAIN}" | rev | cut -d. -f1-2 | rev)
|
|
ZONE_INFO=$(curl -sf -X GET \
|
|
"https://api.cloudflare.com/client/v4/zones?name=${BASE_DOMAIN}" \
|
|
-H "Authorization: Bearer ${CF_TOKEN}" \
|
|
-H "Content-Type: application/json" 2>/dev/null || true)
|
|
|
|
ZONE_ID=$(echo "${ZONE_INFO}" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
data = json.load(sys.stdin)
|
|
if data.get('success') and data.get('result'):
|
|
print(data['result'][0]['id'])
|
|
except: pass
|
|
" 2>/dev/null || true)
|
|
|
|
if [ -z "${ZONE_ID}" ]; then
|
|
echo "[-] Could not find zone for ${BASE_DOMAIN}"
|
|
echo " Add the CNAME manually: ${DOMAIN} → ${TUNNEL_ID}.cfargotunnel.com"
|
|
else
|
|
# Check for existing record
|
|
EXISTING_RECORD=$(curl -sf -X GET \
|
|
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${DOMAIN}" \
|
|
-H "Authorization: Bearer ${CF_TOKEN}" \
|
|
-H "Content-Type: application/json" 2>/dev/null || true)
|
|
|
|
RECORD_ID=$(echo "${EXISTING_RECORD}" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
data = json.load(sys.stdin)
|
|
if data.get('success') and data.get('result'):
|
|
print(data['result'][0]['id'])
|
|
except: pass
|
|
" 2>/dev/null || true)
|
|
|
|
TUNNEL_CNAME="${TUNNEL_ID}.cfargotunnel.com"
|
|
|
|
if [ -n "${RECORD_ID}" ]; then
|
|
# Update existing record to point to tunnel
|
|
echo " Updating existing DNS record..."
|
|
curl -sf -X PUT \
|
|
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
|
|
-H "Authorization: Bearer ${CF_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
--data "{\"type\":\"CNAME\",\"name\":\"${DOMAIN}\",\"content\":\"${TUNNEL_CNAME}\",\"proxied\":true}" \
|
|
>/dev/null 2>&1
|
|
echo "[+] DNS record updated: ${DOMAIN} → tunnel (proxied)"
|
|
else
|
|
# Create new CNAME
|
|
curl -sf -X POST \
|
|
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
|
|
-H "Authorization: Bearer ${CF_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
--data "{\"type\":\"CNAME\",\"name\":\"${DOMAIN}\",\"content\":\"${TUNNEL_CNAME}\",\"proxied\":true}" \
|
|
>/dev/null 2>&1
|
|
echo "[+] DNS CNAME created: ${DOMAIN} → tunnel (proxied)"
|
|
fi
|
|
fi
|
|
|
|
# ── Install systemd service ──────────────────────────────────────────
|
|
echo "[*] Installing cloudflared systemd service..."
|
|
# Stop existing service if running
|
|
systemctl stop cloudflared 2>/dev/null || true
|
|
|
|
# Try cloudflared's built-in installer first
|
|
if ! cloudflared service install 2>/dev/null; then
|
|
# Fallback: write unit file manually
|
|
if [ ! -f /etc/systemd/system/cloudflared.service ]; then
|
|
cat > /etc/systemd/system/cloudflared.service << SVCEOF
|
|
[Unit]
|
|
Description=cloudflared tunnel
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=notify
|
|
TimeoutStartSec=0
|
|
ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run
|
|
Restart=on-failure
|
|
RestartSec=5s
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
SVCEOF
|
|
fi
|
|
fi
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable cloudflared
|
|
systemctl start cloudflared
|
|
echo "[+] cloudflared service started"
|
|
|
|
# ── Verify ────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "[*] Waiting for tunnel to connect..."
|
|
sleep 5
|
|
|
|
if systemctl is-active --quiet cloudflared; then
|
|
echo "[+] cloudflared service is running"
|
|
else
|
|
echo "[-] cloudflared service failed to start"
|
|
echo " Check: journalctl -u cloudflared --no-pager -n 20"
|
|
exit 1
|
|
fi
|
|
|
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://${DOMAIN}/" --max-time 10 2>/dev/null || echo "000")
|
|
if [ "${HTTP_CODE}" != "000" ] && [ "${HTTP_CODE}" != "502" ] && [ "${HTTP_CODE}" != "503" ]; then
|
|
echo "[+] Tunnel verified — https://${DOMAIN}/ returned HTTP ${HTTP_CODE}"
|
|
else
|
|
echo "[!] Tunnel may need a moment to propagate."
|
|
echo " DNS changes can take 1-5 minutes."
|
|
echo " Test: curl -I https://${DOMAIN}/"
|
|
fi
|
|
|
|
echo ""
|
|
echo "============================================"
|
|
echo " Cloudflare Tunnel setup complete!"
|
|
echo ""
|
|
echo " Domain: https://${DOMAIN}"
|
|
echo " Tunnel: ${TUNNEL_NAME} (${TUNNEL_ID})"
|
|
echo " Service: systemctl status cloudflared"
|
|
echo ""
|
|
echo " To remove: cloudflared tunnel delete ${TUNNEL_NAME}"
|
|
echo "============================================"
|