RoboDodd

Thorngate: A Tiny Go WAF That Guards Your Cloudflare Tunnel

Thorngate is a zero-dependency Go WAF for Cloudflare Tunnels: honeypots, IPv6 /64 bans, weeks of searchable request history and an admin portal to manage it.

Neon isometric gate guarding the mouth of a dark data tunnel, in magenta and cyan
WAF 16 min read

If you self-host anything behind a Cloudflare Tunnel, you’ve seen the logs. The moment a hostname goes public, the bots show up: probes for /wp-admin, requests for .env files, /cgi-bin/ shell attempts, .git directory scraping. None of it is aimed at you specifically — it’s the internet’s background radiation, automated scanners sweeping every IP they can reach. Most of it bounces off a 404, but it’s noisy, it wastes cycles, and occasionally one of those probes finds something you forgot to lock down.

I wanted a small, dumb gate that sits right at the mouth of my tunnel and slams the door on anyone who reaches for a doorknob that shouldn’t exist. So I built Thorngate.

Updated September 2026 for v0.12.0. Thorngate now keeps weeks of searchable request history on disk, ships a rebuilt admin portal with IP search and one-click bans, bans IPv6 scanners by their whole /64, escalates repeat offenders to permanent bans, reloads its config live, and exposes Prometheus metrics. This post has been rewritten to cover all of it.

What is Thorngate?

Thorngate is a tiny, zero-dependency, open-source Go reverse-proxy WAF (Web Application Firewall) that sits behind a Cloudflare Tunnel and in front of your web and API services. Think of it as a gate at the mouth of the tunnel that snags intruders before they ever reach your apps.

The idea is deliberately simple. Thorngate reverse-proxies all of your traffic to a default upstream — a Kubernetes service, a raw IP, whatever you point it at — with optional per-hostname overrides. But it also treats a list of configured patterns as honeypots. Any external IP that reaches for one of those patterns is instantly and permanently blacklisted, and from then on Thorngate stops answering it at all. The blacklist is persisted to disk, so it survives restarts.

A scanner that asks for /wp-admin on a site that has never run WordPress has told you everything you need to know about its intentions. So Thorngate stops talking to it. Forever.

The proxy itself is roughly 4,800 lines of Go (plus about 2,500 lines of tests), built entirely on the standard library — no external modules, no supply chain to worry about, and go build works completely offline. The admin portal is a small React app that’s compiled ahead of time and embedded into the binary, so there’s still exactly one file to deploy.

Where it fits

The request path looks like this:

Internet
  → Cloudflare
    → cloudflared (tunnel)
      → thorngate (WAF)
        → your app(s)

And the logic each request runs through is just four steps:

  1. Read the client IP from the Cf-Connecting-Ip header.
  2. If that IP (or a range containing it) is blacklisted → deny it, with no upstream contact.
  3. If the path matches a honeypot → blacklist the IP, persist it, deny it.
  4. Otherwise, proxy to the route whose hostname matches, or to the default upstream.

That’s the entire mental model. Everything else — temp-bans, request history, the admin portal — is optional sugar layered on top of those four steps.

The one security assumption

Thorngate trusts the Cf-Connecting-Ip header to identify the real client. That trust is the load-bearing assumption of the whole design, and it only holds because the Thorngate Service is ClusterIP-only — reachable solely by the in-cluster cloudflared pod and never exposed directly. If you put Thorngate behind a LoadBalancer or Ingress, anyone could spoof that header and either dodge the blacklist or get someone else banned. Keep it internal and the assumption holds.

Honeypots: the core feature

Honeypots are the heart of Thorngate. You define a list of path patterns that no legitimate visitor would ever request, and anyone who does is blacklisted on the spot. There are five match modes so you can be as broad or as surgical as you like:

"honeypots": [
  "/wp-admin",                                            // prefix (boundary-aware)
  { "pattern": ".php",        "match": "contains" },      // anywhere in the path
  { "pattern": ".env",        "match": "suffix"   },      // ends with
  { "pattern": "/cgi-bin/*",  "match": "glob"     },      // shell-style glob
  { "pattern": "\\.(git|svn|hg)(/|$)", "match": "regex" } // full Go regexp
]

A few details I care about:

  • Prefix matching is boundary-aware. /api matches /api and /api/users, but it does not match /apixyz, so a legitimate route can’t get swept up because it happens to share a string prefix with a honeypot.
  • Matching ignores case and checks the cleaned path. Scanners love /WP-Admin, //wp-admin, /x/../wp-admin and (for IIS backends) backslash variants, because your upstream will happily resolve all of them to /wp-admin. Thorngate resolves them the same way before matching.
  • It won’t let you shoot yourself in the foot. A honeypot that would match the site root — an empty pattern, a bare /, a .* regex — is rejected at startup rather than banning every visitor on their first request.

The honeypot request itself is never proxied to your upstream — there’s no reason to bother your app with a request you’ve already decided to reject — so it never even shows up in your app’s logs.

Ghosting, not 403s

The original version answered blocked clients with a polite 403 Forbidden. That still tells a scanner “something is here, and it noticed you.” These days the default is meaner: a tarpit. Thorngate accepts the request and then simply never answers, holding the connection open until the client gives up or the tarpit timer runs out (100 seconds by default, roughly Cloudflare’s own origin timeout), then drops it. Every blocked request costs the scanner a stalled connection.

"block_action": "tarpit",     // or "drop" (close immediately) or "forbidden" (plain 403)
"tarpit_duration": "100s",
"tarpit_max": 512

tarpit_max caps how many connections can be held at once, so an attacker who notices the tarpit can’t turn it around and exhaust your file descriptors — past the cap, blocked requests are dropped instantly instead. Held connections are released the moment the server starts shutting down, so a deploy never waits on them.

IPv6: ban the whole /64

This one came straight out of my own logs. An IPv6 scanner hit a honeypot, got banned — and its very next request arrived from the same address with a different last digit. An IPv6 client almost always controls an entire /64 (that’s 2⁶⁴ addresses) and can hop between them at will, so banning the single address that tripped the wire achieves nothing.

So Thorngate now bans the client’s whole network. A honeypot hit from 2001:db8:bad:7::2 bans 2001:db8:bad:7::/64, and temp-ban strikes are counted per /64 as well, so a scanner rotating through its addresses adds up as one client instead of many polite ones.

"ipv6_ban_prefix": 64   // the default; 128 bans single addresses, 56 or 48 is more aggressive

IPv4 bans stay single-address. A whitelisted IPv6 client never gets its range banned on its own account, and it stays reachable even if its range gets banned because of someone else. Range bans are cheap, too: they’re grouped by prefix length, so checking an address costs one map lookup per prefix length no matter how many ranges are banned.

Temporary bans for the slow scanners

Not every bad actor trips a honeypot. Some just hammer your endpoints looking for weak spots, generating a stream of 401s and 404s. For those, Thorngate has an optional soft layer: temp-bans.

"temp_ban": {
  "enabled": true,
  "status_codes": [401, 403, 404, 429],
  "max": 20,
  "window": "1m",
  "ban_duration": "15m",
  "escalate_after": 3,
  "escalate_window": "24h"
}

That config says: if a single IP (or IPv6 /64) racks up 20 responses with those status codes inside a one-minute sliding window, ban it for 15 minutes. Expired bans are cleaned up on the IP’s next request and by a once-a-minute sweep, so they don’t linger in the blacklist.

The last two lines are new. Patient scanners learned to wait out a 15-minute ban and pick up where they left off, so escalation closes that loop: get temp-banned three times within 24 hours and the ban becomes permanent, recorded as a repeat-offender. Escalation is off by default; set escalate_after to turn it on.

Honeypots are the permanent hammer; temp-bans are the rate-limiter for the patient ones — and now the patient ones run out of patience before Thorngate does.

Forensics: request history on ban

When Thorngate blacklists an IP, it dumps that IP’s recent request history to the log so you can see exactly what the attacker was doing right before the door closed:

BLACKLISTED ip=9.9.9.9 honeypot=/wp-admin ua="curl/7.64.1" total=6
  history ip=9.9.9.9 reason=honeypot 1/3 method=GET host="app.example.com" path="/"          status=200
  history ip=9.9.9.9 reason=honeypot 2/3 method=GET host="app.example.com" path="/robots.txt" status=404
  history ip=9.9.9.9 reason=honeypot 3/3 method=GET host="app.example.com" path="/.env"       status=404

That log dump uses a small per-IP ring buffer, with a configurable depth, a cap on how many distinct IPs are tracked, and a TTL that drops idle entries. It’s there for live forensics in your log stream. For digging through what happened last week, there’s now a proper request history — keep reading.

The admin portal

This is the part that changed the most. The original dashboard was a single page of counters behind a pasted API token. It’s now a real admin portal: a React single-page app compiled ahead of time and embedded into the Go binary with go:embed, so there’s no CDN, no runtime Node, and still just one file to deploy. It runs on a separate port that you keep cluster-internal and reach with a port-forward.

Thorngate admin portal overview with request counters, status mix, a 7-day traffic chart and top IPs and paths
The Overview tab: counters, the status mix, 7 days of traffic from the on-disk archive, and the top IPs and paths

The Overview shows the headline numbers (requests, blocked, bans issued, data sent), a bar of proxied responses by status class, and a traffic chart. The chart shows the last hour minute by minute, or the last 24 hours, 7 days or 30 days by the hour; hover over it for exact counts. Underneath are the busiest IPs and paths for the same range. Click an IP to profile it, or click a path to search every request for it.

Searching requests

The Requests tab is the log I actually wanted when I was staring at raw pod logs. You can filter by a single IP or a CIDR range, by free text (matching the path, host, query string or user agent), by outcome, by status class and by time range. Each row shows the client’s country (from Cloudflare’s Cf-Ipcountry header), a request-count badge on chatty IPs, and a banned marker once an IP has been blocked. Only the first page refreshes live, so the rows don’t shift under you while you read older pages.

Thorngate Requests tab listing recent requests with country codes, status, outcome and banned markers
The Requests tab: every request with its country, status and outcome, and search across all of it

Filtering by range is where it earns its keep. Here’s that IPv6 scanner from earlier, filtered to its /64: five different addresses, one request each. The first / got through, the /wp-login.php probe tripped the honeypot, and everything after it was denied.

Requests filtered to a single IPv6 /64 range showing five rotating scanner addresses
One scanner rotating through its /64: the first request got through, the honeypot hit banned the whole range

Profiling an IP

Click any IP anywhere in the portal, or type one into the search box in the header, and a panel slides out with everything Thorngate knows about it:

  • whether it’s banned or whitelisted, including when it’s covered by a range ban and which honeypot caught it
  • first and last seen
  • outcome and status breakdowns
  • its most-requested paths and user agent
  • its latest requests

From there it’s one click to unban it, to see all of its requests, or to see every request from its whole range.

Thorngate IP panel showing a client banned via its IPv6 range, its activity summary and latest requests
The IP panel for an address banned through its /64 range

Banning opens a small dialog where you pick a duration — 1 hour, 24 hours, 7 days, 30 days or permanent — and an optional reason. For an IPv6 address it offers to ban the whole /64, checked by default.

Thorngate ban dialog with duration options and a checkbox to ban the whole IPv6 /64
Banning from the portal: choose a duration and reason, and optionally the whole /64

Managing the blacklist

The Blacklist tab lists every active ban. You can search it, filter it to permanent bans, temporary bans or ranges, and sort it (including by which temporary bans expire soonest). Unbans ask for confirmation, and temporary bans show a live “expires in 12m” countdown.

There’s also export and import. Export downloads the whole blacklist as JSON. Import takes either a Thorngate export or any plain one-IP-per-line blocklist, which is the format most public blocklists use, and adds everything in a single disk write. Below the table is a read-only view of your whitelist, so you can see at a glance who’s exempt.

Thorngate Blacklist tab with a temp-ban countdown, a range ban, filters, export and import buttons and the whitelist
The Blacklist tab: filters, expiry countdowns, export and import, and the whitelist

Locked down by default

The portal uses a username and password, not a pasted token. A fresh install signs in with admin / admin, but until you change that password the portal is locked to a single set-a-new-password screen, and the API refuses everything else. That also applies to older installs that never got around to changing it.

Thorngate first sign-in screen requiring a new admin password
A fresh install stays locked until the default password is changed

Passwords are stored as salted PBKDF2-HMAC-SHA256 hashes, implemented on the standard library to keep the zero-dependency promise. After five failed logins, sign-in is refused for 30 seconds, and the wait doubles with each further failure up to 15 minutes. Changing your password signs out every other session. For scripts there’s still an optional static API token, which can come from the THORNGATE_ADMIN_TOKEN environment variable (a Kubernetes Secret in practice):

# sign in (or set TOKEN to your static API token)
TOKEN=$(curl -s -d '{"username":"admin","password":"your-password"}' localhost:9000/admin/login \
  | sed 's/.*"token":"\([^"]*\)".*/\1/')

# block an IP permanently, or a whole range for a week
curl -H "Authorization: Bearer $TOKEN" -d '{"ip":"1.2.3.4"}' localhost:9000/admin/blacklist
curl -H "Authorization: Bearer $TOKEN" -d '{"ip":"1.2.3.0/24","duration":"7d"}' localhost:9000/admin/blacklist

# import a public blocklist, then search a range's history
curl -H "Authorization: Bearer $TOKEN" --data-binary @blocklist.txt localhost:9000/admin/blacklist/import
curl -H "Authorization: Bearer $TOKEN" "localhost:9000/admin/requests?ip=2001:db8:bad:7::/64&range=7d"

# pardon an IP
curl -H "Authorization: Bearer $TOKEN" -X DELETE localhost:9000/admin/blacklist/1.2.3.4

Weeks of history without a database

The dashboard used to show only what fit in memory: the last few thousand requests. That’s fine for “what’s happening right now,” but useless for “did this IP poke at us last Tuesday?” I didn’t want to bolt a database onto a zero-dependency proxy, so the history lives in plain files:

"stats": {
  "archive": { "dir": "/data/requests", "retention_days": 30 }
}

Every request is appended as one JSON line to the current hour’s file. When the hour closes, that file is gzipped (JSON compresses roughly 8–10×) and a small index is written next to it, with request totals plus per-IP and per-path counts. Hours older than the retention period are deleted.

/data/requests/
  2026-09-23T14.jsonl      ← current hour, appended live
  2026-09-23T13.jsonl.gz   ← sealed hour
  2026-09-23T13.idx.json   ← its index

The indexes are what make it fast. Looking up an IP reads the small index files and only opens the hours that IP actually appears in, so a scanner seen once last week costs one file read, not a scan of 30 days. The top-IP lists and the long-range traffic chart come straight from the indexes without touching a single event.

A few guarantees I cared about:

  • The proxy never waits on the disk. Requests are handed to a single background writer through a queue. If the writer ever falls behind, events are dropped and counted rather than slowing down traffic.
  • A crash can’t lose a sealed hour. Sealing writes to temporary files and renames them into place, so an interrupted seal is simply redone at the next start. A half-written last line is skipped.
  • It’s small. An event is about 25 bytes once gzipped. A million requests a day comes to about 25 MB a day, and a typical homelab stays at a few MB for the whole month.

Metrics and live reload

Two operational features rounded out this release:

  • Prometheus metrics. /metrics on the admin port exposes the counters: requests, blocked requests, honeypot bans, temp-bans, escalations, responses by status class, bytes sent, blacklist size, connections currently held in the tarpit, and archive size and drops. It needs no login, because scrapers rarely carry credentials and it only exposes counters. You can switch it off in the config.
  • Live config reload. Send SIGHUP, press Reload config in Settings, or POST /admin/reload. Honeypots, the whitelist, the block action and tarpit settings, the header names and the IPv6 prefix all apply immediately without dropping a connection. Anything that genuinely needs a restart (ports, upstreams, routes) is reported back to you instead of being silently ignored. A config that fails to load is rejected, and the running one stays in place.
Thorngate Settings tab with password change and a Reload config button
Settings: change the password or reload the config without a restart

Config parsing is now strict too. A typo like "tempban" used to be silently ignored, quietly leaving a protection switched off. Now it’s an error at startup, which for a security tool is the behaviour you want.

Routing and protocol upgrades

Thorngate isn’t just a single-app gate. It has a default upstream for everything, plus optional hostname-based overrides with wildcard support:

"upstream": "10.0.0.10:8080",
"routes": [
  { "host": "api.example.com",            "upstream": "10.0.0.5:3000" },
  { "host": "*.internal.example.com",     "upstream": "10.0.0.6:9000" }
]

A *.example.com wildcard matches a.example.com and a.b.example.com, but deliberately not the bare apex example.com. And because plenty of real apps need WebSockets or SignalR, Thorngate transparently passes through protocol upgrades via Go’s http.Hijacker interface; upgraded connections are recorded as a 101 so they still show up in your stats. The request log records which upstream each request went to, which makes it easy to spot a hostname that isn’t matching its route.

Running it

The fastest way to try it is the published container image. Thorngate ships as a multi-architecture (amd64 + arm64) distroless image on GitHub Container Registry:

docker run -p 8765:8765 -p 9000:9000 \
  -v /path/to/config.json:/etc/thorngate/config.json \
  -v thorngate-data:/data \
  ghcr.io/timothydodd/thorngate:v0.12.0

The runtime image is gcr.io/distroless/static-debian12:nonroot — no shell, no package manager, running as a non-root user. The binary is built with CGO_ENABLED=0 and -ldflags="-s -w", so it’s a single static binary and the whole image is tiny.

If you’d rather build from source:

go build -o thorngate ./cmd/thorngate
./thorngate -config config.json   # proxy on :8765, admin portal on :9000 if enabled

# simulate a Cloudflare request tripping the ".php contains" honeypot.
# With the default tarpit, curl hangs until it gives up; set
# "block_action": "forbidden" in the config to see a 403 instead.
curl -m 5 -H "Cf-Connecting-Ip: 9.9.9.9" http://localhost:8765/x/shell.php   # IP now blocked
curl -m 5 -H "Cf-Connecting-Ip: 9.9.9.9" http://localhost:8765/             # ghosted forever

go test ./...

On Kubernetes

Since this is what I actually run it on, the repo ships a complete k3s manifest under deploy/k3s/. It sets up:

  • a namespace
  • a ConfigMap for the config
  • a Secret for the optional API token
  • a PersistentVolumeClaim, so the blacklist, credentials, stats and request history survive restarts (1Gi, which is plenty for a month of history)
  • two ClusterIP Services, one for traffic and one for the admin portal

It’s frugal: 25m CPU and 32Mi RAM requested.

kubectl apply -f deploy/k3s/thorngate.yaml

# reach the portal without exposing it
kubectl -n thorngate port-forward svc/thorngate-admin 9000:9000
# then open http://localhost:9000/ and sign in (first time: admin / admin, then set a real password)

Your cloudflared ingress then points at Thorngate instead of directly at your app:

ingress:
  - hostname: example.com
    service: http://thorngate.thorngate.svc.cluster.local:80
  - service: http_status:404

A few design decisions I’m happy with

Zero dependencies, on purpose. The proxy is standard-library Go. There’s no go.sum full of transitive packages to audit, nothing to fetch, and the build works on a plane. The portal’s JavaScript is built once and committed, so even building the binary needs no Node. For a piece of security infrastructure, the smaller the trust surface the better.

Persistence that can’t eat your blacklist. The blacklist is written with the write-temp → fsync → rename dance, and writes are serialized so two bans landing at the same moment can’t corrupt the file or roll it back. If the file on disk is ever unreadable, Thorngate moves it aside and says so loudly instead of quietly starting empty and overwriting it.

Zero overhead when features are off. If you don’t enable temp-bans, request history or stats, Thorngate skips wrapping the response writer entirely and proxies straight through. You only pay for the inspection you actually asked for.

Whitelist always wins. A whitelisted IP is never blocked: not inside a banned range, and not even if it was banned before you whitelisted it. Whitelist your own admin IP and your internal ranges first thing, so you can’t lock yourself out.

Scaling

The default is a single replica, because the blacklist lives in memory and in a local file on a ReadWriteOnce volume, and the request history lives on that same volume. That’s plenty for a homelab or a small fleet of services behind one tunnel. If you ever needed several replicas, the blacklist package is deliberately small: swapping its file store for a shared backend like Redis (SET / SISMEMBER) is a contained change, and then every pod would see the same blocklist.

Get Thorngate

Thorngate is open source under the MIT license, so use it, fork it, and bend it to your setup. Grab the code, read the release notes, or pull the container:

If you’re running services behind a Cloudflare Tunnel and you’re tired of watching the scanner noise roll past in your logs, point them at a honeypot list and let the gate do the rest. Then open the portal and watch them walk into it.