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 { /// /// Manages API keys loaded from a JSON file with hot-reload via FileSystemWatcher. /// public sealed class ApiKeyService : IDisposable { private static readonly ILogger Log = Serilog.Log.ForContext(); private readonly string _configFilePath; private readonly FileSystemWatcher? _watcher; private readonly SemaphoreSlim _reloadLock = new SemaphoreSlim(1, 1); private volatile Dictionary _keys = new Dictionary(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; } } /// /// Validates an API key. Returns the ApiKey if valid and enabled, null otherwise. /// public ApiKey? ValidateApiKey(string apiKey) { if (string.IsNullOrEmpty(apiKey)) return null; return _keys.TryGetValue(apiKey, out var key) && key.Enabled ? key : null; } /// /// Checks if a key has the required role. /// ReadWrite implies ReadOnly. /// 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; } } /// Gets the count of loaded API keys. 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 { 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(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(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(); } } }