# Config-Driven Proxy

> Full annotated rws.config.toml reference for zero-code proxy configuration.

Source: https://rws8.tech/docs/proxy/config-driven/ · Last updated 2026-11-01

When `rws.config.toml` (or the file pointed to by `RWS_CONFIG_FILE`) contains at least one `[[route]]` or `[[upstream]]` section, `rws` boots in proxy mode automatically. No code changes required.

## Minimal example

```toml
[[upstream]]
name = "api"
backends = ["localhost:3000"]

[[route]]
name = "api-proxy"

[route.match]
path = "/api/*"

[route.action]
type = "proxy"

[route.action.proxy]
upstream = "api"
```

## `[[upstream]]` — backend pools

Each `[[upstream]]` block defines a named pool of HTTP or HTTPS backends.

```toml
[[upstream]]
name        = "api"                              # required; referenced by route actions
backends    = ["10.0.0.1:3000", "10.0.0.2:3000"] # plain HTTP (host:port)
strategy    = "round_robin"                       # "round_robin" | "random" | "ip_hash" | "least_connections"
```

- `round_robin` (default) — cycles through live backends in order.
- `random` — picks a live backend at random on each request.
- `ip_hash` — hashes the client IP so the same client always reaches the same backend (useful for WebSocket/SSE stickiness without shared session state).
- `least_connections` — routes to whichever live backend currently has the fewest in-flight requests through this route.

An unrecognized or empty `strategy` value falls back to `round_robin`.

### Performance

A proxy route with no `[route.middleware]` and no global `[middleware]` is
forwarded without blocking a thread — HTTP and HTTPS upstreams alike,
including `tls_ca_file` ones: the request waits for the backend on its
connection's own task, over pooled keep-alive connections. Routes with
middleware use a thread from the blocking pool for the round trip — still
pooled, slower under heavy concurrency. See [Benchmarks](/reference/benchmarks/).

### TLS (HTTPS) upstreams

Prefix backend addresses with `https://` to connect over TLS. Certificate verification uses the WebPKI root store (same CA bundle as browsers). TLS connections are kept alive and reused, like plain ones — one handshake per connection, not per request — and large or chunked responses are streamed.

```toml
[[upstream]]
name     = "secure-api"
backends = ["https://api.example.com:443", "https://api2.example.com:443"]

[upstream.health_check]
path          = "/healthz"
interval_secs = 10
```

For internal services behind a private CA or a self-signed certificate, name
the CA to trust with `tls_ca_file`. It **replaces** the public roots for that
upstream — requests and health checks alike — so only certificates issued by
that CA are accepted:

```toml
[[upstream]]
name        = "billing"
backends    = ["https://billing.internal:8443"]
tls_ca_file = "/etc/rws/internal-ca.pem"
```

A CA file that can't be read fails closed: the upstream answers `502` and the
error is logged, rather than falling back to the public roots. `rws --check`
reports it. A reload re-reads the file, so a rotated CA takes effect on
`SIGHUP`.

:::note[Feature requirement]
HTTPS upstreams require the `http-client` or `http2` feature — both are included in the default (`http3`) build and the `http2`-only build. The `http1`-only build (`--no-default-features --features http1`) returns `502` for HTTPS backends and logs an error.
:::

### HTTP/3 upstreams

`protocol = "h3"` sends an upstream's requests over HTTP/3 (QUIC, UDP) instead
of HTTP/1.1 over TCP. Since 18.13.0.

```toml
[[upstream]]
name        = "edge"
protocol    = "h3"                       # default: HTTP/1.1
backends    = ["https://edge1.internal:443", "https://edge2.internal:443"]
tls_ca_file = "/etc/rws/internal-ca.pem" # optional, as for any https:// upstream
```

- Backends must be `https://` (QUIC is always TLS); trust works as above —
  WebPKI, `tls_ca_file`, client certificates.
- One multiplexed QUIC connection per backend, reused across requests;
  bodies stream both ways, trailers are passed on.
- **Fallback to TCP.** When QUIC can't connect to a backend (UDP blocked, no
  handshake within `connect_timeout_ms`, a TLS failure) the request goes over
  HTTPS on TCP to the same host and port, and that backend stays on TCP for
  5 minutes before QUIC is tried again. The client sees no error;
  `rws_upstream_h3_fallbacks_total{upstream}` counts it. A failure once the
  QUIC connection is up counts like any backend failure (retries, circuit
  breaker).
- Weights, stickiness, retries, the circuit breaker, mirroring and early
  hints work the same, and proxy routes keep the non-blocking forwarder.
- Health checks stay HTTP/1.1 over TCP.
- **It costs more than TCP.** QUIC does its packet handling and encryption
  in user space: in a local test (proxy and backend on one 8-core machine,
  1 KiB responses, 50 connections) proxying over HTTP/3 took about 1.75×
  the CPU per request of HTTP/1.1 over TLS (112 vs 64 ms per 1000 requests)
  at half the throughput (21k vs 42k requests/s). Choose it for what the
  network path gains — lossy or long links, no head-of-line blocking between
  requests — not for speed on a LAN.
- `rws --check` reports a `protocol` other than `http1` / `h3`, `h3` with a
  non-`https://` backend, and `h3` in a build without the `http3` feature.

### Backend URL format summary

| Format | Protocol | Default port |
|--------|----------|-------------|
| `host:port` | Plain TCP / HTTP | — (required) |
| `http://host:port` | Plain TCP / HTTP | 80 |
| `https://host:port` | TLS / HTTPS | 443 |

### `[upstream.health_check]` — automatic health monitoring

Add a nested `[upstream.health_check]` table to enable background health checking. Unhealthy backends are removed from the live rotation until they recover.

```toml
[[upstream]]
name     = "api"
backends = ["10.0.0.1:3000", "10.0.0.2:3000", "10.0.0.3:3000"]

[upstream.health_check]
path                 = "/healthz"   # GET path sent to each backend
interval_secs        = 30           # how often to probe (default: 30)
timeout_ms           = 5000         # connect + read timeout per probe (default: 5000)
healthy_threshold    = 2            # consecutive successes before marking live (default: 2)
unhealthy_threshold  = 3            # consecutive failures before marking dead (default: 3)
```

See [Health Checks](/proxy/health-checks/) for the full implementation details.

### Weights, stickiness, retries and the circuit breaker

Since 18.6.0 an `[[upstream]]` takes the same traffic policies as the library proxy:

```toml
[[upstream]]
name     = "api"
backends = ["10.0.0.1:3000", "10.0.0.2:3000", "10.0.0.3:3000"]
weights  = [3, 1, 1]            # one per backend, ≥ 1 (default: 1 each)

[upstream.sticky]
cookie       = "rws_backend"    # cookie name (default: rws_backend)
max_age_secs = 3600             # omit for a session cookie

[upstream.circuit_breaker]
failure_threshold = 5           # consecutive failures before a backend is taken out (default: 5)
recovery_secs     = 30          # how long it stays out before a probe request (default: 30)
half_open_probes  = 1           # probe requests let through at a time (default: 1)

[upstream.retry]
attempts = 1                    # extra backends to try (default: 1)
status   = [502, 503, 504]      # responses that count as failed (default: 502, 503, 504)
```

- **`weights`** — `round_robin` spreads requests in proportion to the weights without sending runs to one backend (smooth weighted round-robin, as nginx does: 3:1:1 goes `a b a c a`); `random` picks in proportion to them; `least_connections` compares in-flight requests per unit of weight. `ip_hash` ignores them.
- **`[upstream.sticky]`** — the first response to a client sets `Set-Cookie: <cookie>=<id>; Path=/; HttpOnly; SameSite=Lax`. The id is a hash of the backend URL, so it doesn't reveal backend addresses and stays the same across restarts and replicas. A client that sends it goes to that backend while it is in rotation; otherwise (backend removed, ejected or failed on this request) the strategy picks another and the cookie is replaced.
- **`[upstream.circuit_breaker]`** — passive health checking: every proxied request counts. No response, or a `502`/`503`/`504`, is a failure; `failure_threshold` failures in a row take the backend out of rotation for `recovery_secs`, then `half_open_probes` requests are let through, and a success puts it back. It works with or without `[upstream.health_check]` (a backend has to pass both). When every backend is out, the route answers `503 Service Unavailable` with `Retry-After` without contacting any.
- **`[upstream.retry]`** — a request that got no response or one of the `status` codes is sent to up to `attempts` other backends. Only idempotent methods (`GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`, `TRACE`) with a buffered body are retried; a `POST` or a streamed upload gets the first answer. The last answer is returned when every attempt fails.

`rws --check` validates them: weight counts that don't match `backends`, a weight of `0`, an invalid cookie name, `failure_threshold = 0` and retry statuses outside 400–599 are errors; weights with `ip_hash` and `attempts` at or above the number of backends are warnings. Each upstream's traffic shows up on [`/metrics`](/features/metrics/#upstream-metrics).

## `[[route]]` — routing rules

Routes are evaluated in declaration order; the first match wins.

```toml
[[route]]
name = "my-route"    # informational label; no functional effect
```

### `[route.match]` — matching criteria

All fields are optional. Omitting a field means "match anything".

```toml
[route.match]
host         = "api.example.com"     # SNI hostname (TLS) or Host header (plain HTTP)
path         = "/api/*"              # prefix match when ending with *, exact match otherwise
method       = "POST"                # HTTP method (case-insensitive)
content_type = "application/json*"   # Content-Type prefix match when ending with *
```

**Path matching rules:**

| Pattern | Matches |
|---------|---------|
| `/api/*` | Any path starting with `/api/` (prefix match) |
| `/api/ping` | Only `/api/ping` (exact match) |
| _(omitted)_ | All paths |

Paths are normalized before any route is matched, and sent upstream in that
form (since 17.113.1): percent-encoded unreserved characters are decoded
(`/%61dmin` → `/admin`; `%2F` and other escapes stay encoded), repeated
slashes collapse (`//admin` → `/admin`) and `.` / `..` segments are resolved
(`/x/../admin` → `/admin`). So a route that protects `/admin/*` can't be
skipped by spelling the path differently. `host` matches ignoring case, and
ignoring the port unless the route names one.

### `[route.action]` — what to do on match

The `type` field selects the action. An action's keys may also sit directly under `[route.action]` instead of in `[route.action.<type>]` (`type = "proxy"` plus `upstream = "api"` in the same table); if a key appears in both, the nested table wins.

#### `type = "proxy"` — forward to an upstream

```toml
[route.action]
type = "proxy"

[route.action.proxy]
upstream           = "api"      # upstream name defined in [[upstream]]
connect_timeout_ms = 5000       # TCP connect timeout in ms (default: 5000)
read_timeout_ms    = 30000      # response read timeout in ms (default: 30000)
strip_path_prefix  = "/api"     # strip this prefix before forwarding (optional)
add_path_prefix    = "/v2"      # prepend this prefix before forwarding (optional)
```

WebSocket (and other `Connection: upgrade`) requests are relayed too, since
18.21.0: the backend's `101 Switching Protocols` hands the client connection
to a byte relay with that backend, over plain HTTP or TLS on rws's side —
Rails Action Cable, Phoenix LiveView, Socket.IO, GraphQL subscriptions and
dev-server hot reload work through the same route as the app. The backend
must speak plain HTTP (an `https://` backend answers upgrades with `502`),
and a connection is pinned to the backend it started on.

#### `type = "grpc"` — forward gRPC to an HTTP/2 upstream

```toml
[route.action]
type = "grpc"

[route.action.grpc]
upstream           = "grpc-svc"
connect_timeout_ms = 5000
read_timeout_ms    = 30000   # per wait: a stream quiet for longer ends
```

Requests go over HTTP/2 (since 18.3.0; before, this action used HTTP/1.1 and
gRPC couldn't work): every call type streams and the `grpc-status` trailers
reach the client — see [gRPC proxy](/proxy/grpc-proxy/#streaming-and-trailers).
gRPC-web requests from browsers are translated to gRPC on the way
([gRPC-web](/proxy/grpc-proxy/#grpc-web-from-browsers)).
Upstream backends are `grpc://host:port` / `h2://host:port` (cleartext) or
`grpcs://host:port` / `h2s://host:port` (TLS, with the upstream's
`tls_ca_file` and `tls_client_cert_file` / `tls_client_key_file`).

#### `type = "redirect"` — HTTP redirect

```toml
[route.action]
type = "redirect"

[route.action.redirect]
location = "https://example.com$path"   # $path is replaced with the request URI
status   = 301                          # 301, 302, 307, or 308 (default: 301)
```

#### `type = "respond"` — fixed response

```toml
[route.action]
type = "respond"

[route.action.respond]
status       = 200
body         = "{\"status\":\"ok\"}"
content_type = "application/json"
```

#### `type = "static"` — serve a directory

```toml
[route.action]
type = "static"

[route.action.static]
root  = "/var/www/site"        # absolute or relative to the process working directory
index = ["index.html"]         # tried in order for directory requests; defaults to ["index.html"]
```

Requests are resolved against `root`, independent of the server process's working
directory. Any request path containing a `..` segment (before or after
percent-decoding) is rejected with `403`; a request that resolves to a missing
file returns `404`. Directory requests (or any path resolving to a directory)
try each `index` entry in order and 404 if none exist. MIME type is detected
from the file extension, matching the built-in static-file controller.

#### `type = "fastcgi"` — PHP and other FastCGI apps

Since 18.21.0 rws speaks FastCGI itself, so php-fpm needs no second web
server:

```toml
[[route]]
name = "laravel"
[route.match]
host = "shop.example.com"
path = "/*"
[route.action]
type    = "fastcgi"
address = "unix:/run/php/php8.3-fpm.sock"   # or "127.0.0.1:9000"
root    = "/var/www/shop/public"
# index            = ["index.php"]   # directory index scripts
# split_path       = ".php"          # /script.php/path/info → PATH_INFO
# front_controller = "index.php"     # paths that match no file; "" = 404
# connect_timeout_ms = 5000
# read_timeout_ms    = 60000         # per read: how long a slow script may take
# max_idle           = 16            # kept-alive connections to php-fpm
[route.action.params]                # extra CGI variables ($_SERVER)
APP_ENV = "production"
```

For each request rws picks, in order: a file under `root` that isn't a
script, served by rws itself (CSS, images, downloads never reach php-fpm); a
directory's index script (`/admin` → `301` to `/admin/`, then
`/admin/index.php`); `/script.php/rest` with `rest` as `PATH_INFO`; else the
front controller with `REQUEST_URI` unchanged — the routing Laravel, Symfony,
WordPress and most PHP frameworks expect. A script must be a regular file
under `root` ending in `split_path`, so `/uploads/avatar.jpg/x.php` never
runs an upload as PHP, and dot-files (`.env`, `.git/`) are never served or
run.

PHP sees the usual CGI/1.1 variables: `SCRIPT_FILENAME`, `SCRIPT_NAME`,
`REQUEST_URI`, `QUERY_STRING`, `PATH_INFO`, `DOCUMENT_ROOT`, `REMOTE_ADDR`,
`SERVER_NAME`, `HTTPS=on` over TLS, and every request header as `HTTP_*` —
except `Proxy`, which never becomes `HTTP_PROXY` (httpoxy). `Status:` and
`Location:` from the script set the response status, `Set-Cookie` headers
are all kept. php-fpm unreachable → `502`; its stderr goes to rws's log.
Bodies are buffered (uploads up to `max_body_size`); responses too.

### `[route.middleware]` — per-route middleware

A route can also run the [web application firewall](/features/waf/)
(`[route.middleware.waf]`, or `[middleware.waf]` for every route), which
sits outside auth and inside the rate limit and IP filter.

Middleware is applied only to requests that match this route.

#### Rate limiting

```toml
[route.middleware.rate_limit]
max_requests = 100    # requests allowed per window (default: 1000)
window_secs  = 60     # sliding window size in seconds (default: 60)
```

Any sub-table can also be written inline under `[route.middleware]` — `rate_limit = { max_requests = 100, window_secs = 60 }` is the same as the `[route.middleware.rate_limit]` table above.

#### Authentication

Three `type`s, all under `[route.middleware.auth]`:

**Bearer token** — always available, no extra feature:

```toml
[route.middleware.auth]
type      = "bearer"
token_env = "API_TOKEN"    # environment variable holding the expected token
```

Incoming requests must include `Authorization: Bearer <value of API_TOKEN>`. Returns `401 Unauthorized` on mismatch.

**JWT (HS256)** — requires the `auth` feature; wires into [`JwtLayer`](/features/auth/):

```toml
[route.middleware.auth]
type       = "jwt"
secret_env = "JWT_SECRET"   # environment variable holding the HS256 signing secret
```

**HTTP Basic (htpasswd file)** — requires the `auth` feature; wires into [`BasicAuthLayer::from_htpasswd_file`](/features/auth/#validating-against-an-htpasswd-style-file):

```toml
[route.middleware.auth]
type          = "basic"
htpasswd_file = ".htpasswd"
```

The htpasswd file supports plain-text and `{SHA256}` (rws's own scheme) entries only — not Apache's real `{SHA}`/`$apr1$`/bcrypt. See the [auth feature page](/features/auth/#validating-against-an-htpasswd-style-file) for the exact file format and how to generate a `{SHA256}` entry with `openssl`.

:::note[Fails closed, not silently]
Auth fails closed. If `token_env`/`secret_env`/`htpasswd_file` is unset, empty, or unreadable — or the binary was built without the `auth` feature — that route answers `503 Service Unavailable` (logged to stderr at startup) instead of being served unprotected. Other routes keep working. Check your server's startup logs after changing `[route.middleware.auth]`.
:::

#### IP filter

```toml
[route.middleware.ip_filter]
allow = ["10.0.0.0/8", "192.168.1.100", "2001:db8::/32"]   # allowlist (CIDR or exact, IPv4 or IPv6)
deny  = ["1.2.3.4"]                                          # denylist (ignored when allow is set)
```

#### Request rewriting

```toml
[[route.middleware.rewrite.request]]
type  = "header_set"
name  = "X-Real-IP"
value = "client"

[[route.middleware.rewrite.request]]
type   = "uri_strip_prefix"
prefix = "/internal"
```

Request rule types: `header_set` (`name`, `value`), `header_remove` (`name`), `uri_set` (`value`), `uri_strip_prefix` / `strip_prefix` and `uri_add_prefix` / `add_prefix` (`prefix`, or `value`).

#### Response rewriting

```toml
[[route.middleware.rewrite.response]]
type  = "header_set"
name  = "Cache-Control"
value = "no-store"

[[route.middleware.rewrite.response]]
type = "body_replace"
from = "staging.internal"
to   = "example.com"

[[route.middleware.rewrite.response]]
type   = "status"
code   = 410
reason = "Gone"
```

Response rule types: `header_set` (`name`, `value`), `header_remove` (`name`), `status` (`code`, `reason`), `body_replace` (`from`, `to`). An unknown `type` is ignored (and reported by `rws --check`).

#### Response caching

```toml
[route.middleware.cache]
ttl_secs = 60                 # default: 60
vary_by  = ["Accept-Language"] # request headers added to the cache key (optional)
# dir      = "/var/cache/rws/api"   # keep entries on disk (survive restarts)
# max_size = "512M"                 # disk cache bound (default 256M)
# purge_token_env = "RWS_PURGE_TOKEN"  # env var holding the PURGE token
```

Wraps the route in a [`CacheLayer`](/features/caching/): in memory (up to 1000 entries), or with `dir` on disk, bounded by `max_size` (since 18.11.0). `ttl_secs` is the longest an entry is kept; a response's `Cache-Control: s-maxage` / `max-age` can make it shorter, and `max-age=0` isn't cached. `rws --check` reports a `max_size` that isn't a size and a `dir` it can't write to (the route then caches in memory). It is the innermost route middleware, so cached responses still pass through rewrite, auth, rate limit and IP filter.

With `purge_token_env`, `PURGE /path` (or `PURGE /prefix/*`) with `Authorization: Bearer <token>` removes cached entries; `rws --check` fails when the variable is unset. The admin API lists every route's cache as `route:<name>` and purges it too. See [Purging](/features/caching/#purging).

#### Per-route timeout

```toml
[route.middleware]
timeout_ms = 5000   # 504 if this route (including its other middleware) doesn't respond within 5s
```

`0`/absent means no route-specific timeout. Bounds this route's *total* time — every other middleware layer runs inside it. See [Timeouts](/features/timeouts/) for the underlying mechanism and its one honest limitation (a synchronous handler that ignores its deadline keeps running in the background; only the client's wait is bounded).

#### Early hints

```toml
[route.middleware]
early_hints = ["</app.css>; rel=preload; as=style", "<https://cdn.example.com>; rel=preconnect"]
```

Sends these `Link` values as a [`103 Early Hints`](/features/early-hints/) before the route answers, so the browser fetches them while the backend works (since 18.12.0). On every action; a proxy route keeps the non-blocking forwarder. A proxy route also passes a backend's own `103` on (its `Link` values only), with or without this key. Sent over HTTP/2 and HTTP/3; over HTTP/1.1 only with `early_hints_http1 = true`. `rws --check` fails on an empty value or one with a line break, and warns when a value doesn't start with `<URL>`. `/metrics`: `rws_early_hints_total{source}` (`configured`, `upstream`, `handler`).

`early_hints` in the top-level `[middleware]` table sends those hints for every request — routes, static files and health checks — ahead of any route's own. Like any global middleware, it takes proxy routes off the non-blocking forwarder.

#### Authorization

```toml
[route.middleware.authz]
relation = "viewer"          # needed on…
object   = "doc:{path:2}"    # …this object: {path:N}, {query:name}, {header:Name}
```

The caller (set by `[route.middleware.auth]` or the global `[middleware]`
auth) needs `relation` on `object` under the top-level `[authz]` schema —
relationship-based access control, since 18.14.0. `401` without a caller,
`403` when not allowed. See [Authorization](/features/authorization/#in-the-config-file).

#### Per-route max body size

```toml
[route.middleware]
max_body_size = 65536   # 413 if this route's request body exceeds 64 KiB
```

`0`/absent means no route-specific limit. This is a *stricter, additional* cap on top of the global `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` (see [Forms & Uploads](/building-apps/forms-uploads/#size-limits)) — not a substitute for it. It's checked after this route has already been matched, which means the body was necessarily already read in full to build the `Request` that route matching itself required; use it to keep one route (e.g. a small JSON API) stricter than the global ceiling a separate upload route needs, not as protection against unbounded memory use — the global limit is what already provides that, for every route.

The limit is the outermost layer of the route — checked before `timeout_ms`, auth and the rest — and a rejected request gets:

```text
HTTP/1.1 413 Payload Too Large
Content-Type: text/plain

request body of 100000 bytes exceeds this route's 65536-byte limit
```

#### Request mirroring

Send a copy of a share of a route's requests to other backends and throw their answers away: try a new version under real traffic, or feed a shadow service (since 18.11.0).

```toml
[route.middleware.mirror]
upstream = "shadow"      # a declared [[upstream]] (its health-checked backends)…
# backends = ["http://10.0.0.9:8080"]   # …or addresses, round-robin
percent  = 10            # share of requests copied, 0–100 (default 100), spread evenly
```

- **Fire and forget.** The client's response never waits for the mirror and doesn't depend on it: copies go through a queue of 1024 to four worker threads, and when the queue is full a copy is dropped. A slow or down mirror costs the route nothing.
- **What's copied:** the request with its headers and body, plus the usual `X-Forwarded-*`. Requests whose body is streamed or over 64 KiB aren't mirrored (counted as `skipped`).
- **Once per request:** retries to the route's own upstream aren't mirrored again, and the mirror's results never count toward the route's circuit breaker or retries.
- A mirrored proxy route keeps the non-blocking forwarder; on other actions (`static`, `respond`, …) the copy is sent the same way.
- `/metrics`: `rws_mirror_requests_total{mirror, outcome}`, where `mirror` is the upstream's name (or `<route>-mirror` for `backends`) and `outcome` is `sent`, `failed`, `dropped` or `skipped`.

`rws --check` reports a mirror with neither or both of `upstream` and `backends`, a bad address, and a `percent` outside 0–100. In code, wrap any app in `proxy::mirror::MirrorLayer::new(Mirror::new(name, backends).percent(10.0))`.

## L4 proxy sections

### `[[tcp_proxy]]` — raw TCP tunnel

```toml
[[tcp_proxy]]
name               = "pg"
listen             = "0.0.0.0:5432"
backends           = ["db-1:5432", "db-2:5432"]
connect_timeout_ms = 5000
```

### `[[udp_proxy]]` — UDP datagram proxy

```toml
[[udp_proxy]]
name             = "dns"
listen           = "0.0.0.0:53"
backends         = ["8.8.8.8:53", "8.8.4.4:53"]
reply_timeout_ms = 2000
buffer_size      = 65536
```

### `[[ws_proxy]]` — WebSocket proxy

```toml
[[ws_proxy]]
name               = "chat"
listen             = "0.0.0.0:8080"
backends           = ["chat-backend:9000"]
connect_timeout_ms = 5000
read_timeout_ms    = 30000

[ws_proxy.health_check]   # optional — same keys as [upstream.health_check]
path = "/healthz"
```

L4 and WebSocket listeners are started once at boot; a `SIGHUP` reload does not add, remove or change them.

## Global middleware

Middleware that applies to every request goes in a top-level `[middleware]` section (same field structure as `[route.middleware]`). It wraps the whole router: matched routes *and* requests that fall through to the static files, health checks and metrics. Per-route middleware runs inside it.

```toml
[middleware.rate_limit]
max_requests = 500
window_secs  = 60
```

## Using it from Rust

The `rws` binary wires this up automatically. An application embedding the crate can build the same proxy from the config file:

```rust
use rust_web_server::proxy_config::{build_from_file, ProxyConfig};

if ProxyConfig::is_proxy_mode() {
    // `app` is a ConfigDrivenApp (Application + Clone); `_listeners` are the
    // [[tcp_proxy]] / [[udp_proxy]] / [[ws_proxy]] threads.
    let (app, _listeners) = build_from_file();
    // pass `app` to Server::run / run_tls / run_quic
}
```

### Reloading without a restart

`SIGHUP` and `POST /admin/config/reload` reload `[[route]]`, `[[upstream]]` and `[middleware]` — see [Hot Reload](/features/hot-reload/#reloading-proxy-routes). An embedding application can drive the same reload itself, e.g. from its own admin endpoint or a file watcher:

```rust
use rust_web_server::proxy_config::{build_from_file, ReloadOutcome};

let (app, _listeners) = build_from_file();
let reloader = app.clone();          // clones share the live routes

// later, after rws.config.toml changed:
match reloader.reload_from_str(&std::fs::read_to_string("rws.config.toml")?) {
    Ok(ReloadOutcome::Reloaded { routes, warnings }) => println!("{routes} routes live; {warnings:?}"),
    Ok(ReloadOutcome::Unchanged) => {}
    Err(report) => eprintln!("kept the running routes:\n{}", report.render("rws.config.toml")),
}
```

A file with errors never replaces the running routes. `[[tcp_proxy]]`, `[[udp_proxy]]` and `[[ws_proxy]]` listeners are bound once at startup; a reload that changes them returns a warning. To run your own code on every reload, register a hook with `config_reload::on_reload(|| ...)`.

## Full annotated example

```toml
# rws.config.toml — full proxy setup

# ── upstreams ──────────────────────────────────────────────────────────────────

[[upstream]]
name     = "api"
backends = ["api-1:3000", "api-2:3000"]
strategy = "round_robin"

[upstream.health_check]
path                = "/healthz"
interval_secs       = 15
timeout_ms          = 3000
healthy_threshold   = 2
unhealthy_threshold = 3

[[upstream]]
name     = "grpc-svc"
backends = ["grpc-1:50051"]

# ── routes ─────────────────────────────────────────────────────────────────────

[[route]]
name = "maintenance-page"

[route.match]
host = "down.example.com"

[route.action]
type = "respond"

[route.action.respond]
status       = 503
body         = "Under maintenance"
content_type = "text/plain"

[[route]]
name = "grpc"

[route.match]
content_type = "application/grpc*"

[route.action]
type = "grpc"

[route.action.grpc]
upstream = "grpc-svc"

[[route]]
name = "api"

[route.match]
path = "/api/*"

[route.action]
type = "proxy"

[route.action.proxy]
upstream          = "api"
strip_path_prefix = "/api"

[route.middleware.rate_limit]
max_requests = 200
window_secs  = 60

[route.middleware.auth]
type      = "bearer"
token_env = "API_SECRET"

[[route]]
name = "redirect-www"

[route.match]
host = "example.com"

[route.action]
type = "redirect"

[route.action.redirect]
location = "https://www.example.com$path"
status   = 301

# ── L4 proxies ─────────────────────────────────────────────────────────────────

[[tcp_proxy]]
name     = "postgres"
listen   = "0.0.0.0:5432"
backends = ["pg-primary:5432"]

[[udp_proxy]]
name     = "dns"
listen   = "0.0.0.0:53"
backends = ["8.8.8.8:53", "8.8.4.4:53"]
```
