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; /// /// 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 { // 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 _logger; private readonly ISecretResolver? _secretResolver; /// Creates a new browser. Logger defaults to NullLogger when not supplied. /// Optional logger; defaults to . /// /// The host's shared secret resolver (DI-injected where one is registered), used to resolve /// secret:-prefixed Password / UserCertificatePassword exactly as the runtime driver does. /// Null ⇒ a secret: ref fails closed (it is never sent as the literal). /// public OpcUaClientDriverBrowser(ILogger? logger = null, ISecretResolver? secretResolver = null) { _logger = logger ?? NullLogger.Instance; _secretResolver = secretResolver; } /// // 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; /// 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]."); // 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, ownedCertificate) = await BuildBrowseCredentialAsync(opts, _secretResolver, cancellationToken) .ConfigureAwait(false); 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 appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false); 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, ownedCertificate); } catch { try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ } try { session.Dispose(); } catch { /* best-effort */ } throw; } } catch { // Review m9: the certificate (and its private-key handle) is owned by the browse session once handed // over; any failure before that releases it here instead of leaving it to the GC. ownedCertificate?.Dispose(); 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 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 { 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 — Anonymous, Username or Certificate — through /// the factory the runtime driver uses, after resolving any secret:-prefixed credential (fail-closed). /// /// Driver options carrying the form's auth fields. /// The secret store, or null when none is available. /// Cancellation token for the secret resolution. /// The identity the browse session activates with. internal static async Task BuildBrowseUserIdentityAsync( OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct) => (await BuildBrowseCredentialAsync(opts, secretResolver, ct).ConfigureAwait(false)).Identity; /// /// As , also handing back the user certificate a Certificate-auth /// identity holds, so the browse session that uses it can dispose it (review m9: the browse opens one per picker /// session, and an undisposed X509Certificate2 pins a private-key handle until GC). /// /// Driver options carrying the form's auth fields. /// The secret store, or null when none is available. /// Cancellation token for the secret resolution. /// The identity, and the certificate it owns (null for Anonymous / Username). internal static async Task<(UserIdentity Identity, System.Security.Cryptography.X509Certificates.X509Certificate2? Certificate)> BuildBrowseCredentialAsync(OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct) { var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false); if (resolved.AuthType != OpcUaAuthType.Certificate) { return (OpcUaUserIdentityFactory.Build(resolved), null); } var certificate = OpcUaUserIdentityFactory.LoadCertificate(resolved); return (new UserIdentity(certificate), certificate); } /// 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)"; } internal static class BrowseTelemetry { private static readonly Lazy Instance = new(() => Opc.Ua.DefaultTelemetry.Create(_ => { })); /// Gets the shared telemetry context. public static Opc.Ua.ITelemetryContext Context => Instance.Value; }