Files
scadalink-design/lmxproxy/docs/plans/phase-3-host-grpc-security-config.md
Joseph Doherty 4303f06fc3 docs(lmxproxy): add v2 rebuild design, 7-phase implementation plans, and execution prompt
Design doc covers architecture, v2 protocol (TypedValue/QualityCode), COM threading
model, session lifecycle, subscription semantics, error model, and guardrails.
Implementation plans are detailed enough for autonomous Claude Code execution.
Verified all dev tooling on windev (Grpc.Tools, protobuf-net.Grpc, Polly v8, xUnit).
2026-03-21 23:29:42 -04:00

65 KiB

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.csIScadaClient 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

namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
    /// <summary>Root configuration class bound to appsettings.json.</summary>
    public class LmxProxyConfiguration
    {
        /// <summary>gRPC server listen port. Default: 50051.</summary>
        public int GrpcPort { get; set; } = 50051;

        /// <summary>Path to API key configuration file. Default: apikeys.json.</summary>
        public string ApiKeyConfigFile { get; set; } = "apikeys.json";

        /// <summary>MxAccess connection settings.</summary>
        public ConnectionConfiguration Connection { get; set; } = new ConnectionConfiguration();

        /// <summary>Subscription channel settings.</summary>
        public SubscriptionConfiguration Subscription { get; set; } = new SubscriptionConfiguration();

        /// <summary>TLS/SSL settings.</summary>
        public TlsConfiguration Tls { get; set; } = new TlsConfiguration();

        /// <summary>Status web server settings.</summary>
        public WebServerConfiguration WebServer { get; set; } = new WebServerConfiguration();

        /// <summary>Windows SCM service recovery settings.</summary>
        public ServiceRecoveryConfiguration ServiceRecovery { get; set; } = new ServiceRecoveryConfiguration();
    }
}

1.2 ConnectionConfiguration

File: src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConnectionConfiguration.cs

namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
    /// <summary>MxAccess connection settings.</summary>
    public class ConnectionConfiguration
    {
        /// <summary>Auto-reconnect check interval in seconds. Default: 5.</summary>
        public int MonitorIntervalSeconds { get; set; } = 5;

        /// <summary>Initial connection timeout in seconds. Default: 30.</summary>
        public int ConnectionTimeoutSeconds { get; set; } = 30;

        /// <summary>Per-read operation timeout in seconds. Default: 5.</summary>
        public int ReadTimeoutSeconds { get; set; } = 5;

        /// <summary>Per-write operation timeout in seconds. Default: 5.</summary>
        public int WriteTimeoutSeconds { get; set; } = 5;

        /// <summary>Semaphore limit for concurrent MxAccess operations. Default: 10.</summary>
        public int MaxConcurrentOperations { get; set; } = 10;

        /// <summary>Enable auto-reconnect loop. Default: true.</summary>
        public bool AutoReconnect { get; set; } = true;

        /// <summary>MxAccess node name (optional).</summary>
        public string? NodeName { get; set; }

        /// <summary>MxAccess galaxy name (optional).</summary>
        public string? GalaxyName { get; set; }
    }
}

1.3 SubscriptionConfiguration

File: src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs

namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
    /// <summary>Subscription channel settings.</summary>
    public class SubscriptionConfiguration
    {
        /// <summary>Per-client subscription buffer size. Default: 1000.</summary>
        public int ChannelCapacity { get; set; } = 1000;

        /// <summary>Backpressure strategy: DropOldest, DropNewest, or Wait. Default: DropOldest.</summary>
        public string ChannelFullMode { get; set; } = "DropOldest";
    }
}

1.4 TlsConfiguration

File: src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs

namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
    /// <summary>TLS/SSL settings for the gRPC server.</summary>
    public class TlsConfiguration
    {
        /// <summary>Enable TLS on the gRPC server. Default: false.</summary>
        public bool Enabled { get; set; } = false;

        /// <summary>PEM server certificate path. Default: certs/server.crt.</summary>
        public string ServerCertificatePath { get; set; } = "certs/server.crt";

        /// <summary>PEM server private key path. Default: certs/server.key.</summary>
        public string ServerKeyPath { get; set; } = "certs/server.key";

        /// <summary>CA certificate for mutual TLS client validation. Default: certs/ca.crt.</summary>
        public string ClientCaCertificatePath { get; set; } = "certs/ca.crt";

        /// <summary>Require client certificates (mutual TLS). Default: false.</summary>
        public bool RequireClientCertificate { get; set; } = false;

        /// <summary>Check certificate revocation lists. Default: false.</summary>
        public bool CheckCertificateRevocation { get; set; } = false;
    }
}

1.5 WebServerConfiguration

File: src/ZB.MOM.WW.LmxProxy.Host/Configuration/WebServerConfiguration.cs

namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
    /// <summary>HTTP status web server settings.</summary>
    public class WebServerConfiguration
    {
        /// <summary>Enable the status web server. Default: true.</summary>
        public bool Enabled { get; set; } = true;

        /// <summary>HTTP listen port. Default: 8080.</summary>
        public int Port { get; set; } = 8080;

        /// <summary>Custom URL prefix (defaults to http://+:{Port}/ if null).</summary>
        public string? Prefix { get; set; }
    }
}

1.6 ServiceRecoveryConfiguration

File: src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs

namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
    /// <summary>Windows SCM service recovery settings.</summary>
    public class ServiceRecoveryConfiguration
    {
        /// <summary>Restart delay after first failure in minutes. Default: 1.</summary>
        public int FirstFailureDelayMinutes { get; set; } = 1;

        /// <summary>Restart delay after second failure in minutes. Default: 5.</summary>
        public int SecondFailureDelayMinutes { get; set; } = 5;

        /// <summary>Restart delay after subsequent failures in minutes. Default: 10.</summary>
        public int SubsequentFailureDelayMinutes { get; set; } = 10;

        /// <summary>Days before failure count resets. Default: 1.</summary>
        public int ResetPeriodDays { get; set; } = 1;
    }
}

Step 2: ConfigurationValidator

File: src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs

using System;
using System.Collections.Generic;
using System.IO;
using Serilog;

namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
    /// <summary>
    ///     Validates the LmxProxy configuration at startup.
    ///     Throws InvalidOperationException on any validation error.
    /// </summary>
    public static class ConfigurationValidator
    {
        private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator));

        /// <summary>
        ///     Validates all configuration settings and logs the effective values.
        ///     Throws on first validation error.
        /// </summary>
        public static void ValidateAndLog(LmxProxyConfiguration config)
        {
            var errors = new List<string>();

            // 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<string>(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

namespace ZB.MOM.WW.LmxProxy.Host.Security
{
    /// <summary>An API key with description, role, and enabled state.</summary>
    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;
    }

    /// <summary>API key role for authorization.</summary>
    public enum ApiKeyRole
    {
        /// <summary>Read and subscribe only.</summary>
        ReadOnly,
        /// <summary>Full access including writes.</summary>
        ReadWrite
    }
}

3.2 ApiKeyConfiguration

File: src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs

using System.Collections.Generic;

namespace ZB.MOM.WW.LmxProxy.Host.Security
{
    /// <summary>JSON structure for the API key configuration file.</summary>
    public class ApiKeyConfiguration
    {
        public List<ApiKey> ApiKeys { get; set; } = new List<ApiKey>();
    }
}

3.3 ApiKeyService

File: src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs

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
{
    /// <summary>
    ///     Manages API keys loaded from a JSON file with hot-reload via FileSystemWatcher.
    /// </summary>
    public sealed class ApiKeyService : IDisposable
    {
        private static readonly ILogger Log = Serilog.Log.ForContext<ApiKeyService>();

        private readonly string _configFilePath;
        private readonly FileSystemWatcher? _watcher;
        private readonly SemaphoreSlim _reloadLock = new SemaphoreSlim(1, 1);
        private volatile Dictionary<string, ApiKey> _keys = new Dictionary<string, ApiKey>(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;
            }
        }

        /// <summary>
        ///     Validates an API key. Returns the ApiKey if valid and enabled, null otherwise.
        /// </summary>
        public ApiKey? ValidateApiKey(string apiKey)
        {
            if (string.IsNullOrEmpty(apiKey)) return null;
            return _keys.TryGetValue(apiKey, out var key) && key.Enabled ? key : null;
        }

        /// <summary>
        ///     Checks if a key has the required role.
        ///     ReadWrite implies ReadOnly.
        /// </summary>
        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
            };
        }

        /// <summary>Gets the count of loaded API keys.</summary>
        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<ApiKey>
                {
                    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<ApiKeyConfiguration>(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<string, ApiKey>(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

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
{
    /// <summary>
    ///     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.
    /// </summary>
    public class ApiKeyInterceptor : Interceptor
    {
        private static readonly ILogger Log = Serilog.Log.ForContext<ApiKeyInterceptor>();

        private readonly ApiKeyService _apiKeyService;

        /// <summary>RPC method names that require the ReadWrite role.</summary>
        private static readonly HashSet<string> WriteProtectedMethods = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        {
            "/scada.ScadaService/Write",
            "/scada.ScadaService/WriteBatch",
            "/scada.ScadaService/WriteBatchAndWait"
        };

        public ApiKeyInterceptor(ApiKeyService apiKeyService)
        {
            _apiKeyService = apiKeyService;
        }

        public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
            TRequest request,
            ServerCallContext context,
            UnaryServerMethod<TRequest, TResponse> continuation)
        {
            ValidateApiKey(context);
            return await continuation(request, context);
        }

        public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
            TRequest request,
            IServerStreamWriter<TResponse> responseStream,
            ServerCallContext context,
            ServerStreamingServerMethod<TRequest, TResponse> 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

using System.IO;
using Grpc.Core;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;

namespace ZB.MOM.WW.LmxProxy.Host.Security
{
    /// <summary>
    ///     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).
    /// </summary>
    public static class TlsCertificateManager
    {
        private static readonly ILogger Log = Serilog.Log.ForContext(typeof(TlsCertificateManager));

        /// <summary>
        ///     Creates gRPC server credentials based on TLS configuration.
        ///     Returns InsecureServerCredentials if TLS is disabled.
        /// </summary>
        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.

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
{
    /// <summary>
    ///     gRPC service implementation for all 10 SCADA RPCs.
    ///     Inherits from proto-generated ScadaService.ScadaServiceBase.
    /// </summary>
    public class ScadaGrpcService : Scada.ScadaService.ScadaServiceBase
    {
        private static readonly ILogger Log = Serilog.Log.ForContext<ScadaGrpcService>();

        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<Scada.ConnectResponse> 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<Scada.DisconnectResponse> 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<Scada.GetConnectionStateResponse> 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<Scada.ReadResponse> 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<Scada.ReadBatchResponse> 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<Scada.WriteResponse> 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<Scada.WriteBatchResponse> 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<Scada.WriteBatchAndWaitResponse> 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<Scada.VtqMessage> 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<Scada.CheckApiKeyResponse> 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 ────────────────────────────────────────────────

        /// <summary>Converts a domain Vtq to a proto VtqMessage.</summary>
        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)
            };
        }

        /// <summary>Creates a VtqMessage with bad quality for error responses.</summary>
        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

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
{
    /// <summary>
    ///     Service lifecycle manager. Created by Topshelf, handles Start/Stop/Pause/Continue.
    /// </summary>
    public class LmxProxyService
    {
        private static readonly ILogger Log = Serilog.Log.ForContext<LmxProxyService>();

        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;
        }

        /// <summary>
        ///     Topshelf Start callback. Creates and starts all components.
        /// </summary>
        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;
            }
        }

        /// <summary>
        ///     Topshelf Stop callback. Stops and disposes all components in reverse order.
        /// </summary>
        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;
        }

        /// <summary>Topshelf Pause callback — no-op.</summary>
        public bool Pause()
        {
            Log.Information("LmxProxy service paused (no-op)");
            return true;
        }

        /// <summary>Topshelf Continue callback — no-op.</summary>
        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:

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<LmxProxyService>(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:

{
  "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

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<InvalidOperationException>().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<InvalidOperationException>().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<InvalidOperationException>().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<InvalidOperationException>().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<InvalidOperationException>().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<InvalidOperationException>().Where(e => e.Message.Contains("FirstFailureDelayMinutes"));
        }
    }
}

10.2 ApiKeyService tests

File: tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyServiceTests.cs

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<ApiKey>(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

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<string>(
                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<string>(
                System.StringComparer.OrdinalIgnoreCase)
            {
                "/scada.ScadaService/Write",
                "/scada.ScadaService/WriteBatch",
                "/scada.ScadaService/WriteBatchAndWait"
            };
            writeProtected.Should().NotContain(method);
        }
    }
}

Step 11: Build verification

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