Skip to content

v4.3.0

Choose a tag to compare

@GianfriAur GianfriAur released this 12 May 08:52
· 36 commits to master since this release
ab582c6

[v4.3.0] - 2026-04-23

This is a consolidation release. For end users the only action required is
to flush any persistent cache on upgrade. The public Client, ClientBuilder,
and ClientKernelInterface surfaces are unchanged (only additive) — with one
visible behaviour change: NodeManagementModule is back in the default module
list (see Changed below).

Compliance

  • ECC sequence numbers now follow OPC UA 1.05.4 (Part 6 §6.7.2.4). For ECC
    policies the first sequence number is 0 (was 1) and wraps at
    UInt32.MaxValue. RSA is unchanged. Compatible with both pre- and
    post-d188383 UA-.NETStandard servers. No public API change. Covered by 12
    new tests in tests/Unit/Security/SecureChannelSequenceNumberTest.php.
  • RequestHeader.timestamp is now a valid UtcTime. Per OPC UA 1.05 Part 4 §7.33 the field is 100-ns ticks since 1601-01-01; the client was writing 0 (which decodes to 1601-01-01), so servers with verifyRequestTimestamp enabled (e.g. open62541) rejected every request with BadInvalidTimestamp (0x80230000). Fixed in all 7 call sites that build a RequestHeader: Protocol/AbstractProtocolService::writeRequestHeader, Protocol/SessionService (CreateSession + ActivateSession, secure + non-secure variants), Protocol/SecureChannelRequest (OpenSecureChannel), Security/SecureChannel (OPN inner), Client/ManagesSessionTrait (CloseSession), Client/ManagesSecureChannelTrait (CloseSecureChannel). Now every RequestHeader carries writeDateTime(new \DateTimeImmutable()).
  • Anonymous policyId is now discovered for all security modes. Client\ManagesConnectionTrait::performConnect guarded the GetEndpoints discovery call with $isSecure, so with SecurityPolicy::None the client never read the server's advertised UserTokenPolicy[0].policyId and fell back to a hardcoded "anonymous" — the value UA-.NETStandard happens to use. Other servers (open62541: "open62541-anonymous-policy") replied with BadIdentityTokenInvalid (0x80200000). Discovery now runs whenever either the server certificate or the anonymous policy ID is still unknown, independent of the security mode. The cert-required-but-missing error is still raised only when the connection actually needs a cert.
  • NodeManagement service type IDs now reference the DefaultBinary encoding. Protocol\ServiceTypeId::{ADD_NODES,ADD_REFERENCES,DELETE_NODES,DELETE_REFERENCES}_REQUEST held the abstract *Request DataType NodeIds (486 / 492 / 498 / 504); the binary protocol dispatches on the *Request_Encoding_DefaultBinary object NodeIds (488 / 494 / 500 / 506). Browse / Read / Write were already correct. The bug never surfaced in CI because UA-.NETStandard replies with ServiceFault to unsupported services and the client's former crash-on-ServiceFault (EncodingException: Buffer underflow) masked the wrong-type-id symptom.

The three RequestHeader / discovery / type-id items above were latent wire-format bugs — all three had no visible effect against UA-.NETStandard (permissive enough to tolerate them) and only surfaced once integration testing reached open62541 (see CI below).

Security (BREAKING for persistent caches)

  • Removed unserialize() from every cache code path. FileCache, the
    Client cache runtime, and the module-level cache writes now go through
    Cache\WireCacheCodec — plain JSON gated by the existing Wire\WireTypeRegistry
    allowlist. Prevents PHP object-injection attacks on cache backends writable
    by an untrusted party.
  • Pre-v4.3.0 cache entries are discarded on first access (cache miss +
    refetch). Flush persistent caches on upgrade to avoid the transient
    cold-cache period.
  • New: Cache\CacheCodecInterface, Cache\WireCacheCodec, Exception\CacheCorruptedException.
  • Types\StructureDefinition and Types\StructureField now implement
    Wire\WireSerializable (they are cached by discoverDataTypes()).

Added

  • ClientBuilder::setCacheCodec(?CacheCodecInterface) — override the default
    codec. Omit to get the secure WireCacheCodec default.
  • CoreWireTypes::registerForCache(WireTypeRegistry) — register only the
    types actually cached (subset of ::register()).
  • ClientKernelInterface::getCacheCodec(): CacheCodecInterface — additive;
    third-party implementations of the interface must add the method.
  • Client-side ServiceFault decoding. When a server returns a top-level ServiceFault (TypeId ns=0;i=397, OPC UA 1.05 Part 4 §7.35) the client now raises ServiceException carrying the ResponseHeader.ServiceResult instead of reading past the empty fault body and throwing the misleading EncodingException: Buffer underflow: need 4 bytes, have 0. New helper Protocol\ServiceFault::throwIf(NodeId, int) is invoked from AbstractProtocolService::readResponseMetadata() (covers every module service in one hook) and from the two SessionService decoders that have dedicated read paths (decodeCreateSessionResponse / decodeActivateSessionResponse). New constant Protocol\ServiceTypeId::SERVICE_FAULT = 397.
  • Exception\ServiceUnsupportedException — dedicated subclass of ServiceException, raised by ServiceFault::throwIf specifically when the ServiceResult is BadServiceUnsupported (0x800B0000). Lets callers distinguish "this server does not implement this service set" from other transport-level faults without string-matching on the exception message. Extends ServiceException, so existing handlers continue to match.

Fixed

  • Cleaned up dead executeWithRetry() code in the (now-removed) concrete
    Kernel\ClientKernel. Client\ManagesConnectionTrait::executeWithRetry()
    is the single source of truth. The old method logged "retrying" and
    re-threw without calling reconnect(), so no behaviour change for users.
  • ManagesHandshakeTrait::performDiscoveryHandshake() now recognises an ERR response during the HEL/ACK exchange and raises the same HandshakeException("Server error during handshake: [<code>] <message>") as the main handshake. Previously the discovery path threw a generic MessageTypeException("Expected ACK response, got: ERR"), which was less informative and became the observed error whenever the main connect was preceded by discovery.

Changed

  • Removed the unused Kernel\ClientKernel concrete class. It was never
    instantiated at runtime — Client implements ClientKernelInterface
    itself. The interface is unchanged. Third-party code mocking the concrete
    class in tests should switch to mocking the interface.
  • NodeManagementModule is back in ClientBuilder::defaultModules(). With ServiceFault decoding and ServiceUnsupportedException in place, the module is wired unconditionally — the builder does not probe the server at connect time, so there is zero added latency or network traffic for users who never call NodeManagement. If the server does not implement the service set, the first call to addNodes() / deleteNodes() / addReferences() / deleteReferences() raises ServiceUnsupportedException("Server returned ServiceFault: 0x800B0000 BadServiceUnsupported"); subsequent calls behave identically. Default module count is now 8 (was 7 in v4.2.x, 8 in v4.1.x and earlier).

CI

  • open62541 test server consumed via php-opcua/extra-test-suite@v1.0.0. New sibling repo that ships a docker-compose stack of OPC UA servers not covered by uanetstandard-test-suite (open62541 with NodeManagement on :24840, room for Prosys / Milo / node-opcua in future minor releases). Pre-built images are published to GHCR on tag push; every matrix leg of the integration job consumes them via the composite action — docker compose pull + up -d with a CI-specific override that sets restart: "no" (the base compose uses restart: unless-stopped for dev machines). Mandatory on every leg, not an opt-in per PHP version — same treatment as uanetstandard-test-suite. Warm step time ≈ 10–15 s vs the 3–5 min that an in-repo build from source would have required. No endpoint env var is threaded through the workflow: port 24840 is part of the suite's versioned contract and TestHelper::ENDPOINT_NODE_MANAGEMENT hardcodes it, mirroring the ENDPOINT_NO_SECURITY / ENDPOINT_USERPASS / … constants used for uanetstandard-test-suite.
  • composer format:check promoted to a dedicated, non-blocking format job.

Testing

  • Expanded unit coverage with new and extended test files across Cache,
    Security, Types, Module, Wire, Client, and Testing namespaces.
  • tests/Unit/Protocol/ServiceFaultTest.php — 9 cases covering positive detection, status-code preservation, non-fault typeIds, namespace-0 guard, string-identifier guard, buggy-good-status edge case, ServiceUnsupportedException for BadServiceUnsupported, base ServiceException for other statuses, subclass-of-ServiceException backward compatibility.
  • tests/Unit/ClientBuilder/ModuleBuilderTest.php — updated to reflect the 8-module default.
  • tests/Integration/NodeManagementTest.php — six tests un-skipped, tagged ->group('integration') (no dedicated node-management group — the extra-test-suite dependency is mandatory, just like uanetstandard-test-suite). Run whenever the caller starts both suites (locally) or uses both composite actions (CI); there is no env-var gate and no skip. New-node namespace switched from 2 (UA-.NETStandard-specific) to 1 (standard Application namespace).
  • tests/Integration/Helpers/TestHelper::ENDPOINT_NODE_MANAGEMENT (constant) and connectForNodeManagement() (helper) — both now resolve the endpoint from a hardcoded opc.tcp://localhost:24840, matching ENDPOINT_NO_SECURITY / ENDPOINT_USERPASS / … in shape. The module is in the client defaults, so the helper is a one-liner ClientBuilder::connect(self::ENDPOINT_NODE_MANAGEMENT).