Skip to content
PipelineDudePublic

About

rproxy sits behind nginx (or any web server) as a filtering layer in front of your API/backend. Its core value is the filters: JWT auth, IP allow/deny, rate limiting, request validation, and path-canonicalization checks blocking ACL-bypass and request-smuggling tricks. Built in Rust on io_uring.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

rproxy — HTTP/1.1 reverse proxy in Rust + io_uring

The goal of this project: a cheap, fast way to filter invalid requests before they reach your API/backend — not another general-purpose reverse proxy to replace nginx.

rproxy sits behind nginx (or any other front-end web server/load balancer). The front end keeps terminating TLS, serving static files, etc.; rproxy receives the already-proxied traffic and decides what earns the right to reach the backend. The filters are the whole point, everything else is secondary:

  • JWT authentication per route/domain/global, with the signature-check result cached.
  • IP allow/deny (route → domain → global), including a rule for requests with no matched route.
  • Rate limiting per IP per route (per-second sliding window).
  • Request validation: method / headers / query / cookie / form-body — rules from config, with a configurable response on rejection.
  • Path canonicalisation before any decision (route match, filters, JWT, cache key): closes ACL-bypass differentials like /x/../admin, //admin, /%61dmin between what rproxy evaluates and what the backend later resolves.
  • HTTP request-smuggling defenses: strict Content-Length/Transfer-Encoding parsing, a 400 on ambiguous framing, a 400 on duplicate/missing Host — rejected outright, never silently forwarded.

On top of the filters you get what you'd expect from a reverse proxy: Host/path routing, 7 load balancing algorithms, an L7 response cache, active health checks, Prometheus metrics. Under the hood: monoio (io_uring), a prefork / thread-per-core architecture, a zero-alloc hot path, backend state and metrics in shared memory (MAP_SHARED) with no locks — so the filtering itself stays cheap even at high throughput.

A policy layer between front and back, not just a proxy

The point of putting a filter here at all is to give traffic policy between the front end and the backend one place to live, instead of it being scattered across services — which also means infra and security teams get an actual way to shape that traffic (who's allowed in, at what rate, under what conditions) without depending on a backend team to ship a code change for it.

Today that policy usually ends up in one of three places, each with its own cost:

  • Inside application code. Every service reinvents its own auth/rate-limit/validation logic, in whatever language it happens to be written in. There's no single place to review or change the policy — a security engineer wanting a new deny rule needs a PR into someone else's codebase.
  • nginx.conf. nginx can express most of this, but its directive language — location blocks, the well-known surprises of if inside location, module-specific directives spread across included files — is its own DSL that takes real nginx expertise to change safely. An infra engineer adjusting a timeout, or a security engineer adding an IP rule, is one misplaced directive away from an outage or a silent bypass.
  • A service mesh or API gateway (Istio, Envoy, Kong, ...) solves this properly at real scale — but at real cost: a control plane, CRDs or an admin API/DB of its own, sidecars per pod, and a learning curve that puts day-to-day changes out of reach for anyone who isn't a specialist in that specific tool.

rproxy's answer is deliberately boring: the whole policy is one flat, cascading YAML file — listen, domains, routes, backends, filters, all sharing the same global → domain → route → backend inheritance (see Parameter inheritance). No custom DSL, no CRDs, no admin API, nothing to reimplement per service. Anyone who already writes YAML for Kubernetes, Ansible, or CI can read the current policy and safely change it — which is what actually lets infra and security participate in it, rather than filing a ticket and waiting.

What plain nginx config alone can't do

To make that concrete, not just asserted — three tiers of what "properly configuring nginx" actually runs into:

Impossible without third-party code, full stop. Open-source nginx has no directive that verifies an HMAC-signed JWT — auth_jwt is nginx Plus (paid) only; anything else means embedding njs or Lua (OpenResty) and maintaining that as real code, with no built-in caching of the verification result the way rproxy caches a signature check bound to the secret's hash. Likewise, active health checks (proactively probing a path/interval, independent of live traffic) need nginx Plus or a compiled third-party module (ngx_http_upstream_check_module) — stock config has only passive max_fails/fail_timeout. Rendezvous (HRW) hashing, which only remaps the departed node's share of keys on a backend change, isn't a stock directive either.

Possible, but only by leaving "config" for scripting. A validation rule with its own custom status/body on failure means chaining if inside location with return/error_page — nginx's own docs call if in that position a known source of surprises. A sliding-window rate limit that inherits through the same global → domain → route cascade as every other filter means juggling separately-declared limit_req_zone blocks (nginx's own algorithm there is leaky-bucket, not sliding-window) instead of one inheritance model covering every filter type.

Possible, but easy to get quietly wrong — which is not hypothetical here. This project's own history has a bug in exactly this class: an early version matched route/ACL rules against the raw request path, while the backend resolved dot-segments and percent-escapes on its own — /x/../admin, //admin, /%61dmin all bypassed a deny_ip rule on /admin this way. The fix was normalizing the path once, before routing, ACL, JWT, and the cache key all see it, and forwarding backends the same canonical form that was evaluated. Reproducing that guarantee in nginx means hand-assembling rewrite/if/merge_slashes with no structural assurance that normalization happens exactly once and identically for every one of those decisions — the same "one misplaced directive away from a silent bypass" risk from above, not a strawman.

Measured performance

The only source of truth for these numbers is bench/BENCHMARK_RESULTS.md (2026-06-27, bare metal, median of 3 runs after warmup, <2% spread):

Scenario req/s avg lat vs nginx
Bench ceiling (backend, no proxy) 362,163 292 µs —
nginx reverse proxy 153,484 662 µs 1.00×
rproxy 189,810 557 µs 1.24×

Conditions: i5-12450H (12 logical cores), wrk -t4 -c100 -d10s keep-alive on cores 4-7, an nginx backend answering "200 OK" (2-byte body) on cores 0-1, the proxy on cores 2-3, 2 workers each for rproxy and nginx (equal cores).

Three-proxy comparison on the same rig (measured 2026-08-16, bench/haproxy_baseline.sh, nginx backend, wrk -t4 -c100 -d10s, median of 3; the nginx proxy uses keep-alive to the backend — without it nginx opens a TCP connection per request and the numbers would be misleading):

Proxy req/s avg lat vs ceiling vs nginx
Bench ceiling (nginx, no proxy) 632,175 157 µs 1.00× —
rproxy 323,035 352 µs 0.51× 1.15×
nginx reverse proxy 282,032 400 µs 0.45× 1.00×
haproxy 2.8.5 (vendored build) 266,603 395 µs 0.42× 0.95×

rproxy beats haproxy by ~21% and nginx-as-reverse-proxy by ~15% on this rig (nginx and haproxy are nearly tied here). TLS (self-signed, bench/tls_bench.sh): ceiling 629,459 req/s → rproxy TLS 134,168 req/s (~21% of ceiling — the cost of TLS termination).

Scope of these numbers: a small keep-alive response (the case where head+body coalescing helps most), a single backend on the same box, no TLS. Larger bodies, no keep-alive, or under TLS may look different. rproxy = 52% of the rig's HTTP ceiling, i.e. the proxy is the bottleneck, not the rig.

HAProxy was not measured on the current rig. A comparison config exists in bench/haproxy.cfg, but there is no bare-metal baseline for it, so no HAProxy comparison is claimed here.


Table of contents

  1. Key features
  2. Architecture
  3. Build and run
  4. Configuration
  5. Configuration examples
  6. Security and limits
  7. Proxy response codes
  8. Metrics
  9. Production checklist
  10. What's NOT implemented
  11. Repository documents

Key features

  • io_uring via monoio — all worker network I/O.
  • Zero-lock / zero-alloc hot path — reusable buffers (PooledBuf), state in thread_local!, no mutexes. Allocations only happen on rare paths (e.g. a non-canonical request-target — see normalize_path).
  • Shared-memory IPC (MAP_SHARED) — backend liveness and metric counters are visible to every process with no locking; health checks and /metrics polling never contend with traffic.
  • Rendezvous hashing (HRW) for iphash/urlhash — losing a node only moves that node's share of keys.
  • WebSocket / Upgrade — a bidirectional tunnel after 101 Switching Protocols, including bytes that arrived before the upgrade.
  • Keep-alive on both sides — backends get a connection pool (backend_pool_size) with a transparent one-shot retry if the pool handed back a connection the backend had already closed.
  • Protocol defenses — refuses ambiguous framing (smuggling), Slowloris-resistant timeouts, path canonicalisation before ACL matching, QoS shedding under overload.

Architecture

graph TD
    Client((Client)) -->|HTTP/1.1 / WS| W1[Worker 1<br/>monoio/io_uring]
    Client -->|HTTP/1.1 / WS| W2[Worker N<br/>monoio/io_uring]

    subgraph "Master Process"
        M[Master / Supervisor] -->|fork| W1
        M -->|fork| W2
        M -->|fork| HM[Health & Metrics<br/>tokio]
    end

    W1 -.->|read status| SM[(Shared Memory<br/>MAP_SHARED)]
    W2 -.->|read status| SM
    HM -->|write status| SM

    W1 -->|proxy| B1[Backend 1]
    W2 -->|proxy| B2[Backend N]
    HM -.->|active HTTP check| B1
    HM -.->|active HTTP check| B2
Loading
  1. Master reads the config, initializes shared memory, creates the listening sockets, then forks the health/metrics process and N workers. After that it only waits (waitpid) and restarts crashed children — no traffic flows through it.
  2. Health/metrics process — a separate fork with its own tokio runtime: active HTTP backend checks and the HTTP metrics endpoint. Workers have no tokio inside them at all.
  3. Workers — single-threaded monoio processes: accept (connection balancing done by the kernel via SO_REUSEPORT), HTTP parsing, routing, byte proxying.

Every worker is independent: rate_limit counters, the LRU cache, and the backend connection pool are per-worker (see the caveats below).


Build and run

Requires Linux with io_uring (kernel 5.6+, 5.11+ recommended).

cargo build --release

# validate the config and exit (exit 0 = ok, exit 1 = error; CI-friendly)
./target/release/rproxy -t -c /path/to/rproxy.yml

# run
./target/release/rproxy -c /path/to/rproxy.yml
Flag Meaning
-c <path> Path to the config. Defaults to rproxy.yml in the current directory.
-t Validate the config only (syntax is ok / test is successful), don't bind ports. Exit code: 0 on success, 1 on a load error.

A special build with per-cycle profiling (build_upstream_head, build_response_head, jwt_claim_u64): cargo test --release --features cycle_profile cycle_profile_report -- --ignored --nocapture prints count/min/p50/mean/p99/max in TSC ticks per function plus the raw values of the last 1024 calls to each (see src/cycles.rs). The cycle_profile feature is off by default and never reaches a normal build (verified via a byte-for-byte .text/.data/.bss and symbol-table comparison). The tick counts are TSC ticks, not literal core cycles, and the method only works for synchronous functions with no internal .await — an async function that yields partway through would show scheduler wait time, not its real CPU cost.


Configuration

How the file is read

  • Format: YAML (serde_yaml).
  • Environment-variable substitution: ${VAR} and $VAR are replaced with the variable's value; an unset variable becomes an empty string. Exception: $client_ip is not touched — it's a runtime macro for set_headers.
  • Unknown keys are silently ignored (serde without deny_unknown_fields). A typo in a key name will not raise an error — it just won't take effect. Verify important values via the startup log and -t.

Global parameters

Key Type Default Description
listen list of "ip:port" ["0.0.0.0:8080"] HTTP sockets.
tls_listen list of "ip:port" [] HTTPS sockets (needs tls_cert/tls_key).
listen_iface string — Bind sockets to an interface (SO_BINDTODEVICE). A bad name is a startup failure.
tls_cert / tls_key path — Default certificate and key.
workers string "auto" Worker count; "auto" = number of available cores. Capped at 256 (more is clamped). A non-numeric value → WARNING + auto.
balance string roundrobin Default load-balancing algorithm (see Load balancing).
default list of backends — Backends used when no domain/route matched.
health string — Default health-check spec (see Health checks).
client_timeout seconds 60 Client read/write timeout (Slowloris defense).
connect seconds 5 Backend connect timeout.
response seconds 30 Max gap between reads of the backend response.
max_body_size bytes 2097152 (2 MiB) Request body limit; over it → 413.
max_headers_size bytes 8192 Request header size limit; over it → 431.
max_active_requests count 10000 Hard ceiling on concurrent requests per worker; over it → 503. Always active, independent of QoS.
normalize_path bool true Canonicalise the request-target before routing (see below).
reject_encoded_slash bool false Reject an encoded path separator (see below).
allow_ip / deny_ip list of CIDRs — Client-address allow/deny list; inherited by domains and routes.
jwt_secret string — HMAC secret to verify Authorization: Bearer <JWT>; checks the signature, exp, and nbf. Invalid → 401.
cache block — Response cache (see Response cache).
cache_max_bytes bytes 104857600 (100 MiB) Cache budget per worker (LRU). Total memory ≈ cache_max_bytes × workers.
set_headers map — Headers added to the request forwarded to the backend. Values support $client_ip and ${ENV}.
backend_pool_size count 16 Keep-alive connection pool size to a backend per worker. 0 disables reuse.
metrics_listen "ip:port" — Address of the metrics endpoint (runs in a separate process).
metrics_path path /metrics Endpoint path.
metrics_token string — Authorization: Bearer <token>; compared in constant time.
metrics_basic_auth "user:password" — HTTP Basic; a 401 carries WWW-Authenticate.
log_level string info none/off, error, warn, info, debug, trace. Per-request access logging happens at debug (at info the hot path allocates nothing).
cpu_affinity auto/on/off auto Pin workers to cores. auto enables it only when there are more free cores than workers and no cgroup oversubscription.
sqpoll auto/on/off auto io_uring SQPOLL. auto = off: measurements didn't confirm a win, and it burns a core for the poller.
sqpoll_idle_ms count 1000 Idle timeout of the SQPOLL thread, if enabled.
worker_lifetime seconds off Recycle a worker by lifetime (±25% jitter, so workers don't restart in lockstep).
worker_max_requests count off Recycle a worker after it has served this many requests.
worker_drain seconds max(response, 30) + 5 Cap on graceful drain before exiting during a recycle. While draining, a worker accepts no new connections and answers Connection: close. Set this explicitly for long-lived streams (WebSocket/SSE).

normalize_path (on by default)

Brings the request-target to a canonical form before routing and filters, and forwards that same form to the backend:

  • unreserved percent-escapes are decoded (RFC 3986 §6.2.2.2): /%61dmin → /admin;
  • ./.. and empty segments are removed (§6.2.2.3 / §5.2.4): /x/../admin → /admin, //admin → /admin; you can't climb above the root (/../.. → /);
  • a trailing slash is preserved (/a/b/. → /a/b/, /a/b/.. → /a/);
  • the query and fragment are left alone; OPTIONS * is untouched.

Why: otherwise /x/../admin won't match a match: /admin rule, while the backend would still resolve it to /admin — meaning route-level deny_ip/allow_ip/jwt/rate_limit/validate can be bypassed. A fragment in the request-target (# anywhere) is rejected with 400 — RFC 7230 §5.3 doesn't allow one, and otherwise /pub#/../admin would bypass the rule the same way.

%2F/%5C are left encoded — decoding them would invent a segment boundary the client never sent. If your upstream decodes them itself before resolving .., enable reject_encoded_slash. normalize_path: false restores byte-exact forwarding and reopens this ACL bypass.

reject_encoded_slash (off by default)

Answers 400 when the path part carries a separator this proxy treats as an ordinary character but an upstream might not: %2F/%2f, %5C/%5c, and a raw \ (not part of pchar per RFC 3986 §3.3, but parsers accept it, and IIS/Windows backends treat it as a separator).

Off by default because it breaks legitimate users of encoded slashes (S3 keys, git/registry refs, encoded IDs). The query is not checked — ?redirect_uri=…%2F… keeps working. Double encoding (%252F) is not caught: it requires two decode passes on the upstream side.

Domains

domains:
  - match: "^api\\.example\\.com$"    # regex OR a literal (see below)
    routes: [...]
    default: [...]                     # domain backends when no route matched
Key Description
match Literal (no regex metacharacters) → exact match against Host. Otherwise, a regex. The port in Host is stripped: match: "localhost" matches localhost:8080. On multiple matches, the longest pattern wins.
routes List of routes (see below).
default The domain's default backends.
balance, health, connect, response, client_timeout, max_body_size, allow_ip, deny_ip, jwt_secret, cache, set_headers, log_level, backend_pool_size Override the global values for this domain.
listen, tls_listen Restrict the domain to specific sockets.
tls_cert, tls_key, tls_sni Certificate for this domain. For a literal match, the SNI name is derived automatically; for a regex match, tls_sni: ["host1", ...] is required.

Routes and filters

routes:
  - match: "/api/"          # literal → path prefix; regex → full regex
    method: "GET,POST"
    header: "X-Canary"
    absent_header: "X-Internal"
    query: "id=int|str,!debug"
    cookie: "session,!guest"
    post: "action=enum(save,delete)"
    backends: [...]
Key Type Description
match string Literal → matches by path prefix (/api matches /api and /api/users, but not /v1/api). Otherwise, a regex. The longest pattern wins.
method comma-separated list Allowed methods (case-insensitive).
header comma-separated list Required headers; !name requires its absence.
absent_header comma-separated list Headers that must not be present.
query conditions Conditions on query params: name (present), !name (absent), name=int, name=str, name=enum(a,b), name=literal (multiple values separated by |). All conditions are ANDed.
cookie conditions Same, for cookies.
post conditions Same, for an application/x-www-form-urlencoded body. A mismatch → 403 (the body is fully buffered for this).
backends list Route backends. Falls back to the domain/global default if unset.
validate list of rules See below.
rate_limit requests/sec Limit per worker (see the caveat below); over it → 429.
drop_threshold 0–100 cpu_load threshold for QoS shedding; enables QoS. Over the threshold or max_active_requests → 503.
allow_ip, deny_ip, jwt_secret, cache, set_headers, connect, response, client_timeout, max_body_size, log_level, backend_pool_size, balance Override the domain's values.

Filters select the route (a mismatch falls through to the next one), while post, validate, allow_ip/deny_ip, jwt, rate_limit reject the request once a route is already selected.

validate — validation with a custom response

validate:
  - type: header        # header | cookie | query | post
    name: "X-Api-Key"
    regex: "^[a-f0-9]{32}$"   # optional: without it, only presence is checked
    invert: false             # true = the rule fires on a MATCH
    on_fail:
      status: 418             # defaults to 403
      body: "nope\n"          # defaults to "Forbidden\n"
      backends: [...]         # optional: route to a different backend instead of an error response

A literal regex (no metacharacters) is compared for exact equality; otherwise the regex engine runs.

Backends

Two forms:

default:
  - "127.0.0.1:8081"              # short: host:port
  - host: "10.0.0.5:8080"         # detailed
    weight: 3                     # for balance: weighted (defaults to 1)
    connect: 2                    # override the connect timeout (sec)
    response: 60                  # override the response timeout (sec)
  - host: "https://internal-svc:8443"  # https:// = real TLS to the backend
    tls_skip_verify: true         # for self-signed/internal certs (see below)

The backend's key is host (not addr). A missing host in the detailed form is a config load error (Backend missing 'host' field).

An https:// prefix on a backend enables real upstream TLS (monoio_rustls::TlsConnector, certificate verification against the system trust store via rustls-native-certs). SNI comes from host. Any TLS error (untrusted cert, wrong SNI, handshake timeout) is treated as an ordinary connect error — the backend is skipped/retried, ultimately a 502; there is no fallback to plaintext.

For self-signed/internal certs not in the system trust store: tls_skip_verify: true on the backend (detailed form only — the short host:port string can't carry this flag) disables certificate-chain verification for that backend. The handshake signature is still verified (proves the peer holds the certificate's private key) — only the chain/name check is skipped. Using it without an https:// prefix is a config load error (the flag would be a no-op). Using it at all logs a WARN at startup. Important: the health checker (health.rs) uses the same flag for its own HTTP client — otherwise a backend with tls_skip_verify under health: would get full verification in the health probe, get marked DOWN, and never serve traffic despite the flag.

Load balancing

Value Behavior
roundrobin (aliases round_robin, rr) Round-robin. Default.
random A random live backend.
first Always the first live one (fallback mode).
leastconn The one with the fewest active connections (counted per worker).
weighted Proportional to weight.
iphash HRW (rendezvous) on the client IP — session affinity.
urlhash HRW on the request path.

An unknown value is a config load error. iphash/urlhash use a built-in FNV-1a 64 hash (implemented in src/balancer.rs), stable across Rust versions: the key→backend mapping is a contract, not a toolchain property. Within one config, the mapping depends on backend order (ids are assigned in declaration order), so changing the backend list reshuffles keys.

Health checks

A space-separated key=value string; runs as an HTTP GET from a separate process:

health: "path=/health interval=10 timeout=5 rise=2 fall=3"
Parameter Default Meaning
path / Request path.
interval 5 Check period, seconds.
timeout 2 Check timeout, seconds.
rise 2 Consecutive successes to flip to UP.
fall 3 Consecutive failures to flip to DOWN.

The spec is inherited: route → domain → global. There is no TCP-only check — it's always an HTTP request.

Response cache

cache:
  ttl: 60              # required, seconds
  max_size: 1048576    # max size of a single response, bytes (defaults to 1 MiB)
  respect_headers: false  # honor the backend's Cache-Control/max-age

Properties: an LRU per worker with a cache_max_bytes budget; the key is method + Host + canonical path; requests carrying Authorization neither read nor write the cache; responses with Set-Cookie/Vary are never stored; the cache is bypassed on Upgrade.

TLS and SNI

TLS termination happens on tls_listen with tls_cert/tls_key. For a domain with its own certificate, the SNI name is derived from a literal match; if match is a regex, set tls_sni explicitly, or the certificate won't be registered under any name. There is a fallback to the default certificate.

Parameter inheritance

The cascade: global → domain → route → backend. connect, response, client_timeout, max_body_size, balance, health, allow_ip, deny_ip, jwt_secret, cache, log_level, backend_pool_size are all inherited. connect/response can additionally be overridden on the backend itself.


Configuration examples

Every example below is verified via rproxy -t.

1. Minimal

listen:
  - "0.0.0.0:8080"
workers: "auto"
default:
  - "127.0.0.1:8081"

2. QoS and limits

listen:
  - "0.0.0.0:8080"
workers: "4"
balance: "iphash"

# all timeouts are IN SECONDS
client_timeout: 10
connect: 2
response: 5

max_active_requests: 30000     # per-worker ceiling; over it → 503

domains:
  - match: "app.example.com"
    routes:
      # QoS turns on via a route's drop_threshold:
      # cpu_load > 85% OR max_active_requests exceeded → 503
      - match: "/"
        drop_threshold: 85
        rate_limit: 5000        # per worker → globally ≈ 5000 × workers
        backends:
          - "192.168.1.10:80"

3. Domains, routes, and a socket override

listen: ["0.0.0.0:8080"]
tls_listen: ["0.0.0.0:443"]
tls_cert: "global.crt"
tls_key: "global.key"

set_headers:
  X-Real-IP: "$client_ip"
  X-Env: "${DEPLOY_ENV}"

domains:
  - match: "^api\\.example\\.com$"
    tls_sni: ["api.example.com"]      # regex match → SNI must be given explicitly
    listen: ["0.0.0.0:8080"]
    health: "path=/healthz interval=5 fall=2"
    routes:
      - match: "^/v1/"
        method: "GET,POST"
        backends:
          - host: "10.0.0.5:8080"
            weight: 2
          - host: "10.0.0.6:8080"
        balance: "weighted"
      - match: "/admin"
        deny_ip: ["0.0.0.0/0"]        # closed to everyone
        backends:
          - "10.0.0.5:8080"
    default:
      - "10.0.0.9:8080"

Security and limits

  • Framing / smuggling. A duplicate Content-Length, CL alongside TE, a coding list in Transfer-Encoding, a non-numeric CL — all of this is ambiguous framing, rejected with 400. Exactly one canonical Transfer-Encoding: chunked is set toward the backend.
  • Host. A duplicate Host or its absence on HTTP/1.1 → 400 (RFC 7230 §5.4). A missing Host is allowed on HTTP/1.0.
  • Request-target. Only origin-form (/path) and *; absolute-form and authority-form → 400. A fragment (#) → 400. Path canonicalisation: see normalize_path.
  • Hop-by-hop. Connection, Keep-Alive, Upgrade, TE, Trailer, Transfer-Encoding, Proxy-Authenticate, Proxy-Authorization are stripped both directions (except on a legitimate upgrade).
  • Slowloris. Every read/write is wrapped in a timeout (client_timeout, connect, response).
  • Limits. max_headers_size → 431, max_body_size → 413, max_active_requests → 503 (per worker, always on).
  • JWT. The HMAC signature, exp, and nbf are checked; iat is informational. The verification cache is keyed to a hash of the secret (otherwise a token valid under one secret would pass under another).
  • Cache and privacy. A request carrying Authorization is never served from, or written to, the cache; responses with Set-Cookie/Vary are never stored.
  • /metrics. Authentication is off by default. Set metrics_token and/or metrics_basic_auth, or bind metrics_listen to a private interface. The token and Basic auth are checked independently: either one is enough if both are set.

Per-worker arithmetic (a common source of mistakes)

Parameter Actual value
rate_limit ≈ rate_limit × workers globally. A WARNING with the real figure prints at startup and on -t.
max_active_requests per worker
cache_max_bytes per worker → RAM ≈ cache_max_bytes × workers
backend_pool_size per worker → connections to a backend ≈ pool × workers
leastconn counts only its own worker's connections

Proxy response codes

Code When the proxy sends it
400 Ambiguous framing (CL/TE), duplicate or missing Host, absolute/authority-form, a fragment in the path, or — with reject_encoded_slash — an encoded separator.
401 An invalid JWT (signature, exp, nbf, structure).
403 deny_ip/allow_ip, a post-condition mismatch, validate (status configurable via on_fail).
413 Body larger than max_body_size.
429 rate_limit exceeded (per worker).
431 Headers larger than max_headers_size.
502 No live backends, couldn't connect, or the backend closed the connection in a way that isn't safe to retry.
503 QoS shedding (drop_threshold) or max_active_requests exceeded.

Metrics

metrics_listen: "127.0.0.1:9090"
metrics_path: "/metrics"
metrics_token: "<bearer-token>"        # Authorization: Bearer <token>, constant-time comparison
metrics_basic_auth: "user:password"    # HTTP Basic

Prometheus format, read directly from shared memory:

Metric Meaning
rproxy_requests_total Requests broken down by response status.
rproxy_bytes_total{dir="rx"|"tx"} Bytes from clients / to clients.
rproxy_active_connections Active requests (summed across workers).
rproxy_cpu_load CPU load used by QoS (sampled from /proc/stat).
rproxy_backend_up Liveness of each backend (from the health checker).
rproxy_backend_active_connections Active connections per backend.
rproxy_qos_dropped_total Dropped by QoS.
rproxy_rate_limit_dropped_total Rejected by rate_limit.
rproxy_ip_dropped_total Rejected by allow_ip/deny_ip.
rproxy_jwt_dropped_total Rejected by JWT verification.
rproxy_rule_dropped_total Rejected by post/validate rules.

Production checklist

  1. Run rproxy -t -c <config> in CI — exit code 1 means the config won't load.
  2. Verify the important keys actually took effect (unknown keys are silently ignored) — check the startup log and any WARNINGs.
  3. metrics_listen on a private interface and metrics_token/metrics_basic_auth.
  4. Divide the global limits you want by the worker count (rate_limit), and work out cache_max_bytes × workers against available RAM.
  5. Set health — without it, backends are always considered live.
  6. For https:// backends, the certificate must be in the system trust store, or you get a 502; for self-signed/internal ones, use tls_skip_verify: true deliberately (it drops the chain/name check for that backend).
  7. Leave normalize_path: true; if your upstream decodes %2F itself, enable reject_encoded_slash.
  8. For long-lived streams (WebSocket/SSE) with worker recycling enabled, raise worker_drain.
  9. Choose max_body_size and max_headers_size deliberately — the defaults (2 MiB / 8 KiB) are small.

What's NOT implemented

  • Graceful config reload (SIGHUP) — a config change requires a restart.
  • Graceful drain on SIGTERM — a plain SIGTERM to the master correctly terminates the whole process tree now (workers and the health/metrics process included, not left as orphans), and stops respawning instead of restarting crashed children forever. What's still missing is the graceful half: workers exit on the signal's own terms rather than finishing in-flight requests first — that drain logic already exists for worker recycling (worker_lifetime/ worker_max_requests), just not wired to SIGTERM yet.
  • client_ip_header as a standalone directive — use set_headers: {X-Real-IP: "$client_ip"} instead.
  • HTTP/2 and HTTP/3 — HTTP/1.1 only (and HTTP/1.0 on the inbound side).
  • Dual-stack (IPv4+IPv6) verification — the socket family is chosen from the listen address; running both simultaneously hasn't been separately verified.

Repository documents

Current performance measurements: bench/BENCHMARK_RESULTS.md.

About

rproxy sits behind nginx (or any web server) as a filtering layer in front of your API/backend. Its core value is the filters: JWT auth, IP allow/deny, rate limiting, request validation, and path-canonicalization checks blocking ACL-bypass and request-smuggling tricks. Built in Rust on io_uring.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages