Block a path. Inject a header. Cap requests per minute. Three actions, per-tunnel, applied before traffic ever touches your local service. Denied requests don't burn your bandwidth; injected headers reach the upstream exactly like a regular client would set them.
A policy is JSON. It lives on the tunnel row in our database
and applies the next time a public request lands. The hot
path runs deny first (denied paths cost nothing),
then rate_limit, then header_set.
{
"actions": [
{ "kind": "deny", "path_prefix": "/admin" },
{ "kind": "rate_limit", "requests_per_minute": 60 },
{ "kind": "header_set", "name": "X-Forwarded-For",
"value": "203.0.113.42" }
]
} At the public HTTP edge — before the request opens an agent stream. That means:
Reject any request whose URI path starts with
path_prefix. Returns 403 with a fixed body.
Useful for putting /admin, /internal,
or /.git behind your firewall while still
exposing the rest of the app.
curl -X PUT -H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"actions":[{"kind":"deny","path_prefix":"/admin"}]}' \
https://login.21tunnel.com/api/tunnels/$TID/policy $ curl -i https://myapp.21tunnel.com/admin/users
HTTP/1.1 403 Forbidden
Content-Type: text/plain
forbidden by traffic policy Prefix matching, not glob. /admin denies /admin,
/admin/users, and /administrator.
If you want stricter, use a more specific prefix
(/admin/ with the trailing slash).
A path you explicitly denied is not "legitimate traffic" —
it's noise. Counting it against the per-minute budget would
mean an attacker hammering /admin could
exhaust the budget and rate-limit your real users. The
evaluator short-circuits deny before
rate_limit so this can't happen.
Add (or overwrite) a header on the request forwarded to your local service. Inject API keys, mark traffic as edge-trusted, or stamp a tenant tag without modifying your backend.
curl -X PUT -H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"actions":[
{"kind":"header_set","name":"X-Edge-Auth","value":"shh-its-me"}
]}' \
https://login.21tunnel.com/api/tunnels/$TID/policy GET /api/whatever HTTP/1.1
Host: myapp.21tunnel.com
X-Edge-Auth: shh-its-me
... rest of headers ...
The header is overwritten, not appended —
if the public client tries to set X-Edge-Auth
themselves, the policy's value wins. That prevents the
"smuggled header" footgun where the upstream sees two
values and picks unpredictably.
- + _, max 64 chars.header_set for the same name acts as the final write.
Fixed 60-second window. Once requests_per_minute
legitimate requests have been served in the current window,
further requests get 429 with
Retry-After: 60 until the window rolls.
At most one rate_limit action per policy.
curl -X PUT -H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"actions":[{"kind":"rate_limit","requests_per_minute":60}]}' \
https://login.21tunnel.com/api/tunnels/$TID/policy HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: text/plain
rate limit exceeded by traffic policy The bucket is keyed on tunnel ID. 60 / min means 60 requests across all public clients hitting that tunnel — not 60 per source IP. For per-IP rate-limiting inside your app, your own framework still has to do that work; the edge policy is for catching runaway scripts and obvious abuse.
The window resets at the 60-second boundary. That means a burst of 2× the cap is possible right at the rollover (last request of window N + first of N+1 in < 1 ms). We chose fixed-window because:
requests_per_minute capped at 60,000 (≈ 1,000 req/s).
Up to 16 actions per policy, evaluated in the order
deny → rate_limit →
header_set. Mix to taste.
{
"actions": [
{ "kind": "deny", "path_prefix": "/admin" },
{ "kind": "deny", "path_prefix": "/.git" },
{ "kind": "deny", "path_prefix": "/.env" },
{ "kind": "rate_limit", "requests_per_minute": 120 },
{ "kind": "header_set", "name": "X-Tunnel-Source",
"value": "21tunnel-edge" }
]
} That policy:
/admin, /.git, /.env — common bot-probe paths.X-Tunnel-Source: 21tunnel-edge so your local logs can identify proxied traffic.Three endpoints. Authenticate with your dashboard JWT or an API key on the owning org.
GET /tunnels/:id/policy Read the current policy (or null). PUT /tunnels/:id/policy Set or replace. Body is the policy JSON above. DELETE /tunnels/:id/policy Drop the policy. Idempotent. curl -H "Authorization: Bearer $JWT" \
https://login.21tunnel.com/api/tunnels/$TID/policy {
"id": "3a1b2c3d-...",
"policy": {
"actions": [
{ "kind": "deny", "path_prefix": "/admin" }
]
}
} Validation runs before persistence. Common errors:
HTTP/1.1 400 Bad Request
{ "error": "bad_policy",
"message": "action[0] header_set: value must not contain CR or LF" } Other rejected inputs: too many actions (max 16), bad
rate-limit values, missing leading / on
path_prefix, second rate_limit
action.
Policies don't share across tunnels (yet). If you have
five staging tunnels all needing the same rules, you
PUT the policy on each one. Per-org reusable
policies are a roadmap item — file a request.
TCP and UDP tunnels are byte-pipes; there's no "path" or "header" to act on. Policies on a TCP tunnel return 200 + are stored but never fire.
Rate-limit buckets live in the qnt-server process. On restart (rare), the window resets. The DB still holds the policy itself, so re-registration carries it forward.
Per policy. Bounds the worst-case evaluation cost on the hot path. Real policies are typically 1–3 actions — 16 is generous headroom.