feat(opcuaclient.browser): add transient-session factory
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <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
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly ILogger<OpcUaClientDriverBrowser> _logger;
|
||||
|
||||
/// <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>
|
||||
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null)
|
||||
{
|
||||
_logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Driver type key — matches the AdminUI's persisted "OpcUaClient" value.</summary>
|
||||
public string DriverType => "OpcUaClient";
|
||||
|
||||
/// <summary>Opens a transient OPC UA session and returns a browse session over it.</summary>
|
||||
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
|
||||
/// driver would consume.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
|
||||
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].");
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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<ApplicationConfiguration> 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;
|
||||
}
|
||||
|
||||
/// <summary>Build the OPC UA user identity from the form's auth fields.</summary>
|
||||
/// <param name="opts">Driver options carrying the form's auth fields.</param>
|
||||
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()),
|
||||
};
|
||||
|
||||
/// <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(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;
|
||||
}
|
||||
|
||||
/// <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)";
|
||||
}
|
||||
Reference in New Issue
Block a user