d687423c22
- cloudflared: create dedicated service account, run as non-root with ProtectSystem=strict and full hardening directives - cloudflared: skip credentials overwrite when existing tunnel has valid credentials on disk; delete and recreate if credentials are missing - wireguard: add systemd drop-in with ProtectHome, ProtectClock, ProtectHostname, ProtectKernelLogs, PrivateTmp Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
345 lines
13 KiB
Django/Jinja
345 lines
13 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
|
|
|
|
# ── Create service account ───────────────────────────────────────────
|
|
if id cloudflared &>/dev/null; then
|
|
echo "[+] cloudflared user already exists"
|
|
else
|
|
echo "[*] Creating cloudflared service account..."
|
|
useradd --system --no-create-home --shell /usr/sbin/nologin cloudflared
|
|
echo "[+] cloudflared service account created"
|
|
fi
|
|
|
|
# Fix ownership on config and credentials
|
|
chown -R cloudflared:cloudflared /etc/cloudflared
|
|
|
|
# ── Install systemd service ──────────────────────────────────────────
|
|
echo "[*] Installing cloudflared systemd service..."
|
|
# Stop existing service if running
|
|
systemctl stop cloudflared 2>/dev/null || true
|
|
|
|
# Remove any existing unit from `cloudflared service install` (runs as root)
|
|
rm -f /etc/systemd/system/cloudflared.service 2>/dev/null || true
|
|
|
|
cat > /etc/systemd/system/cloudflared.service << SVCEOF
|
|
[Unit]
|
|
Description=cloudflared tunnel
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
User=cloudflared
|
|
Group=cloudflared
|
|
ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run
|
|
Restart=on-failure
|
|
RestartSec=5s
|
|
|
|
# Hardening
|
|
ProtectSystem=strict
|
|
ProtectHome=true
|
|
PrivateTmp=true
|
|
PrivateDevices=true
|
|
NoNewPrivileges=true
|
|
ReadWritePaths=/etc/cloudflared
|
|
ProtectClock=true
|
|
ProtectHostname=true
|
|
ProtectKernelLogs=true
|
|
ProtectKernelModules=true
|
|
ProtectKernelTunables=true
|
|
RestrictRealtime=true
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
SVCEOF
|
|
|
|
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 "============================================"
|