# Phase 3: Host gRPC Server, Security & Configuration — Implementation Plan ## Prerequisites - Phase 1 complete: proto file, domain types, TypedValueConverter, QualityCodeMapper, cross-stack tests passing. - Phase 2 complete: MxAccessClient, SessionManager, SubscriptionManager, StaDispatchThread compiling and tests passing. - The following Phase 2 artifacts are used in this phase: - `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.cs` — `IScadaClient` implementation - `src/ZB.MOM.WW.LmxProxy.Host/Sessions/SessionManager.cs` - `src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs` ## Guardrails 1. **Proto is the source of truth** — all RPC implementations match `scada.proto` exactly. 2. **No v1 code** — no `ParseValue()`, no `ConvertValueToString()`, no string quality comparisons. 3. **status_code is canonical** — use `QualityCodeMapper` factory methods for all quality responses. 4. **x-api-key header is authoritative** — interceptor enforces, `ConnectRequest.api_key` is informational only. 5. **TypedValueConverter for all COM↔proto conversions** — no manual type switching in the gRPC service. 6. **Unit tests for every component** before marking phase complete. --- ## Step 1: Configuration classes All configuration classes go in `src/ZB.MOM.WW.LmxProxy.Host/Configuration/`. ### 1.1 LmxProxyConfiguration (root) **File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs` ```csharp 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(); } } ``` ### 1.2 ConnectionConfiguration **File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConnectionConfiguration.cs` ```csharp 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; } } } ``` ### 1.3 SubscriptionConfiguration **File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs` ```csharp 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"; } } ``` ### 1.4 TlsConfiguration **File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs` ```csharp 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; } } ``` ### 1.5 WebServerConfiguration **File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/WebServerConfiguration.cs` ```csharp 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; } } } ``` ### 1.6 ServiceRecoveryConfiguration **File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs` ```csharp 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; } } ``` --- ## Step 2: ConfigurationValidator **File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs` ```csharp 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); } } } ``` --- ## Step 3: ApiKey model and ApiKeyService ### 3.1 ApiKey model **File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs` ```csharp 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 } } ``` ### 3.2 ApiKeyConfiguration **File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs` ```csharp 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(); } } ``` ### 3.3 ApiKeyService **File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs` ```csharp 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; return requiredRole switch { ApiKeyRole.ReadOnly => true, // Both roles have ReadOnly ApiKeyRole.ReadWrite => key.Role == ApiKeyRole.ReadWrite, _ => 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(); } } } ``` --- ## Step 4: ApiKeyInterceptor **File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs` ```csharp 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; 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; } } } ``` --- ## Step 5: TlsCertificateManager **File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs` ```csharp 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 }); } } } ``` --- ## Step 6: ScadaGrpcService **File**: `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs` This file implements all 10 RPCs. It inherits from the proto-generated `Scada.ScadaService.ScadaServiceBase` base class. The proto codegen produces this base class from the `service ScadaService { ... }` in `scada.proto`. ```csharp 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 }; // Write all items first var values = request.Items.ToDictionary( i => i.Tag, i => TypedValueConverter.FromTypedValue(i.Value)!); 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 }; } } } ``` --- ## Step 7: LmxProxyService **File**: `src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs` ```csharp using System; using System.Threading; using Grpc.Core; 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; } } } ``` --- ## Step 8: Program.cs **File**: `src/ZB.MOM.WW.LmxProxy.Host/Program.cs` Replace the Phase 1 placeholder with the full Topshelf entry point: ```csharp 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 int Main(string[] args) { // 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(); } } } } ``` --- ## Step 9: appsettings.json **File**: `src/ZB.MOM.WW.LmxProxy.Host/appsettings.json` Replace the Phase 1 placeholder with the complete default configuration: ```json { "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" ] } } ``` --- ## Step 10: Unit tests ### 10.1 ConfigurationValidator tests **File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Configuration/ConfigurationValidatorTests.cs` ```csharp 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(); var 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; var 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; var 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; var 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"; var 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; var 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; var act = () => ConfigurationValidator.ValidateAndLog(config); act.Should().Throw().Where(e => e.Message.Contains("FirstFailureDelayMinutes")); } } } ``` ### 10.2 ApiKeyService tests **File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyServiceTests.cs` ```csharp 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")[..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(); } } } ``` ### 10.3 ApiKeyInterceptor tests Testing the interceptor in isolation requires mocking `ServerCallContext`, which is complex with Grpc.Core. Instead, verify the behavior through integration-style tests or verify the write-protected method set: **File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyInterceptorTests.cs` ```csharp 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); } } } ``` --- ## Step 11: Build verification ```bash cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy # Build Client (works on macOS) dotnet build src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj # Run Client tests dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj # Host builds on Windows only (net48/x86): # dotnet build src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj # dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj ``` --- ## Completion Criteria - [ ] All 6 configuration classes compile with correct defaults - [ ] `ConfigurationValidator.ValidateAndLog()` catches all invalid values and tests pass - [ ] `ApiKey` model and `ApiKeyConfiguration` compile - [ ] `ApiKeyService` compiles: load, validate, hot-reload, auto-generate, and all tests pass - [ ] `ApiKeyInterceptor` compiles: x-api-key extraction, validation, write protection - [ ] `TlsCertificateManager` compiles: insecure fallback, server TLS, mutual TLS - [ ] `ScadaGrpcService` compiles with all 10 RPCs implemented: - Connect, Disconnect, GetConnectionState - Read, ReadBatch (TypedValue + QualityCode responses) - Write, WriteBatch, WriteBatchAndWait (TypedValue input, TypedValueEquals for flag comparison) - Subscribe (server streaming from SubscriptionManager channel) - CheckApiKey - [ ] `LmxProxyService` compiles with full Start/Stop/Pause/Continue lifecycle - [ ] `Program.cs` compiles with Topshelf configuration, Serilog setup, service recovery - [ ] `appsettings.json` contains all default configuration values - [ ] All unit tests pass (ConfigurationValidator, ApiKeyService, ApiKeyInterceptor) - [ ] No v1 string serialization code: no `ParseValue()`, no `ConvertValueToString()`, no string quality comparisons - [ ] All quality codes use `QualityCodeMapper` factory methods - [ ] All value conversions use `TypedValueConverter`