378962eeb1
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
272 lines
14 KiB
C#
272 lines
14 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap + OpcUaUserIdentityFactory
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
|
|
|
/// <summary>
|
|
/// Opens transient OPC UA sessions from form-supplied JSON for the AdminUI address picker.
|
|
/// Mirrors the runtime driver's connect path but with a separate PKI store so browse-time
|
|
/// trust decisions cannot poison the runtime driver's cert store.
|
|
/// </summary>
|
|
public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|
{
|
|
// Kept identical to OpcUaClientDriverFactoryExtensions.JsonOptions and
|
|
// OpcUaClientDriverProbe._opts so the browser parses a given DriverConfig JSON
|
|
// the same way as the factory and the probe. The JsonStringEnumConverter lets
|
|
// enum-valued knobs (SecurityPolicy / SecurityMode / AuthType)
|
|
// be authored as their string names — the natural form for AdminUI-emitted JSON.
|
|
// JsonStringEnumConverter also accepts numeric ordinals, so existing numeric configs
|
|
// continue to work without migration.
|
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
|
{
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
PropertyNameCaseInsensitive = true,
|
|
Converters = { new JsonStringEnumConverter() },
|
|
};
|
|
|
|
private readonly ILogger<OpcUaClientDriverBrowser> _logger;
|
|
private readonly ISecretResolver? _secretResolver;
|
|
|
|
/// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
|
|
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
|
|
/// <param name="secretResolver">
|
|
/// The host's shared secret resolver (DI-injected where one is registered), used to resolve
|
|
/// <c>secret:</c>-prefixed <c>Password</c> / <c>UserCertificatePassword</c> exactly as the runtime driver does.
|
|
/// Null ⇒ a <c>secret:</c> ref fails closed (it is never sent as the literal).
|
|
/// </param>
|
|
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null, ISecretResolver? secretResolver = null)
|
|
{
|
|
_logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
|
|
_secretResolver = secretResolver;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
// Sourced from the shared DriverTypeNames set rather than the hand-written literal
|
|
// this line used to carry. The value is unchanged; the constant is what
|
|
// DriverTypeNamesGuardTests pins to the factory's own registration, so a rename on either
|
|
// side can no longer leave this browser indexed under a key nothing dispatches to.
|
|
public string DriverType => DriverTypeNames.OpcUaClient;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
|
|
{
|
|
var opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, JsonOpts)
|
|
?? throw new InvalidOperationException("OpcUaClient options deserialized to null.");
|
|
|
|
var endpoint = opts.EndpointUrls is { Count: > 0 } ? opts.EndpointUrls[0] : opts.EndpointUrl;
|
|
if (string.IsNullOrWhiteSpace(endpoint))
|
|
throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");
|
|
|
|
// R06:O-5 — Certificate auth is supported: the identity is built (and any secret: credential resolved) by the
|
|
// same factory the runtime driver uses, BEFORE any network I/O, so a bad certificate path / password fails fast
|
|
// with its own message.
|
|
var identity = await BuildBrowseUserIdentityAsync(opts, _secretResolver, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
ApplicationInstance? ownedApplication = null;
|
|
try
|
|
{
|
|
if (opts.AutoAcceptCertificates)
|
|
_logger.LogWarning(
|
|
"AdminUI browse session opens against {Endpoint} with form's AutoAcceptCertificates=true — " +
|
|
"browse uses its own cert store and does NOT auto-accept; trust the cert via the runtime " +
|
|
"driver's PKI store instead.",
|
|
endpoint);
|
|
|
|
var app = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
|
ownedApplication = app;
|
|
var appConfig = app.ApplicationConfiguration!;
|
|
|
|
var perEndpointBudget = TimeSpan.FromSeconds(
|
|
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
|
|
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
connectCts.CancelAfter(perEndpointBudget);
|
|
|
|
var endpointDesc = await SelectEndpointAsync(
|
|
appConfig, endpoint, opts.SecurityPolicy, opts.SecurityMode, connectCts.Token).ConfigureAwait(false);
|
|
var endpointCfg = EndpointConfiguration.Create(appConfig);
|
|
endpointCfg.OperationTimeout = (int)opts.Timeout.TotalMilliseconds;
|
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDesc, endpointCfg);
|
|
|
|
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
|
|
appConfig,
|
|
configuredEndpoint,
|
|
updateBeforeConnect: false,
|
|
sessionName: "OtOpcUa AdminUI Browse",
|
|
(uint)opts.SessionTimeout.TotalMilliseconds,
|
|
identity,
|
|
preferredLocales: default,
|
|
connectCts.Token).ConfigureAwait(false);
|
|
|
|
try
|
|
{
|
|
connectCts.Token.ThrowIfCancellationRequested();
|
|
var nsMap = NamespaceMap.FromSession(session);
|
|
var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
|
|
? ObjectIds.ObjectsFolder
|
|
: NodeId.Parse(session.MessageContext, opts.BrowseRoot);
|
|
|
|
_logger.LogInformation(
|
|
"AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
|
|
endpoint, opts.SecurityPolicy, opts.SecurityMode);
|
|
|
|
var browse = new OpcUaClientBrowseSession(session, nsMap, rootNodeId, ownedApplication);
|
|
ownedApplication = null;
|
|
return browse;
|
|
}
|
|
catch
|
|
{
|
|
try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ }
|
|
try { session.Dispose(); } catch { /* best-effort */ }
|
|
throw;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// The SDK application owns its certificate manager until the browse session takes it.
|
|
if (ownedApplication is not null) await ownedApplication.DisposeAsync().ConfigureAwait(false);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a minimal in-memory ApplicationConfiguration using a SEPARATE PKI root from
|
|
/// the runtime driver (<c>%LocalAppData%/OtOpcUa/adminui-browse-pki/</c>). Browse
|
|
/// trust decisions don't leak into the deployed driver's cert store.
|
|
/// </summary>
|
|
/// <param name="ct">Cancellation token for the configuration build.</param>
|
|
private static async Task<ApplicationInstance> BuildBrowseAppConfigurationAsync(CancellationToken ct)
|
|
{
|
|
// The ITelemetryContext overload is the non-obsolete ctor; an empty builder keeps SDK
|
|
// internals off any logging provider.
|
|
var app = new ApplicationInstance(BrowseTelemetry.Context)
|
|
{
|
|
ApplicationName = "OtOpcUa AdminUI Browse",
|
|
ApplicationType = ApplicationType.Client,
|
|
};
|
|
|
|
var pkiRoot = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
"OtOpcUa", "adminui-browse-pki");
|
|
|
|
var config = new ApplicationConfiguration(BrowseTelemetry.Context)
|
|
{
|
|
ApplicationName = "OtOpcUa AdminUI Browse",
|
|
ApplicationType = ApplicationType.Client,
|
|
ApplicationUri = "urn:OtOpcUa:AdminUI:Browse",
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "own"),
|
|
SubjectName = "CN=OtOpcUa AdminUI Browse",
|
|
},
|
|
TrustedPeerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "trusted"),
|
|
},
|
|
TrustedIssuerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "issuers"),
|
|
},
|
|
RejectedCertificateStore = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "rejected"),
|
|
},
|
|
AutoAcceptUntrustedCertificates = false,
|
|
},
|
|
TransportQuotas = new TransportQuotas { OperationTimeout = 30_000 },
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
|
DisableHiResClock = true,
|
|
};
|
|
app.ApplicationConfiguration = config;
|
|
try
|
|
{
|
|
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
|
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
|
|
.ConfigureAwait(false);
|
|
return app;
|
|
}
|
|
catch
|
|
{
|
|
await app.DisposeAsync().ConfigureAwait(false);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build the OPC UA user identity from the form's auth fields — Anonymous, Username or Certificate — through
|
|
/// the factory the runtime driver uses, after resolving any <c>secret:</c>-prefixed credential (fail-closed).
|
|
/// </summary>
|
|
/// <param name="opts">Driver options carrying the form's auth fields.</param>
|
|
/// <param name="secretResolver">The secret store, or null when none is available.</param>
|
|
/// <param name="ct">Cancellation token for the secret resolution.</param>
|
|
/// <returns>The identity the browse session activates with.</returns>
|
|
internal static async Task<UserIdentity> BuildBrowseUserIdentityAsync(
|
|
OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false);
|
|
return OpcUaUserIdentityFactory.Build(resolved);
|
|
}
|
|
|
|
/// <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>
|
|
/// <param name="mode">Required message security mode.</param>
|
|
/// <param name="ct">Cancellation token for the discovery call.</param>
|
|
private static async Task<EndpointDescription> SelectEndpointAsync(
|
|
ApplicationConfiguration appConfig, string url,
|
|
OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, CancellationToken ct)
|
|
{
|
|
using var client = await DiscoveryClient.CreateAsync(
|
|
appConfig, new Uri(url), DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
|
var all = await client.GetEndpointsAsync(default, ct).ConfigureAwait(false);
|
|
return SelectBrowseEndpoint(all.ToList(), url, policy, mode);
|
|
}
|
|
|
|
/// <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
|
|
{
|
|
private static readonly Lazy<Opc.Ua.ITelemetryContext> Instance = new(() => Opc.Ua.DefaultTelemetry.Create(_ => { }));
|
|
|
|
/// <summary>Gets the shared telemetry context.</summary>
|
|
public static Opc.Ua.ITelemetryContext Context => Instance.Value;
|
|
}
|