---
name: cloudflare-dns-and-domains
description: "Manage Cloudflare DNS zones, DNS records, and domain registrations (registrar) through the Cloudflare API. Integration with IT Pro Partner portal for customer DNS management and domain lifecycle."
version: 1.1.0
author: Sho'Nuff
tags: [cloudflare, dns, registrar, api, portal]
related_skills: []
---

# Cloudflare DNS & Domain Management

Manage Cloudflare DNS zones, DNS records, and domain registrations via the Cloudflare API v4. Used for the IT Pro Partner operations portal and Hermes integrations.

## Related Reference

**Before registering: research domain availability** — see `references/domain-availability-research.md` for the full workflow (WHOIS lookups, DNS NXDOMAIN checks, creative name generation, pricing across registrars, and pitfalls). This covers the **pre-registration** phase; this skill covers what happens **after** you own a domain.

## API Authentication

All requests use `Authorization: Bearer <token>` header. Tokens are scoped per-permission — never use a global API key.

### Token scoping rules

| Feature | Required Permission | Example token template |
|---|---|---|
| DNS record management | Zone → DNS → Edit | "Edit zone DNS" template |
| View registered domains | Account → Registrar → Read | Add to existing DNS token |
| Renew domains | Account → Registrar → Admin | Includes read + write + renew |
| Register new domains | Account → Registrar → Admin | Also needs `can_register: true` on TLD |
| Transfer domains | Account → Registrar → Admin | Includes transfer-in operations |
| Access/Zero Trust | Account → Access: Apps and Policies → Edit | Add to existing DNS token at dashboard |

**Start with DNS Edit + Registrar Read.** Upgrade to Admin only when purchasing/transferring domains is needed. For Access operations, edit the existing token from the dashboard to add the Access permission — no need to create a separate token.

**PITFALL — Token generation produces invalid keys via Telegram:** Cloudflare API tokens contain mixed-case and numeric characters that can be mangled when pasted through Telegram. If `curl -s "https://api.cloudflare.com/client/v4/user/tokens/verify"` returns `"Invalid API Token"` after pasting, the token was corrupted in transit. Options: (a) have the user email the token to shonuff@ instead of Telegram, or (b) have them regenerate from the dashboard.

### Verify token

```bash
curl -s -X GET "https://api.cloudflare.com/client/v4/user/tokens/verify" \
  -H "Authorization: Bearer $TOKEN"
```

Returns `{"success": true}` with token ID, status, and permissions list.

The token is stored in `~/.hermes/.env` as `CLOUDFLARE_API_TOKEN`.

**PITFALL — service-health-check.sh uses literal `Bearer ***`:** The health check script (`/root/.hermes/scripts/service-health-check.sh`) previously used `$TOKEN` to capture the real token but then sent `Authorization: Bearer ***` (literal asterisks) instead of `Bearer $TOKEN`. Every 5 minutes it was hitting Cloudflare with literal asterisks and reporting the token as "unhealthy" — but the actual token was fine. If a user reports the Cloudflare token as "unhealthy" in health alerts, check this script first: ensure the variable name used in the `Authorization` header matches what was captured on the line above. Use a distinct variable name like `CLOUD_TOKEN` to avoid shadowing.

## DNS Zone Management

### List all zones

```bash
curl -s "https://api.cloudflare.com/client/v4/zones" \
  -H "Authorization: Bearer $TOKEN" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(f'Count: {d[\"result_info\"][\"total_count\"]}')
for z in d['result']:
    print(f'  {z[\"name\"]} ({z[\"plan\"][\"name\"]}) - {z[\"status\"]}')"
```

### List DNS records for a zone

```bash
ZONE_ID=$(curl -s "https://api.cloudflare.com/client/v4/zones" \
  -H "Authorization: Bearer $TOKEN" | python3 -c "import sys,json;d=json.load(sys.stdin);z=[x['id'] for x in d['result'] if x['name']=='yourdomain.com'][0];print(z)")

curl -s "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?per_page=5" \
  -H "Authorization: Bearer $TOKEN" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for r in d['result']:
    print(f'  {r[\"type\"]} {r[\"name\"]} -> {r[\"content\"]}')"
```

## Domain Registration (Registrar API)

### Pitfall — Caddy + Cloudflare proxy creates 308 redirect loop (Jul 12, 2026)

When Cloudflare proxy (orange cloud) is enabled for a subdomain pointing to a Caddy origin server, Cloudflare connects to the origin on HTTP (port 80), Caddy responds with a 308 redirect to HTTPS, Cloudflare follows the redirect back to its own address, and the browser sees an infinite redirect loop. The response header is `HTTP/1.1 308` with `Location: https://<same-domain>/`.

**Fix options (in order of reliability):**
1. **Path-based proxy** (most reliable) — Serve the service behind an existing working Caddy domain as a sub-path. E.g., `core.itpropartner.com` has `handle_path /vault/* { reverse_proxy localhost:8080 }`. No Cloudflare issues because the parent domain already has a valid cert and Cloudflare handles the path transparently.
2. **DNS-only (gray cloud)** — Disable Cloudflare proxy for the record so traffic goes direct to the origin IP. Requires origin to have public ports open and a valid cert.
3. **Cloudflare Origin Certificate** — Generate an origin cert in the Cloudflare dashboard and configure Caddy to use it (not tested on this infrastructure).

**What does NOT work:** Caddy's automatic Let's Encrypt behind Cloudflare proxy. Let's Encrypt can't reach the origin through the proxy for the HTTP-01 challenge, and the ACME DNS-01 challenge requires Caddy to have the `dns.providers.cloudflare` module installed (not included in the default Caddy binary).

### List all registered domains with expiry

```bash
ACCT_ID=$(curl -s "https://api.cloudflare.com/client/v4/accounts" \
  -H "Authorization: Bearer $TOKEN" | python3 -c "import sys,json;print(json.load(sys.stdin).get('result',[{}])[0].get('id',''))")

curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCT_ID/registrar/domains" \
  -H "Authorization: Bearer $TOKEN" | python3 -c "
import sys, json
from datetime import datetime, timezone
d = json.load(sys.stdin)
for dom in sorted(d.get('result',[]), key=lambda x: x.get('expires_at','')):
    name = dom['name']
    exp = dom.get('expires_at','?')
    renew = dom.get('auto_renew','?')
    if exp != '?':
        exp_dt = datetime.fromisoformat(exp.replace('Z','+00:00'))
        days = (exp_dt - datetime.now(timezone.utc)).days
        print(f'{name:35s} {exp_dt.strftime("%b %d, %Y")} {days:3d}d  renew:{renew}')
    else:
        print(f'{name:35s} expires ?')"
```

### Single domain detail

```bash
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCT_ID/registrar/domains/germainebrown.com" \
  -H "Authorization: Bearer $TOKEN" | python3 -c "
import sys, json
d = json.load(sys.stdin)
dom = d.get('result',{})
print(f'Name: {dom[\"name\"]}')
print(f'Registered: {dom.get(\"registered_at\",\"?\")}')
print(f'Expires: {dom.get(\"expires_at\",\"?\")}')
print(f'Registrar: {dom.get(\"current_registrar\",\"?\")}')
print(f'Auto-renew: {dom.get(\"auto_renew\",\"?\")}')
print(f'Permissions: {dom.get(\"permissions\",[\"none\"])}')
print(f'DNS zones: {dom.get(\"cloudflare_dns\",\"?\")}, registered: {dom.get(\"cloudflare_registration\",\"?\")}')"
```

### Check domain availability for purchase (requires Admin)

**CORRECT endpoint (verified Jul 2026):**
```bash
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCT_ID/registrar/domains/example.com/check" \
  -H "Authorization: Bearer $TOKEN"
```

Returns: `name`, `available`, `can_register`, `premium`, `supported_tld`, `fees.registration_fee`, `fees.renewal_fee`, `fees.transfer_fee`.

**PITFALLS (all tested Jul 6, 2026):**
- POST body `{"domain_name":"..."}` returns "Page not found" — do NOT use POST
- Query param `?name=example.com` returns "Page not found" — do NOT use query params
- The domain goes in the URL path like a resource lookup: `/domains/example.com/check`
- This is a GET, not POST — cloudflare treats it as a resource lookup, not an RPC call

**Verified pricing:** .com TLD costs $10.46/yr (registration, renewal, and transfer all same price). Cloudflare sells domains at cost with no markup.

### Register a new domain

**Endpoint (untested — from Cloudflare API docs):**
```bash
curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCT_ID/registrar/domains/example.com/register" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"registrant_contact": {...}, "period": 1}'
```
Requires Admin permission and contact details for WHOIS registrant.

### Renew a domain

Available with permissions: `['contact_read', 'contact_write', 'domain_renew', 'domain_transfer_out', 'nameserver_write', 'domain_delete']`

```bash
curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCT_ID/registrar/domains/example.com/renew" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"period": 1}'  # years
```

## Permissions reference

A Registrar Read token shows per-domain `permissions`:

```json
['contact_read', 'contact_write', 'domain_renew', 'domain_transfer_out', 'nameserver_write', 'domain_delete']
```

Notable absence: no `domain_create` or `domain_register`. These require Admin level. The domain-level check (`/domains/{name}/check`) also requires Admin.

## Cloudflare Access (Zero Trust)

Cloudflare Access provides an authentication layer that sits in front of subdomains. Requests are checked for valid identity (email + optional 2FA) before reaching the origin server.

### When to use
- Replace Caddy basic_auth for internal dashboards
- Add 2FA to existing services (CRM, DocuSeal)
- Single sign-on across multiple services

### Setup requirements
- **Token scope:** The API token MUST include `Access: Apps and Policies:Edit` permission. The standard DNS-only token (Zone:DNS:Edit) returns "Authentication error" when trying to create Access applications.
- **DNS proxy:** The subdomain must have Cloudflare proxy enabled (orange cloud) — Access cannot intercept traffic sent directly to the origin IP.

### Quick setup path (when you have the right token)
1. Enable orange cloud on the subdomain
2. POST to `https://api.cloudflare.com/client/v4/accounts/{account_id}/access/apps` with name, domain, type: self_hosted, session_duration: 24h
3. Add identity policies per user by email
4. Optionally require 2FA in policy advanced rules

**PITFALL — Setting require TOTP fails via API:** The `"require":[{"totp":{}}]` policy setting returns `access.api.error.invalid_request` when sent via the API — it expects a different require format that isn't documented in the API reference. **Drop the 2FA requirement from the API call** and configure it manually from the dashboard if needed.

**PITFALL — Custom domain for Access login page:** Setting a custom domain (e.g. `auth.debtrecoveryexperts.com`) cannot be done via API — the `/access/custom_pages` endpoint returns "Method not allowed for this authentication scheme". Must be done via the dashboard at https://one.dash.cloudflare.com/ → Settings → Customization → Custom domain. Create a CNAME record pointing to `cloudflareaccess.com` first so Cloudflare can verify the domain.

**PITFALL — Token refresh after permission update:** When a user adds permissions to an existing Cloudflare token from the dashboard, the token value regenerates and the old value becomes invalid. The new value must be saved to `~/.hermes/.env` under `CLOUDFLARE_API_TOKEN`. Like token creation, the value can be mangled in Telegram paste — if `curl -s "https://api.cloudflare.com/client/v4/user/tokens/verify"` returns "Invalid API Token" after the user pastes it, have them email it to shonuff@germainebrown.com instead.

**PITFALL — Access apps need proxy (orange cloud) enabled:** Access cannot intercept traffic sent directly to the origin IP. The DNS record for the subdomain MUST have Cloudflare proxy enabled (orange cloud) before Access will work. Without it, users reach the origin server directly and bypass authentication entirely.

### Manual setup (Cloudflare dashboard)
Go to https://one.dash.cloudflare.com/ → Access → Applications → Add application → Self-hosted. Enter domain, set session duration, add email-based policies.

### Debugging

#### Verify policy membership

Check who is authorized for a specific Access app:

```bash
ACCT="<account_id>"
# Find the app ID by name
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCT/access/apps" \
  -H "Authorization: Bearer $TOKEN" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for app in d.get('result', []):
    print(f'{app[\"name\"]:30s} {app[\"domain\"]:45s} {app[\"id\"]}')
"

# List policies for a specific app
APP_ID="<access_app_id>"
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCT/access/apps/$APP_ID/policies" \
  -H "Authorization: Bearer $TOKEN" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for pol in d.get('result', []):
    print(f'Policy: {pol.get(\"name\",\"\")}  Decision: {pol.get(\"decision\",\"\")}')
    for inc in pol.get('include', []):
        for k,v in inc.items():
            if isinstance(v, dict):
                print(f'  Include → {k}: {list(v.values())[0]}')
            else:
                print(f'  Include → {k}: {v}')
"
```

The output shows which email domains or specific emails are allowed. DRE example: `email_domain: germainebrown.com` + `email_domain: yahoo.com` grants access to anyone @germainebrown.com or @yahoo.com.
Free up to 50 users. No server-side config needed — works with any origin server (Caddy, nginx, Docker).

The IT Pro Partner portal mockup at `/root/portal-mockup/dns.html` has two tabs:
1. **DNS Zones** — zone listing with record editor
2. **Registered Domains** — domain list with expiry dates, auto-renew status, days remaining, and customer links

The portal displays real data from the Cloudflare API. Color coding:
- Green: 90+ days to expiry
- Amber: 30-90 days to expiry  
- Red: Under 30 days or expired
