using System.Diagnostics; using System.Diagnostics.Metrics; using Microsoft.Extensions.Logging; using StackExchange.Redis; using Tiku.Application.Security; namespace Tiku.Infrastructure.Security; internal sealed class RedisSecurityStore( IConnectionMultiplexer connection, string environmentName, ILogger logger) : IRedisSecurityStore { private const string ConsumeScript = """ local now = redis.call('TIME') local nowMs = now[1] * 1000 + math.floor(now[2] / 1000) local retryAfter = 0 for i = 1, #KEYS do local current = tonumber(redis.call('GET', KEYS[i]) or '0') local limit = tonumber(ARGV[(i - 1) * 2 + 1]) if current >= limit then local ttl = redis.call('PTTL', KEYS[i]) if ttl > retryAfter then retryAfter = ttl end end end if retryAfter > 0 then return {0, retryAfter} end for i = 1, #KEYS do local window = tonumber(ARGV[(i - 1) * 2 + 2]) local value = redis.call('INCR', KEYS[i]) if value == 1 then redis.call('PEXPIRE', KEYS[i], window) end end return {1, 0} """; private static readonly Meter Meter = new("Tiku.Security.Redis", "1.0.0"); private static readonly Counter OperationCounter = Meter.CreateCounter("tiku.redis.security.operations"); private static readonly Counter RejectionCounter = Meter.CreateCounter("tiku.redis.rate_limit.rejections"); private static readonly Counter ErrorCounter = Meter.CreateCounter("tiku.redis.security.errors"); private static readonly Histogram ScriptDuration = Meter.CreateHistogram( "tiku.redis.lua.duration", "ms"); private readonly string prefix = $"tiku:{Normalize(environmentName)}"; public bool IsConfigured => true; public async Task ConsumeAsync( IReadOnlyCollection buckets, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); if (buckets.Count == 0) return new DistributedRateLimitResult(true); try { var started = Stopwatch.GetTimestamp(); var keys = buckets.Select(bucket => (RedisKey)$"{prefix}:rl:{bucket.Key}").ToArray(); var values = buckets .SelectMany(bucket => new RedisValue[] { bucket.PermitLimit, (long)bucket.Window.TotalMilliseconds }) .ToArray(); var result = (RedisResult[])(await connection.GetDatabase() .ScriptEvaluateAsync(ConsumeScript, keys, values).WaitAsync(cancellationToken))!; var allowed = (long)result[0] == 1; var retryMs = (long)result[1]; OperationCounter.Add(1, new KeyValuePair("operation", "rate_limit")); ScriptDuration.Record(Stopwatch.GetElapsedTime(started).TotalMilliseconds); if (!allowed) RejectionCounter.Add(1); return new DistributedRateLimitResult( allowed, retryMs > 0 ? TimeSpan.FromMilliseconds(retryMs) : null); } catch (Exception exception) when (exception is RedisException or TimeoutException) { ErrorCounter.Add(1, new KeyValuePair("operation", "rate_limit")); logger.LogError(exception, "Redis security operation failed closed."); throw new RedisSecurityUnavailableException(exception); } } public async Task PingAsync(CancellationToken cancellationToken = default) { try { await connection.GetDatabase().PingAsync().WaitAsync(cancellationToken); OperationCounter.Add(1, new KeyValuePair("operation", "ping")); return true; } catch (Exception exception) when (exception is RedisException or TimeoutException) { ErrorCounter.Add(1, new KeyValuePair("operation", "ping")); return false; } } private static string Normalize(string value) { return string.Concat(value.Trim().ToLowerInvariant().Select(character => char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-')); } } public sealed class NullRedisSecurityStore : IRedisSecurityStore { public bool IsConfigured => false; public Task ConsumeAsync( IReadOnlyCollection buckets, CancellationToken cancellationToken = default) { return Task.FromResult(new DistributedRateLimitResult(true)); } public Task PingAsync(CancellationToken cancellationToken = default) { return Task.FromResult(false); } } public sealed class RedisSecurityUnavailableException(Exception innerException) : Exception("Redis security services are unavailable.", innerException);