How HTTPS transport works
One UA service request = one HTTPS POST. TLS is the secure channel. The transport replaces UA-TCP framing with raw HTTP exchanges.
The wire is HTTP
Every UA service call becomes a single HTTP POST. The body carries the
binary-encoded service request (NodeId TypeId + RequestHeader + body),
the Content-Type is the encoding's MIME, the response body carries
the matching service response. There is no persistent OPC UA channel
on the wire; TLS provides confidentiality and integrity.
UA-TCP (opc.tcp://) |
HTTPS Binary (opc.https://) |
|
|---|---|---|
| HEL/ACK | on the wire | absent (transport fakes locally) |
| OpenSecureChannel | on the wire | absent (TLS is the channel) |
| MSGF framing | on the wire | absent (bare service body in HTTP) |
| SecureChannelId | meaningful | unused (synthetic in re-frames) |
| Content-Type | n/a | application/octet-stream for §7.4.4 binary |
What the transport does
HttpsTransport implements ClientTransportInterface and intercepts the
two frames the core would otherwise send as UA-TCP:
-
01
HEL
— instead of writing it on the wire, the transport asks the encoding strategy for a synthetic ACK (
fakeAcknowledge(string $helFrame): string) and primes it for the nextreceive(). No HTTP traffic is generated. -
02
MSG / CLO
— the encoding strategy strips the 24-byte UA-TCP prefix (8-byte UA header + 4-byte SecureChannelId + 4-byte TokenId + 8-byte SequenceHeader) and the resulting body is POSTed. The response body is re-wrapped in a synthetic UA-TCP frame so the core's decoder reads it transparently.
The OpenSecureChannel frame (OPN) is never even emitted by the core
when the transport reports isSecureChannelExternal() === true, because
ManagesSecureChannelTrait::openSecureChannel() short-circuits to
openSecureChannelExternal(), which only initialises a SessionService
with synthetic channel/token IDs.
Lifecycle of a Client.connect()
ClientBuilder::connect('opc.https://server.example:443/UA/')
│
▼
performConnect()
├── discoverServerCertificate()
│ ├── createProbe() ← fresh HttpsTransport sibling
│ ├── probe->connect() ← no-op (HTTPS is stateless)
│ ├── send(HEL) ← fake ACK
│ ├── receive() ← returns the fake ACK
│ ├── (skip OPN because isSecureChannelExternal())
│ ├── send(GetEndpoints MSG) ← real POST
│ └── receive() ← re-framed response
│
├── transport->connect() ← no-op
├── doHandshake() ← HEL → fake ACK
├── openSecureChannelExternal() ← no OPN, just synthetic SessionService
└── createAndActivateSession() ← POST(CreateSession), POST(ActivateSession)
After this, every read() / write() / browse() / call() becomes one
POST against the configured endpointUrl.
Security model
TLS handles confidentiality and integrity end-to-end:
- Verify the server certificate via
CurlHttpClient(verifyTls: true, caBundle: '...'). Disable only inside controlled test environments. - Mutual TLS is supplied via
clientCertPath/clientKeyPathonCurlHttpClient— not viaClientBuilder::setClientCertificate(), which configures the OPC UA application certificate (a separate concept). - The OPC UA secure channel is not negotiated; the
SecurityPolicy/SecurityModeset onClientBuildershould beNonefor HTTPS deployments unless the server explicitly supports a per-message UA secure channel on top of TLS (rare).
Don't run verifyTls: false in production
A network attacker can interpose silently. Always pin a CA bundle (or rely on the system CA store) in real deployments.
Failure modes
| Cause | Exception |
|---|---|
| Network / DNS / TLS handshake error | HttpsRequestException |
| Non-2xx HTTP response | HttpsStatusException (carries statusCode + responseBody) |
| Encoding strategy could not decode a body | EncodingException |
| Strategy explicitly does not support a frame type yet | UnsupportedEncodingException |
| Anything else from the transport orchestration | HttpsTransportException (base) |
Each failure dispatches a HttpsRequestFailed PSR-14 event when a
dispatcher is configured.