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