# Behind Cloudflare

> Running GroundShade behind Cloudflare orange-cloud. Required trusted_proxies, what works, what gets silenced, and the Caddy recipe.

Cloudflare in front of GroundShade is a supported topology since
v0.7.0 (which fixed connection-cap saturation behind a fronting
proxy). This page covers **orange-cloud** (CF terminating TLS).
Grey-cloud is direct traffic and needs no special setup.

Orange-cloud works, but **two security signals get silenced** when
CF terminates TLS at its edge. Confirm that trade-off matches your
threat model before flipping it on.

## Topology

```
internet → CF edge (TLS) → origin server
                            ├─ fronting Caddy (HTTP)
                            │      ├─ GroundShade
                            │      │      └─ your app
                            │      └─ (or directly to your app)
```

From GroundShade's perspective:

- Immediate TCP peer: your fronting Caddy.
- `X-Forwarded-For` chain: `<real-client>, <cf-edge-ip>`. CF
  appends its edge IP.
- TLS handshake (JA4): CF's edge fingerprint. The real client's
  handshake terminates at CF.

## Required config

`listen.trusted_proxies` must list **both** your fronting Caddy
**and** Cloudflare's edge IP ranges. Without CF in the list, the
XFF parser walks right-to-left and stops at the CF edge IP,
treating it as the real client. Trust tokens, rate signals, and
trustless persistence would then key on CF's edges instead of the
actual user.

```yaml
listen:
  http: "0.0.0.0:8080"
  trusted_proxies:
    # 1. Your fronting Caddy (pinned container IP).
    - "172.18.0.10/32"

    # 2. CloudFlare IPv4 edge ranges.
    #    Source: https://www.cloudflare.com/ips-v4
    - "173.245.48.0/20"
    - "103.21.244.0/22"
    - "103.22.200.0/22"
    - "103.31.4.0/22"
    - "141.101.64.0/18"
    - "108.162.192.0/18"
    - "190.93.240.0/20"
    - "188.114.96.0/20"
    - "197.234.240.0/22"
    - "198.41.128.0/17"
    - "162.158.0.0/15"
    - "104.16.0.0/13"
    - "104.24.0.0/14"
    - "172.64.0.0/13"
    - "131.0.72.0/22"

    # 3. CloudFlare IPv6 edge ranges.
    #    Source: https://www.cloudflare.com/ips-v6
    - "2400:cb00::/32"
    - "2606:4700::/32"
    - "2803:f800::/32"
    - "2405:b500::/32"
    - "2405:8100::/32"
    - "2a06:98c0::/29"
    - "2c0f:f248::/32"
```

Verify the ranges before deploying.
[cloudflare.com/ips-v4](https://www.cloudflare.com/ips-v4) and
[cloudflare.com/ips-v6](https://www.cloudflare.com/ips-v6) are the
source of truth; the ranges shift, so re-check before deploying.

Or set via env:

```sh
GROUNDSHADE_TRUSTED_PROXIES="$(curl -s https://www.cloudflare.com/ips-v4 | tr '\n' ',' | sed 's/,$//'),172.18.0.10/32"
```

## Caddy config

Caddy needs its own `trusted_proxies` pointed at CF, otherwise it
strips CF's `CF-Connecting-IP` and `X-Forwarded-For` instead of
forwarding them:

```
{
    servers {
        trusted_proxies static cloudflare
    }
}

your-site.example.com {
    reverse_proxy groundshade:8080 {
        # Preserve XFF chain so GroundShade can walk through CF to the
        # real client. Caddy appends its own IP to XFF here; the
        # GroundShade-side trusted_proxies handles the rest.
        header_up X-Forwarded-For {>X-Forwarded-For}, {remote_host}
    }
}
```

`trusted_proxies static cloudflare` is a Caddy module that
auto-fetches and refreshes CF's IPs. If you can't use it, list the
CIDRs explicitly: `trusted_proxies static <CIDR> <CIDR> ...`.

## What works behind orange-cloud

| Feature | Status | Notes |
|---|---|---|
| Forwarding | ✓ | Just works |
| Connection-cap saturation | ✓ (v0.7+) | CF edges don't count against the per-IP cap |
| Real client IP in logs | ✓ | XFF chain resolves to the original client |
| Trust tokens | ✓ | Bound to client `/24` from XFF |
| Rate signal, per-IP arm | ✓ | Counts by real client `/24` |
| Trustless persistence | ✓ | Per real client `/24` |
| Defense ladder | ✓ | Detector sees real latency / 5xx |
| Manual shields-up | ✓ | Always works |
| Operator IP allowlists | ✓ | Resolved against real client IP |
| Operator UA allowlists | ✓ | UA is forwarded unchanged |

## What doesn't work (TLS termination limits)

| Feature | Status | Why | Workaround |
|---|---|---|---|
| JA4-keyed rate signal | ✗ silenced | CF terminates TLS; origin sees CF's JA4 | Per-IP/24 arm still works |
| Verified-crawler fast lane (rDNS) | ✗ dead | rDNS runs on the peer IP (Caddy/CF), never resolves to `googlebot.com` | Use `fastlane.allow_user_agents` |
| ForgedBrowser classifier | ✗ degraded | Uses JA4 ↔ UA consistency check; with uniform JA4, every request looks consistent | Rely on rate signal + trustless persistence + UA patterns |

The dashboard will show `JA4: detected` because CF and Caddy are
forwarding *some* JA4. It's CF's, not the real client's. The
per-JA4 counter in `groundshade_signals_rate_tracked_keys{key="ja4"}`
sits at a small number relative to per-IP keys; that's the visible
fingerprint of the limit.

## Recommended fastlane config behind CF

Because verified-crawler rDNS is dead behind CF, swap it for UA
allowlists:

```yaml
fastlane:
  crawlers:
    # rDNS verification can't see past CF; these flags do nothing here.
    google: true
    bing: true
    duckduckgo: true

  allow_user_agents:
    # Substring match, case-insensitive. Spoofable; the rate-signal
    # backstop catches mass abuse from anyone forging these.
    - "Googlebot"
    - "bingbot"
    - "DuckDuckBot"
    - "AppleBot"
    - "UptimeRobot"
    - "Uptime-Kuma"
```

If your threat model requires verified crawlers, **don't use
orange-cloud**. Grey-cloud preserves both JA4 and the rDNS fast
lane.

## Test the setup

1. **Real client IP visible.** Hit your site from a known external
   IP. Check the log:

   ```sh
   docker logs groundshade | grep -E 'client_ip|method=' | head -3
   ```

   The `client_ip` (or `client_ip_hash`) should reflect *your* IP,
   not a `104.16.x.x` / `172.64.x.x` (CF edge) or `172.18.x.x`
   (Caddy bridge) address.

2. **Per-IP rejection counter stays flat.** Under normal load:

   ```sh
   curl -s -H "Authorization: Bearer $TOKEN" \
        http://127.0.0.1:9090/metrics \
     | grep 'connections_rejected_total{reason="per_ip"'
   ```

   Should sit at `0`. If it climbs, your fronting Caddy's IP is
   not in `trusted_proxies`.

3. **JA4 state acknowledges the limit.**

   ```sh
   curl -s -H "Authorization: Bearer $TOKEN" \
        http://127.0.0.1:9090/admin/status \
     | jq .ja4
   ```

   Says `"state": "detected"` with few distinct JA4 keys tracked
   (about one per CF version). Expected.

4. **Trust tokens bind to real prefix.** Solve a challenge from
   one IP, then send a second request from a *different* IP in the
   same `/24` with the same cookie. It should pass (the token
   binds to `/24`). From a different `/24`: re-challenge expected.

## Recommendation

Use grey-cloud (CF DNS-only) when you can. You get:

- Real client JA4. Behavioural signals key on the actual TLS
  fingerprint.
- rDNS verification. Known-good crawlers don't see the wall.
- One less authority in your trust chain.

Use orange-cloud when you need CF's edge protections (WAF, edge
rate limiting, L3/L4 DDoS, image optimisation) and accept losing
JA4-keyed detection. GroundShade still provides per-IP rate,
trustless persistence, and the trust-token wall, on a reduced
signal set.
