36c4751571
Adds OPC UA SDK's CheckApplicationInstanceCertificate call to OpcUaApplicationHost.StartAsync, removing the v1 friction of needing to pre-create the PKI directory tree before booting. - New OpcUaApplicationHostOptions.PkiStoreRoot (defaults to "pki") - BuildConfigurationAsync now derives own/issuer/trusted/rejected from PkiStoreRoot so the cert paths are configurable + consistent - EnsureApplicationCertificateAsync runs before StandardServer.Start, and fails fast with a clear message if the SDK can't produce a valid cert - 2 new tests: fresh-tree creates a cert, second boot reuses it Partial slice of follow-up F13. Endpoint-security, user-token validator, and observability wiring still pending in the F13 follow-up. OpcUaServer tests: 4 → 6.
151 lines
6.8 KiB
C#
151 lines
6.8 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Configuration;
|
|
using Opc.Ua.Server;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
public sealed class OpcUaApplicationHostOptions
|
|
{
|
|
public string ApplicationName { get; set; } = "OtOpcUa";
|
|
public string ApplicationUri { get; set; } = "urn:OtOpcUa";
|
|
public string ProductUri { get; set; } = "https://zb.com/otopcua";
|
|
|
|
/// <summary>Listening port for the binary endpoint (default 4840).</summary>
|
|
public int OpcUaPort { get; set; } = 4840;
|
|
|
|
/// <summary>Hostname or IP advertised in endpoint descriptions.</summary>
|
|
public string PublicHostname { get; set; } = "0.0.0.0";
|
|
|
|
/// <summary>Application config XML path; when set, loaded instead of building from defaults.</summary>
|
|
public string? ApplicationConfigPath { get; set; }
|
|
|
|
/// <summary>
|
|
/// Root of the application's PKI hierarchy. Sub-stores (<c>own</c>, <c>issuer</c>,
|
|
/// <c>trusted</c>, <c>rejected</c>) are created under this path on first start. Defaults
|
|
/// to "pki" (relative to the host's working directory) to keep dev flows identical to v1.
|
|
/// </summary>
|
|
public string PkiStoreRoot { get; set; } = "pki";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Thin facade over the OPC Foundation .NET Standard SDK's application bootstrap.
|
|
/// Owns the <see cref="ApplicationInstance"/> + <see cref="ApplicationConfiguration"/> lifetime
|
|
/// and starts a <see cref="StandardServer"/> with the supplied node-manager factory.
|
|
///
|
|
/// Full extraction from legacy <c>OtOpcUa.Server</c> (security wiring, ScriptedAlarmDescriptor
|
|
/// pipeline, ResilienceController, history backend, observability hooks) is tracked as
|
|
/// follow-up F13. This facade compiles + boots the SDK so Task 53 can wire the fused Host's
|
|
/// driver-role startup against it.
|
|
/// </summary>
|
|
public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|
{
|
|
private readonly OpcUaApplicationHostOptions _options;
|
|
private readonly ILogger<OpcUaApplicationHost> _logger;
|
|
private ApplicationInstance? _application;
|
|
private StandardServer? _server;
|
|
|
|
public OpcUaApplicationHost(
|
|
OpcUaApplicationHostOptions options,
|
|
ILogger<OpcUaApplicationHost> logger)
|
|
{
|
|
_options = options;
|
|
_logger = logger;
|
|
}
|
|
|
|
public ApplicationInstance? ApplicationInstance => _application;
|
|
public StandardServer? Server => _server;
|
|
|
|
public async Task StartAsync(StandardServer server, CancellationToken cancellationToken)
|
|
{
|
|
_server = server;
|
|
_application = new ApplicationInstance
|
|
{
|
|
ApplicationName = _options.ApplicationName,
|
|
ApplicationType = ApplicationType.Server,
|
|
ConfigSectionName = "OtOpcUa",
|
|
};
|
|
|
|
_ = await BuildConfigurationAsync(cancellationToken);
|
|
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
|
|
await _application.Start(server).ConfigureAwait(false);
|
|
|
|
_logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
|
|
_options.PublicHostname, _options.OpcUaPort);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Guarantees the application instance certificate exists in <c>{PkiStoreRoot}/own</c>.
|
|
/// The SDK auto-creates a self-signed certificate the first time this is called on a fresh
|
|
/// PKI tree; subsequent boots reuse the existing cert. Replaces v1's manual "you must
|
|
/// pre-create the PKI directory tree" friction. Partial slice of follow-up F13 — the
|
|
/// remaining endpoint-security, user-token validator, and observability wiring stays in
|
|
/// the follow-up queue.
|
|
/// </summary>
|
|
private async Task EnsureApplicationCertificateAsync(CancellationToken cancellationToken)
|
|
{
|
|
// silent: false → SDK logs cert creation events through its own trace plumbing.
|
|
// minimumKeySize/lifetimeInMonths: 0 → use SDK defaults (2048-bit, 12-month lifetime).
|
|
var ok = await _application!.CheckApplicationInstanceCertificate(
|
|
silent: false, minimumKeySize: 0, lifeTimeInMonths: 0, ct: cancellationToken).ConfigureAwait(false);
|
|
if (!ok)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"OPC UA application certificate validation failed for {_options.ApplicationName}. " +
|
|
$"Cert store root: {Path.GetFullPath(_options.PkiStoreRoot)}");
|
|
}
|
|
}
|
|
|
|
private async Task<ApplicationConfiguration> BuildConfigurationAsync(CancellationToken ct)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(_options.ApplicationConfigPath))
|
|
{
|
|
return await _application!.LoadApplicationConfiguration(_options.ApplicationConfigPath, silent: true);
|
|
}
|
|
|
|
// Minimal defaults — security and certificate stores hardcoded to local files in
|
|
// the app's working directory. Full security wiring stays in legacy Server until F13.
|
|
var config = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = _options.ApplicationName,
|
|
ApplicationUri = _options.ApplicationUri,
|
|
ProductUri = _options.ProductUri,
|
|
ApplicationType = ApplicationType.Server,
|
|
ServerConfiguration = new ServerConfiguration
|
|
{
|
|
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
|
|
MinRequestThreadCount = 5,
|
|
MaxRequestThreadCount = 100,
|
|
MaxQueuedRequestCount = 200,
|
|
},
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
{
|
|
StoreType = "Directory",
|
|
StorePath = Path.Combine(_options.PkiStoreRoot, "own"),
|
|
SubjectName = $"CN={_options.ApplicationName}",
|
|
},
|
|
TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "issuer") },
|
|
TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "trusted") },
|
|
RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") },
|
|
AutoAcceptUntrustedCertificates = false,
|
|
},
|
|
TransportQuotas = new TransportQuotas(),
|
|
ClientConfiguration = new ClientConfiguration(),
|
|
TraceConfiguration = new TraceConfiguration(),
|
|
};
|
|
|
|
await config.Validate(ApplicationType.Server).ConfigureAwait(false);
|
|
_application!.ApplicationConfiguration = config;
|
|
return config;
|
|
}
|
|
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
try { _application?.Stop(); }
|
|
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|