Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
* Copyright (c) 2026 the Eclipse Milo Authors
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/

package org.eclipse.milo.opcua.sdk.server;

import static java.util.Objects.requireNonNullElse;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.milo.opcua.stack.core.StatusCodes;
import org.eclipse.milo.opcua.stack.core.UaException;
import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription;
import org.eclipse.milo.opcua.stack.core.types.structured.UserTokenPolicy;
import org.eclipse.milo.opcua.stack.transport.server.EndpointSelectionKey;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

/**
* An immutable index from {@link EndpointSelectionKey} to the effective endpoint it identifies.
*
* <p>Built once per resolved endpoint set, this index is the single definition of endpoint
* selection shared by configuration validation and runtime channel/session resolution:
*
* <ul>
* <li>{@link #validate()} enforces at startup that no two non-equivalent endpoints share a
* selection key, so runtime selection can never depend on endpoint collection ordering.
* <li>{@link #select(EndpointSelectionKey, String)} resolves a key to exactly one {@link
* ResolvedEndpoint}, preferring the host/port substitution alias that matches the client's
* requested endpoint URL.
* </ul>
*/
@NullMarked
final class EndpointSelectionIndex {

private final Map<EndpointSelectionKey, List<ResolvedEndpoint>> groups;
private final Set<EndpointSelectionKey> collidingKeys;
private final List<String> collisions;

private EndpointSelectionIndex(
Map<EndpointSelectionKey, List<ResolvedEndpoint>> groups,
Set<EndpointSelectionKey> collidingKeys,
List<String> collisions) {

this.groups = groups;
this.collidingKeys = collidingKeys;
this.collisions = collisions;
}

/**
* Build an index over {@code endpoints}, recording any selection key collisions.
*
* <p>Endpoints sharing a key are a collision unless they are {@link
* EndpointSelectionKey#isSessionEquivalent(EndpointDescription, EndpointDescription)
* Session-equivalent}, i.e. host/port substitution aliases of the same effective endpoint.
*
* @param endpoints the resolved endpoints to index.
* @return the index; call {@link #validate()} to fail on recorded collisions.
*/
static EndpointSelectionIndex build(List<ResolvedEndpoint> endpoints) {
var groups = new LinkedHashMap<EndpointSelectionKey, List<ResolvedEndpoint>>();

for (ResolvedEndpoint endpoint : endpoints) {
EndpointSelectionKey key = EndpointSelectionKey.of(endpoint.endpointDescription());
groups.computeIfAbsent(key, k -> new ArrayList<>()).add(endpoint);
}

var collidingKeys = new HashSet<EndpointSelectionKey>();
var collisions = new ArrayList<String>();

groups.forEach(
(key, group) -> {
EndpointDescription first = group.get(0).endpointDescription();

boolean equivalent =
group.stream()
.allMatch(
r ->
EndpointSelectionKey.isSessionEquivalent(first, r.endpointDescription()));

if (!equivalent) {
collidingKeys.add(key);

String colliding =
group.stream()
.map(r -> describe(r.endpointDescription()))
.collect(Collectors.joining(", "));

collisions.add(
String.format(
"endpoints indistinguishable at OpenSecureChannel differ in"
+ " Session-sensitive properties: selectionKey=%s, endpoints=[%s]",
key, colliding));
}
});

return new EndpointSelectionIndex(groups, collidingKeys, collisions);
}

/**
* Fail if any selection key is claimed by non-equivalent endpoints.
*
* <p>Endpoints intended to differ only in supported authentication methods must instead combine
* their {@link UserTokenPolicy}s into a single endpoint configuration; endpoints intended to have
* distinct Session policy or access behavior must be distinguishable by a wire-observable
* selector (SecurityPolicy, MessageSecurityMode, or endpoint certificate).
*
* @throws UaException with {@link StatusCodes#Bad_ConfigurationError} identifying each colliding
* selection key and its endpoints.
*/
void validate() throws UaException {
if (!collisions.isEmpty()) {
throw new UaException(
StatusCodes.Bad_ConfigurationError,
"ambiguous endpoint configuration: " + String.join("; ", collisions));
}
}

/**
* Resolve {@code key} to the unique effective endpoint it identifies.
*
* <p>When multiple host/port substitution aliases share the key, the alias whose URL matches
* {@code requestedEndpointUrl} is preferred (host and port, then host only); the first alias is
* used otherwise. Aliases carry identical Session-sensitive state, so the choice affects only the
* advertised URL.
*
* @param key the {@link EndpointSelectionKey} to resolve.
* @param requestedEndpointUrl the endpoint URL requested by the client, if available.
* @return the unique {@link ResolvedEndpoint} for {@code key}, or empty if there is none or the
* key is among the recorded collisions.
*/
Optional<ResolvedEndpoint> select(
EndpointSelectionKey key, @Nullable String requestedEndpointUrl) {

List<ResolvedEndpoint> group = groups.get(key);

if (group == null || collidingKeys.contains(key)) {
return Optional.empty();
}

return Optional.of(
EndpointSelectionKey.preferRequestedUrl(
group, requestedEndpointUrl, r -> r.endpointDescription().getEndpointUrl()));
}

private static String describe(EndpointDescription endpoint) {
String tokenPolicies =
Stream.of(requireNonNullElse(endpoint.getUserIdentityTokens(), new UserTokenPolicy[0]))
.map(p -> p.getTokenType() + "/" + p.getPolicyId())
.collect(Collectors.joining(",", "[", "]"));

return endpoint.getEndpointUrl() + " userTokenPolicies=" + tokenPolicies;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
import org.eclipse.milo.opcua.stack.core.util.LongSequence;
import org.eclipse.milo.opcua.stack.core.util.ManifestUtil;
import org.eclipse.milo.opcua.stack.core.util.NonceUtil;
import org.eclipse.milo.opcua.stack.transport.server.EndpointSelectionKey;
import org.eclipse.milo.opcua.stack.transport.server.OpcServerTransport;
import org.eclipse.milo.opcua.stack.transport.server.OpcServerTransportFactory;
import org.eclipse.milo.opcua.stack.transport.server.ServerApplicationContext;
Expand Down Expand Up @@ -170,6 +171,7 @@ public class OpcUaServer extends AbstractServiceHandler {
private final ServerDiagnosticsSummary diagnosticsSummary = new ServerDiagnosticsSummary(this);

private final Lazy<List<ResolvedEndpoint>> resolvedEndpoints = new Lazy<>();
private final Lazy<EndpointSelectionIndex> endpointSelectionIndex = new Lazy<>();

private final List<EndpointConfig> boundEndpoints = new CopyOnWriteArrayList<>();
private final CertificateIdentitySelector endpointCertificateIdentitySelector =
Expand Down Expand Up @@ -357,6 +359,15 @@ public ServerTable getServerTable() {
}

public CompletableFuture<OpcUaServer> startup() {
try {
// Reject ambiguous endpoint configurations before binding anything: two Session-capable
// endpoints mapping to the same wire-observable selection key cannot be told apart at
// OpenSecureChannel time, so runtime selection would depend on collection ordering.
getEndpointSelectionIndex().validate();
} catch (UaException e) {
return CompletableFuture.failedFuture(e);
}

eventFactory.startup();
eventInstantiator.startup();

Expand Down Expand Up @@ -928,9 +939,28 @@ private OpcServerTransport getOrCreateTransport(TransportProfile transportProfil
* advertised without a listening socket behind it. Conversely, an endpoint bound at startup
* remains bound even if it no longer resolves. Re-binding after a reset is not currently
* supported; a server restart is required to change the set of bound sockets.
*
* <p>When the endpoint set is next resolved, it is validated for selection-key collisions the
* same way {@link #startup()} validates the initial configuration. A collision cannot fail an
* already-running server, so it is logged as an error instead; the colliding endpoints are not
* selectable until the configuration is corrected and the cache reset again.
*/
public void resetEndpointDescriptionCache() {
resolvedEndpoints.reset();
endpointSelectionIndex.reset();
}

private EndpointSelectionIndex getEndpointSelectionIndex() {
return endpointSelectionIndex.get(
() -> {
var index = EndpointSelectionIndex.build(getResolvedEndpoints());
try {
index.validate();
} catch (UaException e) {
logger.error("Colliding endpoints will not be selectable: {}", e.getMessage());
}
return index;
});
}

private List<ResolvedEndpoint> getResolvedEndpoints() {
Expand Down Expand Up @@ -1167,6 +1197,15 @@ public List<EndpointDescription> getEndpointDescriptions() {
return getResolvedEndpoints().stream().map(ResolvedEndpoint::endpointDescription).toList();
}

@Override
public Optional<EndpointDescription> selectEndpoint(
EndpointSelectionKey key, @Nullable String requestedEndpointUrl) {

return getEndpointSelectionIndex()
.select(key, requestedEndpointUrl)
.map(ResolvedEndpoint::endpointDescription);
}

@Override
public EncodingContext getEncodingContext() {
return staticEncodingContext;
Expand Down Expand Up @@ -1211,6 +1250,11 @@ private void handleServiceRequest(
String path = EndpointUtil.getPath(context.getEndpointUrl());

if (context.getSecureChannel().getSecurityPolicy() == SecurityPolicy.None) {
// An unsecured channel is discovery-only unless an explicit SecurityPolicy.None endpoint
// currently exists for this transport and path. The current endpoint descriptions are
// re-checked on every request, rather than trusting a selection captured at
// OpenSecureChannel time, so that removing the None endpoint and resetting the endpoint
// description cache locks down already-open unsecured channels.
if (getEndpointDescriptions().stream()
.filter(e -> EndpointUtil.getPath(e.getEndpointUrl()).equals(path))
.filter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import static java.util.Objects.requireNonNull;

import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
Expand All @@ -33,7 +34,7 @@

public class OpcUaServerConfigBuilder {

private Set<EndpointConfig> endpoints = new HashSet<>();
private Set<EndpointConfig> endpoints = new LinkedHashSet<>();
private Set<ReverseConnectTarget> reverseConnectTargets = new HashSet<>();

private LocalizedText applicationName =
Expand Down Expand Up @@ -63,8 +64,18 @@ public class OpcUaServerConfigBuilder {
private ExecutorService executor;
private ScheduledExecutorService scheduledExecutor;

/**
* Set the endpoints the server offers.
*
* <p>The builder copies the supplied set, preserving its iteration order, so later changes to
* {@code endpointConfigs} do not affect the built configuration.
*
* @param endpointConfigs the complete set of endpoints the server offers.
* @return this builder.
*/
public OpcUaServerConfigBuilder setEndpoints(Set<EndpointConfig> endpointConfigs) {
this.endpoints = endpointConfigs;
Objects.requireNonNull(endpointConfigs, "endpointConfigs");
this.endpoints = new LinkedHashSet<>(endpointConfigs);
return this;
}

Expand Down
Loading
Loading