feat(lmxproxy): phase 1 — v2 protocol types and domain model
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents an API key with associated permissions
|
||||
/// </summary>
|
||||
public class ApiKey
|
||||
{
|
||||
/// <summary>
|
||||
/// The API key value
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Description of what this API key is used for
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The role assigned to this API key
|
||||
/// </summary>
|
||||
public ApiKeyRole Role { get; set; } = ApiKeyRole.ReadOnly;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this API key is enabled
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the API key is valid
|
||||
/// </summary>
|
||||
public bool IsValid() => Enabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API key roles
|
||||
/// </summary>
|
||||
public enum ApiKeyRole
|
||||
{
|
||||
/// <summary>
|
||||
/// Can only read data
|
||||
/// </summary>
|
||||
ReadOnly,
|
||||
|
||||
/// <summary>
|
||||
/// Can read and write data
|
||||
/// </summary>
|
||||
ReadWrite
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration for API keys loaded from file
|
||||
/// </summary>
|
||||
public class ApiKeyConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// List of API keys
|
||||
/// </summary>
|
||||
public List<ApiKey> ApiKeys { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// gRPC interceptor for API key authentication.
|
||||
/// Validates API keys for incoming requests and enforces role-based access control.
|
||||
/// </summary>
|
||||
public class ApiKeyInterceptor : Interceptor
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<ApiKeyInterceptor>();
|
||||
|
||||
/// <summary>
|
||||
/// List of gRPC method names that require write access.
|
||||
/// </summary>
|
||||
private static readonly string[] WriteMethodNames =
|
||||
{
|
||||
"Write",
|
||||
"WriteBatch",
|
||||
"WriteBatchAndWait"
|
||||
};
|
||||
|
||||
private readonly ApiKeyService _apiKeyService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ApiKeyInterceptor" /> class.
|
||||
/// </summary>
|
||||
/// <param name="apiKeyService">The API key service used for validation.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown if <paramref name="apiKeyService" /> is null.</exception>
|
||||
public ApiKeyInterceptor(ApiKeyService apiKeyService)
|
||||
{
|
||||
_apiKeyService = apiKeyService ?? throw new ArgumentNullException(nameof(apiKeyService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles unary gRPC calls, validating API key and enforcing permissions.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">The request type.</typeparam>
|
||||
/// <typeparam name="TResponse">The response type.</typeparam>
|
||||
/// <param name="request">The request message.</param>
|
||||
/// <param name="context">The server call context.</param>
|
||||
/// <param name="continuation">The continuation delegate.</param>
|
||||
/// <returns>The response message.</returns>
|
||||
/// <exception cref="RpcException">Thrown if authentication or authorization fails.</exception>
|
||||
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ServerCallContext context,
|
||||
UnaryServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
string apiKey = GetApiKeyFromContext(context);
|
||||
string methodName = GetMethodName(context.Method);
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
Logger.Warning("Missing API key for method {Method} from {Peer}",
|
||||
context.Method, context.Peer);
|
||||
throw new RpcException(new Status(StatusCode.Unauthenticated, "API key is required"));
|
||||
}
|
||||
|
||||
ApiKey? key = _apiKeyService.ValidateApiKey(apiKey);
|
||||
if (key == null)
|
||||
{
|
||||
Logger.Warning("Invalid API key for method {Method} from {Peer}",
|
||||
context.Method, context.Peer);
|
||||
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key"));
|
||||
}
|
||||
|
||||
// Check if method requires write access
|
||||
if (IsWriteMethod(methodName) && key.Role != ApiKeyRole.ReadWrite)
|
||||
{
|
||||
Logger.Warning("Insufficient permissions for method {Method} with API key {Description}",
|
||||
context.Method, key.Description);
|
||||
throw new RpcException(new Status(StatusCode.PermissionDenied,
|
||||
"API key does not have write permissions"));
|
||||
}
|
||||
|
||||
// Add API key info to context items for use in service methods
|
||||
context.UserState["ApiKey"] = key;
|
||||
|
||||
Logger.Debug("Authorized method {Method} for API key {Description}",
|
||||
context.Method, key.Description);
|
||||
|
||||
return await continuation(request, context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles server streaming gRPC calls, validating API key and enforcing permissions.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">The request type.</typeparam>
|
||||
/// <typeparam name="TResponse">The response type.</typeparam>
|
||||
/// <param name="request">The request message.</param>
|
||||
/// <param name="responseStream">The response stream writer.</param>
|
||||
/// <param name="context">The server call context.</param>
|
||||
/// <param name="continuation">The continuation delegate.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
/// <exception cref="RpcException">Thrown if authentication fails.</exception>
|
||||
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
ServerStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
string apiKey = GetApiKeyFromContext(context);
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
Logger.Warning("Missing API key for streaming method {Method} from {Peer}",
|
||||
context.Method, context.Peer);
|
||||
throw new RpcException(new Status(StatusCode.Unauthenticated, "API key is required"));
|
||||
}
|
||||
|
||||
ApiKey? key = _apiKeyService.ValidateApiKey(apiKey);
|
||||
if (key == null)
|
||||
{
|
||||
Logger.Warning("Invalid API key for streaming method {Method} from {Peer}",
|
||||
context.Method, context.Peer);
|
||||
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key"));
|
||||
}
|
||||
|
||||
// Add API key info to context items
|
||||
context.UserState["ApiKey"] = key;
|
||||
|
||||
Logger.Debug("Authorized streaming method {Method} for API key {Description}",
|
||||
context.Method, key.Description);
|
||||
|
||||
await continuation(request, responseStream, context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the API key from the gRPC request headers.
|
||||
/// </summary>
|
||||
/// <param name="context">The server call context.</param>
|
||||
/// <returns>The API key value, or an empty string if not found.</returns>
|
||||
private static string GetApiKeyFromContext(ServerCallContext context)
|
||||
{
|
||||
// Check for API key in metadata (headers)
|
||||
Metadata.Entry? entry = context.RequestHeaders.FirstOrDefault(e =>
|
||||
e.Key.Equals("x-api-key", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return entry?.Value ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the method name from the full gRPC method string.
|
||||
/// </summary>
|
||||
/// <param name="method">The full method string (e.g., /package.Service/Method).</param>
|
||||
/// <returns>The method name.</returns>
|
||||
private static string GetMethodName(string method)
|
||||
{
|
||||
// Method format is /package.Service/Method
|
||||
int lastSlash = method.LastIndexOf('/');
|
||||
return lastSlash >= 0 ? method.Substring(lastSlash + 1) : method;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the specified method name requires write access.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The method name.</param>
|
||||
/// <returns><c>true</c> if the method requires write access; otherwise, <c>false</c>.</returns>
|
||||
private static bool IsWriteMethod(string methodName) =>
|
||||
WriteMethodNames.Contains(methodName, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for managing API keys with file-based storage.
|
||||
/// Handles validation, role checking, and automatic reload on file changes.
|
||||
/// </summary>
|
||||
public class ApiKeyService : IDisposable
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<ApiKeyService>();
|
||||
private readonly ConcurrentDictionary<string, ApiKey> _apiKeys;
|
||||
private readonly string _configFilePath;
|
||||
private readonly SemaphoreSlim _reloadLock = new(1, 1);
|
||||
private bool _disposed;
|
||||
private FileSystemWatcher? _fileWatcher;
|
||||
private DateTime _lastReloadTime = DateTime.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ApiKeyService" /> class.
|
||||
/// </summary>
|
||||
/// <param name="configFilePath">The path to the API key configuration file.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown if <paramref name="configFilePath" /> is null.</exception>
|
||||
public ApiKeyService(string configFilePath)
|
||||
{
|
||||
_configFilePath = configFilePath ?? throw new ArgumentNullException(nameof(configFilePath));
|
||||
_apiKeys = new ConcurrentDictionary<string, ApiKey>();
|
||||
|
||||
InitializeFileWatcher();
|
||||
LoadConfiguration();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the <see cref="ApiKeyService" /> and releases resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
_fileWatcher?.Dispose();
|
||||
_reloadLock?.Dispose();
|
||||
|
||||
Logger.Information("API key service disposed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an API key and returns its details if valid.
|
||||
/// </summary>
|
||||
/// <param name="apiKey">The API key value to validate.</param>
|
||||
/// <returns>The <see cref="ApiKey" /> if valid; otherwise, <c>null</c>.</returns>
|
||||
public ApiKey? ValidateApiKey(string apiKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_apiKeys.TryGetValue(apiKey, out ApiKey? key) && key.IsValid())
|
||||
{
|
||||
Logger.Debug("API key validated successfully for {Description}", key.Description);
|
||||
return key;
|
||||
}
|
||||
|
||||
Logger.Warning("Invalid or expired API key attempted");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an API key has the specified role.
|
||||
/// </summary>
|
||||
/// <param name="apiKey">The API key value.</param>
|
||||
/// <param name="requiredRole">The required <see cref="ApiKeyRole" />.</param>
|
||||
/// <returns><c>true</c> if the API key has the required role; otherwise, <c>false</c>.</returns>
|
||||
public bool HasRole(string apiKey, ApiKeyRole requiredRole)
|
||||
{
|
||||
ApiKey? key = ValidateApiKey(apiKey);
|
||||
if (key == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// ReadWrite role has access to everything
|
||||
if (key.Role == ApiKeyRole.ReadWrite)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// ReadOnly role only has access to ReadOnly operations
|
||||
return requiredRole == ApiKeyRole.ReadOnly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the file system watcher for the API key configuration file.
|
||||
/// </summary>
|
||||
private void InitializeFileWatcher()
|
||||
{
|
||||
string? directory = Path.GetDirectoryName(_configFilePath);
|
||||
string? fileName = Path.GetFileName(_configFilePath);
|
||||
|
||||
if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
Logger.Warning("Invalid config file path, file watching disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_fileWatcher = new FileSystemWatcher(directory, fileName)
|
||||
{
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime,
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
|
||||
_fileWatcher.Changed += OnFileChanged;
|
||||
_fileWatcher.Created += OnFileChanged;
|
||||
_fileWatcher.Renamed += OnFileRenamed;
|
||||
|
||||
Logger.Information("File watcher initialized for {FilePath}", _configFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Failed to initialize file watcher for {FilePath}", _configFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles file change events for the configuration file.
|
||||
/// </summary>
|
||||
/// <param name="sender">The event sender.</param>
|
||||
/// <param name="e">The <see cref="FileSystemEventArgs" /> instance containing event data.</param>
|
||||
private void OnFileChanged(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
if (e.ChangeType == WatcherChangeTypes.Changed || e.ChangeType == WatcherChangeTypes.Created)
|
||||
{
|
||||
Logger.Information("API key configuration file changed, reloading");
|
||||
Task.Run(() => ReloadConfigurationAsync());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles file rename events for the configuration file.
|
||||
/// </summary>
|
||||
/// <param name="sender">The event sender.</param>
|
||||
/// <param name="e">The <see cref="RenamedEventArgs" /> instance containing event data.</param>
|
||||
private void OnFileRenamed(object sender, RenamedEventArgs e)
|
||||
{
|
||||
if (e.FullPath.Equals(_configFilePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Logger.Information("API key configuration file renamed, reloading");
|
||||
Task.Run(() => ReloadConfigurationAsync());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously reloads the API key configuration from file.
|
||||
/// Debounces rapid file changes to avoid excessive reloads.
|
||||
/// </summary>
|
||||
private async Task ReloadConfigurationAsync()
|
||||
{
|
||||
// Debounce rapid file changes
|
||||
TimeSpan timeSinceLastReload = DateTime.UtcNow - _lastReloadTime;
|
||||
if (timeSinceLastReload < TimeSpan.FromSeconds(1))
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1) - timeSinceLastReload);
|
||||
}
|
||||
|
||||
await _reloadLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
LoadConfiguration();
|
||||
_lastReloadTime = DateTime.UtcNow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_reloadLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the API key configuration from file.
|
||||
/// If the file does not exist, creates a default configuration.
|
||||
/// </summary>
|
||||
private void LoadConfiguration()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_configFilePath))
|
||||
{
|
||||
Logger.Warning("API key configuration file not found at {FilePath}, creating default",
|
||||
_configFilePath);
|
||||
CreateDefaultConfiguration();
|
||||
return;
|
||||
}
|
||||
|
||||
string json = File.ReadAllText(_configFilePath);
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip
|
||||
};
|
||||
options.Converters.Add(new JsonStringEnumConverter());
|
||||
ApiKeyConfiguration? config = JsonSerializer.Deserialize<ApiKeyConfiguration>(json, options);
|
||||
|
||||
if (config?.ApiKeys == null || !config.ApiKeys.Any())
|
||||
{
|
||||
Logger.Warning("No API keys found in configuration file");
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing keys and load new ones
|
||||
_apiKeys.Clear();
|
||||
|
||||
foreach (ApiKey? apiKey in config.ApiKeys)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(apiKey.Key))
|
||||
{
|
||||
Logger.Warning("Skipping API key with empty key value");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_apiKeys.TryAdd(apiKey.Key, apiKey))
|
||||
{
|
||||
Logger.Information("Loaded API key: {Description} with role {Role}",
|
||||
apiKey.Description, apiKey.Role);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Warning("Duplicate API key found: {Description}", apiKey.Description);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.Information("Loaded {Count} API keys from configuration", _apiKeys.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Failed to load API key configuration from {FilePath}", _configFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default API key configuration file with sample keys.
|
||||
/// </summary>
|
||||
private void CreateDefaultConfiguration()
|
||||
{
|
||||
try
|
||||
{
|
||||
var defaultConfig = new ApiKeyConfiguration
|
||||
{
|
||||
ApiKeys = new List<ApiKey>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Key = Guid.NewGuid().ToString("N"),
|
||||
Description = "Default read-only API key",
|
||||
Role = ApiKeyRole.ReadOnly,
|
||||
Enabled = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Key = Guid.NewGuid().ToString("N"),
|
||||
Description = "Default read-write API key",
|
||||
Role = ApiKeyRole.ReadWrite,
|
||||
Enabled = true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
string? json = JsonSerializer.Serialize(defaultConfig, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
|
||||
string? directory = Path.GetDirectoryName(_configFilePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(_configFilePath, json);
|
||||
Logger.Information("Created default API key configuration at {FilePath}", _configFilePath);
|
||||
|
||||
// Load the created configuration
|
||||
LoadConfiguration();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Failed to create default API key configuration");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages TLS certificates for the LmxProxy service, including generation and validation
|
||||
/// </summary>
|
||||
public class TlsCertificateManager
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<TlsCertificateManager>();
|
||||
private readonly TlsConfiguration _tlsConfiguration;
|
||||
|
||||
public TlsCertificateManager(TlsConfiguration tlsConfiguration)
|
||||
{
|
||||
_tlsConfiguration = tlsConfiguration ?? throw new ArgumentNullException(nameof(tlsConfiguration));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks TLS certificate status and creates new certificates if needed
|
||||
/// </summary>
|
||||
/// <returns>True if certificates are valid or were successfully created</returns>
|
||||
public bool EnsureCertificatesValid()
|
||||
{
|
||||
if (!_tlsConfiguration.Enabled)
|
||||
{
|
||||
Logger.Information("TLS is disabled, skipping certificate check");
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Check if certificate files exist
|
||||
bool certificateExists = File.Exists(_tlsConfiguration.ServerCertificatePath);
|
||||
bool keyExists = File.Exists(_tlsConfiguration.ServerKeyPath);
|
||||
|
||||
if (!certificateExists || !keyExists)
|
||||
{
|
||||
Logger.Warning("TLS certificate or key not found, generating new certificate");
|
||||
return GenerateNewCertificate();
|
||||
}
|
||||
|
||||
// Check certificate expiration
|
||||
if (IsCertificateExpiringSoon(_tlsConfiguration.ServerCertificatePath))
|
||||
{
|
||||
Logger.Warning("TLS certificate is expiring within the next year, generating new certificate");
|
||||
return GenerateNewCertificate();
|
||||
}
|
||||
|
||||
Logger.Information("TLS certificate is valid");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error checking TLS certificates");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a certificate is expiring within the next year
|
||||
/// </summary>
|
||||
private bool IsCertificateExpiringSoon(string certificatePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
string certPem = File.ReadAllText(certificatePath);
|
||||
byte[] certBytes = GetBytesFromPem(certPem, "CERTIFICATE");
|
||||
|
||||
using var cert = new X509Certificate2(certBytes);
|
||||
DateTime expirationDate = cert.NotAfter;
|
||||
double daysUntilExpiration = (expirationDate - DateTime.Now).TotalDays;
|
||||
|
||||
Logger.Information("Certificate expires on {ExpirationDate} ({DaysUntilExpiration:F0} days from now)",
|
||||
expirationDate, daysUntilExpiration);
|
||||
|
||||
// Check if expiring within the next year (365 days)
|
||||
return daysUntilExpiration <= 365;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error checking certificate expiration");
|
||||
// If we can't check expiration, assume it needs renewal
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new self-signed certificate
|
||||
/// </summary>
|
||||
private bool GenerateNewCertificate()
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.Information("Generating new self-signed TLS certificate");
|
||||
|
||||
// Ensure directory exists
|
||||
string? certDir = Path.GetDirectoryName(_tlsConfiguration.ServerCertificatePath);
|
||||
if (!string.IsNullOrEmpty(certDir) && !Directory.Exists(certDir))
|
||||
{
|
||||
Directory.CreateDirectory(certDir);
|
||||
Logger.Information("Created certificate directory: {Directory}", certDir);
|
||||
}
|
||||
|
||||
// Generate a new self-signed certificate
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
"CN=LmxProxy, O=SCADA Bridge, C=US",
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
// Add certificate extensions
|
||||
request.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(false, false, 0, false));
|
||||
|
||||
request.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
|
||||
false));
|
||||
|
||||
request.CertificateExtensions.Add(
|
||||
new X509EnhancedKeyUsageExtension(
|
||||
new OidCollection
|
||||
{
|
||||
new Oid("1.3.6.1.5.5.7.3.1") // Server Authentication
|
||||
},
|
||||
false));
|
||||
|
||||
// Add Subject Alternative Names
|
||||
var sanBuilder = new SubjectAlternativeNameBuilder();
|
||||
sanBuilder.AddDnsName("localhost");
|
||||
sanBuilder.AddDnsName(Environment.MachineName);
|
||||
sanBuilder.AddIpAddress(IPAddress.Loopback);
|
||||
sanBuilder.AddIpAddress(IPAddress.IPv6Loopback);
|
||||
request.CertificateExtensions.Add(sanBuilder.Build());
|
||||
|
||||
// Create the certificate with 2-year validity
|
||||
DateTimeOffset notBefore = DateTimeOffset.Now.AddDays(-1);
|
||||
DateTimeOffset notAfter = DateTimeOffset.Now.AddYears(2);
|
||||
|
||||
using X509Certificate2? cert = request.CreateSelfSigned(notBefore, notAfter);
|
||||
|
||||
// Export certificate to PEM format
|
||||
string certPem = ExportCertificateToPem(cert);
|
||||
File.WriteAllText(_tlsConfiguration.ServerCertificatePath, certPem);
|
||||
Logger.Information("Saved certificate to {Path}", _tlsConfiguration.ServerCertificatePath);
|
||||
|
||||
// Export private key to PEM format
|
||||
string keyPem = ExportPrivateKeyToPem(rsa);
|
||||
File.WriteAllText(_tlsConfiguration.ServerKeyPath, keyPem);
|
||||
Logger.Information("Saved private key to {Path}", _tlsConfiguration.ServerKeyPath);
|
||||
|
||||
// If client CA path is specified and doesn't exist, create it
|
||||
if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ClientCaCertificatePath) &&
|
||||
!File.Exists(_tlsConfiguration.ClientCaCertificatePath))
|
||||
{
|
||||
// For self-signed certificates, the CA cert is the same as the server cert
|
||||
File.WriteAllText(_tlsConfiguration.ClientCaCertificatePath, certPem);
|
||||
Logger.Information("Saved CA certificate to {Path}", _tlsConfiguration.ClientCaCertificatePath);
|
||||
}
|
||||
|
||||
Logger.Information("Successfully generated new TLS certificate valid until {NotAfter}", notAfter);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Failed to generate new TLS certificate");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports a certificate to PEM format
|
||||
/// </summary>
|
||||
private static string ExportCertificateToPem(X509Certificate2 cert)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("-----BEGIN CERTIFICATE-----");
|
||||
builder.AppendLine(Convert.ToBase64String(cert.Export(X509ContentType.Cert),
|
||||
Base64FormattingOptions.InsertLineBreaks));
|
||||
builder.AppendLine("-----END CERTIFICATE-----");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports an RSA private key to PEM format
|
||||
/// </summary>
|
||||
private static string ExportPrivateKeyToPem(RSA rsa)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("-----BEGIN RSA PRIVATE KEY-----");
|
||||
|
||||
// For .NET Framework 4.8, we need to use the older export method
|
||||
RSAParameters parameters = rsa.ExportParameters(true);
|
||||
byte[] keyBytes = EncodeRSAPrivateKey(parameters);
|
||||
builder.AppendLine(Convert.ToBase64String(keyBytes, Base64FormattingOptions.InsertLineBreaks));
|
||||
|
||||
builder.AppendLine("-----END RSA PRIVATE KEY-----");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes RSA parameters to PKCS#1 format for .NET Framework 4.8
|
||||
/// </summary>
|
||||
private static byte[] EncodeRSAPrivateKey(RSAParameters parameters)
|
||||
{
|
||||
using (var stream = new MemoryStream())
|
||||
using (var writer = new BinaryWriter(stream))
|
||||
{
|
||||
// Write version
|
||||
writer.Write((byte)0x02); // INTEGER
|
||||
writer.Write((byte)0x01); // Length
|
||||
writer.Write((byte)0x00); // Version
|
||||
|
||||
// Write modulus
|
||||
WriteIntegerBytes(writer, parameters.Modulus);
|
||||
|
||||
// Write public exponent
|
||||
WriteIntegerBytes(writer, parameters.Exponent);
|
||||
|
||||
// Write private exponent
|
||||
WriteIntegerBytes(writer, parameters.D);
|
||||
|
||||
// Write prime1
|
||||
WriteIntegerBytes(writer, parameters.P);
|
||||
|
||||
// Write prime2
|
||||
WriteIntegerBytes(writer, parameters.Q);
|
||||
|
||||
// Write exponent1
|
||||
WriteIntegerBytes(writer, parameters.DP);
|
||||
|
||||
// Write exponent2
|
||||
WriteIntegerBytes(writer, parameters.DQ);
|
||||
|
||||
// Write coefficient
|
||||
WriteIntegerBytes(writer, parameters.InverseQ);
|
||||
|
||||
byte[] innerBytes = stream.ToArray();
|
||||
|
||||
// Create SEQUENCE wrapper
|
||||
using (var finalStream = new MemoryStream())
|
||||
using (var finalWriter = new BinaryWriter(finalStream))
|
||||
{
|
||||
finalWriter.Write((byte)0x30); // SEQUENCE
|
||||
WriteLength(finalWriter, innerBytes.Length);
|
||||
finalWriter.Write(innerBytes);
|
||||
return finalStream.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteIntegerBytes(BinaryWriter writer, byte[] bytes)
|
||||
{
|
||||
if (bytes == null)
|
||||
{
|
||||
bytes = new byte[] { 0 };
|
||||
}
|
||||
|
||||
writer.Write((byte)0x02); // INTEGER
|
||||
|
||||
if (bytes[0] >= 0x80)
|
||||
{
|
||||
// Add padding byte for positive number
|
||||
WriteLength(writer, bytes.Length + 1);
|
||||
writer.Write((byte)0x00);
|
||||
writer.Write(bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteLength(writer, bytes.Length);
|
||||
writer.Write(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteLength(BinaryWriter writer, int length)
|
||||
{
|
||||
if (length < 0x80)
|
||||
{
|
||||
writer.Write((byte)length);
|
||||
}
|
||||
else if (length <= 0xFF)
|
||||
{
|
||||
writer.Write((byte)0x81);
|
||||
writer.Write((byte)length);
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.Write((byte)0x82);
|
||||
writer.Write((byte)(length >> 8));
|
||||
writer.Write((byte)(length & 0xFF));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts bytes from PEM format
|
||||
/// </summary>
|
||||
private static byte[] GetBytesFromPem(string pem, string section)
|
||||
{
|
||||
string header = $"-----BEGIN {section}-----";
|
||||
string footer = $"-----END {section}-----";
|
||||
|
||||
int start = pem.IndexOf(header, StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
{
|
||||
throw new InvalidOperationException($"PEM {section} header not found");
|
||||
}
|
||||
|
||||
start += header.Length;
|
||||
int end = pem.IndexOf(footer, start, StringComparison.Ordinal);
|
||||
|
||||
if (end < 0)
|
||||
{
|
||||
throw new InvalidOperationException($"PEM {section} footer not found");
|
||||
}
|
||||
|
||||
// Use Substring instead of range syntax for .NET Framework 4.8 compatibility
|
||||
string base64 = pem.Substring(start, end - start).Replace("\r", "").Replace("\n", "");
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user