feat(lmxproxy): phase 3 — host gRPC server, security, configuration, service hosting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Grpc.Core;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Sessions;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
|
||||
{
|
||||
/// <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 };
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
191
lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs
Normal file
191
lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Grpc.Services;
|
||||
using ZB.MOM.WW.LmxProxy.Host.MxAccess;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Security;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Sessions;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host
|
||||
{
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,78 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Serilog;
|
||||
using Topshelf;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host
|
||||
{
|
||||
internal static class Program
|
||||
{
|
||||
static void Main(string[] args)
|
||||
static int Main(string[] args)
|
||||
{
|
||||
// Placeholder - Phase 3 will implement full Topshelf startup.
|
||||
// 1. Build configuration
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
// 2. Configure Serilog
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(configuration)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithMachineName()
|
||||
.Enrich.WithThreadId()
|
||||
.CreateLogger();
|
||||
|
||||
try
|
||||
{
|
||||
// 3. Bind configuration
|
||||
var config = new LmxProxyConfiguration();
|
||||
configuration.Bind(config);
|
||||
|
||||
// 4. Configure Topshelf
|
||||
var exitCode = HostFactory.Run(host =>
|
||||
{
|
||||
host.UseSerilog();
|
||||
|
||||
host.Service<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
20
lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs
Normal file
20
lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Security
|
||||
{
|
||||
/// <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 ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
Log.Warning("Request rejected: missing x-api-key header for {Method}", context.Method);
|
||||
throw new RpcException(new Status(StatusCode.Unauthenticated, "Missing x-api-key header"));
|
||||
}
|
||||
|
||||
var key = _apiKeyService.ValidateApiKey(apiKey);
|
||||
if (key == null)
|
||||
{
|
||||
Log.Warning("Request rejected: invalid API key for {Method}", context.Method);
|
||||
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key"));
|
||||
}
|
||||
|
||||
// Check write authorization
|
||||
if (WriteProtectedMethods.Contains(context.Method) && key.Role != ApiKeyRole.ReadWrite)
|
||||
{
|
||||
Log.Warning("Request rejected: ReadOnly key attempted write operation {Method}", context.Method);
|
||||
throw new RpcException(new Status(StatusCode.PermissionDenied,
|
||||
"Write operations require a ReadWrite API key"));
|
||||
}
|
||||
|
||||
// Store the validated key in UserState for downstream use
|
||||
context.UserState["ApiKey"] = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
183
lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs
Normal file
183
lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Newtonsoft.Json;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Security
|
||||
{
|
||||
/// <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;
|
||||
|
||||
switch (requiredRole)
|
||||
{
|
||||
case ApiKeyRole.ReadOnly:
|
||||
return true; // Both roles have ReadOnly
|
||||
case ApiKeyRole.ReadWrite:
|
||||
return key.Role == ApiKeyRole.ReadWrite;
|
||||
default:
|
||||
return 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.IO;
|
||||
using Grpc.Core;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Security
|
||||
{
|
||||
/// <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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,80 @@
|
||||
{
|
||||
"GrpcPort": 50051,
|
||||
"ApiKeyConfigFile": "apikeys.json",
|
||||
|
||||
"Connection": {
|
||||
"MonitorIntervalSeconds": 5,
|
||||
"ConnectionTimeoutSeconds": 30,
|
||||
"ReadTimeoutSeconds": 5,
|
||||
"WriteTimeoutSeconds": 5,
|
||||
"MaxConcurrentOperations": 10,
|
||||
"AutoReconnect": true,
|
||||
"NodeName": null,
|
||||
"GalaxyName": null
|
||||
},
|
||||
|
||||
"Subscription": {
|
||||
"ChannelCapacity": 1000,
|
||||
"ChannelFullMode": "DropOldest"
|
||||
},
|
||||
|
||||
"Tls": {
|
||||
"Enabled": false,
|
||||
"ServerCertificatePath": "certs/server.crt",
|
||||
"ServerKeyPath": "certs/server.key",
|
||||
"ClientCaCertificatePath": "certs/ca.crt",
|
||||
"RequireClientCertificate": false,
|
||||
"CheckCertificateRevocation": false
|
||||
},
|
||||
|
||||
"WebServer": {
|
||||
"Enabled": true,
|
||||
"Port": 8080
|
||||
},
|
||||
|
||||
"ServiceRecovery": {
|
||||
"FirstFailureDelayMinutes": 1,
|
||||
"SecondFailureDelayMinutes": 5,
|
||||
"SubsequentFailureDelayMinutes": 10,
|
||||
"ResetPeriodDays": 1
|
||||
},
|
||||
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File",
|
||||
"Serilog.Enrichers.Environment",
|
||||
"Serilog.Enrichers.Thread"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"System": "Warning",
|
||||
"Grpc": "Information"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "File",
|
||||
"Args": {
|
||||
"path": "logs/lmxproxy-.txt",
|
||||
"rollingInterval": "Day",
|
||||
"retainedFileCountLimit": 30,
|
||||
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{MachineName}/{ThreadId}] {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
"FromLogContext",
|
||||
"WithMachineName",
|
||||
"WithThreadId"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Configuration
|
||||
{
|
||||
public class ConfigurationValidatorTests
|
||||
{
|
||||
private static LmxProxyConfiguration ValidConfig() => new LmxProxyConfiguration();
|
||||
|
||||
[Fact]
|
||||
public void ValidConfig_PassesValidation()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(70000)]
|
||||
public void InvalidGrpcPort_Throws(int port)
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.GrpcPort = port;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("GrpcPort"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidMonitorInterval_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.Connection.MonitorIntervalSeconds = 0;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("MonitorIntervalSeconds"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidChannelCapacity_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.Subscription.ChannelCapacity = -1;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("ChannelCapacity"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidChannelFullMode_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.Subscription.ChannelFullMode = "InvalidMode";
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("ChannelFullMode"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidResetPeriodDays_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.ServiceRecovery.ResetPeriodDays = 0;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("ResetPeriodDays"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeFailureDelay_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.ServiceRecovery.FirstFailureDelayMinutes = -1;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("FirstFailureDelayMinutes"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Security
|
||||
{
|
||||
public class ApiKeyInterceptorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("/scada.ScadaService/Write")]
|
||||
[InlineData("/scada.ScadaService/WriteBatch")]
|
||||
[InlineData("/scada.ScadaService/WriteBatchAndWait")]
|
||||
public void WriteProtectedMethods_AreCorrectlyDefined(string method)
|
||||
{
|
||||
// This test verifies the set of write-protected methods is correct.
|
||||
// The actual interceptor logic is tested via integration tests.
|
||||
var writeProtected = new System.Collections.Generic.HashSet<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Security;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Security
|
||||
{
|
||||
public class ApiKeyServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public ApiKeyServiceTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "lmxproxy-test-" + Guid.NewGuid().ToString("N").Substring(0, 8));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, true);
|
||||
}
|
||||
|
||||
private string CreateKeyFile(params ApiKey[] keys)
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "apikeys.json");
|
||||
var config = new ApiKeyConfiguration { ApiKeys = new System.Collections.Generic.List<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.2" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.29.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user