677263af57
The per-IP unlock throttle's counters were a process-local ConcurrentDictionary on each central node, so the effective fleet cap was N x MaxUnlockAttemptsPerIpPerHour per client (failing over to the other node handed the client a fresh budget) and a node restart cleared the window outright. The window now lives in the central MS SQL BundleUnlockAttempt table — the HA store both central nodes already share, so no new infrastructure, no new port, no consensus protocol: - Commons: BundleUnlockAttempt entity + IBundleUnlockAttemptRepository. - ConfigurationDatabase: entity config (one index, IX_BundleUnlockAttempt_Client_Attempted, serving both the windowed count and the prune), repository (AsNoTracking count, ExecuteDeleteAsync prune), DbSet, DI registration, migration AddBundleUnlockAttemptTable + the hand-written idempotent production script under docs/plans/sql/. - Transport: IBundleUnlockAttemptWindow seam + SqlBundleUnlockAttemptWindow (singleton; resolves the scoped repository from its own scope per call, like the central purge singletons). BundleUnlockRateLimiter keeps the policy and gains TryRegisterAttemptAsync; BundleImporter.LoadAsync calls it. Behaviour preserved from row 44: locked bundles spend no hourly budget (the lockout check still runs first), throttle denials burn no per-bundle strike and are NOT recorded so they cannot roll the window forward, unattributable clients share the stricter <unattributed> bucket, and the budget counts attempts rather than failures. Config keys and their semantics are unchanged, so the rig's 500 override stays valid. Three deliberate trades, documented in both Transport docs: - FAILS OPEN. A store error logs a Warning naming the client and cap, and permits the attempt. This is a best-effort rate cap over an already authenticated, Administrator-gated surface — the availability of legitimate imports outranks the cap, and a DB blip must not make the import wizard unusable. Falling back to the process-local window was considered and rejected: it would still block a legitimate operator's 11th import. - Approximate. Count-then-insert is not atomic across nodes, so simultaneous attempts can overshoot by the number in flight — bounded, self-correcting within the window, and not worth provider-specific raw SQL. - Pruned opportunistically on the unlock path (CAS-claimed at most every 15 min per process, bounded to 1000 rows, retaining 2x the window, best-effort try/catch) rather than by a new background service — Transport owns no central scheduler. Same shape as DeploymentService's terminal-record sweep. Tests: Transport.IntegrationTests 116 passed (+10 — two limiter instances over one store enforcing a single combined window, restart durability, denials burning no budget, sliding release, bucket isolation, store-failure fail-open, caller-cancellation not swallowed, no-shared-window fallback, prune and its negative control); ConfigurationDatabase 395 passed (+5).
76 lines
4.2 KiB
C#
76 lines
4.2 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Export;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Transport;
|
|
|
|
public static class ServiceCollectionExtensions
|
|
{
|
|
public const string OptionsSection = "ScadaBridge:Transport";
|
|
|
|
/// <summary>
|
|
/// Registers the Transport component services: encryptor, manifest builder/validator, serializers, resolver, exporter, importer, and session store.
|
|
/// </summary>
|
|
/// <param name="services">The service collection to register into.</param>
|
|
/// <returns>The same <see cref="IServiceCollection"/> to allow chaining.</returns>
|
|
public static IServiceCollection AddTransport(this IServiceCollection services)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(services);
|
|
// Eager startup validation (arch-review 08 §1.5): ValidateOnStart makes a
|
|
// bad ScadaBridge:Transport section — a zero/negative cap, or (critically)
|
|
// a Pbkdf2Iterations below the security floor that would silently weaken
|
|
// bundle key derivation — fail fast at boot with a clear, key-naming
|
|
// message instead of surfacing deep inside the import pipeline.
|
|
services.AddOptions<TransportOptions>().BindConfiguration(OptionsSection).ValidateOnStart();
|
|
services.TryAddEnumerable(
|
|
ServiceDescriptor.Singleton<IValidateOptions<TransportOptions>, TransportOptionsValidator>());
|
|
services.TryAddSingleton(TimeProvider.System);
|
|
|
|
// Pipeline building blocks: stateless services live as singletons; the
|
|
// resolver and exporter are scoped because they reach into per-request
|
|
// repository scopes and the scoped DbContext.
|
|
services.AddSingleton<BundleSecretEncryptor>();
|
|
services.AddSingleton<ManifestBuilder>();
|
|
services.AddSingleton<ManifestValidator>();
|
|
services.AddSingleton<BundleSerializer>();
|
|
services.AddSingleton<EntitySerializer>();
|
|
services.AddScoped<DependencyResolver>();
|
|
services.AddScoped<IBundleExporter, BundleExporter>();
|
|
services.AddSingleton<IBundleSessionStore, BundleSessionStore>();
|
|
// T-004: per-IP-per-hour unlock rate limiter — design doc §11. Singleton so the
|
|
// trailing-hour window is shared across every importer scope.
|
|
//
|
|
// Register row 52: the window is FLEET-WIDE, backed by the central MS SQL
|
|
// BundleUnlockAttempt table that both central nodes already share — the counters
|
|
// used to be per-node and per-process, so the effective cap was N x limit per
|
|
// client and a restart cleared the window. The window resolves its scoped
|
|
// repository from its own scope per call, so it is safe as a singleton.
|
|
// Constructed explicitly rather than by convention so it is unambiguous which
|
|
// constructor overload production uses.
|
|
services.AddSingleton<IBundleUnlockAttemptWindow, SqlBundleUnlockAttemptWindow>();
|
|
services.AddSingleton(sp => new BundleUnlockRateLimiter(
|
|
sp.GetRequiredService<TimeProvider>(),
|
|
BundleUnlockRateLimiter.DefaultWindow,
|
|
sp.GetRequiredService<IBundleUnlockAttemptWindow>(),
|
|
sp.GetService<ILogger<BundleUnlockRateLimiter>>()));
|
|
// T-007: periodic eviction sweep so abandoned sessions clear without
|
|
// needing a fresh Get() to trigger lazy eviction.
|
|
services.AddHostedService<BundleSessionEvictionService>();
|
|
// SemanticValidator is a stateless utility used by ApplyAsync; use
|
|
// TryAdd so a host that already calls AddTemplateEngine() (which
|
|
// registers the same type as Transient) wins. Either registration
|
|
// satisfies the BundleImporter constructor.
|
|
services.TryAddTransient<SemanticValidator>();
|
|
services.AddScoped<IBundleImporter, BundleImporter>();
|
|
// Remaining concrete services added in later tasks.
|
|
return services;
|
|
}
|
|
}
|