# PROXY Protocol & Peek Histogram (https://portunus.bybee.dev/en/docs/features/proxy-protocol)



Available since v0.10.0. Two additions:

1. Per-target **PROXY-protocol** prelude (v1 text or v2 binary).
2. A SNI ClientHello **peek-duration histogram** for SNI-mode listeners.

## PROXY protocol [#proxy-protocol]

### Why it exists [#why-it-exists]

Once traffic is relayed through a forwarder, the backend sees the TCP
connection as originating from the **forwarder's own IP**, not the real
client. The original `(src, dst)` tuple is lost at that hop, which breaks:

* **Audit / logging** — access logs record the forwarder IP, so you can
  no longer trace a request back to the real visitor.
* **IP-based controls** — rate limiting, WAF rules, geo-IP, and
  allow/deny lists all key off the client IP and silently misfire.

The PROXY protocol fixes this by sending the original address tuple to
the backend *before* any real payload, so the backend can recover the
client identity as if it were a direct connection.

### How it works [#how-it-works]

When a backend opts in via `proxy_protocol = "v1"` or `"v2"`, the client
emits a PROXY header **before** any payload bytes from the original
connection. The backend can then observe the original client's
`(src_addr, src_port, dst_addr, dst_port)`.

<Callout type="warn">
  The backend **must** be configured to expect a PROXY header. Sending one
  to a backend that does not understand it makes the backend parse the
  header as application data and corrupts the connection. This is a
  both-ends agreement — only enable it per target when the backend is
  PROXY-aware. TCP only; UDP paths are unaffected.
</Callout>

Per-target opt-in means a single rule can mix PROXY-aware and
PROXY-unaware backends. `proxy_protocol` is set per target in the
`targets[]` array — via `--targets-json` (or `POST /v1/rules`), or in a
standalone TOML target. The repeatable `--target host:port[@priority]`
shorthand cannot carry it.

```sh
# backend-a gets the v2 binary header; backend-b gets the legacy byte stream
portunus-server push-rule edge-01 443 --targets-json '[
  {"host":"backend-a.local","port":8080,"priority":1,"proxy_protocol":"v2"},
  {"host":"backend-b.local","port":8080,"priority":2}
]'
```

```toml
# portunus-standalone: per-target in the rule's targets array
[[rule]]
name = "ha-https"
protocol = "tcp"
listen_port = 443
targets = [
  { host = "backend-a.local", port = 8080, priority = 1, proxy_protocol = "v2" },
  { host = "backend-b.local", port = 8080, priority = 2 },
]
```

UDP rules carrying `proxy_protocol` are rejected with
`validation.proxy_protocol_on_unsupported_rule`. The accepted values are
`"v1"` and `"v2"`; anything else fails `validation.proxy_protocol_invalid`.

## Modes [#modes]

| Mode             | Description                                                                                                                                           |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `v1`             | Human-readable text header `PROXY TCP4 <src-ip> <dst-ip> <src-port> <dst-port>\r\n` (e.g. `PROXY TCP4 1.2.3.4 5.6.7.8 12345 80\r\n`; `TCP6` for IPv6) |
| `v2`             | Binary header — 16-byte signature/command prefix followed by the address block (28 bytes total for IPv4, 52 for IPv6) per the PROXY-protocol v2 spec  |
| absent (default) | No prelude — legacy byte stream                                                                                                                       |

The choice is **per-target**, not per-rule, so multi-target rules can
mix opt-in and opt-out backends.

Since v1.7.0 the prelude write is **time-boxed**: a slow or stuck
upstream can no longer hang connection setup while the header is being
written.

## Peek-duration histogram (SNI-mode listeners only) [#peek-duration-histogram-sni-mode-listeners-only]

### What it is for [#what-it-is-for]

In SNI dispatch mode the listener must *peek* at the inbound TLS
ClientHello — without consuming it — to extract the SNI hostname before
it can decide which backend the connection belongs to. That peek sits
synchronously on the connection-setup path: it has to wait for enough of
the ClientHello to arrive (possibly across several TCP segments) and then
parse the TLS record. Slow clients, network jitter, or stalled handshakes
make this step slow and drag out connection-establishment tail latency.

The histogram measures exactly that step, so you can:

* **Observe the cost of SNI routing** — watch p50/p99 peek time to tell
  whether SNI dispatch is becoming a bottleneck.
* **Spot slow clients / abuse** — a mass of long peeks can signal a
  slowloris-style slow-handshake attack or a network problem.
* **Tune capacity and timeouts** — set peek timeouts from the real
  distribution rather than guessing.

### Metrics [#metrics]

When a listener runs in [SNI dispatch mode](/en/docs/features/tls-sni-routing),
Portunus records how long the ClientHello peek+parse takes:

```
portunus_tls_client_hello_peek_duration_seconds_bucket{...}
portunus_tls_client_hello_peek_duration_seconds_sum
portunus_tls_client_hello_peek_duration_seconds_count
```

Buckets follow Prometheus convention with finite boundaries up to
**3 seconds**; observations above the 3 s boundary increment `_count`
and `le="+Inf"` without bumping `le="3"`.

The histogram does **not** exist for legacy plain-TCP listeners — no
peek happens there.
