Renames all 11 projects (5 src + 6 tests), the .slnx solution file, all source-file namespaces, all axaml namespace references, and all v1 documentation references in CLAUDE.md and docs/*.md (excluding docs/v2/ which is already in OtOpcUa form). Also updates the TopShelf service registration name from "LmxOpcUa" to "OtOpcUa" per Phase 0 Task 0.6.
Preserves runtime identifiers per Phase 0 Out-of-Scope rules to avoid breaking v1/v2 client trust during coexistence: OPC UA `ApplicationUri` defaults (`urn:{GalaxyName}:LmxOpcUa`), server `EndpointPath` (`/LmxOpcUa`), `ServerName` default (feeds cert subject CN), `MxAccessConfiguration.ClientName` default (defensive — stays "LmxOpcUa" for MxAccess audit-trail consistency), client OPC UA identifiers (`ApplicationName = "LmxOpcUaClient"`, `ApplicationUri = "urn:localhost:LmxOpcUaClient"`, cert directory `%LocalAppData%\LmxOpcUaClient\pki\`), and the `LmxOpcUaServer` class name (class rename out of Phase 0 scope per Task 0.5 sed pattern; happens in Phase 1 alongside `LmxNodeManager → GenericDriverNodeManager` Core extraction). 23 LmxOpcUa references retained, all enumerated and justified in `docs/v2/implementation/exit-gate-phase-0.md`.
Build clean: 0 errors, 30 warnings (lower than baseline 167). Tests at strict improvement over baseline: 821 passing / 1 failing vs baseline 820 / 2 (one flaky pre-existing failure passed this run; the other still fails — both pre-existing and unrelated to the rename). `Client.UI.Tests`, `Historian.Aveva.Tests`, `Client.Shared.Tests`, `IntegrationTests` all match baseline exactly. Exit gate compliance results recorded in `docs/v2/implementation/exit-gate-phase-0.md` with all 7 checks PASS or DEFERRED-to-PR-review (#7 service install verification needs Windows service permissions on the reviewer's box).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
325 lines
14 KiB
C#
325 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Configuration;
|
|
using Serilog;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
|
{
|
|
/// <summary>
|
|
/// Manages the OPC UA ApplicationInstance lifecycle. Programmatic config, no XML. (OPC-001, OPC-012, OPC-013)
|
|
/// </summary>
|
|
public class OpcUaServerHost : IDisposable
|
|
{
|
|
private static readonly ILogger Log = Serilog.Log.ForContext<OpcUaServerHost>();
|
|
private readonly AlarmObjectFilter? _alarmObjectFilter;
|
|
private readonly AuthenticationConfiguration _authConfig;
|
|
private readonly IUserAuthenticationProvider? _authProvider;
|
|
|
|
private readonly OpcUaConfiguration _config;
|
|
private readonly IHistorianDataSource? _historianDataSource;
|
|
private readonly PerformanceMetrics _metrics;
|
|
private readonly IMxAccessClient _mxAccessClient;
|
|
private readonly RedundancyConfiguration _redundancyConfig;
|
|
private readonly SecurityProfileConfiguration _securityConfig;
|
|
private ApplicationInstance? _application;
|
|
private LmxOpcUaServer? _server;
|
|
|
|
/// <summary>
|
|
/// Initializes a new host for the Galaxy-backed OPC UA server instance.
|
|
/// </summary>
|
|
/// <param name="config">The endpoint and session settings for the OPC UA host.</param>
|
|
/// <param name="mxAccessClient">The runtime client used by the node manager for live reads, writes, and subscriptions.</param>
|
|
/// <param name="metrics">The metrics collector shared with the node manager and runtime bridge.</param>
|
|
/// <param name="historianDataSource">The optional historian adapter that enables OPC UA history read support.</param>
|
|
public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
|
|
IHistorianDataSource? historianDataSource = null,
|
|
AuthenticationConfiguration? authConfig = null,
|
|
IUserAuthenticationProvider? authProvider = null,
|
|
SecurityProfileConfiguration? securityConfig = null,
|
|
RedundancyConfiguration? redundancyConfig = null,
|
|
AlarmObjectFilter? alarmObjectFilter = null,
|
|
MxAccessConfiguration? mxAccessConfig = null,
|
|
HistorianConfiguration? historianConfig = null)
|
|
{
|
|
_config = config;
|
|
_mxAccessClient = mxAccessClient;
|
|
_metrics = metrics;
|
|
_historianDataSource = historianDataSource;
|
|
_authConfig = authConfig ?? new AuthenticationConfiguration();
|
|
_authProvider = authProvider;
|
|
_securityConfig = securityConfig ?? new SecurityProfileConfiguration();
|
|
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
|
|
_alarmObjectFilter = alarmObjectFilter;
|
|
_mxAccessConfig = mxAccessConfig ?? new MxAccessConfiguration();
|
|
_historianConfig = historianConfig ?? new HistorianConfiguration();
|
|
}
|
|
|
|
private readonly MxAccessConfiguration _mxAccessConfig;
|
|
private readonly HistorianConfiguration _historianConfig;
|
|
|
|
/// <summary>
|
|
/// Gets the active node manager that holds the published Galaxy namespace.
|
|
/// </summary>
|
|
public LmxNodeManager? NodeManager => _server?.NodeManager;
|
|
|
|
/// <summary>
|
|
/// Gets the number of currently connected OPC UA client sessions.
|
|
/// </summary>
|
|
public int ActiveSessionCount => _server?.ActiveSessionCount ?? 0;
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether the OPC UA server has been started and not yet stopped.
|
|
/// </summary>
|
|
public bool IsRunning => _server != null;
|
|
|
|
/// <summary>
|
|
/// Gets the list of opc.tcp base addresses the server is currently listening on.
|
|
/// Returns an empty list when the server has not started.
|
|
/// </summary>
|
|
public IReadOnlyList<string> BaseAddresses
|
|
{
|
|
get
|
|
{
|
|
var addrs = _application?.ApplicationConfiguration?.ServerConfiguration?.BaseAddresses;
|
|
return addrs != null ? addrs.ToList() : Array.Empty<string>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the list of active security policies advertised to clients (SecurityMode + PolicyUri).
|
|
/// Returns an empty list when the server has not started.
|
|
/// </summary>
|
|
public IReadOnlyList<ServerSecurityPolicy> SecurityPolicies
|
|
{
|
|
get
|
|
{
|
|
var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.SecurityPolicies;
|
|
return policies != null ? policies.ToList() : Array.Empty<ServerSecurityPolicy>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the list of user token policy names advertised to clients (Anonymous, UserName, Certificate).
|
|
/// Returns an empty list when the server has not started.
|
|
/// </summary>
|
|
public IReadOnlyList<string> UserTokenPolicies
|
|
{
|
|
get
|
|
{
|
|
var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.UserTokenPolicies;
|
|
return policies != null ? policies.Select(p => p.TokenType.ToString()).ToList() : Array.Empty<string>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops the host and releases server resources.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
Stop();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the OPC UA ServiceLevel based on current runtime health.
|
|
/// </summary>
|
|
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
|
|
{
|
|
_server?.UpdateServiceLevel(mxAccessConnected, dbConnected);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts the OPC UA application instance, prepares certificates, and binds the Galaxy namespace to the configured
|
|
/// endpoint.
|
|
/// </summary>
|
|
public async Task StartAsync()
|
|
{
|
|
var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa";
|
|
var applicationUri = _config.ApplicationUri ?? namespaceUri;
|
|
|
|
// Resolve configured security profiles
|
|
var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles);
|
|
foreach (var sp in securityPolicies)
|
|
Log.Information("Security profile active: {PolicyUri} / {Mode}", sp.SecurityPolicyUri, sp.SecurityMode);
|
|
|
|
// Build PKI paths
|
|
var pkiRoot = _securityConfig.PkiRootPath ?? Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
"OPC Foundation", "pki");
|
|
var certSubject = _securityConfig.CertificateSubject ?? $"CN={_config.ServerName}, O=ZB MOM, DC=localhost";
|
|
|
|
var serverConfig = new ServerConfiguration
|
|
{
|
|
BaseAddresses = { $"opc.tcp://{_config.BindAddress}:{_config.Port}{_config.EndpointPath}" },
|
|
MaxSessionCount = _config.MaxSessions,
|
|
MaxSessionTimeout = _config.SessionTimeoutMinutes * 60 * 1000, // ms
|
|
MinSessionTimeout = 10000,
|
|
UserTokenPolicies = BuildUserTokenPolicies()
|
|
};
|
|
foreach (var policy in securityPolicies)
|
|
serverConfig.SecurityPolicies.Add(policy);
|
|
|
|
var secConfig = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "own"),
|
|
SubjectName = certSubject
|
|
},
|
|
TrustedIssuerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "issuer")
|
|
},
|
|
TrustedPeerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "trusted")
|
|
},
|
|
RejectedCertificateStore = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "rejected")
|
|
},
|
|
AutoAcceptUntrustedCertificates = _securityConfig.AutoAcceptClientCertificates,
|
|
RejectSHA1SignedCertificates = _securityConfig.RejectSHA1Certificates,
|
|
MinimumCertificateKeySize = (ushort)_securityConfig.MinimumCertificateKeySize
|
|
};
|
|
|
|
var appConfig = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = _config.ServerName,
|
|
ApplicationUri = applicationUri,
|
|
ApplicationType = ApplicationType.Server,
|
|
ProductUri = namespaceUri,
|
|
ServerConfiguration = serverConfig,
|
|
SecurityConfiguration = secConfig,
|
|
|
|
TransportQuotas = new TransportQuotas
|
|
{
|
|
OperationTimeout = 120000,
|
|
MaxStringLength = 4 * 1024 * 1024,
|
|
MaxByteStringLength = 4 * 1024 * 1024,
|
|
MaxArrayLength = 65535,
|
|
MaxMessageSize = 4 * 1024 * 1024,
|
|
MaxBufferSize = 65535,
|
|
ChannelLifetime = 600000,
|
|
SecurityTokenLifetime = 3600000
|
|
},
|
|
|
|
TraceConfiguration = new TraceConfiguration
|
|
{
|
|
OutputFilePath = null,
|
|
TraceMasks = 0
|
|
}
|
|
};
|
|
|
|
await appConfig.Validate(ApplicationType.Server);
|
|
|
|
// Hook certificate validation logging
|
|
appConfig.CertificateValidator.CertificateValidation += OnCertificateValidation;
|
|
|
|
_application = new ApplicationInstance
|
|
{
|
|
ApplicationName = _config.ServerName,
|
|
ApplicationType = ApplicationType.Server,
|
|
ApplicationConfiguration = appConfig
|
|
};
|
|
|
|
// Check/create application certificate
|
|
var minKeySize = (ushort)_securityConfig.MinimumCertificateKeySize;
|
|
var certLifetimeMonths = (ushort)_securityConfig.CertificateLifetimeMonths;
|
|
var certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize, certLifetimeMonths);
|
|
if (!certOk)
|
|
{
|
|
Log.Warning("Application certificate check failed, attempting to create...");
|
|
certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize, certLifetimeMonths);
|
|
}
|
|
|
|
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource,
|
|
_config.AlarmTrackingEnabled, _authConfig, _authProvider, _redundancyConfig, applicationUri,
|
|
_alarmObjectFilter,
|
|
_mxAccessConfig.RuntimeStatusProbesEnabled,
|
|
_mxAccessConfig.RuntimeStatusUnknownTimeoutSeconds,
|
|
_mxAccessConfig.RequestTimeoutSeconds,
|
|
_historianConfig.RequestTimeoutSeconds);
|
|
await _application.Start(_server);
|
|
|
|
Log.Information(
|
|
"OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (applicationUri={ApplicationUri}, namespace={Namespace})",
|
|
_config.BindAddress, _config.Port, _config.EndpointPath, applicationUri, namespaceUri);
|
|
}
|
|
|
|
private void OnCertificateValidation(CertificateValidator sender, CertificateValidationEventArgs e)
|
|
{
|
|
var cert = e.Certificate;
|
|
var subject = cert?.Subject ?? "Unknown";
|
|
var thumbprint = cert?.Thumbprint ?? "N/A";
|
|
|
|
if (_securityConfig.AutoAcceptClientCertificates)
|
|
{
|
|
e.Accept = true;
|
|
Log.Warning(
|
|
"Client certificate auto-accepted: Subject={Subject}, Thumbprint={Thumbprint}, ValidTo={ValidTo}",
|
|
subject, thumbprint, cert?.NotAfter.ToString("yyyy-MM-dd"));
|
|
}
|
|
else
|
|
{
|
|
Log.Warning(
|
|
"Client certificate validation: Error={Error}, Subject={Subject}, Thumbprint={Thumbprint}, Accepted={Accepted}",
|
|
e.Error?.StatusCode, subject, thumbprint, e.Accept);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops the OPC UA application instance and releases its in-memory server objects.
|
|
/// </summary>
|
|
public void Stop()
|
|
{
|
|
try
|
|
{
|
|
_server?.Stop();
|
|
Log.Information("OPC UA server stopped");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex, "Error stopping OPC UA server");
|
|
}
|
|
finally
|
|
{
|
|
_server = null;
|
|
_application = null;
|
|
}
|
|
}
|
|
|
|
private UserTokenPolicyCollection BuildUserTokenPolicies()
|
|
{
|
|
var policies = new UserTokenPolicyCollection();
|
|
if (_authConfig.AllowAnonymous)
|
|
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
|
|
if (_authConfig.Ldap.Enabled || _authProvider != null)
|
|
policies.Add(new UserTokenPolicy(UserTokenType.UserName));
|
|
|
|
// X.509 certificate authentication is always available when security is configured
|
|
if (_securityConfig.Profiles.Any(p =>
|
|
!p.Equals("None", StringComparison.OrdinalIgnoreCase)))
|
|
policies.Add(new UserTokenPolicy(UserTokenType.Certificate));
|
|
|
|
if (policies.Count == 0)
|
|
{
|
|
Log.Warning("No authentication methods configured — adding Anonymous as fallback");
|
|
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
|
|
}
|
|
|
|
return policies;
|
|
}
|
|
}
|
|
} |