# Why I Split-Tunnel My VPN for AI Services — and Let Cloudflare's Application Library Pick the Domains

> I maintain a small set of open-source GitHub repos that hold split-tunnel VPN configs for reaching AI...

- Author: Gabriel Koo (AWS Community Builder)
- Published: 2026-06-19
- Topics: networking, cloudflare, vpn, ai
- HTML: https://gabrielkoo.com/blog/i-used-cloudflares-app-library-to-map-every-ai-services-domains-and-why-networking-still-matters-2and/
- Original canonical URL: https://dev.to/gabrielkoo/i-used-cloudflares-app-library-to-map-every-ai-services-domains-and-why-networking-still-matters-2and

I maintain a small set of **open-source GitHub repos** that hold split-tunnel VPN configs for reaching AI services — [Tailscale app connectors](https://github.com/gabrielkoo/tailscale-config-for-ai-services), [WireGuard](https://github.com/gabrielkoo/wireguard-configs-for-ai-services), and [OpenVPN](https://github.com/gabrielkoo/openvpn-configs-for-ai-services). The single hardest part of maintaining them isn't the VPN config. It's answering one deceptively boring question:

**Which domains does this AI platform actually use?**

This post is about a shortcut I lean on — Cloudflare's Application Library, queried over its REST API — and a broader point I keep coming back to: even in an era where AI can scaffold most of your code and config for you, **networking fundamentals are still worth learning**. Not because the AI can't write the config, but because the difference between a clean setup and one that gets your account flagged comes down to a judgment call — *what you route and what you deliberately don't* — and that judgment is exactly the part an AI can't make for you without understanding your intent.

(One caveat before we start: this is for routing traffic you're already entitled to — not for evading restrictions that legitimately apply to you. Full legitimate-use note at the end.)

## The problem: a "domain list" is never just one domain

When you want to route only one app through a VPN, you need the set of hostnames (or IPs) that app actually talks to. That sounds trivial until you open DevTools on a modern web app and watch it fan out to twenty different domains: the main API, an auth provider, a CDN for static assets, telemetry, a fraud-detection pixel, a feature-flag service.

Route too little and the app half-works (login spins forever, streaming responses stall). Route too much — say, the bare `*.cloudflarestorage.com` wildcard — and you scoop up huge amounts of *unrelated* traffic, which defeats the entire point of a split tunnel and can even get your exit node rate-limited.

So you need the **narrowest accurate** set of hostnames. Where do you get it?

![One app fans out to many hostnames — a single ChatGPT app talking to api.openai.com, auth0.openai.com, cdn.oaistatic.com, oaiusercontent.com and more](/assets/img/caaaed39c4d2.jpg)

## The shortcut: Cloudflare's Application Library

Cloudflare's Zero Trust product ships an [**Application Library**](https://developers.cloudflare.com/cloudflare-one/team-and-resources/app-library) — a catalog of well-known SaaS apps, each annotated with the hostnames Cloudflare has observed it using. It's meant for admins building Access policies, but it doubles as a fantastic *domain-discovery* tool. Someone at Cloudflare already did the tedious traffic-watching for ChatGPT, Claude, and friends.

![Stop sniffing traffic by hand — the Cloudflare Application Library hands you the hostnames so you don't have to map them manually](/assets/img/503230b95394.jpg)

You can browse it in the dashboard under **Zero Trust → Team & Resources → Application Library**. But I don't want to click through a UI every time a provider quietly adds a domain — I want it in code. So I query the [REST API](https://developers.cloudflare.com/api/).

Here's the helper I use to pull ChatGPT's hostnames. Pure stdlib, no SDK:

```python
import json, urllib.parse, urllib.request

CF_API = "https://api.cloudflare.com/client/v4"


def cf_hostnames(token, account, app_name, search):
    url = "%s/accounts/%s/resource-library/applications?%s" % (
        CF_API, account, urllib.parse.urlencode({"search": search, "limit": 25}))
    req = urllib.request.Request(url, headers={"Authorization": "Bearer " + token})
    with urllib.request.urlopen(req, timeout=30) as r:
        d = json.load(r)
    for a in (d.get("result") or []):
        if a.get("name") == app_name:
            return sorted(set(a.get("hostnames") or []))
    raise SystemExit("CF: application %r not found (search=%r)" % (app_name, search))
```

A few things worth calling out:

- **The endpoint is `accounts/{account_id}/resource-library/applications`.** It takes a `search` query and returns matching catalog entries, each with a `hostnames` array. I match on the exact `name` (e.g. `"ChatGPT"`, `"Claude"`) because a search can return several near-matches.
- **The token only needs read access** to the resource library. Scope it minimally — there's no reason this token should be able to change anything.
- **It's deterministic and CI-friendly.** No browser automation, no scraping. That matters for the next step.
- **You don't need a paid plan.** This lives in Cloudflare's Zero Trust product, and Zero Trust has a [**free tier (up to 50 seats)**](https://blog.cloudflare.com/teams-plans/) that's plenty for personal use. The Application Library and its REST API are available on that free plan — so the entire domain-discovery pipeline costs you nothing but an API token.

### What the API actually returns

Here's the real output for **Claude** (`name == "Claude"`) as of this writing:

```json
{
  "service": "claude",
  "application": "Claude",
  "hostnames": [
    "a-api.anthropic.com",
    "a-cdn.anthropic.com",
    "anthropic.com",
    "claude.ai",
    "claude.com"
  ]
}
```

Five hostnames, and notice they cover the things that actually matter: the web app (`claude.ai`, `claude.com`), the API (`a-api.anthropic.com`), the static asset CDN (`a-cdn.anthropic.com`), and the marketing/auth origin (`anthropic.com`). That's the *narrow accurate set* I was after — nothing extraneous to prune.

And the same call for **ChatGPT** (App ID `1199`):

```json
{
  "service": "openai",
  "application": "ChatGPT",
  "hostnames": [
    "api.openai.com",
    "auth.openai.com",
    "auth0.openai.com",
    "cdn.oaistatic.com",
    "chat.openai.com",
    "chatgpt.com",
    "oaistatic.com",
    "oaiusercontent.com",
    "openai.com"
  ]
}
```

Nine hostnames, and the list is more revealing than it looks. Miss the Auth0-backed `auth0.openai.com` (still in the login path at time of writing) and sign-in silently hangs; miss `oaiusercontent.com` and uploads/generated files break. Try assembling that from DevTools by hand and you'll miss the auth host you only hit on first login — then spend an afternoon debugging a broken login flow.

Cloudflare already did the traffic-watching.

## From hostnames to a routable config

Hostnames are perfect for **[Tailscale app connectors](https://tailscale.com/kb/1281/app-connectors)**, which match on the domain name directly — Tailscale handles the DNS-to-IP mapping for you, so the config keeps working even when the provider rotates IPs.

For **WireGuard and OpenVPN** — which you'd reach for when you need router-level enforcement, or you're on a client where Tailscale isn't an option — you can't route by hostname; those tunnels route by IP. So the pipeline becomes:

1. **Pull hostnames** from the Cloudflare Application Library (the function above).
2. **Resolve each to A + AAAA records** over DNS-over-HTTPS, so the result doesn't depend on whatever resolver the CI runner happens to use.
3. **Aggregate** the addresses to CIDR blocks, then collapse overlapping ranges. I default to `/24` (v4) and `/48` (v6), but understand this is a *heuristic*, not precision: expanding one resolved IP to a `/24` also routes the other 255 addresses on that edge node, some of which belong to unrelated tenants. It's a deliberate trade — narrower (`/32`, `/28`) is more precise but churns more often; wider (`/20`, a supernet) means fewer entries but more collateral traffic. Pick the prefix that matches how much drift and collateral you can tolerate.
4. **Union with a static "floor"** for providers that publish a stable, authoritative prefix — for Anthropic that's `160.79.104.0/21` (their own AS399230), so a single resolved `/24` never accidentally blackholes the rest of the block.
5. **Rewrite the config files** between marker comments, and let a [scheduled GitHub Action](https://github.com/gabrielkoo/wireguard-configs-for-ai-services/blob/main/.github/workflows/update-ips.yml) open the change.

The whole thing is one stdlib Python script ([`scripts/update_ips.py`](https://github.com/gabrielkoo/wireguard-configs-for-ai-services/blob/main/scripts/update_ips.py)) that runs on a cron schedule. The configs stay fresh without me touching them.

![The resolve-and-aggregate pipeline — scattered cloud hostnames funnel through resolve + aggregate into a single clean config](/assets/img/de6fa0294637.jpg)

And here's where the two providers diverge in a way that proves the whole point. Run the resolve-and-aggregate step and **ChatGPT** collapses to a pile of Cloudflare ranges:

```text
# IPv4 (all shared Cloudflare anycast)
104.18.32.0/23
104.18.37.0/24
104.18.41.0/24
162.159.140.0/24
172.64.146.0/24
172.64.150.0/24
172.64.154.0/23
172.65.90.0/24
172.66.0.0/24

# IPv6 — 9 more Cloudflare /48s
```

Every one of those is **shared Cloudflare anycast** — [`104.18.x`, `172.64.x`, `172.66.x` are Cloudflare's](https://www.cloudflare.com/ips/), not OpenAI's. Route them and you're routing a slice of Cloudflare's entire customer base, and the specific `/24`s will drift as Cloudflare reshuffles. That's why ChatGPT-by-IP needs the scheduled refresh, and why it's genuinely better as a Tailscale *hostname* connector.

**Claude** collapses to something completely different:

```text
34.36.57.0/24    ← Google Cloud LB (shared)
160.79.104.0/21  ← Anthropic's OWN block (AS399230)
2607:6bc0::/32   ← Anthropic's own IPv6
```

The `160.79.104.0/21` is Anthropic's own registered allocation — a `whois`/RDAP lookup shows the block (160.79.104.0–160.79.111.255) registered directly to Anthropic, PBC, announced via AS399230. So I can route the whole `/21` and trust *who* it belongs to, even though it's 2,048 addresses — wider than Claude strictly needs today. That's the deliberate stability-over-precision trade for a block whose owner won't suddenly hand it to someone else. The `34.36.57.0/24` is a [Google Cloud front-end](https://www.gstatic.com/ipranges/cloud.json) I let the script re-resolve each run rather than hardcode, because it *is* shared infrastructure that drifts. **Same pipeline, two completely different risk profiles — and you only know which is which if you understand who owns the address space.**

![Same pipeline, two risk profiles — one resolve+aggregate funnel splits into ChatGPT on shared Cloudflare anycast that drifts, versus Claude on Anthropic's own stable 160.79.104.0/21 block](/assets/img/25e7faa7e55b.jpg)

## Why this is a networking lesson, not a VPN lesson

Every step above is a networking decision, and getting them wrong has real consequences:

![Hostname routing vs IP routing — hostname routing follows the name and survives IP churn; IP routing pins addresses and breaks on drift](/assets/img/be11d59d9672.jpg)

- **Hostname routing vs. IP routing is a fundamental tradeoff.** DNS/SNI-based routing (Tailscale) survives IP churn and cleanly isolates one app on a shared CDN. IP-CIDR routing (WireGuard/OpenVPN) is simpler and dependency-free but brittle — it breaks when addresses drift, and it *can't* separate two services that share an anycast front end. Knowing which tool fits which provider saves hours.
- **Shared anycast CDNs are a trap.** `chatgpt.com` sits behind Cloudflare's shared anycast — the same `/24`s serve thousands of unrelated sites. Route the whole block and you've quietly tunneled a chunk of the internet. Route too narrowly and it breaks tomorrow. You have to *understand* that the IP doesn't belong to OpenAI to make a sane call.

![Route a shared /24, scoop unrelated traffic — a net catches the one IP you wanted along with 254 strangers sharing the block](/assets/img/6a15b83413ab.jpg)

- **Knowing who owns an IP block matters.** `160.79.104.0/21` is Anthropic's own allocation (AS399230). That's a stable, safe thing to route wholesale. A `34.36.x` Google Cloud load-balancer front-end in front of the same service is *not* — it's shared infrastructure. A quick `whois` or a look at the AS tells you which is which.
- **Split tunneling is precision, and precision is how you stay out of trouble.** This circles back to the disclaimer. The reason I route the *narrowest* set of hostnames isn't tidiness — it's that the less unrelated traffic I push through an exit node, the less I look like abuse, and the less I disrupt the security-sensitive services (banking, corporate SSO) that legitimately *don't* want to see me arriving from a VPN IP. Full-tunneling everything is both lazier and riskier.

None of this requires Cisco's CCNA certification. But it does require knowing what a routing table is, what a CIDR block means, what an autonomous system is, and why DNS resolution is a separate concern from packet routing. That foundation turns "I copied a VPN config off the internet and hope it works" into "I know exactly what traffic goes where, and why."

## Takeaways

- **Cloudflare's Application Library is an underrated domain-discovery tool** — queryable over a simple REST endpoint, no scraping required.
- **Pick your routing primitive to match the provider:** hostname-based for shared CDNs and IP-churning services, IP-CIDR for providers on their own stable address space.
- **Scope narrowly on purpose.** It's better for performance, it keeps your other services unaffected, and it keeps you on the right side of a provider's abuse heuristics.
- **And stay legitimate.** Use this for what you're already entitled to use — careful routing, continuity while traveling — not for getting around restrictions that apply to you.

If you want the runnable code, the three repos are linked at the top. The Cloudflare helper and the full resolve-and-aggregate pipeline live in [`scripts/update_ips.py`](https://github.com/gabrielkoo/wireguard-configs-for-ai-services/blob/main/scripts/update_ips.py).

## One last thing: the legitimate-use note

I parked this for the end so it didn't bog down the technical walkthrough, but it matters. This whole approach is for one specific, legitimate situation: **your account and home region are permitted to use the service, and you want precise control over your own traffic.** Two concrete cases:

- **You want to route carefully — or deliberately NOT route — to avoid tripping a provider's fraud/abuse checks.** Many platforms run security engines (WAFs, fraud-detection, bot management) that get nervous when a logged-in session suddenly appears from a datacenter IP or a region that doesn't match your account history. This cuts both ways: the same is true of *other* services you use — banks especially — which actively distrust traffic arriving from a VPN exit IP. Precision routing is as much about *keeping the wrong traffic off the tunnel* as putting the right traffic on it.
- **You normally reside in a region that's allowed to use the service, but you're temporarily somewhere it isn't reachable** — a short work trip, a conference, a layover. You're a legitimate user of an unrestricted region who wants continuity of access you're already entitled to.

What this is **not** for: evading a restriction that legitimately applies to you. If your account or country of residence isn't permitted to use a service, a VPN config doesn't change that, and nothing here is an invitation to break a provider's Terms of Service or your local law. **Read the ToS, respect it, and when in doubt don't.** I keep my configs scoped as narrowly as possible precisely *because* the goal is to not look like abuse traffic.

## Sources

- [Cloudflare — Application Library (Cloudflare One docs)](https://developers.cloudflare.com/cloudflare-one/team-and-resources/app-library)
- [Cloudflare — REST API reference](https://developers.cloudflare.com/api/)
- [Tailscale — App connectors](https://tailscale.com/kb/1281/app-connectors)
- [Cloudflare — published IP ranges](https://www.cloudflare.com/ips/) · [Google Cloud — published IP ranges](https://www.gstatic.com/ipranges/cloud.json) · [ARIN RDAP — `160.79.104.0/21` (Anthropic, PBC)](https://rdap.arin.net/registry/ip/160.79.104.0)
- Companion repos: [Tailscale](https://github.com/gabrielkoo/tailscale-config-for-ai-services) · [WireGuard](https://github.com/gabrielkoo/wireguard-configs-for-ai-services) · [OpenVPN](https://github.com/gabrielkoo/openvpn-configs-for-ai-services)
