Skip to content

Repository files navigation

thorngate logo

thorngate

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_action can switch this to an instant drop or a plain 403.
  • Resolves the real external IP from Cloudflare's Cf-Connecting-Ip header.
  • Persists the blacklist to disk so it survives restarts.

Standard library only — no modules to fetch, go build works offline. Default listen port: 8765.

Request flow

Internet → Cloudflare → cloudflared (tunnel) → thorngate (this) → your app(s)
  1. Read client IP from Cf-Connecting-Ip.
  2. If IP is blacklisted → tarpit (default; or drop/403, per block_action).
  3. If the path matches a honeypot → blacklist the IP, persist, tarpit.
  4. Otherwise proxy to the upstream with the longest matching path prefix.

/healthz is reserved for k8s probes (never proxied, never a honeypot).

Repository layout

.
├── 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

Config (config.json)

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

Whitelist (whitelist)

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

Honeypot matching

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

Block action (block_action)

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.

Routing

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:3000http://10.0.0.5:3000).
  • host matches the request Host header (case-insensitive, port ignored).
  • A leading *. is a wildcard: *.example.com matches a.example.com and a.b.example.com, but not the apex example.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.

Temporary bans (temp_ban)

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_ban block to disable it (zero overhead — responses aren't even wrapped).

Request history on blacklist (request_log)

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 the ttl interval, 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.

Traffic stats (stats)

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 last save_interval of 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) and GET /admin/stats/recent (the paginated 24-hour feed) — same bearer token as the rest of the admin API.

Admin portal (admin)

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 stats above), 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.

thorngate admin dashboard

"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 in credentials_file (default admin_credentials.json, relative to the working directory — point it at a persistent volume like /data/admin_credentials.json so 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.
  • token is an optional legacy bearer token for scripted/API access, accepted alongside interactive login. Leave it empty (or set it via THORNGATE_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.4

Rebuilding the portal

The 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 :9000

Run locally

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

CI / release

  • ci.yml runs go vet, go build, and go test on every push/PR.
  • release.yml builds a multi-arch (amd64 + arm64) image and pushes it to GHCR on a vX.Y.Z tag (or manual dispatch). Image: ghcr.io/<owner>/<repo>. Tag and push to publish:
git tag v0.1.0 && git push origin v0.1.0

Deploy to k3s

Edit the image ref and your upstream service names in deploy/k3s/thorngate.yaml, then:

kubectl apply -f deploy/k3s/thorngate.yaml

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

Point cloudflared at it

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

cloudflared automatically sets Cf-Connecting-Ip, which is how thorngate identifies the real client.

Security notes

  • Keep the Service ClusterIP-only. Never expose it via LoadBalancer/Ingress. The Cf-Connecting-Ip header is trusted, so anything that can reach thorngate directly could spoof it. Because only the in-cluster cloudflared can reach a ClusterIP service, the header is trustworthy in this topology.
  • Always whitelist your own admin IP and internal CIDRs so you can't lock yourself out.

Scaling beyond one replica

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.

About

A tiny, zero-dependency Go reverse-proxy WAF that sits behind a Cloudflare Tunnel, honeypot-blacklisting intruders and routing traffic to your internal services.

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages