A tiny, zero-dependency Go reverse-proxy WAF that sits behind a Cloudflare Tunnel and in front of your web/API services — a gate at the mouth of the tunnel that snags intruders. It:
- Reverse-proxies configured path prefixes → internal upstreams (k8s services or raw IPs), including WebSocket / SignalR (protocol-upgrade) connections.
- Treats configured patterns as honeypots. Any external IP that matches one is blacklisted instantly and ghosted on every future request — by default the connection is tarpitted (held open, never answered) and then dropped;
block_actioncan switch this to an instant drop or a plain403. - Resolves the real external IP from Cloudflare's
Cf-Connecting-Ipheader. - Persists the blacklist to disk so it survives restarts.
Standard library only — no modules to fetch, go build works offline. Default listen port: 8765.
Internet → Cloudflare → cloudflared (tunnel) → thorngate (this) → your app(s)
- Read client IP from
Cf-Connecting-Ip. - If IP is blacklisted → tarpit (default; or drop/403, per
block_action). - If the path matches a honeypot → blacklist the IP, persist, tarpit.
- Otherwise proxy to the upstream with the longest matching path prefix.
/healthz is reserved for k8s probes (never proxied, never a honeypot).
.
├── cmd/thorngate/ # main entrypoint
├── internal/
│ ├── config/ # config load + honeypot matchers (+ tests)
│ ├── blacklist/ # thread-safe, file-persisted blocklist
│ ├── monitor/ # per-IP sliding-window strike counter (temp bans)
│ ├── history/ # bounded per-IP request history (dumped on ban)
│ ├── stats/ # in-memory traffic counters for the dashboard
│ ├── auth/ # admin credential store (PBKDF2) + sessions
│ ├── admin/ # admin API + embedded React portal (internal/admin/dist)
│ └── proxy/ # IP extraction, honeypot check, host routing
├── web/ # React + Vite source for the portal (built into admin/dist)
├── deploy/k3s/ # k3s manifests (ConfigMap, PVC, Deployment, Service)
├── .github/workflows/ # ci.yml (build/vet/test) + release.yml (GHCR image)
├── Dockerfile # distroless, multi-arch
└── config.json # example config
| field | meaning |
|---|---|
listen |
listen address, default :8765 |
client_ip_header |
header with the real IP — Cf-Connecting-Ip for Cloudflare |
blacklist_file |
where to persist blocked IPs (mount a volume in k8s) |
whitelist |
IPs never blacklisted (your own IP, internal ranges) — see below |
honeypots |
patterns that trigger an instant blacklist (see below) |
block_action |
what blocked clients get: tarpit (default, never respond), drop, or forbidden (plain 403) — see below |
tarpit_duration |
max time a tarpit connection is held, default 100s |
tarpit_max |
max connections held by the tarpit at once (overflow is dropped instead), default 512 |
upstream |
default internal target for all traffic (IP / host:port / URL) |
routes |
optional host → upstream overrides (see Routing below) |
temp_ban |
optional auto-ban for too many bad responses (see below) |
admin |
optional login-protected admin portal + API on a separate port (see below) |
request_log |
per-IP request history dumped to the log on blacklist (on by default, see below) |
stats |
in-memory traffic counters for the admin dashboard (on by default, see below) |
IPs listed here are never blacklisted — not by a honeypot hit, not by a temp-ban, and not even if they fall inside a banned CIDR range. Whitelist your own admin IP and internal ranges so you can't lock yourself out.
Each entry is either a bare string or an object. The address may be a single IP, a CIDR, or an octet wildcard:
"whitelist": [
"9.9.9.9", // single IP
"107.214.211.0/24", // CIDR — the standard way to whitelist a range
"107.214.211.*", // wildcard sugar — same as 107.214.211.0/24
"107.214.*", // wildcard for a /16
{ "ip": "10.0.0.0/8", "no_log": true } // object form: also keep this range out of logs
]A range like 107.214.211.0/24 covers 107.214.211.0–107.214.211.255. The wildcard forms are just shorthand: 107.214.211.* → /24, 107.214.* → /16, 107.* → /8 (the *s must be the trailing octets).
Set no_log on an entry (object form) to also exclude its traffic from the stats dashboard and request history entirely. Use it for your own health checks / uptime monitors that would otherwise flood the live feed. A no_log IP is proxied straight through with no response wrapping, so it adds zero per-request overhead.
A honeypot is either a bare string (prefix match) or an object with a match mode:
"honeypots": [
"/wp-admin", // prefix (boundary-aware)
{ "pattern": ".php", "match": "contains" }, // block ANY path containing .php
{ "pattern": ".env", "match": "suffix" }, // any path ending in .env
{ "pattern": "/cgi-bin/*", "match": "glob" }, // shell-style glob (path.Match)
{ "pattern": "\\.(git|svn|hg)(/|$)", "match": "regex" }
]match |
semantics |
|---|---|
prefix (default) |
path starts with pattern, on a / boundary (/api ≠ /apixyz) |
contains |
pattern appears anywhere in the path — e.g. .php |
suffix |
path ends with pattern |
glob |
path.Match against the full path (* does not cross / — use contains/suffix for "anywhere") |
regex |
Go regexp against the full path |
A blocked client (blacklisted IP or honeypot hit) is tarpitted by default: thorngate never responds at all, holding the connection open until the client gives up or tarpit_duration elapses, then dropping it. Set block_action to change what blocked clients get:
"block_action": "tarpit", // "tarpit" (default) | "drop" | "forbidden"
"tarpit_duration": "100s", // tarpit only: max hold per connection
"tarpit_max": 512 // tarpit only: max connections held at once| action | behavior |
|---|---|
tarpit (default) |
never respond: hold the connection open until the client gives up or tarpit_duration elapses, then drop it |
drop |
close the connection immediately without writing any response |
forbidden |
respond with a plain 403 Forbidden |
tarpit is the nastiest for scanners — their worker sits waiting on a socket that will never answer, while on thorngate's side the held request is just one parked goroutine doing no work. Two caps bound the cost: tarpit_duration (default 100s, roughly Cloudflare's own origin timeout) limits how long each connection is held, and tarpit_max (default 512) limits how many are held at once — past it, blocked requests are dropped immediately instead, so a flood can never exhaust file descriptors.
Requests denied without a response show up in the admin dashboard's request feed with tarpit or dropped in the Status column instead of a status code (a tarpit that overflowed tarpit_max records as dropped, since that's what actually happened).
Behind Cloudflare the attacker never sees raw silence — the connection thorngate drops belongs to cloudflared, so Cloudflare converts a drop into an immediate 502-style error page and a tarpit into a 524 after its ~100s origin timeout. The 403 fingerprint is hidden either way, and tarpit still stalls the scanner for the full wait.
All traffic is proxied to the single default upstream. routes are optional hostname overrides — a request whose Host matches a route goes to that route's upstream, everything else falls through to the default.
"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" }
]- Upstream values accept an IP, host:port, or full URL — scheme defaults to
http(10.0.0.5:3000→http://10.0.0.5:3000). hostmatches the requestHostheader (case-insensitive, port ignored).- A leading
*.is a wildcard:*.example.commatchesa.example.comanda.b.example.com, but not the apexexample.com(add an explicit route for that).
If you only need a single backend, just set upstream and omit routes entirely.
Protocol upgrades (WebSocket, SignalR) pass through transparently — thorngate hijacks the connection and hands it to the upstream, so long-lived bidirectional streams work without extra config. Upgraded connections are recorded as a 101 status for temp-ban/history accounting.
Honeypots are an instant, permanent ban. temp_ban is the softer, optional layer: it watches the response codes your upstreams return and temporarily blacklists an IP that produces too many bad ones — e.g. a scanner racking up 404s.
"temp_ban": {
"enabled": true,
"status_codes": [401, 403, 404, 429],
"max": 20,
"window": "1m",
"ban_duration": "15m"
}| field | meaning | default |
|---|---|---|
enabled |
turn the feature on | false |
status_codes |
which response codes count as "bad" | [401, 403, 404, 429] |
max |
bad responses allowed within window before a ban |
20 |
window |
sliding counting window (Go duration) | "1m" |
ban_duration |
how long the temporary ban lasts (Go duration) | "15m" |
- Bans auto-expire after
ban_duration(lazily removed on the next request, and dropped on restart if already lapsed). - Whitelisted IPs are never temp-banned; a honeypot (permanent) ban always outranks a temporary one.
- Omit the whole
temp_banblock to disable it (zero overhead — responses aren't even wrapped).
When an IP trips a honeypot (or a temp-ban), the requests it made just before getting blocked are often the most useful signal — the recon and probing that led up to the trap. Thorngate keeps a short, in-memory ring buffer of the last few requests each client IP made while it was still allowed through, and dumps them to the log the moment that IP is blacklisted:
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 at=2026-06-14T12:34:55Z method=GET host="app.example.com" path="/" query="" upstream="http://10.0.0.5:8080" status=200 ua="curl/7.64.1"
history ip=9.9.9.9 reason=honeypot 2/3 at=2026-06-14T12:34:56Z method=GET host="app.example.com" path="/robots.txt" query="" upstream="http://10.0.0.5:8080" status=404 ua="curl/7.64.1"
history ip=9.9.9.9 reason=honeypot 3/3 at=2026-06-14T12:34:57Z method=GET host="app.example.com" path="/.env" query="" upstream="http://10.0.0.5:8080" status=404 ua="curl/7.64.1"
It is on by default with sensible bounds — disable or tune it with a request_log block:
"request_log": {
"disabled": false,
"depth": 10,
"max_ips": 4096,
"ttl": "15m"
}| field | meaning | default |
|---|---|---|
disabled |
turn the feature off entirely (zero overhead) | false |
depth |
how many recent requests to keep per IP | 10 |
max_ips |
cap on distinct IPs tracked; least-recently-active is evicted past this | 4096 |
ttl |
drop history for IPs idle longer than this (Go duration) | "15m" |
- Every request that reaches an upstream is recorded (any response code), so probing that returned
404/401— usually the telling part — is captured too. The honeypot request itself isn't proxied, so it never appears in its own history. - Memory is bounded on both axes (
depth×max_ips) and idle IPs are swept on thettlinterval, so a flood of distinct sources can't exhaust memory. An IP's history is freed immediately once it's been logged. - History lives in memory only — it is not persisted to the blacklist file; restart and it's gone.
thorngate keeps lightweight in-memory counters so the admin Dashboard can show traffic at a glance: total requests, requests blocked by the blacklist, honeypot bans, temp-bans, a 2xx/3xx/4xx/5xx breakdown of proxied responses, and total data sent (response bytes written to clients by proxied requests), plus a per-minute requests-vs-blocked chart and a paginated feed of the last 24 hours of requests (time, IP, method, host, path + query, the upstream it was routed to, status, bytes sent, and outcome — proxied / blocked / honeypot). The host and upstream columns make it easy to spot routing problems — e.g. a hostname that isn't matching its route and falling through to the default upstream.
It is on by default — the headline totals are lock-free atomics and the time series is a small per-minute ring buffer, so the overhead is a few increments per request. Disable or tune it with a stats block:
"stats": {
"disabled": false,
"window_minutes": 60,
"recent_requests": 5000,
"file": "/data/stats.json",
"save_interval": "1m"
}| field | meaning | default |
|---|---|---|
disabled |
turn the feature off entirely (zero overhead; the dashboard reports stats as off) | false |
window_minutes |
how many minutes the traffic-over-time chart covers | 60 |
recent_requests |
cap on how many recent requests the 24-hour request feed can hold (~1 MB at the default); set negative to omit the feed | 5000 |
file |
when set, counters + series + the recent-requests feed are saved here periodically (and on graceful shutdown) and loaded back at startup, so the dashboard survives restarts. Point it at a persistent volume. Empty keeps stats memory-only | (empty) |
save_interval |
how often the stats file is rewritten (Go duration); ignored when file is empty |
"1m" |
- Without
file, counters live in memory only and reset to zero on restart. With it, at most the lastsave_intervalof traffic is lost on a crash (a graceful shutdown saves on exit). Saving happens on a background timer, never on the request path. - Read them programmatically via
GET /admin/stats(counters + series) andGET /admin/stats/recent(the paginated 24-hour feed) — same bearer token as the rest of the admin API.
An optional admin server on a separate port hosts the portal: a React single-page app (embedded into the binary via go:embed, so no CDN and no runtime dependencies) plus the JSON API it talks to. It lets you view traffic and add/remove blocked IPs live — changes hit the in-memory store and are persisted immediately, no restart. Three tabs:
- Dashboard — traffic stats (see
statsabove), a per-minute requests-vs-blocked chart, and a paginated feed of the last 24 hours of requests. An IP with more than 5 requests in the window gets a count badge, and each row has a Ban button (permanent blacklist, one click) plus a Details button that opens the full request (host, path, query, resolved upstream, status, bytes sent, outcome). - Blacklist — add/remove bans (single IP or CIDR).
- Settings — change the admin password.
"admin": {
"enabled": true,
"listen": ":9000",
"credentials_file": "/data/admin_credentials.json",
"token": "optional-legacy-api-token"
}- Login. The portal authenticates with a username + password. A fresh install seeds
admin/admin— sign in and change the password from the Settings tab immediately. The username and a salted PBKDF2-HMAC-SHA256 hash of the password are stored incredentials_file(defaultadmin_credentials.json, relative to the working directory — point it at a persistent volume like/data/admin_credentials.jsonso the change survives restarts). thorngate refuses to start if this file can't be written, and a password change that can't be saved to disk fails with an error instead of silently living in memory only. Sessions are opaque tokens held in memory with a sliding 12-hour TTL; they do not survive a restart. tokenis an optional legacy bearer token for scripted/API access, accepted alongside interactive login. Leave it empty (or set it viaTHORNGATE_ADMIN_TOKEN) to disable it and rely solely on login.- Keep this port cluster-internal — never attach it to the Cloudflare tunnel. In k3s it's a separate ClusterIP
Service(thorngate-admin); reach it with port-forward.
kubectl -n thorngate port-forward svc/thorngate-admin 9000:9000
# then open http://localhost:9000/ and sign in (default admin / admin)API (session token from /admin/login, or the legacy bearer token, sent as Authorization: Bearer <token>):
| method | path | body | action |
|---|---|---|---|
POST |
/admin/login |
{"username":"...","password":"..."} |
log in → {"token":"...","username":"..."} |
POST |
/admin/logout |
— | invalidate the current session |
GET |
/admin/me |
— | current username (validates the session) |
POST |
/admin/password |
{"current_password":"...","new_password":"..."} |
change the admin password |
GET |
/admin/blacklist |
— | list all entries (JSON) |
POST |
/admin/blacklist |
{"ip":"1.2.3.4","reason":"..."} |
permanently ban an IP or CIDR range |
DELETE |
/admin/blacklist/{key} |
— | unban an IP or CIDR range |
GET |
/admin/stats |
— | traffic counters + per-minute series (JSON; {"enabled":false} if stats are off) |
GET |
/admin/stats/recent?page=1&page_size=50 |
— | last 24h of requests, paginated newest-first (page_size max 200), with per-IP request counts for the returned page |
GET |
/ (+ assets) |
— | the React portal |
The ban key may be a single IP (1.2.3.4) or a CIDR range (1.2.3.0/24) — a range bans every address it contains, which is useful when an attacker rotates through a subnet. A whitelisted IP is never blocked, even if it falls inside a banned range.
# Log in and reuse the session token, or set TOKEN to your legacy admin.token.
TOKEN=$(curl -s -d '{"username":"admin","password":"admin"}' localhost:9000/admin/login | sed 's/.*"token":"\([^"]*\)".*/\1/')
curl -H "Authorization: Bearer $TOKEN" localhost:9000/admin/blacklist
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"}' localhost:9000/admin/blacklist
curl -H "Authorization: Bearer $TOKEN" -X DELETE localhost:9000/admin/blacklist/1.2.3.4The built app lives in internal/admin/dist/ and is committed, so go build needs no Node toolchain. To change the UI, edit the source under web/ and rebuild:
cd web
npm install # first time only
npm run build # type-checks, then builds straight into internal/admin/dist/
npm run dev # optional: Vite dev server on :5173, proxying /admin to :9000go build -o thorngate ./cmd/thorngate
./thorngate -config config.json # listens on :8765
# simulate a Cloudflare request that trips the ".php contains" honeypot
# (default block_action is tarpit — curl hangs until tarpit_duration, then the
# connection drops with no response; set "block_action": "forbidden" for a 403)
curl -m 5 -H "Cf-Connecting-Ip: 9.9.9.9" http://localhost:8765/x/shell.php # hangs, IP now blocked
curl -m 5 -H "Cf-Connecting-Ip: 9.9.9.9" http://localhost:8765/ # ghosted forever
go test ./... # exercises the matchersci.ymlrunsgo vet,go build, andgo teston every push/PR.release.ymlbuilds a multi-arch (amd64 + arm64) image and pushes it to GHCR on avX.Y.Ztag (or manual dispatch). Image:ghcr.io/<owner>/<repo>. Tag and push to publish:
git tag v0.1.0 && git push origin v0.1.0Edit the image ref and your upstream service names in deploy/k3s/thorngate.yaml, then:
kubectl apply -f deploy/k3s/thorngate.yamlThe blacklist is stored on a PersistentVolumeClaim (k3s local-path default StorageClass). Runs as a single replica with the Recreate strategy because the store is in-memory + a local file. The image is multi-arch, so it runs on amd64 and arm64 (e.g. Raspberry Pi) nodes.
ingress:
- hostname: example.com
service: http://thorngate.thorngate.svc.cluster.local:80
- service: http_status:404cloudflared automatically sets Cf-Connecting-Ip, which is how thorngate identifies the real client.
- Keep the Service
ClusterIP-only. Never expose it via LoadBalancer/Ingress. TheCf-Connecting-Ipheader is trusted, so anything that can reach thorngate directly could spoof it. Because only the in-clustercloudflaredcan reach a ClusterIP service, the header is trustworthy in this topology. - Always
whitelistyour own admin IP and internal CIDRs so you can't lock yourself out.
The blacklist is per-pod (in-memory + local file). To run multiple replicas, replace the file store in internal/blacklist with a shared backend (Redis SET/SISMEMBER is a ~30-line swap) so all pods see the same blocklist.
