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

1800 lines
65 KiB
Markdown

# 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
{
/// <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`
```csharp
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`
```csharp
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`
```csharp
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`
```csharp
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`
```csharp
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`
```csharp
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`
```csharp
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`
```csharp
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`
```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
{
/// <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`
```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
{
/// <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`
```csharp
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`.
```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
{
/// <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`
```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
{
/// <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:
```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<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:
```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<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`
```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<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`
```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<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
```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`