How Reverse Connect works
Wire format of the ReverseHello frame, lifecycle of a reverse-connected session, and the security implications of letting the server dial the client.
Reverse Connect inverts who initiates the TCP connection. Everything after the inversion is the standard UA-TCP protocol.
Sequence
┌───────────┐ ┌───────────┐
│ Client │ │ Server │
│ (this lib)│ │ (RC-aware)│
└─────┬─────┘ └─────┬─────┘
│ │
│ stream_socket_server / accept() │
│ ◄────────── TCP SYN ─────────────────│ initiates
│ │
│ ◄────────── RHE frame ───────────────│
│ decode + validate ServerUri │
│ │
│ ───────── HEL ──────────────────► │
│ ◄────────── ACK ─────────────────────│
│ ───────── OPN ──────────────────► │
│ ◄────────── OPN ─────────────────────│
│ ───────── CreateSession ─────────► │
│ ◄────────── CreateSession ────────────│
│ …business calls… │
The only step that differs from the classic flow is the direction of
the TCP SYN. From HEL onwards every byte is byte-for-byte the
same as a regular UA-TCP session.
RHE frame layout
The wire format is defined in OPC UA Part 6 §7.1.2.3:
| Field | Type | Size | Notes |
|---|---|---|---|
| MessageType | 3 bytes ASCII | 3 | Always "RHE" |
| ChunkType | 1 byte ASCII | 1 | Always "F" (final) |
| MessageSize | UInt32 LE | 4 | Total frame size including header |
| ServerUri | OPC UA String | variable | Application URI announced by the server |
| EndpointUrl | OPC UA String | variable | Endpoint the client uses in the CreateSession |
OPC UA String encoding: Int32 little-endian length prefix followed by
UTF-8 bytes. Length -1 represents the null string; the parser
normalises it to the empty string.
The package exposes the two compile-time bounds as constants on
ReverseHelloParser:
ReverseHelloParser::MIN_FRAME_SIZE = 16— header + two empty string length prefixesReverseHelloParser::DEFAULT_MAX_FRAME_SIZE = 65535— soft upper bound applied by the parser when the caller does not pass an explicit$maxFrameSize
A declared MessageSize outside that window raises
ReverseHelloParseException
before any further bytes are interpreted.
Lifecycle of a session
ReverseConnectListener::__construct(...)— set bind host / port, supply a validator (and optionally a logger / dispatcher / max frame size).listen()— open the TCP server socket. Idempotent.accept(float $timeoutSeconds)— block until a server connects and sends a full RHE, or until the timeout elapses.ReverseConnectSession— returned on success. Owns the live socket; the consumer is responsible for closing it (or for letting the resulting transport close it).ReverseConnectClientFactory::buildClient($session, $configure)— wrap the socket into aTcpTransport::fromConnectedSocket(), apply caller configuration to a freshClientBuilder, then callconnect($session->endpointUrl).- Use the
Clientas you would after a normal connect. close()— release the listener socket when the loop ends. Subsequentaccept()calls on a closed listener raiseReverseConnectException.
Security model
Two checks gate every connection:
- Whitelist validation, before UA framing. The validator —
ReverseHelloValidator— refuses messages whoseServerUriis not in the allow list, with case-sensitive exact match (per RFC 3986). Anyone capable of reaching the listener port can present a frame; without this check an impostor could reach the UA secure channel before being rejected. - UA secure channel certificate validation, after HEL/ACK. The
inversion changes who opens the socket, not who validates which
certificate. If you want mutual authentication, configure security
policy and trust store inside the
$configureclosure passed toReverseConnectClientFactory::buildClient()exactly as you would for a classic client.
The whitelist is mandatory in the sense that an empty whitelist rejects every incoming frame — the validator is fail-secure by default.
Threading
The listener does not spin a thread or run an event loop. accept()
is a single blocking call bounded by a caller-supplied timeout. To
service multiple servers, call accept() in a loop on the same
listener instance, or run multiple listeners on different ports.
The PHP CLI process is the natural host for this kind of long-running loop; running the listener inside a request-scoped FPM worker is possible but uncommon, since the listener has to outlive a single request.