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).
1800 lines
65 KiB
Markdown
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`
|