feat(lmxproxy): phase 1 — v2 protocol types and domain model

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-21 23:41:56 -04:00
parent 08d2a07d8b
commit 0d63fb1105
87 changed files with 3389 additions and 956 deletions

View File

@@ -0,0 +1,592 @@
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;
}
}
}
}