Search by

swsn / react-proxy-protocol

skydiablo

PROXY protocol v1/v2 decoder/encoder and ReactPHP TCP/UDP socket helpers

dev-main 2026-10-02 22:07 UTC

This package is auto-updated.

Last update: 2026-10-02 20:12:02 UTC


README

PROXY protocol v1/v2 for ReactPHP — inbound listener and outbound encoder / write helpers.

NamespaceSWSN\ProxyProtocol\
PHP^8.4
LicenseMIT

TCP (inbound)

Wraps a ServerInterface, strips the PROXY header, then re-emits a React\Socket\Connection so SecureServer / STARTTLS still work.

The header is read from the non-blocking raw socket via MSG_PEEK (same idea as React’s StreamEncryption): only the header bytes are consumed. Application payload / TLS ClientHello stay in the kernel — no full-stream buffer, no per-chunk copy after detach. Declared v2 lengths above ProxyProtocolDecoder::MAX_HEADER_BYTES (4096) are rejected early.

resumeAfterHeader only controls whether the connection is resumed after the header (it does not select a different decode strategy):

true (default)Plaintext apps — resume so data flows
falseNext layer is SecureServer — stay paused

Plaintext

$tcp = new React\Socket\SocketServer('0.0.0.0:25');
$server = new SWSN\ProxyProtocol\ProxyProtocolServer(
    $tcp,
    trustedCidrs: ['10.90.0.0/24'],
    required: true,
);

Implicit TLS

SecureServer requires the internal Connection class and must own the read watcher for stream_socket_enable_crypto. Keep the connection paused:

$tcp = new React\Socket\SocketServer('0.0.0.0:465');
$behindProxy = new SWSN\ProxyProtocol\ProxyProtocolServer(
    $tcp,
    trustedCidrs: ['10.90.0.0/24'],
    required: true,
    resumeAfterHeader: false,
);
$tls = new React\Socket\SecureServer($behindProxy, null, [
    'local_cert' => '/certs/server.pem',
]);

Stack: TcpServer → ProxyProtocolServer → SecureServer.

getRemoteAddress() on the emitted connection is the PROXY source (tcp://client:port, tls://… after handshake).

UDP (inbound)

Wraps a React\Datagram\Socket. Per the PROXY protocol spec, header and payload MUST be in the same datagram; each datagram is decoded independently (typically v2 with family 0x12 / 0x22).

$factory = new React\Datagram\Factory();
$udp = await($factory->createServer('0.0.0.0:162'));
$server = new SWSN\ProxyProtocol\ProxyProtocolDatagramServer(
    $udp,
    trustedCidrs: ['10.90.0.0/24'],
    required: true,
);

$server->on('message', function (
    string $payload,
    string $address,
    SWSN\ProxyProtocol\ProxyProtocolDatagramServer $socket,
    string $proxyPeer,
): void {
    // $address   = client from PROXY header (host:port)
    // $proxyPeer = actual UDP peer (load balancer) — use for replies via LB
});

Outgoing

ProxyProtocolEncoder builds v1/v2 headers. TCP: send the header once at the start of the connection. UDP: prepend the header on every datagram.

TCP connector (header once)

$encoder = new SWSN\ProxyProtocol\ProxyProtocolEncoder();
$header = $encoder->encodeV2(
    SWSN\ProxyProtocol\ProxyProtocolV2Family::TcpOverIpv4,
    '203.0.113.10',
    56424,
    '10.0.0.1',
    25,
);
$connector = new SWSN\ProxyProtocol\ProxyProtocolConnector(
    new React\Socket\Connector(),
    $header, // or fn (string $uri): string => …
);
$connector->connect('127.0.0.1:25')->then(function ($conn) {
    $conn->write("EHLO\r\n");
});

TCP writable wrap (header once)

$out = new SWSN\ProxyProtocol\ProxyProtocolWritableStream($connection, $header);
$out->write('payload'); // sends header + payload, further writes are plain

TLS outbound

Same order as inbound: PROXY on cleartext, then TLS. Put ProxyProtocolConnector inside SecureConnector (not the other way around):

$encoder = new SWSN\ProxyProtocol\ProxyProtocolEncoder();
$header = $encoder->encodeV2(
    SWSN\ProxyProtocol\ProxyProtocolV2Family::TcpOverIpv4,
    '203.0.113.10',
    56424,
    '10.0.0.1',
    465,
);

// TcpConnector → PROXY header once → TLS handshake → app writes
$tcp = new React\Socket\Connector(); // or TcpConnector
$behindProxy = new SWSN\ProxyProtocol\ProxyProtocolConnector($tcp, $header);
$tls = new React\Socket\SecureConnector($behindProxy, null, [
    'verify_peer' => true,
    'peer_name' => 'mail.example.com',
]);

$tls->connect('tls://mail.example.com:465')->then(function (
    React\Socket\ConnectionInterface $connection,
): void {
    // connection is encrypted; PROXY header was already sent on cleartext
    $connection->write("EHLO mail.example.com\r\n");
});

Stack: Connector → ProxyProtocolConnector → SecureConnector.

Alternatively, write the header yourself on a paused cleartext connection, then StreamEncryption::enable($connection) — same wire order.

UDP (header every packet)

$datagram = $encoder->wrap($header, $payload);
$socket->send($datagram, $remote);

// or when answering via ProxyProtocolDatagramServer:
$server->sendProxied($payload, $header, $proxyPeer);

Testing

composer install
vendor/bin/phpunit