PROXY protocol
Recover the real client address behind an L4 load balancer by parsing PROXY protocol v1/v2 headers into request extensions.
When Tako sits behind an L4 (TCP) load balancer — HAProxy, AWS NLB, fly.io edges — the kernel only sees the balancer's address, not the real client's. The PROXY protocol solves this: the balancer prepends a small header to every TCP connection carrying the original source and destination. Tako parses that header and rewrites the request so handlers see the true client. Both v1 (human-readable text) and v2 (binary, TLV-extensible) formats are supported.
This lives in tako-rs-server behind the proxy-protocol feature and works on
both runtimes.
Serving with PROXY protocol
try_spawn_proxy_protocol serves your Router exactly like
try_spawn_http, but reads and strips the PROXY header on
each connection before dispatching. A handler that reports what the server
recovered:
use tako::Method;
use tako::proxy_protocol::ProxyHeader;
use tako::responder::Responder;
use tako::router::Router;
use tako::types::{BoxError, Request};
async fn handler(req: Request) -> impl Responder {
let real_addr = req
.extensions()
.get::<std::net::SocketAddr>()
.map(|a| a.to_string())
.unwrap_or_else(|| "unknown".into());
let proxy_info = req
.extensions()
.get::<ProxyHeader>()
.map(|h| format!("version={:?}, transport={:?}, src={:?}", h.version, h.transport, h.source))
.unwrap_or_else(|| "no PROXY header".into());
format!("Real client: {real_addr}\nPROXY header: {proxy_info}\n")
}Serve it on either runtime:
#[tokio::main]
async fn main() -> Result<(), BoxError> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
let mut router = Router::new();
router.route(Method::GET, "/", handler);
let handle = tako::Server::builder()
.build()
.try_spawn_proxy_protocol(listener, router)?;
handle.result().await?;
Ok(())
}#[compio::main]
async fn main() -> Result<(), BoxError> {
let listener = compio::net::TcpListener::bind("127.0.0.1:8080").await?;
let mut router = Router::new();
router.route(Method::GET, "/", handler);
let handle = tako::CompioServer::builder()
.build()
.try_spawn_proxy_protocol(listener, router)?;
handle.result().await?;
Ok(())
}What handlers see
After parsing, the server populates the request extensions:
- A
std::net::SocketAddr— the real client address from the PROXY header. This overrides the raw TCP peer address, so any extractor or middleware that reads the client IP gets the true value. - A
ConnInfocarrying the same address. - The full
ProxyHeaderstruct, for code that needs the raw fields.
The server also rewrites forwarding headers defensively: inbound Forwarded and
X-Forwarded-* are stripped (a client behind the hop must not spoof its address),
and a single RFC 7239 Forwarded: for=… is re-emitted from the PROXY-supplied
source so downstream middleware sees a consistent view.
The ProxyHeader struct
ProxyHeader exposes the parsed connection plus PROXY v2 metadata:
version—ProxyVersion::V1orV2.transport—ProxyTransport::Tcp,Udp, orUnknown.source/destination— the originalSocketAddrs (NoneforUNKNOWN/LOCAL).source_unix/destination_unix—AF_UNIXpaths when the family is Unix.authority,alpn,aws_vpc_endpoint_id,tls,unique_id— typed PROXY v2 TLVs surfaced where they map cleanly.tlvs— the raw TLV list, for custom or future-defined types.crc32c_verified— see below.
Security and robustness
The v2 parser is TLV-aware and CRC32C-verified. When a PP2_TYPE_CRC32C TLV is
present, the checksum is recomputed over the full reconstructed header (with the CRC
field zeroed):
Some(true)— TLV present and matched.Some(false)— present but mismatched; the header may be corrupt or spoofed.None— no CRC TLV (it is optional), or a v1 header.
A CRC32C mismatch is logged but does not abort the parse — the framework hands
you crc32c_verified == Some(false) and lets the operator decide. If you require
verified headers, reject connections where it is Some(false). The advertised v2
address length is also capped (536 bytes) so a malformed header cannot force a large
per-connection allocation.
The PROXY header is parsed under a proxy_read_timeout (from
ServerConfig) so a client that connects but never
sends the header cannot pin a worker task forever.
Manual parsing on raw TCP
If you are running a custom protocol over raw TCP
rather than HTTP, call tako::proxy_protocol::read_proxy_protocol(&mut stream)
yourself. It consumes the header and leaves the stream positioned at the start of
your protocol data. The same code compiles on Compio, where serve_tcp hands the
handler a compio stream and read_proxy_protocol reads it through compio I/O:
use tako::proxy_protocol::read_proxy_protocol;
use tako::server_tcp::serve_tcp;
#[tokio::main]
async fn main() -> std::io::Result<()> {
serve_tcp("0.0.0.0:8080", |mut stream, _addr| {
Box::pin(async move {
let header = read_proxy_protocol(&mut stream).await?;
eprintln!("real client: {:?}", header.source);
Ok(())
})
})
.await?;
Ok(())
}Configuration and shutdown
Pass a ServerConfig to the builder with .config(…) for connection caps,
keep-alive, header and drain timeouts, and the PROXY read timeout — all sourced
from one struct, as with every other transport. The returned ServerHandle drives
graceful shutdown.
Testing
You can exercise the v1 text format by hand — prepend the header line to a raw HTTP request:
printf 'PROXY TCP4 192.168.1.100 10.0.0.1 56324 8080\r\nGET / HTTP/1.1\r\nHost: localhost\r\n\r\n' | nc 127.0.0.1 8080Example
examples/proxy-protocol— HTTP server reading the real client address from the PROXY header, with anc-based v1 test command.
Last updated on