DOCS · TRAFFIC POLICY

Rules at the edge.

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.

1 Concept

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.

Shape

{
  "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" }
  ]
}

Where it runs

At the public HTTP edge — before the request opens an agent stream. That means:

  • Denied requests get a 403 from us, no traffic to your laptop.
  • Rate-limited requests get a 429 from us, no traffic to your laptop.
  • Header injections happen as we replay the buffered prefix to the agent — your local service reads the new header from the standard HTTP/1.1 request.

2 deny — block paths

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.

Example: hide /admin from the world

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

What public requests see:

$ 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).

Why denies don't consume rate-limit budget

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.

3 header_set — inject a header

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.

Example: inject a shared secret

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

What your local service sees:

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.

Restrictions

  • Name: ASCII alphanumeric + - + _, max 64 chars.
  • Value: max 1024 chars; CR / LF rejected at write time (prevents header smuggling).
  • Idempotent: repeated header_set for the same name acts as the final write.

4 rate_limit — cap requests / minute

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.

Example: 60 / minute

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

What public clients see at the cap:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: text/plain

rate limit exceeded by traffic policy

Per-tunnel, not per-IP

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.

Why fixed-window, not sliding

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:

  • Trivially correct under concurrent access via CAS — no lock, no clock-coordination on the hot path.
  • Smoother sliding-window is upgradeable later without a wire-format change.
  • The burst-at-boundary case is essentially irrelevant for the rate-limit's purpose (catch a buggy script burning hundreds of requests per second).

Limits

  • Max: requests_per_minute capped at 60,000 (≈ 1,000 req/s).
  • Min: 1.
  • At most one rate_limit action per policy.
  • State is in-memory. If qnt-server restarts, the bucket resets — accepted because the 60-second window is short.

5 Combine all three

Up to 16 actions per policy, evaluated in the order denyrate_limitheader_set. Mix to taste.

Example: belt + suspenders staging tunnel

{
  "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:

  • Returns 403 for /admin, /.git, /.env — common bot-probe paths.
  • Caps the tunnel at 120 req/min so a buggy load test can't burn it.
  • Stamps every request with X-Tunnel-Source: 21tunnel-edge so your local logs can identify proxied traffic.

6 REST API

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.

Read

curl -H "Authorization: Bearer $JWT" \
    https://login.21tunnel.com/api/tunnels/$TID/policy
{
  "id": "3a1b2c3d-...",
  "policy": {
    "actions": [
      { "kind": "deny", "path_prefix": "/admin" }
    ]
  }
}

Bad shapes return 400

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.

7 Limits + caveats

Per-tunnel only

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.

HTTP / HTTPS only

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.

Edge state is in-memory

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.

16 actions max

Per policy. Bounds the worst-case evaluation cost on the hot path. Real policies are typically 1–3 actions — 16 is generous headroom.

Next