Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs
T
2026-11-10 10:17:38 -05:00

287 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>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</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);
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;
}
/// <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)";
}
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;
}