refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-004: in-memory sliding-window rate limiter for bundle-unlock passphrase
|
||||
/// attempts, keyed by client IP. The design doc (§11) declares a per-IP-per-hour cap
|
||||
/// (default 10) as a brute-force defence against a stolen bundle; this class is the
|
||||
/// minimal server-side implementation.
|
||||
/// <para>
|
||||
/// Algorithm: each key (an IP string, or any opaque caller identifier) holds a queue
|
||||
/// of attempt timestamps. <see cref="TryRegisterAttempt"/> first prunes entries older
|
||||
/// than the configured window, then either appends the current timestamp and returns
|
||||
/// <c>true</c> if the count is still under the threshold, or refuses to append and
|
||||
/// returns <c>false</c> if appending would cross it. The trailing-hour count is the
|
||||
/// queue length post-prune.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Storage is a process-local <see cref="ConcurrentDictionary{TKey,TValue}"/>. The
|
||||
/// counters do not survive a host restart — that is by design: a restart resets the
|
||||
/// brute-force window in favour of legitimate operators after an outage. Persisting
|
||||
/// the counters would require a multi-node consensus story the simple in-memory
|
||||
/// design avoids.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Thread-safety: the per-key queue is protected by a per-key lock taken inside the
|
||||
/// dictionary value; the dictionary itself is concurrent. The class is safe to call
|
||||
/// from multiple threads / circuits without external coordination.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class BundleUnlockRateLimiter
|
||||
{
|
||||
/// <summary>
|
||||
/// Default trailing window. The design doc's "per-IP-per-hour" wording fixes this
|
||||
/// at 60 minutes; a constructor overload accepts a different window for tests.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultWindow = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly ConcurrentDictionary<string, AttemptBucket> _buckets = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly TimeSpan _window;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="BundleUnlockRateLimiter"/> using the documented
|
||||
/// 1-hour trailing window and the system clock. Suitable for production DI.
|
||||
/// </summary>
|
||||
public BundleUnlockRateLimiter() : this(TimeProvider.System, DefaultWindow)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="BundleUnlockRateLimiter"/> with an injected clock
|
||||
/// (for deterministic tests) and a custom trailing window.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Clock used for both timestamping new attempts and pruning expired ones.</param>
|
||||
/// <param name="window">Trailing window over which attempts are counted (typically 1 hour).</param>
|
||||
public BundleUnlockRateLimiter(TimeProvider timeProvider, TimeSpan window)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
if (window <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(window), "Window must be positive.");
|
||||
}
|
||||
|
||||
_timeProvider = timeProvider;
|
||||
_window = window;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to register a new passphrase try against the configured per-key
|
||||
/// limit. Returns <c>true</c> when the attempt is permitted (and recorded);
|
||||
/// returns <c>false</c> when the key has exhausted its budget for the trailing
|
||||
/// window — the caller should reject the unlock request with a 429-equivalent.
|
||||
/// </summary>
|
||||
/// <param name="clientKey">
|
||||
/// Opaque caller identifier — typically the remote IP, but any stable per-source
|
||||
/// string is acceptable (the limiter does not interpret it). Trimmed for matching.
|
||||
/// </param>
|
||||
/// <param name="maxAttemptsPerWindow">
|
||||
/// The trailing-window cap (e.g. <c>TransportOptions.MaxUnlockAttemptsPerIpPerHour</c>,
|
||||
/// default 10). Must be at least 1.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the attempt was registered (within budget); <c>false</c> if the
|
||||
/// caller has already used <paramref name="maxAttemptsPerWindow"/> within the
|
||||
/// trailing window.
|
||||
/// </returns>
|
||||
public bool TryRegisterAttempt(string clientKey, int maxAttemptsPerWindow)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
|
||||
if (maxAttemptsPerWindow < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(maxAttemptsPerWindow), "Limit must be at least 1.");
|
||||
}
|
||||
|
||||
var bucket = _buckets.GetOrAdd(clientKey.Trim(), _ => new AttemptBucket());
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var cutoff = now - _window;
|
||||
|
||||
lock (bucket)
|
||||
{
|
||||
// Prune expired entries first so a caller that paused longer than the
|
||||
// window starts the next round at zero — not penalised by stale rows.
|
||||
while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() <= cutoff)
|
||||
{
|
||||
bucket.Timestamps.Dequeue();
|
||||
}
|
||||
|
||||
if (bucket.Timestamps.Count >= maxAttemptsPerWindow)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bucket.Timestamps.Enqueue(now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of recorded attempts for <paramref name="clientKey"/> still
|
||||
/// within the trailing window. Primarily for tests / diagnostics; not part of the
|
||||
/// hot-path.
|
||||
/// </summary>
|
||||
public int GetAttemptCount(string clientKey)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
|
||||
if (!_buckets.TryGetValue(clientKey.Trim(), out var bucket))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var cutoff = _timeProvider.GetUtcNow() - _window;
|
||||
lock (bucket)
|
||||
{
|
||||
while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() <= cutoff)
|
||||
{
|
||||
bucket.Timestamps.Dequeue();
|
||||
}
|
||||
|
||||
return bucket.Timestamps.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-key queue of attempt timestamps. A class (rather than a bare
|
||||
/// <see cref="Queue{T}"/>) so the dictionary value identity is stable across
|
||||
/// concurrent <see cref="ConcurrentDictionary{TKey,TValue}.GetOrAdd(TKey,Func{TKey,TValue})"/>
|
||||
/// races — letting the per-bucket lock guard the queue mutations.
|
||||
/// </summary>
|
||||
private sealed class AttemptBucket
|
||||
{
|
||||
public Queue<DateTimeOffset> Timestamps { get; } = new();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user