Split LmxProxy Host and Client into a self-contained solution under lmxproxy/, ported from the ScadaBridge monorepo with updated namespaces (ZB.MOM.WW.LmxProxy.*). Client project (.NET 10) inlines Core/DataEngine dependencies and builds clean. Host project (.NET Fx 4.8) retains ArchestrA.MXAccess for Windows deployment. Added windev.md documenting the WW_DEV_VM development environment setup.
593 lines
24 KiB
C#
593 lines
24 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Grpc.Core;
|
|
using Grpc.Core.Interceptors;
|
|
using Serilog;
|
|
using ZB.MOM.WW.LmxProxy.Host.Configuration;
|
|
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
|
using ZB.MOM.WW.LmxProxy.Host.Grpc.Services;
|
|
using ZB.MOM.WW.LmxProxy.Host.Implementation;
|
|
using ZB.MOM.WW.LmxProxy.Host.Security;
|
|
using ZB.MOM.WW.LmxProxy.Host.Services;
|
|
using ZB.MOM.WW.LmxProxy.Host.Grpc;
|
|
using ConnectionState = ZB.MOM.WW.LmxProxy.Host.Domain.ConnectionState;
|
|
|
|
namespace ZB.MOM.WW.LmxProxy.Host
|
|
{
|
|
/// <summary>
|
|
/// Windows service that hosts the gRPC server and MxAccess client.
|
|
/// Manages lifecycle of gRPC server, SCADA client, subscription manager, and API key service.
|
|
/// </summary>
|
|
public class LmxProxyService
|
|
{
|
|
private static readonly ILogger Logger = Log.ForContext<LmxProxyService>();
|
|
private readonly LmxProxyConfiguration _configuration;
|
|
private readonly SemaphoreSlim _reconnectSemaphore = new(1, 1);
|
|
private readonly Func<LmxProxyConfiguration, IScadaClient> _scadaClientFactory;
|
|
private readonly CancellationTokenSource _shutdownCts = new();
|
|
private ApiKeyService? _apiKeyService;
|
|
private Task? _connectionMonitorTask;
|
|
private DetailedHealthCheckService? _detailedHealthCheckService;
|
|
|
|
private Server? _grpcServer;
|
|
private HealthCheckService? _healthCheckService;
|
|
private PerformanceMetrics? _performanceMetrics;
|
|
private IScadaClient? _scadaClient;
|
|
private SessionManager? _sessionManager;
|
|
private StatusReportService? _statusReportService;
|
|
private StatusWebServer? _statusWebServer;
|
|
private SubscriptionManager? _subscriptionManager;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="LmxProxyService" /> class.
|
|
/// </summary>
|
|
/// <param name="configuration">Configuration settings for the service.</param>
|
|
/// <exception cref="ArgumentNullException">Thrown if configuration is null.</exception>
|
|
public LmxProxyService(LmxProxyConfiguration configuration,
|
|
Func<LmxProxyConfiguration, IScadaClient>? scadaClientFactory = null)
|
|
{
|
|
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
|
_scadaClientFactory = scadaClientFactory ?? (config => new MxAccessClient(config.Connection));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts the LmxProxy service, initializing all required components and starting the gRPC server.
|
|
/// </summary>
|
|
/// <returns><c>true</c> if the service started successfully; otherwise, <c>false</c>.</returns>
|
|
public bool Start()
|
|
{
|
|
try
|
|
{
|
|
Logger.Information("Starting LmxProxy service on port {Port}", _configuration.GrpcPort);
|
|
|
|
// Validate configuration before proceeding
|
|
if (!ValidateConfiguration())
|
|
{
|
|
Logger.Error("Configuration validation failed");
|
|
return false;
|
|
}
|
|
|
|
// Check and ensure TLS certificates are valid
|
|
if (_configuration.Tls.Enabled)
|
|
{
|
|
Logger.Information("Checking TLS certificate configuration");
|
|
var tlsManager = new TlsCertificateManager(_configuration.Tls);
|
|
if (!tlsManager.EnsureCertificatesValid())
|
|
{
|
|
Logger.Error("Failed to ensure valid TLS certificates");
|
|
throw new InvalidOperationException("TLS certificate validation or generation failed");
|
|
}
|
|
|
|
Logger.Information("TLS certificates validated successfully");
|
|
}
|
|
|
|
// Create performance metrics service
|
|
_performanceMetrics = new PerformanceMetrics();
|
|
Logger.Information("Performance metrics service initialized");
|
|
|
|
// Create API key service
|
|
string apiKeyConfigPath = Path.GetFullPath(_configuration.ApiKeyConfigFile);
|
|
_apiKeyService = new ApiKeyService(apiKeyConfigPath);
|
|
Logger.Information("API key service initialized with config file: {ConfigFile}", apiKeyConfigPath);
|
|
|
|
// Create SCADA client via factory
|
|
_scadaClient = _scadaClientFactory(_configuration) ??
|
|
throw new InvalidOperationException("SCADA client factory returned null.");
|
|
|
|
// Subscribe to connection state changes
|
|
_scadaClient.ConnectionStateChanged += OnConnectionStateChanged;
|
|
|
|
// Automatically connect to MxAccess on startup
|
|
try
|
|
{
|
|
Logger.Information("Connecting to MxAccess...");
|
|
Task connectTask = _scadaClient.ConnectAsync();
|
|
if (!connectTask.Wait(TimeSpan.FromSeconds(_configuration.Connection.ConnectionTimeoutSeconds)))
|
|
{
|
|
throw new TimeoutException(
|
|
$"Timeout connecting to MxAccess after {_configuration.Connection.ConnectionTimeoutSeconds} seconds");
|
|
}
|
|
|
|
Logger.Information("Successfully connected to MxAccess");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error(ex, "Failed to connect to MxAccess on startup");
|
|
throw;
|
|
}
|
|
|
|
// Start connection monitoring if auto-reconnect is enabled
|
|
if (_configuration.Connection.AutoReconnect)
|
|
{
|
|
_connectionMonitorTask = Task.Run(() => MonitorConnectionAsync(_shutdownCts.Token));
|
|
Logger.Information("Connection monitoring started with {Interval} second interval",
|
|
_configuration.Connection.MonitorIntervalSeconds);
|
|
}
|
|
|
|
// Create subscription manager with configuration
|
|
_subscriptionManager = new SubscriptionManager(_scadaClient, _configuration.Subscription);
|
|
|
|
// Create session manager for tracking client sessions
|
|
_sessionManager = new SessionManager();
|
|
Logger.Information("Session manager initialized");
|
|
|
|
// Create health check services
|
|
_healthCheckService = new HealthCheckService(_scadaClient, _subscriptionManager, _performanceMetrics);
|
|
_detailedHealthCheckService = new DetailedHealthCheckService(_scadaClient);
|
|
Logger.Information("Health check services initialized");
|
|
|
|
// Create status report service and web server
|
|
_statusReportService = new StatusReportService(
|
|
_scadaClient,
|
|
_subscriptionManager,
|
|
_performanceMetrics,
|
|
_healthCheckService,
|
|
_detailedHealthCheckService);
|
|
|
|
_statusWebServer = new StatusWebServer(_configuration.WebServer, _statusReportService);
|
|
Logger.Information("Status web server initialized");
|
|
|
|
// Create gRPC service with session manager and performance metrics
|
|
var scadaService = new ScadaGrpcService(_scadaClient, _subscriptionManager, _sessionManager, _performanceMetrics);
|
|
|
|
// Create API key interceptor
|
|
var apiKeyInterceptor = new ApiKeyInterceptor(_apiKeyService);
|
|
|
|
// Configure server credentials based on TLS configuration
|
|
ServerCredentials serverCredentials;
|
|
if (_configuration.Tls.Enabled)
|
|
{
|
|
serverCredentials = CreateTlsCredentials(_configuration.Tls);
|
|
Logger.Information("TLS enabled for gRPC server");
|
|
}
|
|
else
|
|
{
|
|
serverCredentials = ServerCredentials.Insecure;
|
|
Logger.Warning("gRPC server running without TLS encryption - not recommended for production");
|
|
}
|
|
|
|
// Configure and start gRPC server with interceptor
|
|
_grpcServer = new Server
|
|
{
|
|
Services = { ScadaService.BindService(scadaService).Intercept(apiKeyInterceptor) },
|
|
Ports = { new ServerPort("0.0.0.0", _configuration.GrpcPort, serverCredentials) }
|
|
};
|
|
|
|
_grpcServer.Start();
|
|
|
|
string securityMode = _configuration.Tls.Enabled ? "TLS/SSL" : "INSECURE";
|
|
Logger.Information("LmxProxy service started successfully on port {Port} ({SecurityMode})",
|
|
_configuration.GrpcPort, securityMode);
|
|
Logger.Information("gRPC server listening on 0.0.0.0:{Port}", _configuration.GrpcPort);
|
|
|
|
// Start status web server
|
|
if (_statusWebServer != null && !_statusWebServer.Start())
|
|
{
|
|
Logger.Warning("Failed to start status web server, continuing without it");
|
|
}
|
|
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Fatal(ex, "Failed to start LmxProxy service");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops the LmxProxy service, shutting down the gRPC server and disposing all resources.
|
|
/// </summary>
|
|
/// <returns><c>true</c> if the service stopped successfully; otherwise, <c>false</c>.</returns>
|
|
public bool Stop()
|
|
{
|
|
try
|
|
{
|
|
Logger.Information("Stopping LmxProxy service");
|
|
|
|
_shutdownCts.Cancel();
|
|
|
|
// Stop connection monitoring
|
|
if (_connectionMonitorTask != null)
|
|
{
|
|
try
|
|
{
|
|
_connectionMonitorTask.Wait(TimeSpan.FromSeconds(5));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warning(ex, "Error stopping connection monitor");
|
|
}
|
|
}
|
|
|
|
// Shutdown gRPC server
|
|
if (_grpcServer != null)
|
|
{
|
|
Logger.Information("Shutting down gRPC server");
|
|
Task? shutdownTask = _grpcServer.ShutdownAsync();
|
|
|
|
// Wait up to 10 seconds for graceful shutdown
|
|
if (!shutdownTask.Wait(TimeSpan.FromSeconds(10)))
|
|
{
|
|
Logger.Warning("gRPC server shutdown timeout, forcing kill");
|
|
_grpcServer.KillAsync().Wait(TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
_grpcServer = null;
|
|
}
|
|
|
|
// Stop status web server
|
|
if (_statusWebServer != null)
|
|
{
|
|
Logger.Information("Stopping status web server");
|
|
try
|
|
{
|
|
_statusWebServer.Stop();
|
|
_statusWebServer.Dispose();
|
|
_statusWebServer = null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warning(ex, "Error stopping status web server");
|
|
}
|
|
}
|
|
|
|
// Dispose status report service
|
|
if (_statusReportService != null)
|
|
{
|
|
Logger.Information("Disposing status report service");
|
|
_statusReportService = null;
|
|
}
|
|
|
|
// Dispose health check services
|
|
if (_detailedHealthCheckService != null)
|
|
{
|
|
Logger.Information("Disposing detailed health check service");
|
|
_detailedHealthCheckService = null;
|
|
}
|
|
|
|
if (_healthCheckService != null)
|
|
{
|
|
Logger.Information("Disposing health check service");
|
|
_healthCheckService = null;
|
|
}
|
|
|
|
// Dispose subscription manager
|
|
if (_subscriptionManager != null)
|
|
{
|
|
Logger.Information("Disposing subscription manager");
|
|
_subscriptionManager.Dispose();
|
|
_subscriptionManager = null;
|
|
}
|
|
|
|
// Dispose session manager
|
|
if (_sessionManager != null)
|
|
{
|
|
Logger.Information("Disposing session manager");
|
|
_sessionManager.Dispose();
|
|
_sessionManager = null;
|
|
}
|
|
|
|
// Dispose API key service
|
|
if (_apiKeyService != null)
|
|
{
|
|
Logger.Information("Disposing API key service");
|
|
_apiKeyService.Dispose();
|
|
_apiKeyService = null;
|
|
}
|
|
|
|
// Dispose performance metrics
|
|
if (_performanceMetrics != null)
|
|
{
|
|
Logger.Information("Disposing performance metrics service");
|
|
_performanceMetrics.Dispose();
|
|
_performanceMetrics = null;
|
|
}
|
|
|
|
// Disconnect and dispose SCADA client
|
|
if (_scadaClient != null)
|
|
{
|
|
Logger.Information("Disconnecting SCADA client");
|
|
|
|
// Unsubscribe from events
|
|
_scadaClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
|
|
|
try
|
|
{
|
|
Task disconnectTask = _scadaClient.DisconnectAsync();
|
|
if (!disconnectTask.Wait(TimeSpan.FromSeconds(10)))
|
|
{
|
|
Logger.Warning("SCADA client disconnect timeout");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warning(ex, "Error disconnecting SCADA client");
|
|
}
|
|
|
|
try
|
|
{
|
|
Task? disposeTask = _scadaClient.DisposeAsync().AsTask();
|
|
if (!disposeTask.Wait(TimeSpan.FromSeconds(5)))
|
|
{
|
|
Logger.Warning("SCADA client dispose timeout");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warning(ex, "Error disposing SCADA client");
|
|
}
|
|
|
|
_scadaClient = null;
|
|
}
|
|
|
|
Logger.Information("LmxProxy service stopped successfully");
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error(ex, "Error stopping LmxProxy service");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pauses the LmxProxy service. No operation is performed except logging.
|
|
/// </summary>
|
|
public void Pause() => Logger.Information("LmxProxy service paused");
|
|
|
|
/// <summary>
|
|
/// Continues the LmxProxy service after a pause. No operation is performed except logging.
|
|
/// </summary>
|
|
public void Continue() => Logger.Information("LmxProxy service continued");
|
|
|
|
/// <summary>
|
|
/// Requests shutdown of the LmxProxy service and stops all components.
|
|
/// </summary>
|
|
public void Shutdown()
|
|
{
|
|
Logger.Information("LmxProxy service shutdown requested");
|
|
Stop();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles connection state changes from the SCADA client.
|
|
/// </summary>
|
|
private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
|
|
{
|
|
Logger.Information("MxAccess connection state changed from {Previous} to {Current}",
|
|
e.PreviousState, e.CurrentState);
|
|
|
|
if (e.CurrentState == ConnectionState.Disconnected &&
|
|
e.PreviousState == ConnectionState.Connected)
|
|
{
|
|
Logger.Warning("MxAccess connection lost. Automatic reconnection will be attempted.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Monitors the connection and attempts to reconnect when disconnected.
|
|
/// </summary>
|
|
private async Task MonitorConnectionAsync(CancellationToken cancellationToken)
|
|
{
|
|
Logger.Information("Starting connection monitor");
|
|
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(TimeSpan.FromSeconds(_configuration.Connection.MonitorIntervalSeconds),
|
|
cancellationToken);
|
|
|
|
if (_scadaClient != null && !_scadaClient.IsConnected && !cancellationToken.IsCancellationRequested)
|
|
{
|
|
await _reconnectSemaphore.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
if (_scadaClient != null && !_scadaClient.IsConnected)
|
|
{
|
|
Logger.Information("Attempting to reconnect to MxAccess...");
|
|
|
|
try
|
|
{
|
|
await _scadaClient.ConnectAsync(cancellationToken);
|
|
Logger.Information("Successfully reconnected to MxAccess");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warning(ex,
|
|
"Failed to reconnect to MxAccess. Will retry in {Interval} seconds.",
|
|
_configuration.Connection.MonitorIntervalSeconds);
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_reconnectSemaphore.Release();
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected when shutting down
|
|
break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error(ex, "Error in connection monitor");
|
|
}
|
|
}
|
|
|
|
Logger.Information("Connection monitor stopped");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates TLS server credentials from configuration
|
|
/// </summary>
|
|
private static ServerCredentials CreateTlsCredentials(TlsConfiguration tlsConfig)
|
|
{
|
|
try
|
|
{
|
|
// Read certificate and key files
|
|
string serverCert = File.ReadAllText(tlsConfig.ServerCertificatePath);
|
|
string serverKey = File.ReadAllText(tlsConfig.ServerKeyPath);
|
|
|
|
var keyCertPairs = new List<KeyCertificatePair>
|
|
{
|
|
new(serverCert, serverKey)
|
|
};
|
|
|
|
// Configure client certificate requirements
|
|
if (tlsConfig.RequireClientCertificate && !string.IsNullOrWhiteSpace(tlsConfig.ClientCaCertificatePath))
|
|
{
|
|
string clientCaCert = File.ReadAllText(tlsConfig.ClientCaCertificatePath);
|
|
return new SslServerCredentials(
|
|
keyCertPairs,
|
|
clientCaCert,
|
|
tlsConfig.CheckCertificateRevocation
|
|
? SslClientCertificateRequestType.RequestAndRequireAndVerify
|
|
: SslClientCertificateRequestType.RequestAndRequireButDontVerify);
|
|
}
|
|
|
|
if (tlsConfig.RequireClientCertificate)
|
|
{
|
|
// Require client certificate but no CA specified - use system CA
|
|
return new SslServerCredentials(
|
|
keyCertPairs,
|
|
null,
|
|
SslClientCertificateRequestType.RequestAndRequireAndVerify);
|
|
}
|
|
|
|
// No client certificate required
|
|
return new SslServerCredentials(keyCertPairs);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error(ex, "Failed to create TLS credentials");
|
|
throw new InvalidOperationException("Failed to configure TLS for gRPC server", ex);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates the service configuration and returns false if any critical issues are found
|
|
/// </summary>
|
|
private bool ValidateConfiguration()
|
|
{
|
|
try
|
|
{
|
|
// Validate gRPC port
|
|
if (_configuration.GrpcPort <= 0 || _configuration.GrpcPort > 65535)
|
|
{
|
|
Logger.Error("Invalid gRPC port: {Port}. Port must be between 1 and 65535",
|
|
_configuration.GrpcPort);
|
|
return false;
|
|
}
|
|
|
|
// Validate API key configuration file
|
|
if (string.IsNullOrWhiteSpace(_configuration.ApiKeyConfigFile))
|
|
{
|
|
Logger.Error("API key configuration file path is null or empty");
|
|
return false;
|
|
}
|
|
|
|
// Check if API key file exists or can be created
|
|
string apiKeyPath = Path.GetFullPath(_configuration.ApiKeyConfigFile);
|
|
string? apiKeyDirectory = Path.GetDirectoryName(apiKeyPath);
|
|
|
|
if (!string.IsNullOrEmpty(apiKeyDirectory) && !Directory.Exists(apiKeyDirectory))
|
|
{
|
|
try
|
|
{
|
|
Directory.CreateDirectory(apiKeyDirectory);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error(ex, "Cannot create directory for API key file: {Directory}", apiKeyDirectory);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// If API key file exists, validate it can be read
|
|
if (File.Exists(apiKeyPath))
|
|
{
|
|
try
|
|
{
|
|
string content = File.ReadAllText(apiKeyPath);
|
|
if (!string.IsNullOrWhiteSpace(content))
|
|
{
|
|
// Try to parse as JSON to validate format
|
|
JsonDocument.Parse(content);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error(ex, "API key configuration file is invalid or unreadable: {FilePath}", apiKeyPath);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Validate TLS configuration if enabled
|
|
if (_configuration.Tls.Enabled)
|
|
{
|
|
if (!_configuration.Tls.Validate())
|
|
{
|
|
Logger.Error("TLS configuration validation failed");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Validate web server configuration if enabled
|
|
if (_configuration.WebServer.Enabled)
|
|
{
|
|
if (_configuration.WebServer.Port <= 0 || _configuration.WebServer.Port > 65535)
|
|
{
|
|
Logger.Error("Invalid web server port: {Port}. Port must be between 1 and 65535",
|
|
_configuration.WebServer.Port);
|
|
return false;
|
|
}
|
|
|
|
// Check for port conflicts
|
|
if (_configuration.WebServer.Port == _configuration.GrpcPort)
|
|
{
|
|
Logger.Error("Web server port {WebPort} conflicts with gRPC port {GrpcPort}",
|
|
_configuration.WebServer.Port, _configuration.GrpcPort);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Logger.Information("Configuration validation passed");
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error(ex, "Error during configuration validation");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|