proxy-protocol-rs 0.8.0

Tokio-native Proxy Protocol v1/v2 listener wrapper
Documentation
# A Proxy Protocol (v1 and v2) Implementation for Rust and Tokio

This crate implements the [Proxy Protocol](https://www.haproxy.org/download/2.9/doc/proxy-protocol.txt) (versions 1 and 2)
on top of Tokio.

It provides a drop-in `TcpListener` wrapper that reads and strips Proxy Protocol headers,
exposing the real client address to the application. Beyond the listener, the crate includes
a standalone parser, a header builder, and TLV support with vendor-specific extensions for AWS, Azure, and GCP.

This library was heavily inspired by [Ranch](https://github.com/ninenines/ranch) (Erlang, Elixir) and
[go-proxyproto](https://github.com/pires/go-proxyproto) (Go).


## Key Features

* Proxy Protocol v1 (text) and v2 (binary) parsing and building
* `ProxyProtocolListener`: a TCP listener wrapper with configurable policies
* `axum::serve` integration behind the `axum` feature via `axum::serve::Listener`
* TLV extensions: ALPN, authority, TLS metadata, CRC32c, unique ID, NETNS
* Connection policies: trusted proxies (exact IPs and CIDRs), mixed mode, custom closures
* Header validation: reject connections based on parsed header content
* Bounded concurrency: configurable semaphore for in-flight PP handshakes
* Vendor-specific TLV support: AWS VPC endpoint ID, Azure Private Link ID, GCP PSC connection ID


## Project Maturity

This library is young but the API is stabilizing.

Before `1.0`, breaking API changes can happen.


## Requirements

* Rust 1.88+ (edition 2024)
* Tokio runtime


## Dependency

```toml
proxy-protocol-rs = "0.8"
```

### With Axum Integration

```toml
proxy-protocol-rs = { version = "0.8", features = ["axum"] }
```

### With Vendor TLV Support

With [AWS NLB](https://docs.aws.amazon.com/elasticloadbalancing/latest/network/edit-target-group-attributes.html):

```toml
# AWS VPC endpoint ID (PP2_TYPE_AWS, 0xEA)
proxy-protocol-rs = { version = "0.8", features = ["aws"] }
```

With [GCP Private Service Connect](https://cloud.google.com/vpc/docs/configure-private-service-connect-producer#proxy-protocol):

```toml
# GCP Private Service Connect connection ID (PP2_TYPE_GCE, 0xE0)
proxy-protocol-rs = { version = "0.8", features = ["gcp"] }
```

With [Azure Private Link](https://learn.microsoft.com/en-us/azure/private-link/private-link-service-overview):

```toml
# Azure Private Endpoint LinkID (PP2_TYPE_AZURE, 0xEE)
proxy-protocol-rs = { version = "0.8", features = ["azure"] }
```


## Usage

### With `axum::serve`

`ProxyProtocolListener` implements `axum::serve::Listener`, so it plugs directly
into `axum::serve`:

```rust
use axum::{Router, extract::ConnectInfo, routing::get};
use tokio::net::TcpListener;
use proxy_protocol_rs::{ProxyProtocolListener, ProxyConnectInfo};

let tcp = TcpListener::bind("0.0.0.0:8080").await?;
let pp = ProxyProtocolListener::new(tcp, Default::default());

let app = Router::new().route("/", get(handler));
axum::serve(pp, app.into_make_service_with_connect_info::<ProxyConnectInfo>())
    .with_graceful_shutdown(shutdown_signal())
    .await?;

async fn handler(ConnectInfo(info): ConnectInfo<ProxyConnectInfo>) -> String {
    // info.client_addr  — real client (from PP header, or TCP peer if absent)
    // info.peer_addr    — the load balancer's address
    // info.proxy_info   — full parsed ProxyInfo, if a PP header was present
    // info.is_proxied() — whether a PP header was present
    format!("client: {} (proxied: {})", info.client_addr, info.is_proxied())
}
```

Connection errors are logged via `tracing` and retried internally;
your handlers only see successfully accepted connections.

### Accept Connections Directly

```rust
use tokio::net::TcpListener;
use proxy_protocol_rs::ProxyProtocolListener;

let listener = TcpListener::bind("0.0.0.0:8080").await?;
let pp = ProxyProtocolListener::new(listener, Default::default());

loop {
    let stream = pp.accept().await?;

    tokio::spawn(async move {
        println!("client: {}", stream.client_addr());
        println!("peer (LB): {}", stream.peer_addr());

        if let Some(info) = stream.proxy_info() {
            println!("{} {} via {:?}", info.version, info.command, info.transport);
        }

        // ProxiedStream implements AsyncRead + AsyncWrite;
        // leftover bytes after the PP header are buffered transparently
    });
}
```

### Configuration

Using the builder:

```rust
use std::time::Duration;
use proxy_protocol_rs::{ProxyProtocolConfig, VersionPreference};

let config = ProxyProtocolConfig::builder()
    .header_timeout(Duration::from_secs(10))
    .max_header_size(8192)
    .max_pending_handshakes(512)
    .version(VersionPreference::V2Only)
    .build()?;
```

Or using struct literals with `..Default::default()`:

```rust
use std::time::Duration;
use proxy_protocol_rs::{ProxyProtocolConfig, VersionPreference};

let config = ProxyProtocolConfig {
    header_timeout: Duration::from_secs(10),
    max_header_size: 8192,
    max_pending_handshakes: 512,
    version: VersionPreference::V2Only,
    ..Default::default()
};
```

### TLS Termination

The PP header is always cleartext *before* any TLS handshake.
`ProxiedStream` implements `AsyncRead + AsyncWrite`, so a TLS acceptor wraps it directly:

```rust
let stream = pp.accept().await?;
// capture metadata before TLS consumes the stream
let info = stream.connect_info();
let tls_stream = tls_acceptor.accept(stream).await?;
```

### Parse a Header from a Byte Buffer

```rust
use proxy_protocol_rs::parse;

let (info, consumed) = parse(buf)?;
let leftover = &buf[consumed..];

println!("{} {}", info.version, info.command);
if let Some(src) = info.source_inet() {
    println!("client: {src}");
}
if let Some(dst) = info.destination_inet() {
    println!("destination: {dst}");
}
```

### Trusted Proxies

Only accept PP headers from known load balancers; reject everyone else:

```rust
use proxy_protocol_rs::{IpNet, ProxyProtocolConfig, ProxyProtocolListener, TrustedProxies};

let cidrs: Vec<IpNet> = vec![
    "10.0.0.0/8".parse()?,
    "172.16.0.0/12".parse()?,
];
let policy = TrustedProxies::from_ipnets(cidrs);
let config = ProxyProtocolConfig::builder()
    .policy(policy)
    .build()?;
let pp = ProxyProtocolListener::new(listener, config);
```

### Mixed Mode (Proxied and Direct Connections)

Accept PP from trusted proxies, pass direct connections through as-is:

```rust
use proxy_protocol_rs::{MixedMode, TrustedProxies, ProxyProtocolConfig, ProxyProtocolListener};

let trusted = TrustedProxies::new(["10.0.0.1".parse()?]);
let config = ProxyProtocolConfig::builder()
    .policy(MixedMode::new(trusted))
    .build()?;
let pp = ProxyProtocolListener::new(listener, config);
```

### Header Validation

Reject connections after parsing, e.g. to block spoofed source addresses:

```rust
use std::net::SocketAddr;
use proxy_protocol_rs::{ProxyProtocolConfig, ProxyInfo};

let config = ProxyProtocolConfig::builder()
    .validator(|info: &ProxyInfo, _peer: SocketAddr| {
        if info.source_ip().is_some_and(|ip| ip.is_loopback()) {
            return Err("loopback source rejected".into());
        }
        Ok(())
    })
    .build()?;
```

### Build and Send a Header

```rust
use proxy_protocol_rs::HeaderBuilder;

let header = HeaderBuilder::v2_proxy(
    "203.0.113.42:54321".parse()?,
    "10.0.0.1:8080".parse()?,
)
.with_authority("example.com")
.with_unique_id(b"conn-abc-123")
.with_crc32c()
.build();

stream.write_all(&header).await?;

// or write directly without using an intermediate Vec:
// builder.write_to(&mut stream).await?;
```

```rust
// v2 LOCAL (health-check / proxy-to-self, no addresses)
let header = HeaderBuilder::v2_local().build();
```

```rust
// v1 text header
let header = HeaderBuilder::v1_proxy(
    "203.0.113.42:54321".parse()?,
    "10.0.0.1:8080".parse()?,
).build();
// => b"PROXY TCP4 203.0.113.42 10.0.0.1 54321 8080\r\n"
```

`build` panics if any single TLV value or the total v2 payload exceeds
65,535 bytes (the hard limit of the v2 format).

### TLV Extensions

V2 headers carry TLV (Type-Length-Value) extensions, parsed into typed fields
with the raw bytes always preserved in `tlvs.raw`:

```rust
let (info, _) = parse(buf)?;

if let Some(alpn) = &info.tlvs.alpn { /* e.g. b"h2" */ }
if let Some(authority) = &info.tlvs.authority { /* SNI hostname */ }
if let Some(id) = &info.tlvs.unique_id { /* up to 128 bytes */ }
if let Some(ns) = &info.tlvs.netns { /* network namespace path */ }
if let Some(crc) = info.tlvs.crc32c { /* already validated during parse */ }

if let Some(ssl) = &info.tlvs.ssl {
    println!("TLS: {:?}, verified: {}", ssl.version, ssl.verified);
    println!("cipher: {:?}, CN: {:?}", ssl.cipher, ssl.cn);
}

// all TLVs are also available as raw (type, value) pairs
for (tlv_type, value) in &info.tlvs.raw {
    println!("TLV 0x{tlv_type:02x}: {} bytes", value.len());
}
```

### Public Cloud Vendor TLVs

Each requires its feature flag:

```rust
// feature = "aws"
if let Some(Ok(aws)) = info.tlvs.aws() {
    println!("VPC endpoint: {:?}", aws.vpc_endpoint_id);
}

// feature = "azure"
if let Some(Ok(azure)) = info.tlvs.azure() {
    println!("link ID: {:?}", azure.private_endpoint_link_id);
}

// feature = "gcp"
if let Some(Ok(gcp)) = info.tlvs.gcp() {
    println!("PSC connection: {}", gcp.psc_connection_id);
}
```


## Copyright

(c) 2025-2026 Michael S. Klishin and Contributors.


## License

This crate, `proxy-protocol-rs`, is dual-licensed under
the Apache Software License 2.0 and the MIT license.

SPDX-License-Identifier: Apache-2.0 OR MIT