sozu-lib 2.3.3

sozu library to build hot reconfigurable HTTP reverse proxies
Documentation
# PROXY Protocol — Session Workflow

Reference document for maintainers of `lib/src/protocol/proxy_protocol/`.
Companion to `lib/src/protocol/mux/LIFECYCLE.md` (the downstream H1/H2
datapath) and `lib/src/protocol/kawa_h1/LIFECYCLE.md` (the H1 vocabulary it
builds on).

Every claim is anchored to code. Where the prose names an item — a function, a
method, a struct, an enum — the anchor is that item plus its file, with no line
number, because a line number does not survive an edit above it. A `file.rs:LINE`
or `file.rs:LINE-LINE` anchor is kept only where the claim is about a specific
statement or branch inside an item; those were refreshed against `main` at
`0cb1e2a7` on 2026-09-20.

---

## 1. Wire Format Reminder

The PROXY protocol prepends a fixed metadata block to a TCP connection so the
inner protocol (H1 / H2 / TLS / TCP-passthrough) sees the original client
address even after the connection traversed an upstream load balancer.

Two wire versions exist:

- **v1** is text-only. Lines have the shape
  `PROXY TCP4 src_ip dst_ip src_port dst_port\r\n` (or `TCP6` /
  `UNKNOWN\r\n`). See `HeaderV1` (`lib/src/protocol/proxy_protocol/header.rs`)
  and the comment at `header.rs:47` flagging that **v1 is never used inside
  Sōzu** — only v2 is parsed or emitted on the wire. The v1 serializer
  (`HeaderV1::into_bytes`, `header.rs`) survives as dead weight pending the
  documented removal.
- **v2** is binary. The wire frame is a 12-byte signature
  (`0x0D 0x0A 0x0D 0x0A 0x00 0x0D 0x0A 0x51 0x55 0x49 0x54 0x0A`, see
  `header.rs:186-188`) followed by version+command, family, address-block
  length (big-endian `u16`), and the address block. `HeaderV2`
  (`header.rs`) carries the parsed shape; `HeaderV2::into_bytes`
  (`header.rs`) emits it.

Address families are modelled by `ProxyAddr` (`header.rs`): `Ipv4Addr`,
`Ipv6Addr`, `UnixAddr` (108 bytes per side per the AF_UNIX socket-path
limit), and `AfUnspec` for unknown/legacy.

The v2 parser lives in `lib/src/protocol/proxy_protocol/parser.rs`; the
public entry point is `parse_v2_header` (`parser.rs`). It uses `nom` and
returns either a complete `HeaderV2`, an `Incomplete` request for more
bytes, or a parse error.

**`LOCAL` headers carry no attributable address.** The command nibble
distinguishes `PROXY` (`0x21`) from `LOCAL` (`0x20`); per the HAProxy PROXY
protocol specification §2.2 a `LOCAL` header describes a connection the
upstream proxy originated itself — typically a health check — and its address
block must be discarded in favour of the real socket endpoints. The wire format
does **not** require a `LOCAL` header to declare `AF_UNSPEC`: only HAProxy's own
emitter pairs the two, and a crafted peer may send `LOCAL` with a fully
populated `AF_INET` / `AF_INET6` block. `parse_v2_header` therefore parses that
block (so a malformed or unknown family is still rejected, and the declared
length still delimits the header) and then yields `ProxyAddr::AfUnspec` for it.
Discarding at the parse boundary is what makes the fallback in the four
TCP-side consumers — `expect.rs`, `relay.rs`, `lib/src/protocol/tcp_preread/mod.rs`, and
`TcpSession::effective_session_address` (`lib/src/tcp.rs`), which reads
`ExpectProxyProtocol::addresses` / `RelayProxyProtocol::addresses` directly
rather than through `into_pipe`, all of which attribute `ProxyAddr::source()`
to the client — hold: none of them reads `command` or `family`, and none
re-serializes the parsed header. The `family` byte is still reported as it was
read off the wire.

Those four are not the only consumers of the parsed pair, and the other two
do not fall back. `HttpSession::upgrade_expect` (`lib/src/http.rs`) and
`HttpsSession::upgrade_expect` (`lib/src/https.rs`) each need a source
**and** a destination to build the downstream session; `ProxyAddr::AfUnspec`
returns `None` from both accessors (`ProxyAddr::source` /
`ProxyAddr::destination` in `header.rs`), so the upgrade is
refused and `HttpSession::upgrade` reports `SessionIsToBeClosed`
(`lib/src/http.rs`). An HTTP or HTTPS session that presents a `LOCAL` header is closed here, not
re-attributed to `peer_addr`. That is unchanged for the `AF_UNSPEC` block
HAProxy actually pairs with `LOCAL` — those sessions already closed — and is
the intended outcome for a forged populated block.

---

## 2. Three Roles, Three SessionStates

`lib/src/protocol/proxy_protocol/mod.rs` exposes three sibling
`SessionState`s, one per role in the data path:

| Role     | Module         | Front interest at boot | Back interest at boot | When used                                                                                |
|----------|----------------|------------------------|-----------------------|------------------------------------------------------------------------------------------|
| `expect` | `expect.rs`    | `READABLE\|HUP\|ERROR` | n/a                   | Server-side ingress: the upstream LB sent us a v2 header; consume it, capture peer pair, transition to `Pipe`. |
| `relay`  | `relay.rs`     | `READABLE\|HUP\|ERROR` | `HUP\|ERROR`          | Forward an inbound v2 header verbatim onto a freshly opened backend socket.              |
| `send`   | `send.rs`      | `HUP\|ERROR`           | `HUP\|ERROR`          | Synthesise a v2 header describing the original client and emit it on the backend socket. |

All three carry a per-connection ULID (`request_id`) used by the log
context macros (`log_context!` in each module: `expect.rs`,
`relay.rs`, `send.rs`) so a session is grep-correlatable across the
PROXY phase and the downstream protocol.

### 2.1 `ExpectProxyProtocol`

- Type: `ExpectProxyProtocol<Front: SocketHandler>` (`expect.rs`).
- Buffer: `frontend_buffer: [u8; 232]` — Sōzu's accepted v2-header capacity
  (enough for the Unix-socket family, which carries 2 × 108 bytes of address
  plus header overhead). It is hard-bounded to defend against a malicious peer
  that opens TCP and never finishes the header.
- Entry point: `ExpectProxyProtocol::readable` (`expect.rs`).
  - `read_target` starts at the fixed 16-byte v2 prelude. Once the parser has
    validated that prelude and reports the address block incomplete, bytes
    14-15 set the exact target to `16 + len`. The socket read never extends
    beyond that target, so application payload coalesced after a short header
    stays available to TCP, HTTP or HTTPS after the expect upgrade.
  - A 0-byte read with `index == 0` closes the session
    immediately; this is the standard HAProxy bare-TCP healthcheck pattern
    (SYN/ACK/FIN with no `send-proxy`). Closing fast avoids zombie sessions
    sitting on `request_timeout` (default 10 s) and consuming the
    `nb_connections` quota.
  - A target above 232 is rejected immediately after the prelude, before
    another read. If the parser is still `Incomplete` at the declared target,
    the header is malformed. Both cases increment `proxy_protocol.errors` and
    close.
  - `readable` never removes READABLE from the frontend interest, not even
    when a 232-byte header fills the buffer: `HttpsSession::upgrade_expect`
    (`lib/src/https.rs`) copies the whole readiness, interest included, into
    the TLS handshake, which would otherwise never read the ClientHello.
  - A successful parse stores the `ProxyAddr` into `self.addresses` and returns
    `SessionResult::Upgrade`; the proxy then swaps the session for a `Pipe` via
    `ExpectProxyProtocol::into_pipe` (`expect.rs`), which prefers
    `ProxyAddr::source()` and falls back to the front socket's `peer_addr` when
    it is `AfUnspec` — including for every `LOCAL` header (§1).
- HUP is honoured only after the readable bytes are drained. A client
  that half-closes right behind its header and payload can deliver all of
  it as ONE `READABLE | HUP` event (Linux coalesces `EPOLLIN |
  EPOLLRDHUP`), so closing on HUP first would discard a complete header
  ([#1823]https://github.com/sozu-proxy/sozu/issues/1823). On TCP,
  `TcpSession::ready_inner` (`lib/src/tcp.rs`) calls `readable` while
  READABLE and HUP are both set, before its frontend-HUP check; on HTTP and
  HTTPS, `SessionState::ready` for `ExpectProxyProtocol` (`expect.rs`)
  closes on HUP only once READABLE is gone. A parsed header still upgrades
  and hands HUP on to the next stage (`Pipe::frontend_hup` keeps the session
  while unread or buffered request bytes remain; what the pipe forwards is
  whatever `readable` did not consume as header); an empty or truncated
  header ends with READABLE cleared by the zero-byte read and closes, so a
  bare-TCP healthcheck or a dead client never dials a backend.

### 2.2 `RelayProxyProtocol`

- Type: `RelayProxyProtocol<Front: SocketHandler>` (`relay.rs`).
- Used when Sōzu sits between two PROXY-aware peers: read the inbound
  header, then write those exact bytes (and only those bytes) to the
  backend before any user-payload byte.
- Entry points: `RelayProxyProtocol::readable` (`relay.rs`) feeds the parser; on a complete
  parse it flips `frontend_readiness.interest` to drop READABLE and arms
  the backend WRITABLE bit (`relay.rs:163-164`). It then records
  `header_size` and consumes **nothing**: those buffered bytes are the only
  copy of the header, since this state never re-serializes `addresses`.
  `RelayProxyProtocol::back_writable` (`relay.rs`) drains exactly the first `header_size`
  bytes of `frontend_buffer` onto the backend socket and returns
  `SessionResult::Upgrade` once the cursor reaches them.
- Anything the client pipelined into the same read stays in
  `frontend_buffer` for the pipe phase, and `RelayProxyProtocol::into_pipe` (`relay.rs`)
  hands that same `Checkout` to `Pipe::new`. Note where the surviving
  wake-up actually comes from: `Pipe::new`'s own
  `arm_inherited_buffer_writes` is immediately overwritten by the restored
  event words, so it is `into_pipe`'s `restore_readiness_events` — which
  restores both words and THEN re-runs that arm — that leaves the backend
  WRITABLE readiness set. Never replace it with a bare
  `pipe.backend_readiness.event = …` pair; `tcp.rs`'s
  `build_pipe_from_preread` and `upgrade_send` carry the same warning for
  the same reason. The relay must also never widen its write past the
  header tail, and never consume past what it forwarded — either would
  silently drop client payload.
- Three yield-instead-of-spin guards, all of the same class:
  - A zero-length **write** drops the backend WRITABLE event and breaks.
    Without it the loop spins: a connected non-blocking socket answers
    `write(&[])` with `Ok(0)` indefinitely, `cursor_header` never advances,
    and `MAX_LOOP_ITERATIONS` bounds only the outer `tcp.rs::ready_inner`
    dispatch loop, so the worker burns 100% CPU with its event loop
    starved.
  - A zero-length **read** in `readable` drops the frontend READABLE event,
    as `expect.rs:182` does. `tcp_socket_read` returns
    `(0, SocketResult::Continue)` for an empty slice, which is what
    `space()` yields once the buffer is full, so without the guard a client
    that declares a large `len` and stalls has `ready_inner` re-enter until
    the outer bound trips.
  - `back_writable`'s `Err` arm distinguishes the retryable kinds.
    `WouldBlock` clears only the stale WRITABLE **event** and keeps the
    interest (as `send.rs::back_writable` does); `Interrupted` touches
    neither word so `ready_inner` retries under its own bound. Only a
    genuine error resets both words. Clearing `interest` on backpressure
    would mean no later epoll edge is ever acted on, stranding a
    half-written header on the backend until the frontend timeout, and
    clearing the `event` on EINTR would strand it the same way — a signal
    is not a socket state change, so edge-triggered epoll owes no new edge
    for it. The `Interrupted` arm is defensive and unreachable in
    production: the backend socket comes from `mio::net::TcpStream::connect`
    (`Backend::try_connect`, `backends.rs`), so it is always non-blocking and its `send` answers
    EAGAIN, never EINTR. Exercising it takes a deliberately blocking
    socketpair — see
    `back_writable_keeps_its_readiness_when_a_signal_interrupts_the_write`
    (`relay.rs`).
- `relay.rs` places **no** upper bound on header size, unlike `expect.rs`'s
  fixed 232-byte staging array: a v2 header is `16 + len` with `len: u16`,
  and this state reads into a pool `Checkout`, so a declared `len` larger
  than the session buffer stalls until the frontend timeout. Bounded and
  non-spinning since the zero-read guard, but still unbounded in size.

### 2.3 `SendProxyProtocol`

- Type: `SendProxyProtocol<Front: SocketHandler>` (`send.rs`).
- Used when the front-end accepted a non-PROXY connection but the
  downstream backend expects PROXY-v2. Sōzu synthesises a header from the
  TCP peer pair captured on the frontend socket
  (`peer_addr` / `local_addr` at `send.rs:118-120`).
- Entry point: `SendProxyProtocol::back_writable` (`send.rs`). On first call it builds the
  header lazily (`send.rs:117-140`). The drain loop (`send.rs:145-192`)
  writes until the cursor reaches `header.len()` and returns
  `SessionResult::Upgrade`; partial writes set `WouldBlock` and yield to
  the event loop.

---

## 3. State Machine

```
     pre-protocol idle
            │
            │  (event-loop notices READABLE on frontend)
            ▼
       ─────────────────
       header parse loop
       ─────────────────
            │
   ┌────────┴────────┐
   │                 │
parse error      Incomplete
or oversize          │
   │            (await more bytes)
   ▼                 │
SessionResult::      │   parse OK
Close                │       │
                     ▼       ▼
              header captured: peer pair
              stored, role-specific handoff
                       │
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
     expect        relay          send
   (into_pipe)   (back_writable, (back_writable,
   transitions  drains prefix to drains synth
   to Pipe)     backend)         header to backend)
        │              │              │
        ▼              ▼              ▼
            SessionResult::Upgrade
            (proxy swaps in the
             downstream protocol's
             SessionState)
```

The `Pipe` (`lib/src/protocol/pipe.rs`) is the typical downstream — it owns
the bidirectional byte-stream forwarding for TCP listeners. For HTTP(S)
listeners the downstream is the mux `Connection` enum (`ConnectionH1` or
`ConnectionH2`, depending on the negotiated protocol).

---

## 4. Hardening Notes

The PROXY-protocol surface is the very first byte path on a new connection,
which makes it an attractive target. These rules are load-bearing.

1. **Bounded buffers, no growth.** `ExpectProxyProtocol::frontend_buffer`
   is a stack-sized `[u8; 232]` (`expect.rs:35/90`) — Sōzu's accepted v2-header
   capacity. There is no growable backing — a peer that floods bytes
   without a valid header fails the v2 signature parse
   (`expect.rs:283-292`) and is closed, and a prelude declaring more than
   232 bytes trips the oversized-header branch (`expect.rs:247-257`) before
   any further read.
2. **TCP healthchecks bypass the protocol.** Upstream LBs probe backends
   with bare TCP (SYN/ACK/FIN) and never send `send-proxy`. The fast-close
   branch in `expect.rs:200-212` handles that gracefully — without it,
   every healthcheck would idle for the full `request_timeout`. Do not
   "fix" that branch without measuring against an HAProxy mesh.
3. **`MAX_LOOP_ITERATIONS` ceiling.** All three modules import
   `MAX_LOOP_ITERATIONS` from `sozu_command::config`
   (e.g. `expect.rs:15`); any drain or read loop must respect it so a
   misbehaving peer cannot starve the single-threaded event loop.
4. **Error counters on every reject.** Every `Close` path bumps the
   `proxy_protocol.errors` counter via `incr!` so operators can alert on a
   sudden spike (e.g. a backend that started rejecting the protocol).
5. **No panic on adversarial input.** Per the repo `CLAUDE.md`
   security-sensitive areas list, the proxy-protocol path must convert
   parse errors / partial reads / oversized headers into
   `SessionResult::Close` plus a metric and a contextual log line. New
   error paths follow the existing pattern (see `expect.rs:283-292`).
6. **`HeaderV1` is dead weight.** Per the `header.rs:47` comment the v1
   variant is never produced or consumed; tests are commented out
   (`header.rs:110-141`). Removing it is a documented follow-up — until
   then, do not extend it.

---

## 5. Cross-References

- `lib/src/protocol/pipe.rs` — the typical downstream after `expect`.
- `lib/src/protocol/mux/LIFECYCLE.md` — the H1/H2 mux that follows PROXY-v2
  ingress on HTTP and HTTPS listeners.
- `lib/src/protocol/kawa_h1/LIFECYCLE.md` — the H1 vocabulary that mux
  builds on (default answers, `HttpContext`, `Method`).
- `bin/src/command/LIFECYCLE.md` — how listener configuration (incl.
  `expect_proxy`, `send_proxy`) is delivered from the supervisor.
- HAProxy upstream spec: <https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt>