fix(client): Client.Shared EndpointSelector uses the shared OpcUaEndpointUrlRewriter (#812)

Client.Shared now project-references Driver.OpcUaClient.Contracts (Core.Abstractions
only, no NuGet/SDK; pinned by ContractsIsDependencyLightTests) and delegates the
host/port rewrite to OpcUaEndpointUrlRewriter, deleting its own copy.

The two copies differed; reconciled toward the client so its pinned behaviour is
unchanged (EndpointSelectorRewriteParityTests, written green against the old copy):
- no-op rewrite (same host case-insensitively + same port) now returns the advertised
  string verbatim in the shared rewriter too (it re-serialised: added a trailing slash
  and case-folded a case-only host difference);
- a real rewrite serialises via AbsoluteUri, not Uri.ToString(), which un-escaped the
  advertised path (/x%20y became /x y) in the driver + browser;
- unusable URLs: the rewriter still falls back to the configured URL (driver/browser);
  the client keeps its loud failure as call-site validation (null -> InvalidOperation,
  unparseable -> UriFormatException).
This commit is contained in:
Joseph Doherty
2026-11-10 22:46:51 -05:00
parent 90491aa8b5
commit d52a3b1c64
4 changed files with 64 additions and 29 deletions
@@ -1,6 +1,7 @@
using Opc.Ua;
using Opc.Ua.Client;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
@@ -36,8 +37,7 @@ internal static class EndpointSelector
/// <summary>
/// Picks the best endpoint from the discovery response that matches the requested
/// security mode, preferring <c>Basic256Sha256</c>, and rewrites the endpoint URL
/// host to match the user-supplied URL when the discovery response advertises a
/// different hostname.
/// host and port to the user-supplied ones via the shared <see cref="OpcUaEndpointUrlRewriter"/>.
/// </summary>
/// <param name="allEndpoints">Endpoints returned by the discovery query, in any order.</param>
/// <param name="endpointUrl">The endpoint URL the operator supplied; supplies the hostname rewrite target.</param>
@@ -85,22 +85,23 @@ internal static class EndpointSelector
$"No endpoint found with security mode '{requestedMode}'. Available endpoints: {available}");
}
// Rewrite the endpoint URL host AND port to the user-supplied ones (as the SDK's
// CoreClientUtils.SelectEndpoint does). A server behind a port mapping or internal DNS
// advertises its own host:port (docker-dev containers all say :4840); following that would
// open the session on a different server and fail the secure-channel certificate check.
// Only when the scheme matches: a different scheme (https vs opc.tcp) is a different transport.
var serverUri = new Uri(best.EndpointUrl ?? throw new InvalidOperationException("Endpoint has no URL."));
var requestedUri = new Uri(endpointUrl);
// A URL typed without a port (opc.tcp has no registered default, so Port is -1) keeps the advertised port.
var port = requestedUri.Port > 0 ? requestedUri.Port : serverUri.Port;
if (string.Equals(serverUri.Scheme, requestedUri.Scheme, StringComparison.OrdinalIgnoreCase)
&& (serverUri.Host != requestedUri.Host || serverUri.Port != port))
// Rewrite the endpoint URL host AND port to the user-supplied ones, keeping the advertised path. A server
// behind a port mapping or internal DNS advertises its own host:port (docker-dev containers all say :4840);
// following that would open the session on a different server and fail the secure-channel certificate check.
// The rule is the ONE shared with the OpcUaClient driver and the AdminUI browse picker (#812, #806, #676):
// same scheme only, configured port only when it names one, the advertised string verbatim on a no-op.
//
// Validation stays here, not in the rewriter: the shared rewriter falls back to the configured URL on an
// unusable advertised one (right for a deployed driver), while this interactive tool reports it — a null URL
// throws InvalidOperationException and an unparseable one (either side) UriFormatException, as before #812.
var advertisedUrl = best.EndpointUrl ?? throw new InvalidOperationException("Endpoint has no URL.");
_ = new Uri(advertisedUrl);
_ = new Uri(endpointUrl);
var rewritten = OpcUaEndpointUrlRewriter.Rewrite(advertisedUrl, endpointUrl);
if (!string.Equals(rewritten, advertisedUrl, StringComparison.Ordinal))
{
var builder = new UriBuilder(best.EndpointUrl) { Host = requestedUri.Host, Port = port };
best.EndpointUrl = builder.ToString();
Logger.Debug("Rewrote endpoint from {ServerHost}:{ServerPort} to {RequestedHost}:{RequestedPort}",
serverUri.Host, serverUri.Port, requestedUri.Host, port);
best.EndpointUrl = rewritten;
Logger.Debug("Rewrote endpoint from {AdvertisedUrl} to {RewrittenUrl}", advertisedUrl, rewritten);
}
return best;
@@ -12,6 +12,14 @@
<PackageReference Include="Serilog"/>
</ItemGroup>
<ItemGroup>
<!-- #812: the endpoint-URL rewrite rule shared with the OpcUaClient driver + AdminUI browse picker
(OpcUaEndpointUrlRewriter). Contracts is dependency-light by contract (Core.Abstractions only, no
NuGet packages, no OPC UA SDK; pinned by ContractsIsDependencyLightTests), so this adds two small
first-party assemblies and nothing else to the CLI/UI. -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Client.Shared.Tests"/>
</ItemGroup>