diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs new file mode 100644 index 00000000..dd0a0e64 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs @@ -0,0 +1,234 @@ +using System.Text.Json; +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.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; + +/// +/// 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. +/// +public sealed class OpcUaClientDriverBrowser : IDriverBrowser +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, + PropertyNameCaseInsensitive = true, + }; + + private readonly ILogger _logger; + + /// Creates a new browser. Logger defaults to NullLogger when not supplied. + /// Optional logger; defaults to . + public OpcUaClientDriverBrowser(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + /// Driver type key — matches the AdminUI's persisted "OpcUaClient" value. + public string DriverType => "OpcUaClient"; + + /// Opens a transient OPC UA session and returns a browse session over it. + /// Driver options serialized as JSON; same shape the runtime + /// driver would consume. + /// Cancellation for the connect phase only. + public async Task OpenAsync(string configJson, CancellationToken cancellationToken) + { + var opts = JsonSerializer.Deserialize(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]."); + + if (opts.AuthType == OpcUaAuthType.Certificate) + throw new InvalidOperationException( + "Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username."); + + 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 appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false); + var identity = BuildBrowseUserIdentity(opts); + + 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: null, + connectCts.Token).ConfigureAwait(false); + + try + { + 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); + + return new OpcUaClientBrowseSession(session, nsMap, rootNodeId); + } + catch + { + try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ } + try { session.Dispose(); } catch { /* best-effort */ } + throw; + } + } + + /// + /// Build a minimal in-memory ApplicationConfiguration using a SEPARATE PKI root from + /// the runtime driver (%LocalAppData%/OtOpcUa/adminui-browse-pki/). Browse + /// trust decisions don't leak into the deployed driver's cert store. + /// + /// Cancellation token for the configuration build. + private static async Task BuildBrowseAppConfigurationAsync(CancellationToken ct) + { + // The default ctor is obsolete in favour of the ITelemetryContext overload; suppress + // locally rather than plumbing a telemetry context all the way through — the browser + // emits no per-request telemetry of its own and the SDK's internal fallback is fine + // for a transient picker session (mirrors the runtime driver's same suppression). +#pragma warning disable CS0618 + var app = new ApplicationInstance + { + ApplicationName = "OtOpcUa AdminUI Browse", + ApplicationType = ApplicationType.Client, + }; +#pragma warning restore CS0618 + + var pkiRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OtOpcUa", "adminui-browse-pki"); + + var config = new ApplicationConfiguration + { + 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, + }; + await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false); + app.ApplicationConfiguration = config; + await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct) + .ConfigureAwait(false); + return config; + } + + /// Build the OPC UA user identity from the form's auth fields. + /// Driver options carrying the form's auth fields. + private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch + { + OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()), + OpcUaAuthType.Username => new UserIdentity( + opts.Username ?? string.Empty, + System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)), + _ => new UserIdentity(new AnonymousIdentityToken()), + }; + + /// Select the endpoint matching the requested SecurityPolicy + SecurityMode pair. + /// Application configuration used by the discovery client. + /// Remote endpoint URL to query. + /// Required security policy. + /// Required message security mode. + /// Cancellation token for the discovery call. + private static async Task 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(null, 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.FirstOrDefault(e => + e.SecurityPolicyUri == wantedPolicy && e.SecurityMode == wantedMode); + if (match is null) + { + var advertised = string.Join(", ", all.Select(e => + $"{ShortName(e.SecurityPolicyUri)}/{e.SecurityMode}")); + throw new InvalidOperationException( + $"No endpoint at '{url}' matches SecurityPolicy={policy} + SecurityMode={mode}. " + + $"Server advertises: {advertised}"); + } + return match; + } + + /// Convert the driver options enum to the OPC UA policy URI. + /// The driver security policy to map. + 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)), + }; + + /// Render an OPC UA security-policy URI as its short suffix for diag messages. + /// Full policy URI to shorten. + private static string ShortName(string uri) => + uri?.Substring(uri.LastIndexOf('#') + 1) ?? "(null)"; +}