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 @@ +