diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs
new file mode 100644
index 0000000..27decec
--- /dev/null
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Serilog;
+
+namespace ZB.MOM.WW.LmxProxy.Host.Configuration
+{
+ ///
+ /// Validates the LmxProxy configuration at startup.
+ /// Throws InvalidOperationException on any validation error.
+ ///
+ public static class ConfigurationValidator
+ {
+ private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator));
+
+ ///
+ /// Validates all configuration settings and logs the effective values.
+ /// Throws on first validation error.
+ ///
+ public static void ValidateAndLog(LmxProxyConfiguration config)
+ {
+ var errors = new List();
+
+ // GrpcPort
+ if (config.GrpcPort < 1 || config.GrpcPort > 65535)
+ errors.Add($"GrpcPort must be 1-65535, got {config.GrpcPort}");
+
+ // Connection
+ var conn = config.Connection;
+ if (conn.MonitorIntervalSeconds <= 0)
+ errors.Add($"Connection.MonitorIntervalSeconds must be > 0, got {conn.MonitorIntervalSeconds}");
+ if (conn.ConnectionTimeoutSeconds <= 0)
+ errors.Add($"Connection.ConnectionTimeoutSeconds must be > 0, got {conn.ConnectionTimeoutSeconds}");
+ if (conn.ReadTimeoutSeconds <= 0)
+ errors.Add($"Connection.ReadTimeoutSeconds must be > 0, got {conn.ReadTimeoutSeconds}");
+ if (conn.WriteTimeoutSeconds <= 0)
+ errors.Add($"Connection.WriteTimeoutSeconds must be > 0, got {conn.WriteTimeoutSeconds}");
+ if (conn.MaxConcurrentOperations <= 0)
+ errors.Add($"Connection.MaxConcurrentOperations must be > 0, got {conn.MaxConcurrentOperations}");
+ if (conn.NodeName != null && conn.NodeName.Length > 255)
+ errors.Add("Connection.NodeName must be <= 255 characters");
+ if (conn.GalaxyName != null && conn.GalaxyName.Length > 255)
+ errors.Add("Connection.GalaxyName must be <= 255 characters");
+
+ // Subscription
+ var sub = config.Subscription;
+ if (sub.ChannelCapacity < 0 || sub.ChannelCapacity > 100000)
+ errors.Add($"Subscription.ChannelCapacity must be 0-100000, got {sub.ChannelCapacity}");
+ var validModes = new HashSet(StringComparer.OrdinalIgnoreCase)
+ { "DropOldest", "DropNewest", "Wait" };
+ if (!validModes.Contains(sub.ChannelFullMode))
+ errors.Add($"Subscription.ChannelFullMode must be DropOldest, DropNewest, or Wait, got '{sub.ChannelFullMode}'");
+
+ // ServiceRecovery
+ var sr = config.ServiceRecovery;
+ if (sr.FirstFailureDelayMinutes < 0)
+ errors.Add($"ServiceRecovery.FirstFailureDelayMinutes must be >= 0, got {sr.FirstFailureDelayMinutes}");
+ if (sr.SecondFailureDelayMinutes < 0)
+ errors.Add($"ServiceRecovery.SecondFailureDelayMinutes must be >= 0, got {sr.SecondFailureDelayMinutes}");
+ if (sr.SubsequentFailureDelayMinutes < 0)
+ errors.Add($"ServiceRecovery.SubsequentFailureDelayMinutes must be >= 0, got {sr.SubsequentFailureDelayMinutes}");
+ if (sr.ResetPeriodDays <= 0)
+ errors.Add($"ServiceRecovery.ResetPeriodDays must be > 0, got {sr.ResetPeriodDays}");
+
+ // TLS
+ if (config.Tls.Enabled)
+ {
+ if (!File.Exists(config.Tls.ServerCertificatePath))
+ Log.Warning("TLS enabled but server certificate not found at {Path} (will auto-generate)",
+ config.Tls.ServerCertificatePath);
+ if (!File.Exists(config.Tls.ServerKeyPath))
+ Log.Warning("TLS enabled but server key not found at {Path} (will auto-generate)",
+ config.Tls.ServerKeyPath);
+ }
+
+ // WebServer
+ if (config.WebServer.Enabled)
+ {
+ if (config.WebServer.Port < 1 || config.WebServer.Port > 65535)
+ errors.Add($"WebServer.Port must be 1-65535, got {config.WebServer.Port}");
+ }
+
+ if (errors.Count > 0)
+ {
+ foreach (var error in errors)
+ Log.Error("Configuration error: {Error}", error);
+ throw new InvalidOperationException(
+ $"Configuration validation failed with {errors.Count} error(s): {string.Join("; ", errors)}");
+ }
+
+ // Log effective configuration
+ Log.Information("Configuration validated successfully");
+ Log.Information(" GrpcPort: {Port}", config.GrpcPort);
+ Log.Information(" ApiKeyConfigFile: {File}", config.ApiKeyConfigFile);
+ Log.Information(" Connection.AutoReconnect: {AutoReconnect}", conn.AutoReconnect);
+ Log.Information(" Connection.MonitorIntervalSeconds: {Interval}", conn.MonitorIntervalSeconds);
+ Log.Information(" Connection.MaxConcurrentOperations: {Max}", conn.MaxConcurrentOperations);
+ Log.Information(" Subscription.ChannelCapacity: {Capacity}", sub.ChannelCapacity);
+ Log.Information(" Subscription.ChannelFullMode: {Mode}", sub.ChannelFullMode);
+ Log.Information(" Tls.Enabled: {Enabled}", config.Tls.Enabled);
+ Log.Information(" WebServer.Enabled: {Enabled}, Port: {Port}", config.WebServer.Enabled, config.WebServer.Port);
+ }
+ }
+}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConnectionConfiguration.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConnectionConfiguration.cs
new file mode 100644
index 0000000..d8a06df
--- /dev/null
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConnectionConfiguration.cs
@@ -0,0 +1,30 @@
+namespace ZB.MOM.WW.LmxProxy.Host.Configuration
+{
+ /// MxAccess connection settings.
+ public class ConnectionConfiguration
+ {
+ /// Auto-reconnect check interval in seconds. Default: 5.
+ public int MonitorIntervalSeconds { get; set; } = 5;
+
+ /// Initial connection timeout in seconds. Default: 30.
+ public int ConnectionTimeoutSeconds { get; set; } = 30;
+
+ /// Per-read operation timeout in seconds. Default: 5.
+ public int ReadTimeoutSeconds { get; set; } = 5;
+
+ /// Per-write operation timeout in seconds. Default: 5.
+ public int WriteTimeoutSeconds { get; set; } = 5;
+
+ /// Semaphore limit for concurrent MxAccess operations. Default: 10.
+ public int MaxConcurrentOperations { get; set; } = 10;
+
+ /// Enable auto-reconnect loop. Default: true.
+ public bool AutoReconnect { get; set; } = true;
+
+ /// MxAccess node name (optional).
+ public string? NodeName { get; set; }
+
+ /// MxAccess galaxy name (optional).
+ public string? GalaxyName { get; set; }
+ }
+}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs
new file mode 100644
index 0000000..89ea71a
--- /dev/null
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs
@@ -0,0 +1,27 @@
+namespace ZB.MOM.WW.LmxProxy.Host.Configuration
+{
+ /// Root configuration class bound to appsettings.json.
+ public class LmxProxyConfiguration
+ {
+ /// gRPC server listen port. Default: 50051.
+ public int GrpcPort { get; set; } = 50051;
+
+ /// Path to API key configuration file. Default: apikeys.json.
+ public string ApiKeyConfigFile { get; set; } = "apikeys.json";
+
+ /// MxAccess connection settings.
+ public ConnectionConfiguration Connection { get; set; } = new ConnectionConfiguration();
+
+ /// Subscription channel settings.
+ public SubscriptionConfiguration Subscription { get; set; } = new SubscriptionConfiguration();
+
+ /// TLS/SSL settings.
+ public TlsConfiguration Tls { get; set; } = new TlsConfiguration();
+
+ /// Status web server settings.
+ public WebServerConfiguration WebServer { get; set; } = new WebServerConfiguration();
+
+ /// Windows SCM service recovery settings.
+ public ServiceRecoveryConfiguration ServiceRecovery { get; set; } = new ServiceRecoveryConfiguration();
+ }
+}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs
new file mode 100644
index 0000000..af92c11
--- /dev/null
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs
@@ -0,0 +1,18 @@
+namespace ZB.MOM.WW.LmxProxy.Host.Configuration
+{
+ /// Windows SCM service recovery settings.
+ public class ServiceRecoveryConfiguration
+ {
+ /// Restart delay after first failure in minutes. Default: 1.
+ public int FirstFailureDelayMinutes { get; set; } = 1;
+
+ /// Restart delay after second failure in minutes. Default: 5.
+ public int SecondFailureDelayMinutes { get; set; } = 5;
+
+ /// Restart delay after subsequent failures in minutes. Default: 10.
+ public int SubsequentFailureDelayMinutes { get; set; } = 10;
+
+ /// Days before failure count resets. Default: 1.
+ public int ResetPeriodDays { get; set; } = 1;
+ }
+}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs
new file mode 100644
index 0000000..346f738
--- /dev/null
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.LmxProxy.Host.Configuration
+{
+ /// Subscription channel settings.
+ public class SubscriptionConfiguration
+ {
+ /// Per-client subscription buffer size. Default: 1000.
+ public int ChannelCapacity { get; set; } = 1000;
+
+ /// Backpressure strategy: DropOldest, DropNewest, or Wait. Default: DropOldest.
+ public string ChannelFullMode { get; set; } = "DropOldest";
+ }
+}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs
new file mode 100644
index 0000000..cb432b4
--- /dev/null
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs
@@ -0,0 +1,24 @@
+namespace ZB.MOM.WW.LmxProxy.Host.Configuration
+{
+ /// TLS/SSL settings for the gRPC server.
+ public class TlsConfiguration
+ {
+ /// Enable TLS on the gRPC server. Default: false.
+ public bool Enabled { get; set; } = false;
+
+ /// PEM server certificate path. Default: certs/server.crt.
+ public string ServerCertificatePath { get; set; } = "certs/server.crt";
+
+ /// PEM server private key path. Default: certs/server.key.
+ public string ServerKeyPath { get; set; } = "certs/server.key";
+
+ /// CA certificate for mutual TLS client validation. Default: certs/ca.crt.
+ public string ClientCaCertificatePath { get; set; } = "certs/ca.crt";
+
+ /// Require client certificates (mutual TLS). Default: false.
+ public bool RequireClientCertificate { get; set; } = false;
+
+ /// Check certificate revocation lists. Default: false.
+ public bool CheckCertificateRevocation { get; set; } = false;
+ }
+}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/WebServerConfiguration.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/WebServerConfiguration.cs
new file mode 100644
index 0000000..9717b12
--- /dev/null
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/WebServerConfiguration.cs
@@ -0,0 +1,15 @@
+namespace ZB.MOM.WW.LmxProxy.Host.Configuration
+{
+ /// HTTP status web server settings.
+ public class WebServerConfiguration
+ {
+ /// Enable the status web server. Default: true.
+ public bool Enabled { get; set; } = true;
+
+ /// HTTP listen port. Default: 8080.
+ public int Port { get; set; } = 8080;
+
+ /// Custom URL prefix (defaults to http://+:{Port}/ if null).
+ public string? Prefix { get; set; }
+ }
+}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs
new file mode 100644
index 0000000..deb4879
--- /dev/null
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs
@@ -0,0 +1,412 @@
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading.Tasks;
+using Grpc.Core;
+using Serilog;
+using ZB.MOM.WW.LmxProxy.Host.Domain;
+using ZB.MOM.WW.LmxProxy.Host.Sessions;
+using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
+
+namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
+{
+ ///
+ /// gRPC service implementation for all 10 SCADA RPCs.
+ /// Inherits from proto-generated ScadaService.ScadaServiceBase.
+ ///
+ public class ScadaGrpcService : Scada.ScadaService.ScadaServiceBase
+ {
+ private static readonly ILogger Log = Serilog.Log.ForContext();
+
+ private readonly IScadaClient _scadaClient;
+ private readonly SessionManager _sessionManager;
+ private readonly SubscriptionManager _subscriptionManager;
+
+ public ScadaGrpcService(
+ IScadaClient scadaClient,
+ SessionManager sessionManager,
+ SubscriptionManager subscriptionManager)
+ {
+ _scadaClient = scadaClient;
+ _sessionManager = sessionManager;
+ _subscriptionManager = subscriptionManager;
+ }
+
+ // -- Connection Management ------------------------------------
+
+ public override Task Connect(
+ Scada.ConnectRequest request, ServerCallContext context)
+ {
+ try
+ {
+ if (!_scadaClient.IsConnected)
+ {
+ return Task.FromResult(new Scada.ConnectResponse
+ {
+ Success = false,
+ Message = "MxAccess is not connected"
+ });
+ }
+
+ var sessionId = _sessionManager.CreateSession(request.ClientId, request.ApiKey);
+
+ return Task.FromResult(new Scada.ConnectResponse
+ {
+ Success = true,
+ Message = "Connected",
+ SessionId = sessionId
+ });
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Connect failed for client {ClientId}", request.ClientId);
+ return Task.FromResult(new Scada.ConnectResponse
+ {
+ Success = false,
+ Message = ex.Message
+ });
+ }
+ }
+
+ public override Task Disconnect(
+ Scada.DisconnectRequest request, ServerCallContext context)
+ {
+ try
+ {
+ // Clean up subscriptions for this session
+ _subscriptionManager.UnsubscribeClient(request.SessionId);
+
+ var terminated = _sessionManager.TerminateSession(request.SessionId);
+ return Task.FromResult(new Scada.DisconnectResponse
+ {
+ Success = terminated,
+ Message = terminated ? "Disconnected" : "Session not found"
+ });
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Disconnect failed for session {SessionId}", request.SessionId);
+ return Task.FromResult(new Scada.DisconnectResponse
+ {
+ Success = false,
+ Message = ex.Message
+ });
+ }
+ }
+
+ public override Task GetConnectionState(
+ Scada.GetConnectionStateRequest request, ServerCallContext context)
+ {
+ var session = _sessionManager.GetSession(request.SessionId);
+ return Task.FromResult(new Scada.GetConnectionStateResponse
+ {
+ IsConnected = _scadaClient.IsConnected,
+ ClientId = session?.ClientId ?? "",
+ ConnectedSinceUtcTicks = session?.ConnectedSinceUtcTicks ?? 0
+ });
+ }
+
+ // -- Read Operations ------------------------------------------
+
+ public override async Task Read(
+ Scada.ReadRequest request, ServerCallContext context)
+ {
+ if (!_sessionManager.ValidateSession(request.SessionId))
+ {
+ return new Scada.ReadResponse
+ {
+ Success = false,
+ Message = "Invalid session",
+ Vtq = CreateBadVtq(request.Tag, QualityCodeMapper.Bad())
+ };
+ }
+
+ try
+ {
+ var vtq = await _scadaClient.ReadAsync(request.Tag, context.CancellationToken);
+ return new Scada.ReadResponse
+ {
+ Success = true,
+ Message = "",
+ Vtq = ConvertToProtoVtq(request.Tag, vtq)
+ };
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Read failed for tag {Tag}", request.Tag);
+ return new Scada.ReadResponse
+ {
+ Success = false,
+ Message = ex.Message,
+ Vtq = CreateBadVtq(request.Tag, QualityCodeMapper.BadCommunicationFailure())
+ };
+ }
+ }
+
+ public override async Task ReadBatch(
+ Scada.ReadBatchRequest request, ServerCallContext context)
+ {
+ if (!_sessionManager.ValidateSession(request.SessionId))
+ {
+ return new Scada.ReadBatchResponse
+ {
+ Success = false,
+ Message = "Invalid session"
+ };
+ }
+
+ try
+ {
+ var results = await _scadaClient.ReadBatchAsync(request.Tags, context.CancellationToken);
+
+ var response = new Scada.ReadBatchResponse
+ {
+ Success = true,
+ Message = ""
+ };
+
+ // Return results in request order
+ foreach (var tag in request.Tags)
+ {
+ if (results.TryGetValue(tag, out var vtq))
+ {
+ response.Vtqs.Add(ConvertToProtoVtq(tag, vtq));
+ }
+ else
+ {
+ response.Vtqs.Add(CreateBadVtq(tag, QualityCodeMapper.BadConfigurationError()));
+ }
+ }
+
+ return response;
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "ReadBatch failed");
+ return new Scada.ReadBatchResponse
+ {
+ Success = false,
+ Message = ex.Message
+ };
+ }
+ }
+
+ // -- Write Operations -----------------------------------------
+
+ public override async Task Write(
+ Scada.WriteRequest request, ServerCallContext context)
+ {
+ if (!_sessionManager.ValidateSession(request.SessionId))
+ {
+ return new Scada.WriteResponse { Success = false, Message = "Invalid session" };
+ }
+
+ try
+ {
+ var value = TypedValueConverter.FromTypedValue(request.Value);
+ await _scadaClient.WriteAsync(request.Tag, value!, context.CancellationToken);
+ return new Scada.WriteResponse { Success = true, Message = "" };
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Write failed for tag {Tag}", request.Tag);
+ return new Scada.WriteResponse { Success = false, Message = ex.Message };
+ }
+ }
+
+ public override async Task WriteBatch(
+ Scada.WriteBatchRequest request, ServerCallContext context)
+ {
+ if (!_sessionManager.ValidateSession(request.SessionId))
+ {
+ return new Scada.WriteBatchResponse { Success = false, Message = "Invalid session" };
+ }
+
+ var response = new Scada.WriteBatchResponse { Success = true, Message = "" };
+
+ foreach (var item in request.Items)
+ {
+ try
+ {
+ var value = TypedValueConverter.FromTypedValue(item.Value);
+ await _scadaClient.WriteAsync(item.Tag, value!, context.CancellationToken);
+ response.Results.Add(new Scada.WriteResult
+ {
+ Tag = item.Tag, Success = true, Message = ""
+ });
+ }
+ catch (Exception ex)
+ {
+ response.Success = false;
+ response.Results.Add(new Scada.WriteResult
+ {
+ Tag = item.Tag, Success = false, Message = ex.Message
+ });
+ }
+ }
+
+ return response;
+ }
+
+ public override async Task WriteBatchAndWait(
+ Scada.WriteBatchAndWaitRequest request, ServerCallContext context)
+ {
+ if (!_sessionManager.ValidateSession(request.SessionId))
+ {
+ return new Scada.WriteBatchAndWaitResponse { Success = false, Message = "Invalid session" };
+ }
+
+ var response = new Scada.WriteBatchAndWaitResponse { Success = true };
+
+ try
+ {
+ // Execute writes and collect results
+ foreach (var item in request.Items)
+ {
+ try
+ {
+ var value = TypedValueConverter.FromTypedValue(item.Value);
+ await _scadaClient.WriteAsync(item.Tag, value!, context.CancellationToken);
+ response.WriteResults.Add(new Scada.WriteResult
+ {
+ Tag = item.Tag, Success = true, Message = ""
+ });
+ }
+ catch (Exception ex)
+ {
+ response.Success = false;
+ response.Message = "One or more writes failed";
+ response.WriteResults.Add(new Scada.WriteResult
+ {
+ Tag = item.Tag, Success = false, Message = ex.Message
+ });
+ }
+ }
+
+ // If any write failed, return immediately
+ if (!response.Success)
+ return response;
+
+ // Poll flag tag
+ var flagValue = TypedValueConverter.FromTypedValue(request.FlagValue);
+ var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : 5000;
+ var pollIntervalMs = request.PollIntervalMs > 0 ? request.PollIntervalMs : 100;
+
+ var sw = Stopwatch.StartNew();
+ while (sw.ElapsedMilliseconds < timeoutMs)
+ {
+ context.CancellationToken.ThrowIfCancellationRequested();
+
+ var vtq = await _scadaClient.ReadAsync(request.FlagTag, context.CancellationToken);
+ if (vtq.Quality.IsGood() && TypedValueComparer.Equals(vtq.Value, flagValue))
+ {
+ response.FlagReached = true;
+ response.ElapsedMs = (int)sw.ElapsedMilliseconds;
+ return response;
+ }
+
+ await Task.Delay(pollIntervalMs, context.CancellationToken);
+ }
+
+ // Timeout -- not an error
+ response.FlagReached = false;
+ response.ElapsedMs = (int)sw.ElapsedMilliseconds;
+ return response;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "WriteBatchAndWait failed");
+ return new Scada.WriteBatchAndWaitResponse
+ {
+ Success = false, Message = ex.Message
+ };
+ }
+ }
+
+ // -- Subscription ---------------------------------------------
+
+ public override async Task Subscribe(
+ Scada.SubscribeRequest request,
+ IServerStreamWriter responseStream,
+ ServerCallContext context)
+ {
+ if (!_sessionManager.ValidateSession(request.SessionId))
+ {
+ throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid session"));
+ }
+
+ var reader = _subscriptionManager.Subscribe(
+ request.SessionId, request.Tags, context.CancellationToken);
+
+ try
+ {
+ while (await reader.WaitToReadAsync(context.CancellationToken))
+ {
+ while (reader.TryRead(out var item))
+ {
+ var protoVtq = ConvertToProtoVtq(item.address, item.vtq);
+ await responseStream.WriteAsync(protoVtq);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Client disconnected -- normal
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Subscribe stream error for session {SessionId}", request.SessionId);
+ throw new RpcException(new Status(StatusCode.Internal, ex.Message));
+ }
+ finally
+ {
+ _subscriptionManager.UnsubscribeClient(request.SessionId);
+ }
+ }
+
+ // -- API Key Check --------------------------------------------
+
+ public override Task CheckApiKey(
+ Scada.CheckApiKeyRequest request, ServerCallContext context)
+ {
+ // The interceptor already validated the x-api-key header.
+ // This RPC lets clients explicitly check a specific key.
+ // The validated key from the interceptor is in context.UserState.
+ var isValid = context.UserState.ContainsKey("ApiKey");
+ return Task.FromResult(new Scada.CheckApiKeyResponse
+ {
+ IsValid = isValid,
+ Message = isValid ? "Valid" : "Invalid"
+ });
+ }
+
+ // -- Helpers --------------------------------------------------
+
+ /// Converts a domain Vtq to a proto VtqMessage.
+ private static Scada.VtqMessage ConvertToProtoVtq(string tag, Vtq vtq)
+ {
+ return new Scada.VtqMessage
+ {
+ Tag = tag,
+ Value = TypedValueConverter.ToTypedValue(vtq.Value),
+ TimestampUtcTicks = vtq.Timestamp.Ticks,
+ Quality = QualityCodeMapper.ToQualityCode(vtq.Quality)
+ };
+ }
+
+ /// Creates a VtqMessage with bad quality for error responses.
+ private static Scada.VtqMessage CreateBadVtq(string tag, Scada.QualityCode quality)
+ {
+ return new Scada.VtqMessage
+ {
+ Tag = tag,
+ TimestampUtcTicks = DateTime.UtcNow.Ticks,
+ Quality = quality
+ };
+ }
+ }
+}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs
new file mode 100644
index 0000000..80f42ae
--- /dev/null
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Threading;
+using Grpc.Core;
+using Grpc.Core.Interceptors;
+using Serilog;
+using ZB.MOM.WW.LmxProxy.Host.Configuration;
+using ZB.MOM.WW.LmxProxy.Host.Grpc.Services;
+using ZB.MOM.WW.LmxProxy.Host.MxAccess;
+using ZB.MOM.WW.LmxProxy.Host.Security;
+using ZB.MOM.WW.LmxProxy.Host.Sessions;
+using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
+
+namespace ZB.MOM.WW.LmxProxy.Host
+{
+ ///
+ /// Service lifecycle manager. Created by Topshelf, handles Start/Stop/Pause/Continue.
+ ///
+ public class LmxProxyService
+ {
+ private static readonly ILogger Log = Serilog.Log.ForContext();
+
+ private readonly LmxProxyConfiguration _config;
+
+ private MxAccessClient? _mxAccessClient;
+ private SessionManager? _sessionManager;
+ private SubscriptionManager? _subscriptionManager;
+ private ApiKeyService? _apiKeyService;
+ private Server? _grpcServer;
+
+ public LmxProxyService(LmxProxyConfiguration config)
+ {
+ _config = config;
+ }
+
+ ///
+ /// Topshelf Start callback. Creates and starts all components.
+ ///
+ public bool Start()
+ {
+ try
+ {
+ Log.Information("LmxProxy service starting...");
+
+ // 1. Validate configuration
+ ConfigurationValidator.ValidateAndLog(_config);
+
+ // 2. Check/generate TLS certificates
+ var credentials = TlsCertificateManager.CreateServerCredentials(_config.Tls);
+
+ // 3. Create ApiKeyService
+ _apiKeyService = new ApiKeyService(_config.ApiKeyConfigFile);
+
+ // 4. Create MxAccessClient
+ _mxAccessClient = new MxAccessClient(
+ maxConcurrentOperations: _config.Connection.MaxConcurrentOperations,
+ readTimeoutSeconds: _config.Connection.ReadTimeoutSeconds,
+ writeTimeoutSeconds: _config.Connection.WriteTimeoutSeconds,
+ monitorIntervalSeconds: _config.Connection.MonitorIntervalSeconds,
+ autoReconnect: _config.Connection.AutoReconnect,
+ nodeName: _config.Connection.NodeName,
+ galaxyName: _config.Connection.GalaxyName);
+
+ // 5. Connect to MxAccess synchronously (with timeout)
+ Log.Information("Connecting to MxAccess (timeout: {Timeout}s)...",
+ _config.Connection.ConnectionTimeoutSeconds);
+ using (var cts = new CancellationTokenSource(
+ TimeSpan.FromSeconds(_config.Connection.ConnectionTimeoutSeconds)))
+ {
+ _mxAccessClient.ConnectAsync(cts.Token).GetAwaiter().GetResult();
+ }
+
+ // 6. Start auto-reconnect monitor
+ _mxAccessClient.StartMonitorLoop();
+
+ // 7. Create SubscriptionManager
+ var channelFullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest;
+ if (_config.Subscription.ChannelFullMode.Equals("DropNewest", StringComparison.OrdinalIgnoreCase))
+ channelFullMode = System.Threading.Channels.BoundedChannelFullMode.DropNewest;
+ else if (_config.Subscription.ChannelFullMode.Equals("Wait", StringComparison.OrdinalIgnoreCase))
+ channelFullMode = System.Threading.Channels.BoundedChannelFullMode.Wait;
+
+ _subscriptionManager = new SubscriptionManager(
+ _mxAccessClient, _config.Subscription.ChannelCapacity, channelFullMode);
+
+ // Wire MxAccessClient data change events to SubscriptionManager
+ _mxAccessClient.OnTagValueChanged = _subscriptionManager.OnTagValueChanged;
+
+ // Wire MxAccessClient disconnect to SubscriptionManager
+ _mxAccessClient.ConnectionStateChanged += (sender, e) =>
+ {
+ if (e.CurrentState == Domain.ConnectionState.Disconnected ||
+ e.CurrentState == Domain.ConnectionState.Error)
+ {
+ _subscriptionManager.NotifyDisconnection();
+ }
+ };
+
+ // 8. Create SessionManager
+ _sessionManager = new SessionManager(inactivityTimeoutMinutes: 5);
+
+ // 9. Create gRPC service
+ var grpcService = new ScadaGrpcService(
+ _mxAccessClient, _sessionManager, _subscriptionManager);
+
+ // 10. Create and configure interceptor
+ var interceptor = new ApiKeyInterceptor(_apiKeyService);
+
+ // 11. Build and start gRPC server
+ _grpcServer = new Server
+ {
+ Services =
+ {
+ Scada.ScadaService.BindService(grpcService)
+ .Intercept(interceptor)
+ },
+ Ports =
+ {
+ new ServerPort("0.0.0.0", _config.GrpcPort, credentials)
+ }
+ };
+
+ _grpcServer.Start();
+ Log.Information("gRPC server started on port {Port}", _config.GrpcPort);
+
+ Log.Information("LmxProxy service started successfully");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Log.Fatal(ex, "LmxProxy service failed to start");
+ return false;
+ }
+ }
+
+ ///
+ /// Topshelf Stop callback. Stops and disposes all components in reverse order.
+ ///
+ public bool Stop()
+ {
+ Log.Information("LmxProxy service stopping...");
+
+ try
+ {
+ // 1. Stop reconnect monitor (5s wait)
+ _mxAccessClient?.StopMonitorLoop();
+
+ // 2. Graceful gRPC shutdown (10s timeout, then kill)
+ if (_grpcServer != null)
+ {
+ Log.Information("Shutting down gRPC server...");
+ _grpcServer.ShutdownAsync().Wait(TimeSpan.FromSeconds(10));
+ Log.Information("gRPC server stopped");
+ }
+
+ // 3. Dispose components in reverse order
+ _subscriptionManager?.Dispose();
+ _sessionManager?.Dispose();
+ _apiKeyService?.Dispose();
+
+ // 4. Disconnect MxAccess (10s timeout)
+ if (_mxAccessClient != null)
+ {
+ Log.Information("Disconnecting from MxAccess...");
+ _mxAccessClient.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(10));
+ Log.Information("MxAccess disconnected");
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Error during shutdown");
+ }
+
+ Log.Information("LmxProxy service stopped");
+ return true;
+ }
+
+ /// Topshelf Pause callback -- no-op.
+ public bool Pause()
+ {
+ Log.Information("LmxProxy service paused (no-op)");
+ return true;
+ }
+
+ /// Topshelf Continue callback -- no-op.
+ public bool Continue()
+ {
+ Log.Information("LmxProxy service continued (no-op)");
+ return true;
+ }
+ }
+}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs
index 8989326..cebe23d 100644
--- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs
@@ -1,10 +1,78 @@
+using System;
+using Microsoft.Extensions.Configuration;
+using Serilog;
+using Topshelf;
+using ZB.MOM.WW.LmxProxy.Host.Configuration;
+
namespace ZB.MOM.WW.LmxProxy.Host
{
internal static class Program
{
- static void Main(string[] args)
+ static int Main(string[] args)
{
- // Placeholder - Phase 3 will implement full Topshelf startup.
+ // 1. Build configuration
+ var configuration = new ConfigurationBuilder()
+ .SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
+ .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
+ .AddEnvironmentVariables()
+ .Build();
+
+ // 2. Configure Serilog
+ Log.Logger = new LoggerConfiguration()
+ .ReadFrom.Configuration(configuration)
+ .Enrich.FromLogContext()
+ .Enrich.WithMachineName()
+ .Enrich.WithThreadId()
+ .CreateLogger();
+
+ try
+ {
+ // 3. Bind configuration
+ var config = new LmxProxyConfiguration();
+ configuration.Bind(config);
+
+ // 4. Configure Topshelf
+ var exitCode = HostFactory.Run(host =>
+ {
+ host.UseSerilog();
+
+ host.Service(service =>
+ {
+ service.ConstructUsing(() => new LmxProxyService(config));
+ service.WhenStarted(s => s.Start());
+ service.WhenStopped(s => s.Stop());
+ service.WhenPaused(s => s.Pause());
+ service.WhenContinued(s => s.Continue());
+ service.WhenShutdown(s => s.Stop());
+ });
+
+ host.SetServiceName("ZB.MOM.WW.LmxProxy.Host");
+ host.SetDisplayName("SCADA Bridge LMX Proxy");
+ host.SetDescription("gRPC proxy for AVEVA System Platform via MXAccess COM API");
+
+ host.StartAutomatically();
+ host.EnablePauseAndContinue();
+
+ host.EnableServiceRecovery(recovery =>
+ {
+ recovery.RestartService(config.ServiceRecovery.FirstFailureDelayMinutes);
+ recovery.RestartService(config.ServiceRecovery.SecondFailureDelayMinutes);
+ recovery.RestartService(config.ServiceRecovery.SubsequentFailureDelayMinutes);
+ recovery.SetResetPeriod(config.ServiceRecovery.ResetPeriodDays);
+ });
+ });
+
+ return (int)exitCode;
+ }
+ catch (Exception ex)
+ {
+ Log.Fatal(ex, "LmxProxy service terminated unexpectedly");
+ return 1;
+ }
+ finally
+ {
+ Log.CloseAndFlush();
+ }
}
}
}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs
new file mode 100644
index 0000000..ba7c779
--- /dev/null
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs
@@ -0,0 +1,20 @@
+namespace ZB.MOM.WW.LmxProxy.Host.Security
+{
+ /// An API key with description, role, and enabled state.
+ public class ApiKey
+ {
+ public string Key { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public ApiKeyRole Role { get; set; } = ApiKeyRole.ReadOnly;
+ public bool Enabled { get; set; } = true;
+ }
+
+ /// API key role for authorization.
+ public enum ApiKeyRole
+ {
+ /// Read and subscribe only.
+ ReadOnly,
+ /// Full access including writes.
+ ReadWrite
+ }
+}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs
new file mode 100644
index 0000000..1fc9ae2
--- /dev/null
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs
@@ -0,0 +1,10 @@
+using System.Collections.Generic;
+
+namespace ZB.MOM.WW.LmxProxy.Host.Security
+{
+ /// JSON structure for the API key configuration file.
+ public class ApiKeyConfiguration
+ {
+ public List ApiKeys { get; set; } = new List();
+ }
+}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs
new file mode 100644
index 0000000..93c1416
--- /dev/null
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Grpc.Core;
+using Grpc.Core.Interceptors;
+using Serilog;
+
+namespace ZB.MOM.WW.LmxProxy.Host.Security
+{
+ ///
+ /// gRPC server interceptor that enforces API key authentication and role-based authorization.
+ /// Extracts x-api-key from metadata, validates via ApiKeyService, enforces ReadWrite for writes.
+ ///
+ public class ApiKeyInterceptor : Interceptor
+ {
+ private static readonly ILogger Log = Serilog.Log.ForContext();
+
+ private readonly ApiKeyService _apiKeyService;
+
+ /// RPC method names that require the ReadWrite role.
+ private static readonly HashSet WriteProtectedMethods = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "/scada.ScadaService/Write",
+ "/scada.ScadaService/WriteBatch",
+ "/scada.ScadaService/WriteBatchAndWait"
+ };
+
+ public ApiKeyInterceptor(ApiKeyService apiKeyService)
+ {
+ _apiKeyService = apiKeyService;
+ }
+
+ public override async Task UnaryServerHandler(
+ TRequest request,
+ ServerCallContext context,
+ UnaryServerMethod continuation)
+ {
+ ValidateApiKey(context);
+ return await continuation(request, context);
+ }
+
+ public override async Task ServerStreamingServerHandler(
+ TRequest request,
+ IServerStreamWriter responseStream,
+ ServerCallContext context,
+ ServerStreamingServerMethod continuation)
+ {
+ ValidateApiKey(context);
+ await continuation(request, responseStream, context);
+ }
+
+ private void ValidateApiKey(ServerCallContext context)
+ {
+ // Extract x-api-key from metadata
+ var apiKeyEntry = context.RequestHeaders.Get("x-api-key");
+ var apiKey = apiKeyEntry?.Value ?? string.Empty;
+
+ if (string.IsNullOrEmpty(apiKey))
+ {
+ Log.Warning("Request rejected: missing x-api-key header for {Method}", context.Method);
+ throw new RpcException(new Status(StatusCode.Unauthenticated, "Missing x-api-key header"));
+ }
+
+ var key = _apiKeyService.ValidateApiKey(apiKey);
+ if (key == null)
+ {
+ Log.Warning("Request rejected: invalid API key for {Method}", context.Method);
+ throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key"));
+ }
+
+ // Check write authorization
+ if (WriteProtectedMethods.Contains(context.Method) && key.Role != ApiKeyRole.ReadWrite)
+ {
+ Log.Warning("Request rejected: ReadOnly key attempted write operation {Method}", context.Method);
+ throw new RpcException(new Status(StatusCode.PermissionDenied,
+ "Write operations require a ReadWrite API key"));
+ }
+
+ // Store the validated key in UserState for downstream use
+ context.UserState["ApiKey"] = key;
+ }
+ }
+}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs
new file mode 100644
index 0000000..2b81084
--- /dev/null
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs
@@ -0,0 +1,183 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using Newtonsoft.Json;
+using Serilog;
+
+namespace ZB.MOM.WW.LmxProxy.Host.Security
+{
+ ///
+ /// Manages API keys loaded from a JSON file with hot-reload via FileSystemWatcher.
+ ///
+ public sealed class ApiKeyService : IDisposable
+ {
+ private static readonly ILogger Log = Serilog.Log.ForContext();
+
+ private readonly string _configFilePath;
+ private readonly FileSystemWatcher? _watcher;
+ private readonly SemaphoreSlim _reloadLock = new SemaphoreSlim(1, 1);
+ private volatile Dictionary _keys = new Dictionary(StringComparer.Ordinal);
+ private DateTime _lastReloadTime = DateTime.MinValue;
+ private static readonly TimeSpan DebounceInterval = TimeSpan.FromSeconds(1);
+
+ public ApiKeyService(string configFilePath)
+ {
+ _configFilePath = Path.GetFullPath(configFilePath);
+
+ // Auto-generate default file if missing
+ if (!File.Exists(_configFilePath))
+ {
+ GenerateDefaultKeyFile();
+ }
+
+ // Initial load
+ LoadKeys();
+
+ // Set up FileSystemWatcher for hot-reload
+ var directory = Path.GetDirectoryName(_configFilePath);
+ var fileName = Path.GetFileName(_configFilePath);
+ if (directory != null)
+ {
+ _watcher = new FileSystemWatcher(directory, fileName)
+ {
+ NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size,
+ EnableRaisingEvents = true
+ };
+ _watcher.Changed += OnFileChanged;
+ }
+ }
+
+ ///
+ /// Validates an API key. Returns the ApiKey if valid and enabled, null otherwise.
+ ///
+ public ApiKey? ValidateApiKey(string apiKey)
+ {
+ if (string.IsNullOrEmpty(apiKey)) return null;
+ return _keys.TryGetValue(apiKey, out var key) && key.Enabled ? key : null;
+ }
+
+ ///
+ /// Checks if a key has the required role.
+ /// ReadWrite implies ReadOnly.
+ ///
+ public bool HasRole(string apiKey, ApiKeyRole requiredRole)
+ {
+ var key = ValidateApiKey(apiKey);
+ if (key == null) return false;
+
+ switch (requiredRole)
+ {
+ case ApiKeyRole.ReadOnly:
+ return true; // Both roles have ReadOnly
+ case ApiKeyRole.ReadWrite:
+ return key.Role == ApiKeyRole.ReadWrite;
+ default:
+ return false;
+ }
+ }
+
+ /// Gets the count of loaded API keys.
+ public int KeyCount => _keys.Count;
+
+ private void GenerateDefaultKeyFile()
+ {
+ Log.Information("API key file not found at {Path}, generating defaults", _configFilePath);
+
+ var config = new ApiKeyConfiguration
+ {
+ ApiKeys = new List
+ {
+ new ApiKey
+ {
+ Key = GenerateRandomKey(),
+ Description = "Default ReadOnly key (auto-generated)",
+ Role = ApiKeyRole.ReadOnly,
+ Enabled = true
+ },
+ new ApiKey
+ {
+ Key = GenerateRandomKey(),
+ Description = "Default ReadWrite key (auto-generated)",
+ Role = ApiKeyRole.ReadWrite,
+ Enabled = true
+ }
+ }
+ };
+
+ var directory = Path.GetDirectoryName(_configFilePath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ Directory.CreateDirectory(directory);
+
+ var json = JsonConvert.SerializeObject(config, Formatting.Indented);
+ File.WriteAllText(_configFilePath, json);
+ Log.Information("Default API key file generated at {Path}", _configFilePath);
+ }
+
+ private static string GenerateRandomKey()
+ {
+ // 32 random bytes -> 64-char hex string
+ var bytes = new byte[32];
+ using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create())
+ {
+ rng.GetBytes(bytes);
+ }
+ return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
+ }
+
+ private void LoadKeys()
+ {
+ try
+ {
+ var json = File.ReadAllText(_configFilePath);
+ var config = JsonConvert.DeserializeObject(json);
+ if (config?.ApiKeys != null)
+ {
+ _keys = config.ApiKeys
+ .Where(k => !string.IsNullOrEmpty(k.Key))
+ .ToDictionary(k => k.Key, k => k, StringComparer.Ordinal);
+ Log.Information("Loaded {Count} API keys from {Path}", _keys.Count, _configFilePath);
+ }
+ else
+ {
+ Log.Warning("API key file at {Path} contained no keys", _configFilePath);
+ _keys = new Dictionary(StringComparer.Ordinal);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Failed to load API keys from {Path}", _configFilePath);
+ }
+ }
+
+ private void OnFileChanged(object sender, FileSystemEventArgs e)
+ {
+ // Debounce: ignore rapid changes within 1 second
+ if (DateTime.UtcNow - _lastReloadTime < DebounceInterval) return;
+
+ if (_reloadLock.Wait(0))
+ {
+ try
+ {
+ _lastReloadTime = DateTime.UtcNow;
+ Log.Information("API key file changed, reloading");
+
+ // Small delay to let the file system finish writing
+ Thread.Sleep(100);
+ LoadKeys();
+ }
+ finally
+ {
+ _reloadLock.Release();
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ _watcher?.Dispose();
+ _reloadLock.Dispose();
+ }
+ }
+}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs
new file mode 100644
index 0000000..26beaba
--- /dev/null
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs
@@ -0,0 +1,56 @@
+using System.IO;
+using Grpc.Core;
+using Serilog;
+using ZB.MOM.WW.LmxProxy.Host.Configuration;
+
+namespace ZB.MOM.WW.LmxProxy.Host.Security
+{
+ ///
+ /// Manages TLS certificates for the gRPC server.
+ /// If TLS is enabled but certs are missing, logs a warning (self-signed generation
+ /// would be added as a future enhancement, or done manually).
+ ///
+ public static class TlsCertificateManager
+ {
+ private static readonly ILogger Log = Serilog.Log.ForContext(typeof(TlsCertificateManager));
+
+ ///
+ /// Creates gRPC server credentials based on TLS configuration.
+ /// Returns InsecureServerCredentials if TLS is disabled.
+ ///
+ public static ServerCredentials CreateServerCredentials(TlsConfiguration config)
+ {
+ if (!config.Enabled)
+ {
+ Log.Information("TLS disabled, using insecure server credentials");
+ return ServerCredentials.Insecure;
+ }
+
+ if (!File.Exists(config.ServerCertificatePath) || !File.Exists(config.ServerKeyPath))
+ {
+ Log.Warning("TLS enabled but certificate files not found. Falling back to insecure credentials. " +
+ "Cert: {CertPath}, Key: {KeyPath}",
+ config.ServerCertificatePath, config.ServerKeyPath);
+ return ServerCredentials.Insecure;
+ }
+
+ var certChain = File.ReadAllText(config.ServerCertificatePath);
+ var privateKey = File.ReadAllText(config.ServerKeyPath);
+
+ var keyCertPair = new KeyCertificatePair(certChain, privateKey);
+
+ if (config.RequireClientCertificate && File.Exists(config.ClientCaCertificatePath))
+ {
+ var caCert = File.ReadAllText(config.ClientCaCertificatePath);
+ Log.Information("TLS enabled with mutual TLS (client certificate required)");
+ return new SslServerCredentials(
+ new[] { keyCertPair },
+ caCert,
+ SslClientCertificateRequestType.RequestAndRequireAndVerify);
+ }
+
+ Log.Information("TLS enabled (server-only)");
+ return new SslServerCredentials(new[] { keyCertPair });
+ }
+ }
+}
diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json
index 2c63c08..94fe869 100644
--- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json
+++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json
@@ -1,2 +1,80 @@
{
+ "GrpcPort": 50051,
+ "ApiKeyConfigFile": "apikeys.json",
+
+ "Connection": {
+ "MonitorIntervalSeconds": 5,
+ "ConnectionTimeoutSeconds": 30,
+ "ReadTimeoutSeconds": 5,
+ "WriteTimeoutSeconds": 5,
+ "MaxConcurrentOperations": 10,
+ "AutoReconnect": true,
+ "NodeName": null,
+ "GalaxyName": null
+ },
+
+ "Subscription": {
+ "ChannelCapacity": 1000,
+ "ChannelFullMode": "DropOldest"
+ },
+
+ "Tls": {
+ "Enabled": false,
+ "ServerCertificatePath": "certs/server.crt",
+ "ServerKeyPath": "certs/server.key",
+ "ClientCaCertificatePath": "certs/ca.crt",
+ "RequireClientCertificate": false,
+ "CheckCertificateRevocation": false
+ },
+
+ "WebServer": {
+ "Enabled": true,
+ "Port": 8080
+ },
+
+ "ServiceRecovery": {
+ "FirstFailureDelayMinutes": 1,
+ "SecondFailureDelayMinutes": 5,
+ "SubsequentFailureDelayMinutes": 10,
+ "ResetPeriodDays": 1
+ },
+
+ "Serilog": {
+ "Using": [
+ "Serilog.Sinks.Console",
+ "Serilog.Sinks.File",
+ "Serilog.Enrichers.Environment",
+ "Serilog.Enrichers.Thread"
+ ],
+ "MinimumLevel": {
+ "Default": "Information",
+ "Override": {
+ "Microsoft": "Warning",
+ "System": "Warning",
+ "Grpc": "Information"
+ }
+ },
+ "WriteTo": [
+ {
+ "Name": "Console",
+ "Args": {
+ "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
+ }
+ },
+ {
+ "Name": "File",
+ "Args": {
+ "path": "logs/lmxproxy-.txt",
+ "rollingInterval": "Day",
+ "retainedFileCountLimit": 30,
+ "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{MachineName}/{ThreadId}] {Message:lj}{NewLine}{Exception}"
+ }
+ }
+ ],
+ "Enrich": [
+ "FromLogContext",
+ "WithMachineName",
+ "WithThreadId"
+ ]
+ }
}
diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Configuration/ConfigurationValidatorTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Configuration/ConfigurationValidatorTests.cs
new file mode 100644
index 0000000..f5720ac
--- /dev/null
+++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Configuration/ConfigurationValidatorTests.cs
@@ -0,0 +1,77 @@
+using System;
+using FluentAssertions;
+using Xunit;
+using ZB.MOM.WW.LmxProxy.Host.Configuration;
+
+namespace ZB.MOM.WW.LmxProxy.Host.Tests.Configuration
+{
+ public class ConfigurationValidatorTests
+ {
+ private static LmxProxyConfiguration ValidConfig() => new LmxProxyConfiguration();
+
+ [Fact]
+ public void ValidConfig_PassesValidation()
+ {
+ var config = ValidConfig();
+ Action act = () => ConfigurationValidator.ValidateAndLog(config);
+ act.Should().NotThrow();
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(70000)]
+ public void InvalidGrpcPort_Throws(int port)
+ {
+ var config = ValidConfig();
+ config.GrpcPort = port;
+ Action act = () => ConfigurationValidator.ValidateAndLog(config);
+ act.Should().Throw().Where(e => e.Message.Contains("GrpcPort"));
+ }
+
+ [Fact]
+ public void InvalidMonitorInterval_Throws()
+ {
+ var config = ValidConfig();
+ config.Connection.MonitorIntervalSeconds = 0;
+ Action act = () => ConfigurationValidator.ValidateAndLog(config);
+ act.Should().Throw().Where(e => e.Message.Contains("MonitorIntervalSeconds"));
+ }
+
+ [Fact]
+ public void InvalidChannelCapacity_Throws()
+ {
+ var config = ValidConfig();
+ config.Subscription.ChannelCapacity = -1;
+ Action act = () => ConfigurationValidator.ValidateAndLog(config);
+ act.Should().Throw().Where(e => e.Message.Contains("ChannelCapacity"));
+ }
+
+ [Fact]
+ public void InvalidChannelFullMode_Throws()
+ {
+ var config = ValidConfig();
+ config.Subscription.ChannelFullMode = "InvalidMode";
+ Action act = () => ConfigurationValidator.ValidateAndLog(config);
+ act.Should().Throw().Where(e => e.Message.Contains("ChannelFullMode"));
+ }
+
+ [Fact]
+ public void InvalidResetPeriodDays_Throws()
+ {
+ var config = ValidConfig();
+ config.ServiceRecovery.ResetPeriodDays = 0;
+ Action act = () => ConfigurationValidator.ValidateAndLog(config);
+ act.Should().Throw().Where(e => e.Message.Contains("ResetPeriodDays"));
+ }
+
+ [Fact]
+ public void NegativeFailureDelay_Throws()
+ {
+ var config = ValidConfig();
+ config.ServiceRecovery.FirstFailureDelayMinutes = -1;
+ Action act = () => ConfigurationValidator.ValidateAndLog(config);
+ act.Should().Throw().Where(e => e.Message.Contains("FirstFailureDelayMinutes"));
+ }
+ }
+}
diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyInterceptorTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyInterceptorTests.cs
new file mode 100644
index 0000000..982ea9e
--- /dev/null
+++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyInterceptorTests.cs
@@ -0,0 +1,46 @@
+using FluentAssertions;
+using Xunit;
+
+namespace ZB.MOM.WW.LmxProxy.Host.Tests.Security
+{
+ public class ApiKeyInterceptorTests
+ {
+ [Theory]
+ [InlineData("/scada.ScadaService/Write")]
+ [InlineData("/scada.ScadaService/WriteBatch")]
+ [InlineData("/scada.ScadaService/WriteBatchAndWait")]
+ public void WriteProtectedMethods_AreCorrectlyDefined(string method)
+ {
+ // This test verifies the set of write-protected methods is correct.
+ // The actual interceptor logic is tested via integration tests.
+ var writeProtected = new System.Collections.Generic.HashSet(
+ System.StringComparer.OrdinalIgnoreCase)
+ {
+ "/scada.ScadaService/Write",
+ "/scada.ScadaService/WriteBatch",
+ "/scada.ScadaService/WriteBatchAndWait"
+ };
+ writeProtected.Should().Contain(method);
+ }
+
+ [Theory]
+ [InlineData("/scada.ScadaService/Connect")]
+ [InlineData("/scada.ScadaService/Disconnect")]
+ [InlineData("/scada.ScadaService/GetConnectionState")]
+ [InlineData("/scada.ScadaService/Read")]
+ [InlineData("/scada.ScadaService/ReadBatch")]
+ [InlineData("/scada.ScadaService/Subscribe")]
+ [InlineData("/scada.ScadaService/CheckApiKey")]
+ public void ReadMethods_AreNotWriteProtected(string method)
+ {
+ var writeProtected = new System.Collections.Generic.HashSet(
+ System.StringComparer.OrdinalIgnoreCase)
+ {
+ "/scada.ScadaService/Write",
+ "/scada.ScadaService/WriteBatch",
+ "/scada.ScadaService/WriteBatchAndWait"
+ };
+ writeProtected.Should().NotContain(method);
+ }
+ }
+}
diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyServiceTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyServiceTests.cs
new file mode 100644
index 0000000..bea4ca8
--- /dev/null
+++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyServiceTests.cs
@@ -0,0 +1,118 @@
+using System;
+using System.IO;
+using FluentAssertions;
+using Newtonsoft.Json;
+using Xunit;
+using ZB.MOM.WW.LmxProxy.Host.Security;
+
+namespace ZB.MOM.WW.LmxProxy.Host.Tests.Security
+{
+ public class ApiKeyServiceTests : IDisposable
+ {
+ private readonly string _tempDir;
+
+ public ApiKeyServiceTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "lmxproxy-test-" + Guid.NewGuid().ToString("N").Substring(0, 8));
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDir))
+ Directory.Delete(_tempDir, true);
+ }
+
+ private string CreateKeyFile(params ApiKey[] keys)
+ {
+ var path = Path.Combine(_tempDir, "apikeys.json");
+ var config = new ApiKeyConfiguration { ApiKeys = new System.Collections.Generic.List(keys) };
+ File.WriteAllText(path, JsonConvert.SerializeObject(config, Formatting.Indented));
+ return path;
+ }
+
+ [Fact]
+ public void AutoGeneratesDefaultFile_WhenMissing()
+ {
+ var path = Path.Combine(_tempDir, "missing.json");
+ using (var svc = new ApiKeyService(path))
+ {
+ File.Exists(path).Should().BeTrue();
+ svc.KeyCount.Should().Be(2);
+ }
+ }
+
+ [Fact]
+ public void ValidateApiKey_ReturnsKey_WhenValid()
+ {
+ var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = true });
+ using (var svc = new ApiKeyService(path))
+ {
+ var key = svc.ValidateApiKey("test-key");
+ key.Should().NotBeNull();
+ key!.Role.Should().Be(ApiKeyRole.ReadWrite);
+ }
+ }
+
+ [Fact]
+ public void ValidateApiKey_ReturnsNull_WhenInvalid()
+ {
+ var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = true });
+ using (var svc = new ApiKeyService(path))
+ {
+ svc.ValidateApiKey("wrong-key").Should().BeNull();
+ }
+ }
+
+ [Fact]
+ public void ValidateApiKey_ReturnsNull_WhenDisabled()
+ {
+ var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = false });
+ using (var svc = new ApiKeyService(path))
+ {
+ svc.ValidateApiKey("test-key").Should().BeNull();
+ }
+ }
+
+ [Fact]
+ public void HasRole_ReadWrite_CanRead()
+ {
+ var path = CreateKeyFile(new ApiKey { Key = "rw", Role = ApiKeyRole.ReadWrite, Enabled = true });
+ using (var svc = new ApiKeyService(path))
+ {
+ svc.HasRole("rw", ApiKeyRole.ReadOnly).Should().BeTrue();
+ }
+ }
+
+ [Fact]
+ public void HasRole_ReadOnly_CannotWrite()
+ {
+ var path = CreateKeyFile(new ApiKey { Key = "ro", Role = ApiKeyRole.ReadOnly, Enabled = true });
+ using (var svc = new ApiKeyService(path))
+ {
+ svc.HasRole("ro", ApiKeyRole.ReadWrite).Should().BeFalse();
+ }
+ }
+
+ [Fact]
+ public void HasRole_ReadWrite_CanWrite()
+ {
+ var path = CreateKeyFile(new ApiKey { Key = "rw", Role = ApiKeyRole.ReadWrite, Enabled = true });
+ using (var svc = new ApiKeyService(path))
+ {
+ svc.HasRole("rw", ApiKeyRole.ReadWrite).Should().BeTrue();
+ }
+ }
+
+ [Fact]
+ public void ValidateApiKey_EmptyString_ReturnsNull()
+ {
+ var path = CreateKeyFile(new ApiKey { Key = "test", Enabled = true });
+ using (var svc = new ApiKeyService(path))
+ {
+ svc.ValidateApiKey("").Should().BeNull();
+ svc.ValidateApiKey(null!).Should().BeNull();
+ }
+ }
+ }
+}
diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj
index 52cce04..d19f829 100644
--- a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj
+++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj
@@ -19,6 +19,7 @@
+