fix(opcuaclient-browser): keep configured host/port behind NAT (#806)
The AdminUI OPC UA browse picker connected to the EndpointUrl the server advertised in GetEndpoints verbatim, so behind NAT / a container port publish it dialled the server's private hostname + internal port and failed DNS where the runtime driver (which rewrites per #676) connected. - Move the rewrite to the shared OpcUaEndpointUrlRewriter (.Contracts, pure Uri logic, no SDK dependency) and the policy+mode selection to OpcUaEndpointSelection (.Session leaf). Driver and browser both select through it, so the browse connects exactly where the driver does. - Policy/mode filter, server certificate and strict browse PKI (no auto-accept) are unchanged. - Unit tests: rewrite (host/port differ, same, IPv6 both sides, path kept, port-less, scheme mismatch untouched), browser selection seam. - Live NAT tests (Fixture=opc-plc-nat, OPCUA_NAT_ENDPOINT): browse via a published port with a premise check that the advertised host:port differs and does not resolve, nsu= round-trip, unreachable endpoint fails cleanly, SignAndEncrypt reaches the server and is refused BadCertificateUntrusted. Verified red without the rewrite. Refs #806
This commit is contained in:
+23
-38
@@ -223,7 +223,10 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
return OpcUaUserIdentityFactory.Build(resolved);
|
||||
}
|
||||
|
||||
/// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
|
||||
/// <summary>
|
||||
/// Discover the server's endpoints and select the one matching the requested SecurityPolicy + SecurityMode pair,
|
||||
/// through <see cref="SelectBrowseEndpoint"/>.
|
||||
/// </summary>
|
||||
/// <param name="appConfig">Application configuration used by the discovery client.</param>
|
||||
/// <param name="url">Remote endpoint URL to query.</param>
|
||||
/// <param name="policy">Required security policy.</param>
|
||||
@@ -236,45 +239,27 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
using var client = await DiscoveryClient.CreateAsync(
|
||||
appConfig, new Uri(url), DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||
var all = await client.GetEndpointsAsync(default, ct).ConfigureAwait(false);
|
||||
|
||||
var wantedPolicy = MapPolicy(policy);
|
||||
var wantedMode = mode switch
|
||||
{
|
||||
OpcUaSecurityMode.None => MessageSecurityMode.None,
|
||||
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
|
||||
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
|
||||
};
|
||||
var match = all.ToList().FirstOrDefault(e =>
|
||||
e.SecurityPolicyUri == wantedPolicy && e.SecurityMode == wantedMode);
|
||||
if (match is null)
|
||||
{
|
||||
var advertised = string.Join(", ", all.ToList().Select(e =>
|
||||
$"{ShortName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
|
||||
throw new InvalidOperationException(
|
||||
$"No endpoint at '{url}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
|
||||
$"Server advertises: {advertised}");
|
||||
}
|
||||
return match;
|
||||
return SelectBrowseEndpoint(all.ToList(), url, policy, mode);
|
||||
}
|
||||
|
||||
/// <summary>Convert the driver options enum to the OPC UA policy URI.</summary>
|
||||
/// <param name="p">The driver security policy to map.</param>
|
||||
private static string MapPolicy(OpcUaSecurityPolicy p) => p switch
|
||||
{
|
||||
OpcUaSecurityPolicy.None => SecurityPolicies.None,
|
||||
OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15,
|
||||
OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256,
|
||||
OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256,
|
||||
OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep,
|
||||
OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(p)),
|
||||
};
|
||||
|
||||
/// <summary>Render an OPC UA security-policy URI as its short suffix for diag messages.</summary>
|
||||
/// <param name="uri">Full policy URI to shorten.</param>
|
||||
private static string ShortName(string? uri) =>
|
||||
uri?.Substring(uri.LastIndexOf('#') + 1) ?? "(null)";
|
||||
/// <summary>
|
||||
/// Gitea #806 — the browse session's endpoint choice, separated from the network so it is testable. It is the
|
||||
/// runtime driver's selection (<see cref="OpcUaEndpointSelection.SelectMatching"/>): the policy + mode filter
|
||||
/// is unchanged, and the matched endpoint's URL keeps the operator-configured host AND port with the advertised
|
||||
/// path (<see cref="OpcUaEndpointUrlRewriter"/>). The browse used to connect to the advertised URL verbatim, so
|
||||
/// behind NAT / a container port publish it dialled the server's private hostname and internal port and failed
|
||||
/// DNS where the deployed driver connected fine. Certificate validation is not touched: the browse PKI still
|
||||
/// refuses an untrusted server certificate.
|
||||
/// </summary>
|
||||
/// <param name="advertised">Everything GetEndpoints returned.</param>
|
||||
/// <param name="configuredUrl">The URL the form configured (the one GetEndpoints was sent to).</param>
|
||||
/// <param name="policy">Required security policy.</param>
|
||||
/// <param name="mode">Required message security mode.</param>
|
||||
/// <returns>The endpoint to open the browse session on.</returns>
|
||||
internal static EndpointDescription SelectBrowseEndpoint(
|
||||
IEnumerable<EndpointDescription> advertised, string configuredUrl,
|
||||
OpcUaSecurityPolicy policy, OpcUaSecurityMode mode) =>
|
||||
OpcUaEndpointSelection.SelectMatching(advertised, configuredUrl, policy, mode);
|
||||
}
|
||||
|
||||
internal static class BrowseTelemetry
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE implementation of "connect where the operator pointed us, not where the server says it lives" for the
|
||||
/// OpcUaClient driver family. A server reports its GetEndpoints results with ITS view of its own address; behind
|
||||
/// NAT, a port map, a container port publish or a proxy that address is unreachable from here (or bypasses the
|
||||
/// proxy). Shared by the runtime driver (Gitea #676) and the AdminUI live-browse picker (Gitea #806, which used
|
||||
/// the advertised URL unchanged and failed DNS on a published-port fixture the driver handled).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pure string/<see cref="Uri"/> logic, deliberately in <c>.Contracts</c>: it is not session-typed, so it must not
|
||||
/// pull the OPC UA SDK in (pinned by <c>ContractsIsDependencyLightTests</c>). It only chooses the network
|
||||
/// address; the security policy, mode and server certificate of the selected endpoint are untouched, so the
|
||||
/// secure-channel certificate check still validates the server actually reached.
|
||||
/// </remarks>
|
||||
public static class OpcUaEndpointUrlRewriter
|
||||
{
|
||||
/// <summary>
|
||||
/// The address to open the session on. When the advertised URL's scheme matches the configured one, the
|
||||
/// configured host AND port (when it names one) replace the advertised ones and the advertised path is kept —
|
||||
/// the SDK's own <c>CoreClientUtils.SelectEndpoint</c> rule. A different scheme is a different transport and is
|
||||
/// left alone; an unparseable advertised URL falls back to the configured one.
|
||||
/// </summary>
|
||||
/// <param name="advertisedUrl">The <c>EndpointUrl</c> the server returned from GetEndpoints.</param>
|
||||
/// <param name="configuredUrl">The URL the operator configured (the one GetEndpoints was sent to).</param>
|
||||
/// <returns>The URL to connect the session to.</returns>
|
||||
public static string Rewrite(string? advertisedUrl, string configuredUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(configuredUrl, UriKind.Absolute, out var configured)) return advertisedUrl ?? configuredUrl;
|
||||
if (string.IsNullOrWhiteSpace(advertisedUrl)
|
||||
|| !Uri.TryCreate(advertisedUrl, UriKind.Absolute, out var advertised))
|
||||
return configuredUrl;
|
||||
if (!string.Equals(advertised.Scheme, configured.Scheme, StringComparison.OrdinalIgnoreCase)) return advertisedUrl;
|
||||
// A port-less configured URL (Uri.Port == -1 for opc.tcp) keeps the advertised port — copying -1 would drop it
|
||||
// and the SDK would fall back to 4840 (review of 3b88056f; same fix as the client's EndpointSelector).
|
||||
var builder = new UriBuilder(advertised)
|
||||
{
|
||||
Host = configured.DnsSafeHost,
|
||||
Port = configured.Port > 0 ? configured.Port : advertised.Port,
|
||||
};
|
||||
return builder.Uri.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// Policy-aware endpoint selection shared by the runtime driver and the AdminUI browse picker, so the browser
|
||||
/// connects exactly where (and how) the deployed driver will. The SDK's <c>CoreClientUtils.SelectEndpointAsync</c>
|
||||
/// only honours a boolean "use security" flag; an operator asking for <c>Basic256Sha256</c> against a server that
|
||||
/// also offers <c>Basic128Rsa15</c> must not silently end up on the weaker cipher, so the policy + mode filter is
|
||||
/// explicit and fails loudly. The selected endpoint's URL is then normalised by
|
||||
/// <see cref="OpcUaEndpointUrlRewriter.Rewrite"/> (Gitea #676 driver, #806 browser).
|
||||
/// </summary>
|
||||
public static class OpcUaEndpointSelection
|
||||
{
|
||||
/// <summary>
|
||||
/// Pick the first advertised endpoint whose <c>SecurityPolicyUri</c> AND <c>SecurityMode</c> match, and point its
|
||||
/// <c>EndpointUrl</c> at the configured host/port (advertised path kept). Security policy, mode, server
|
||||
/// certificate and user-token policies are taken from the server's advertisement unchanged.
|
||||
/// </summary>
|
||||
/// <param name="advertised">Everything GetEndpoints returned.</param>
|
||||
/// <param name="configuredUrl">The URL GetEndpoints was sent to — the operator-configured, reachable address.</param>
|
||||
/// <param name="policy">Required security policy.</param>
|
||||
/// <param name="mode">Required message security mode.</param>
|
||||
/// <returns>The matching endpoint, its URL rewritten.</returns>
|
||||
/// <exception cref="InvalidOperationException">No advertised endpoint matches the policy + mode pair.</exception>
|
||||
public static EndpointDescription SelectMatching(
|
||||
IEnumerable<EndpointDescription> advertised, string configuredUrl,
|
||||
OpcUaSecurityPolicy policy, OpcUaSecurityMode mode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(advertised);
|
||||
var all = advertised.ToList();
|
||||
var wantedPolicyUri = MapSecurityPolicy(policy);
|
||||
var wantedMode = ToMessageSecurityMode(mode);
|
||||
|
||||
var match = all.FirstOrDefault(e => e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode);
|
||||
if (match is null)
|
||||
{
|
||||
var list = string.Join(", ", all.Select(e => $"{ShortPolicyName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
|
||||
throw new InvalidOperationException(
|
||||
$"No endpoint at '{configuredUrl}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
|
||||
$"Server advertises: {list}");
|
||||
}
|
||||
|
||||
// Connect where the operator pointed us, not where the server says it lives.
|
||||
match.EndpointUrl = OpcUaEndpointUrlRewriter.Rewrite(match.EndpointUrl, configuredUrl);
|
||||
return match;
|
||||
}
|
||||
|
||||
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
|
||||
/// <param name="policy">The driver security policy to map.</param>
|
||||
/// <returns>The corresponding OPC UA security policy URI.</returns>
|
||||
public static string MapSecurityPolicy(OpcUaSecurityPolicy policy) => policy switch
|
||||
{
|
||||
OpcUaSecurityPolicy.None => SecurityPolicies.None,
|
||||
OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15,
|
||||
OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256,
|
||||
OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256,
|
||||
OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep,
|
||||
OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(policy), policy, null),
|
||||
};
|
||||
|
||||
/// <summary>Convert a driver <see cref="OpcUaSecurityMode"/> to the SDK message security mode.</summary>
|
||||
/// <param name="mode">The driver security mode to map.</param>
|
||||
/// <returns>The corresponding <see cref="MessageSecurityMode"/>.</returns>
|
||||
public static MessageSecurityMode ToMessageSecurityMode(OpcUaSecurityMode mode) => mode switch
|
||||
{
|
||||
OpcUaSecurityMode.None => MessageSecurityMode.None,
|
||||
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
|
||||
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
|
||||
};
|
||||
|
||||
/// <summary>Render an OPC UA security-policy URI as its short suffix for diagnostic messages.</summary>
|
||||
/// <param name="policyUri">Full policy URI to shorten.</param>
|
||||
/// <returns>The suffix after the last <c>#</c>, or <c>(null)</c>.</returns>
|
||||
public static string ShortPolicyName(string? policyUri) =>
|
||||
policyUri?.Substring(policyUri.LastIndexOf('#') + 1) ?? "(null)";
|
||||
}
|
||||
@@ -978,13 +978,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
&& !string.IsNullOrWhiteSpace(e.EndpointUrl))
|
||||
.Select(e => e.EndpointUrl!);
|
||||
|
||||
private static MessageSecurityMode ToMessageSecurityMode(OpcUaSecurityMode mode) => mode switch
|
||||
{
|
||||
OpcUaSecurityMode.None => MessageSecurityMode.None,
|
||||
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
|
||||
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
|
||||
};
|
||||
private static MessageSecurityMode ToMessageSecurityMode(OpcUaSecurityMode mode) =>
|
||||
OpcUaEndpointSelection.ToMessageSecurityMode(mode);
|
||||
|
||||
/// <summary>
|
||||
/// Range checks for the subscription-tuning and discovery options. Throws at
|
||||
@@ -1071,11 +1066,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select the remote endpoint matching both the requested <paramref name="policy"/>
|
||||
/// and <paramref name="mode"/>. The SDK's <c>CoreClientUtils.SelectEndpointAsync</c>
|
||||
/// only honours a boolean "use security" flag; we need policy-aware matching so an
|
||||
/// operator asking for <c>Basic256Sha256</c> against a server that also offers
|
||||
/// <c>Basic128Rsa15</c> doesn't silently end up on the weaker cipher.
|
||||
/// Select the remote endpoint matching both the requested <paramref name="policy"/> and <paramref name="mode"/>,
|
||||
/// with its URL pointed at the configured host/port. The filter + rewrite live in the shared
|
||||
/// <see cref="OpcUaEndpointSelection.SelectMatching"/> so the AdminUI browse picker selects identically (#806).
|
||||
/// </summary>
|
||||
private static async Task<EndpointDescription> SelectMatchingEndpointAsync(
|
||||
ApplicationConfiguration appConfig,
|
||||
@@ -1084,61 +1077,20 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
OpcUaSecurityMode mode,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// GetEndpoints returns everything the server advertises; policy + mode filter is
|
||||
// applied client-side so the selection is explicit and fails loudly if the operator
|
||||
// asks for a combination the server doesn't publish. Use the SDK's asynchronous
|
||||
// discovery factory and pass the caller's cancellation token.
|
||||
// GetEndpoints returns everything the server advertises; policy + mode filter is applied client-side so the
|
||||
// selection is explicit and fails loudly if the operator asks for a combination the server doesn't publish.
|
||||
using var client = await DiscoveryClient.CreateAsync(
|
||||
appConfig, new Uri(endpointUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||
var all = await client.GetEndpointsAsync(default, ct).ConfigureAwait(false);
|
||||
|
||||
var wantedPolicyUri = MapSecurityPolicy(policy);
|
||||
var wantedMode = ToMessageSecurityMode(mode);
|
||||
|
||||
var match = all.ToList().FirstOrDefault(e =>
|
||||
e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode);
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
var advertised = string.Join(", ", all.ToList()
|
||||
.Select(e => $"{ShortPolicyName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
|
||||
throw new InvalidOperationException(
|
||||
$"No endpoint at '{endpointUrl}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
|
||||
$"Server advertises: {advertised}");
|
||||
}
|
||||
// connect where the operator pointed us, not where the server says it lives.
|
||||
match.EndpointUrl = RewriteEndpointUrl(match.EndpointUrl, endpointUrl);
|
||||
return match;
|
||||
return OpcUaEndpointSelection.SelectMatching(all.ToList(), endpointUrl, policy, mode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// the address to open the session on. A server reports its endpoints with ITS view of its own
|
||||
/// address; behind NAT, a port map, a container port publish or a proxy that address is unreachable from here
|
||||
/// (or bypasses the proxy). When the advertised URL's scheme matches the configured one, the configured host
|
||||
/// AND port (when it names one) replace the advertised ones and the advertised path is kept — the SDK's own
|
||||
/// <c>CoreClientUtils.SelectEndpoint</c> rule, which this driver's policy-aware selection bypassed. A different
|
||||
/// scheme is a different transport and is left alone; an unparseable advertised URL falls back to the
|
||||
/// configured one.
|
||||
/// </summary>
|
||||
/// <summary>Delegates to the shared <see cref="OpcUaEndpointUrlRewriter.Rewrite"/> (Gitea #676 / #806).</summary>
|
||||
/// <param name="advertisedUrl">The <c>EndpointUrl</c> the server returned from GetEndpoints.</param>
|
||||
/// <param name="configuredUrl">The URL the driver was configured with (the one GetEndpoints was sent to).</param>
|
||||
/// <returns>The URL to connect the session to.</returns>
|
||||
internal static string RewriteEndpointUrl(string? advertisedUrl, string configuredUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(configuredUrl, UriKind.Absolute, out var configured)) return advertisedUrl ?? configuredUrl;
|
||||
if (string.IsNullOrWhiteSpace(advertisedUrl)
|
||||
|| !Uri.TryCreate(advertisedUrl, UriKind.Absolute, out var advertised))
|
||||
return configuredUrl;
|
||||
if (!string.Equals(advertised.Scheme, configured.Scheme, StringComparison.OrdinalIgnoreCase)) return advertisedUrl;
|
||||
// A port-less configured URL (Uri.Port == -1 for opc.tcp) keeps the advertised port — copying -1 would drop it
|
||||
// and the SDK would fall back to 4840 (review of 3b88056f; same fix as the client's EndpointSelector).
|
||||
var builder = new UriBuilder(advertised)
|
||||
{
|
||||
Host = configured.DnsSafeHost,
|
||||
Port = configured.Port > 0 ? configured.Port : advertised.Port,
|
||||
};
|
||||
return builder.Uri.ToString();
|
||||
}
|
||||
internal static string RewriteEndpointUrl(string? advertisedUrl, string configuredUrl) =>
|
||||
OpcUaEndpointUrlRewriter.Rewrite(advertisedUrl, configuredUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="UserIdentity"/> carrying a client user-authentication
|
||||
@@ -1156,19 +1108,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
|
||||
/// <param name="policy">The driver security policy to map.</param>
|
||||
/// <returns>The corresponding OPC UA security policy URI.</returns>
|
||||
internal static string MapSecurityPolicy(OpcUaSecurityPolicy policy) => policy switch
|
||||
{
|
||||
OpcUaSecurityPolicy.None => SecurityPolicies.None,
|
||||
OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15,
|
||||
OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256,
|
||||
OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256,
|
||||
OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep,
|
||||
OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(policy), policy, null),
|
||||
};
|
||||
|
||||
private static string ShortPolicyName(string? policyUri) =>
|
||||
policyUri?.Substring(policyUri.LastIndexOf('#') + 1) ?? "(null)";
|
||||
internal static string MapSecurityPolicy(OpcUaSecurityPolicy policy) =>
|
||||
OpcUaEndpointSelection.MapSecurityPolicy(policy);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
|
||||
Reference in New Issue
Block a user