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";
/// Listening port for the binary endpoint (default 4840).
public int OpcUaPort { get; set; } = 4840;
/// Hostname or IP advertised in endpoint descriptions.
public string PublicHostname { get; set; } = "0.0.0.0";
/// Application config XML path; when set, loaded instead of building from defaults.
public string? ApplicationConfigPath { get; set; }
///
/// Root of the application's PKI hierarchy. Sub-stores (own, issuer,
/// trusted, rejected) 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.
///
public string PkiStoreRoot { get; set; } = "pki";
}
///
/// Thin facade over the OPC Foundation .NET Standard SDK's application bootstrap.
/// Owns the + lifetime
/// and starts a with the supplied node-manager factory.
///
/// Full extraction from legacy OtOpcUa.Server (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.
///
public sealed class OpcUaApplicationHost : IAsyncDisposable
{
private readonly OpcUaApplicationHostOptions _options;
private readonly ILogger _logger;
private ApplicationInstance? _application;
private StandardServer? _server;
public OpcUaApplicationHost(
OpcUaApplicationHostOptions options,
ILogger 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);
}
///
/// Guarantees the application instance certificate exists in {PkiStoreRoot}/own.
/// 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.
///
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 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;
}
}