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)"; }