DEV Community

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

Posted on

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 services — Tailscale app connectors, WireGuard, and OpenVPN. 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

The shortcut: Cloudflare's Application Library

Cloudflare's Zero Trust product ships an Application 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

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.

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

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))
Enter fullscreen mode Exit fullscreen mode

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) 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:

{
  "service": "claude",
  "application": "Claude",
  "hostnames": [
    "a-api.anthropic.com",
    "a-cdn.anthropic.com",
    "anthropic.com",
    "claude.ai",
    "claude.com"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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):

{
  "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"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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, 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 open the change.

The whole thing is one stdlib Python script (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

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:

# 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
Enter fullscreen mode Exit fullscreen mode

Every one of those is shared Cloudflare anycast104.18.x, 172.64.x, 172.66.x are Cloudflare's, not OpenAI's. Route them and you're routing a slice of Cloudflare's entire customer base, and the specific /24s 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:

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
Enter fullscreen mode Exit fullscreen mode

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 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

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

  • 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 /24s 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

  • 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.

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

Top comments (0)