Skip to content

Make server endpoint selection deterministic - #1833

Merged
kevinherron merged 2 commits into
integration/1.2from
kevinherron/dig-19-endpoint-selection
Aug 1, 2026
Merged

Make server endpoint selection deterministic#1833
kevinherron merged 2 commits into
integration/1.2from
kevinherron/dig-19-endpoint-selection

Conversation

@kevinherron

Copy link
Copy Markdown
Contributor

Summary

When a client opens a secure channel, OPC UA doesn't tell the server which configured endpoint the client picked — the server has to figure that out from what's observable on the wire (the URL, security policy, security mode, and certificate). Previously, the server re-derived this guess on every request using first-match scans over the endpoint list. If several endpoints looked alike, whichever happened to come first won, so security-sensitive Session state (user token policies, the advertised certificate) could silently depend on collection ordering — and a Session could end up bound to a different endpoint than the one its channel actually negotiated.

This PR makes endpoint selection deterministic: the endpoint is selected once, at OpenSecureChannel time, from the channel's wire-observable identity, and that selection travels with every request on the channel. If a configuration contains endpoints the server could never tell apart on the wire, that's now a startup error instead of a silent arbitrary choice.

Changes

  • EndpointSelectionKey (new, transport layer): the wire-observable identity of an endpoint — transport profile, URL path, SecurityPolicy, MessageSecurityMode, and certificate thumbprint. Selection yields exactly one endpoint or none, never an ordering-dependent pick. Endpoints differing only in host/port (hostname aliases) legitimately share a key and are disambiguated by preferring the client's requested URL.
  • Endpoint propagation: the endpoint selected during OpenSecureChannel is captured on the channel and exposed via ServiceRequestContext.getEndpoint() (returns Optional), so SessionManager binds Sessions to the negotiated endpoint instead of re-deriving one.
  • EndpointSelectionIndex (new, SDK layer): built once per resolved endpoint set; OpcUaServer.startup() fails with Bad_ConfigurationError when two non-equivalent endpoints share a selection key. resetEndpointDescriptionCache() re-validates and logs an error (it can't fail a running server).
  • Discovery-only lockdown preserved: unsecured channels are restricted to discovery services unless an explicit SecurityPolicy.None endpoint currently exists for the transport/path — re-checked per request, so removing the None endpoint and resetting the description cache immediately locks down already-open unsecured channels.
  • Spec-compliance guard: an OpenSecureChannel that omits receiverCertificateThumbprint on a secured policy is rejected explicitly with Bad_SecurityChecksFailed (Part 6 §6.7.2.3) instead of failing incidentally with a misleading error.

Breaking changes (intentional)

  • Configurations with endpoints that are indistinguishable at OpenSecureChannel but differ in Session-sensitive properties (e.g. same URL/policy/mode/certificate but different user token policies) previously "worked" via arbitrary first-match; they now fail startup with Bad_ConfigurationError. Combine the token policies into a single endpoint, or make the endpoints wire-distinguishable.
  • OpcUaServerConfigBuilder.setEndpoints now copies the supplied set; mutations after the call no longer affect the built config.
  • ServiceRequestContext.getEndpoint() is a new abstract method returning Optional<EndpointDescription>; the endpoint-less ServiceRequest/UascServiceRequest constructors were removed.

Testing

  • New SessionEndpointBindingTest covering session-to-endpoint binding, hostname-alias preference, discovery-only rejection, and ambiguity handling.
  • mvn clean verify passes for opc-ua-stack/transport and opc-ua-sdk/sdk-server (full compile across all modules).

Follow-up tracked in DIG-20: precompute the discovery-only gate lookup in EndpointSelectionIndex.build to avoid the per-request endpoint scan on unsecured channels.

🤖 Generated with Claude Code

kevinherron and others added 2 commits August 1, 2026 12:12
Servers previously re-derived the endpoint for each request with
first-match scans over the endpoint descriptions, so ambiguous
configurations were resolved arbitrarily and a Session could bind to a
different endpoint than the one its channel negotiated.

Introduce EndpointSelectionKey, the wire-observable identity of an
endpoint at OpenSecureChannel time, and select exactly one endpoint (or
none) per key. The selection is captured on the channel and accompanies
every inbound request via ServiceRequestContext.getEndpoint(), so
Sessions always bind to the negotiated endpoint.

OpcUaServer.startup() now fails with Bad_ConfigurationError when two
non-equivalent endpoints share a selection key; this is intentionally
backwards-incompatible for configurations that previously relied on
arbitrary first-match resolution. Endpoints differing only in host/port
(hostname aliases) remain valid and are preferred by requested URL.

Unsecured channels stay discovery-only unless an explicit None endpoint
currently exists, re-checked per request so removing the endpoint and
resetting the description cache locks down open channels. An OPN that
omits receiverCertificateThumbprint on a secured policy is now rejected
explicitly per Part 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CI formatting check downloads the latest google-java-format
release, which now strips the empty leading javadoc lines, while the
google-java-format bundled by spotless re-adds them; the empty-summary
javadoc was the one construct the two versions disagree on, failing
the formatting check for every PR. A real summary sentence satisfies
both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kevinherron
kevinherron force-pushed the kevinherron/dig-19-endpoint-selection branch from 5557c32 to c4d5251 Compare August 1, 2026 19:13
@kevinherron
kevinherron merged commit 58e9475 into integration/1.2 Aug 1, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant