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 (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 (Erlang, Elixir) and 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

proxy-protocol-rs = "0.8"

With Axum Integration

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

With Vendor TLV Support

With AWS NLB:

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

With GCP Private Service Connect:

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

With Azure Private Link:

# 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:

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

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:

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():

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:

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

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:

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:

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:

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

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?;
// v2 LOCAL (health-check / proxy-to-self, no addresses)
let header = HeaderBuilder::v2_local().build();
// 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:

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:

// 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