merge(r2): PLAN-R2-07 UI/Management/Security

This commit is contained in:
Joseph Doherty
2026-07-13 11:08:10 -04:00
26 changed files with 852 additions and 92 deletions
@@ -291,9 +291,27 @@
try
{
var result = await AlarmSummaryService.GetSiteAlarmsAsync(siteId);
_rows = result.Alarms;
// Stale-site guard (arch-review R2 N4): the operator may have switched sites
// while this fan-out was in flight — drop the result rather than labeling
// site A's alarms under site B's picker. Mirrors OnLiveAlarmsChanged (:374).
if (_selectedSiteId != siteId)
{
return;
}
// _notReporting is the poll's unique authority — the alarm-only live cache
// cannot compute it — so it is always refreshed.
_notReporting = result.NotReportingInstances;
_rollup = AlarmSummaryService.ComputeRollup(_rows);
// While the cache is live, the live deltas own the row set: a poll whose fan-out
// started BEFORE a delta must not land after it and momentarily revert the alarm
// state (arch-review R2 N5). When not live (pre-seed / degraded stream / dead
// aggregator — see R2 N6), the poll remains the full-rebuild safety net.
if (!LiveAlarmCache.IsLive(siteId))
{
_rows = result.Alarms;
_rollup = AlarmSummaryService.ComputeRollup(_rows);
}
RecomputeVisibleRows();
}
catch
@@ -341,10 +359,11 @@
// aggregated alarm set changes. We rebuild _rows/_rollup/_visibleRows from the
// immutable live snapshot — but deliberately DO NOT touch _notReporting, since
// the alarm-only live cache can't compute it.
// • The 15s poll (RefreshAsync) still runs untouched: it is the authority for
// _notReporting and the safety net when the cache is not live (pre-seed or a
// degraded/failed stream — IsLive == false). When live, the poll simply
// re-affirms the same snapshot; the live path just makes updates arrive sooner.
// • The 15s poll (RefreshAsync) is the authority for _notReporting and the
// full-rebuild safety net ONLY while the cache is not live (pre-seed or a
// degraded/failed stream — IsLive == false); when live it deliberately leaves
// _rows to the delta path, so a slow fan-out can never revert a fresher live
// delta (arch-review R2 N5).
// • Both paths mutate shared state only via the Blazor dispatcher (the poll via
// its InvokeAsync callback, the live delta via OnLiveChanged's InvokeAsync), so
// they are serialized and never race. Each rebuild is an idempotent snapshot, so
@@ -363,15 +382,20 @@
_liveSubscription = null;
}
// N7: set BEFORE teardown so a live callback racing Dispose is dropped both
// before the InvokeAsync marshal and inside it (mirrors DebugView.razor).
private volatile bool _disposed;
// Raised on the aggregator's thread — marshal onto the circuit before touching state.
private void OnLiveAlarmsChanged(int siteId)
{
if (_disposed) return;
_ = InvokeAsync(() =>
{
// Drop stale callbacks for a site we've since navigated away from, and
// let the poll drive until the aggregator has actually seeded (so we never
// clobber a good poll snapshot with an empty pre-seed list).
if (_selectedSiteId != siteId || !LiveAlarmCache.IsLive(siteId))
if (_disposed || _selectedSiteId != siteId || !LiveAlarmCache.IsLive(siteId))
{
return;
}
@@ -506,6 +530,7 @@
public void Dispose()
{
_disposed = true;
StopTimer();
DisposeLiveSubscription();
}
@@ -46,6 +46,11 @@ public interface ISecuredWriteRepository
/// <param name="siteId">Site id filter; <c>null</c> matches every site.</param>
/// <param name="skip">Number of rows to skip (offset paging).</param>
/// <param name="take">Maximum number of rows to return.</param>
/// <param name="permittedSiteIds">When non-null, only rows whose <c>SiteId</c> (site
/// identifier) is in the set match; <c>null</c> matches every site. Applied IN ADDITION
/// to <paramref name="siteId"/>. Used by the ManagementActor to constrain an unfiltered
/// list to a site-scoped caller's permitted sites at the query level (arch-review R2 N3),
/// so paging and totals stay correct.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to a page of matching rows, newest submission first.</returns>
Task<IReadOnlyList<PendingSecuredWrite>> QueryAsync(
@@ -53,6 +58,7 @@ public interface ISecuredWriteRepository
string? siteId,
int skip,
int take,
IReadOnlyCollection<string>? permittedSiteIds = null,
CancellationToken ct = default);
/// <summary>
@@ -106,7 +112,16 @@ public interface ISecuredWriteRepository
/// </summary>
/// <param name="status">Status filter; <c>null</c> matches every status.</param>
/// <param name="siteId">Site id filter; <c>null</c> matches every site.</param>
/// <param name="permittedSiteIds">When non-null, only rows whose <c>SiteId</c> (site
/// identifier) is in the set match; <c>null</c> matches every site. Applied IN ADDITION
/// to <paramref name="siteId"/>. Used by the ManagementActor to constrain an unfiltered
/// list to a site-scoped caller's permitted sites at the query level (arch-review R2 N3),
/// so paging and totals stay correct.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to the count of matching rows.</returns>
Task<int> CountAsync(string? status, string? siteId, CancellationToken ct = default);
Task<int> CountAsync(
string? status,
string? siteId,
IReadOnlyCollection<string>? permittedSiteIds = null,
CancellationToken ct = default);
}
@@ -60,6 +60,6 @@ public interface ISiteAlarmLiveCache
/// cache is authoritative.
/// </summary>
/// <param name="siteId">The numeric site id.</param>
/// <returns><c>true</c> once the site's aggregator has seeded and published at least once.</returns>
/// <returns><c>true</c> while a <b>living</b> aggregator has seeded and published; reverts to false if the aggregator terminates (R2 N6) — a frozen snapshot is never reported as live.</returns>
bool IsLive(int siteId);
}
@@ -239,6 +239,14 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
TimeSpan.FromSeconds(60))); // stability window — former StabilityWindow static default
entry.Actor = system.ActorOf(props, $"site-alarm-aggregator-{entry.SiteId}-{Guid.NewGuid():N}");
var aggregator = entry.Actor;
// Deathwatch (arch-review R2 N6): if the aggregator terminates for any reason,
// liveness must reset — a dead feed must never keep IsLive == true, or the page
// grafts a frozen snapshot over fresh poll data for up to 15 s.
system.ActorOf(Props.Create(() => new AggregatorTerminationWatcher(
aggregator!, () => OnAggregatorTerminated(entry.SiteId, aggregator!))));
entry.StartRetryTimer?.Dispose();
entry.StartRetryTimer = null;
_logger.LogInformation("Started live alarm aggregator for site {SiteId} ({SiteIdentifier})",
@@ -421,6 +429,57 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
}
}
// ── Deathwatch (R2 N6) ──────────────────────────────────────────────────────
/// <summary>Watches one aggregator and fires a callback exactly once on Terminated.</summary>
private sealed class AggregatorTerminationWatcher : ReceiveActor
{
public AggregatorTerminationWatcher(IActorRef watched, Action onTerminated)
{
Context.Watch(watched);
Receive<Terminated>(_ =>
{
onTerminated();
Context.Stop(Self);
});
}
}
/// <summary>
/// Terminated hook (R2 N6). A DELIBERATE stop (linger fired, entry already removed
/// from _sites) is a no-op — the ReferenceEquals guard also ignores a stale watcher
/// firing after a newer aggregator replaced the dead one. A crash-death with live
/// viewers resets liveness (IsLive → false, snapshot cleared, so the page's poll is
/// authoritative again) and arms the existing bounded start-retry so the feature
/// self-heals, mirroring StartAggregatorAsync's transient-failure path.
/// </summary>
private void OnAggregatorTerminated(int siteId, IActorRef deadActor)
{
lock (_lock)
{
if (!_sites.TryGetValue(siteId, out var entry) || !ReferenceEquals(entry.Actor, deadActor))
return;
entry.Actor = null;
entry.HasPublished = false;
entry.Current = Empty;
if (entry.Subscribers.Count > 0 && !entry.Starting)
{
entry.Starting = true;
entry.StartRetryTimer?.Dispose();
entry.StartRetryTimer = new Timer(
_ => { _ = StartAggregatorAsync(entry); },
state: null,
dueTime: _options.LiveAlarmCacheReconcileInterval,
period: Timeout.InfiniteTimeSpan);
}
}
_logger.LogWarning(
"Live alarm aggregator for site {SiteId} terminated unexpectedly; liveness reset " +
"(page falls back to polling) and a restart is armed while viewers remain", siteId);
}
// ── Nested types ────────────────────────────────────────────────────────────
/// <summary>Per-site bookkeeping. All mutable fields are guarded by the service's <c>_lock</c>.</summary>
@@ -57,6 +57,7 @@ public class SecuredWriteRepository : ISecuredWriteRepository
string? siteId,
int skip,
int take,
IReadOnlyCollection<string>? permittedSiteIds = null,
CancellationToken ct = default)
{
IQueryable<PendingSecuredWrite> query = _context.Set<PendingSecuredWrite>()
@@ -72,6 +73,11 @@ public class SecuredWriteRepository : ISecuredWriteRepository
query = query.Where(p => p.SiteId == siteId);
}
if (permittedSiteIds is not null)
{
query = query.Where(p => permittedSiteIds.Contains(p.SiteId));
}
return await query
.OrderByDescending(p => p.SubmittedAtUtc)
.ThenByDescending(p => p.Id)
@@ -128,7 +134,11 @@ WHERE Id = {id}
}
/// <inheritdoc />
public async Task<int> CountAsync(string? status, string? siteId, CancellationToken ct = default)
public async Task<int> CountAsync(
string? status,
string? siteId,
IReadOnlyCollection<string>? permittedSiteIds = null,
CancellationToken ct = default)
{
IQueryable<PendingSecuredWrite> query = _context.Set<PendingSecuredWrite>()
.AsNoTracking();
@@ -143,6 +153,11 @@ WHERE Id = {id}
query = query.Where(p => p.SiteId == siteId);
}
if (permittedSiteIds is not null)
{
query = query.Where(p => permittedSiteIds.Contains(p.SiteId));
}
return await query.CountAsync(ct);
}
}
+12
View File
@@ -337,6 +337,18 @@ try
}
// Middleware pipeline
// Trusted-proxy forwarded headers (arch-review R2 N2): behind Traefik every client
// shares the proxy's IP, collapsing the LoginThrottle's {username}|{ip} keys onto
// one address (per-IP isolation gone + cheap username-lockout DoS). When enabled,
// X-Forwarded-For from the CONFIGURED proxies only is honored. MUST run first —
// everything downstream keys on Connection.RemoteIpAddress.
var forwardedOptions = builder.Configuration
.GetSection(ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSecurityOptions.SectionName)
.Get<ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSecurityOptions>() ?? new();
if (ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSetup.Build(forwardedOptions) is { } fho)
app.UseForwardedHeaders(fho);
app.UseWebSockets();
app.UseRouting();
app.UseAuthentication();
@@ -18,7 +18,7 @@ internal static class ConfigSecretScrubber
private static readonly string[] SecretNameFragments =
["password", "secret", "token", "apikey", "credential", "passphrase"];
private static bool IsSecretName(string propertyName)
internal static bool IsSecretName(string propertyName)
{
foreach (var fragment in SecretNameFragments)
{
@@ -127,6 +127,15 @@ internal static class ConfigSecretScrubber
/// <param name="incoming">The client-submitted config JSON, possibly containing sentinel values.</param>
/// <param name="stored">The previously stored config JSON to source real secret values from.</param>
/// <returns>The incoming JSON with sentinel values replaced by the corresponding stored values.</returns>
/// <remarks>
/// <b>Array limitation (arch-review R2 N9):</b> sentinel restoration inside arrays
/// pairs incoming[i] with stored[i] BY INDEX. A client that reorders an array of
/// credentialed objects while round-tripping sentinels grafts the wrong stored
/// secret into each slot — a silent misconfiguration (never a disclosure). Clients
/// must not reorder arrays that carry sentinels (the CLI round-trip does not);
/// key-based matching is a possible future refinement if a config type ever nests
/// credentialed arrays.
/// </remarks>
public static string? MergeSentinels(string? incoming, string? stored)
{
if (string.IsNullOrEmpty(incoming))
@@ -1,4 +1,3 @@
using System.Text;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -6,7 +5,6 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
@@ -75,73 +73,35 @@ public class DebugStreamHub : Hub
return;
}
// Dev/test: when login is disabled, auto-authenticate the debug stream as the configured
// dev user (ALL roles incl. Deployer, system-wide) — mirroring the cookie/management
// bypass so the CLI debug stream is usable in a login-disabled (e.g. no-LDAP) deployment.
// See ManagementAuthenticator.
var bypassUser = ManagementAuthenticator.TryDisableLoginUser(httpContext);
if (bypassUser is not null)
// Authenticate via the SAME shared Basic→LDAP flow as POST /management and the
// audit REST (arch-review R2 N1): ManagementAuthenticator applies the DisableLogin
// bypass, the LoginThrottle lockout check BEFORE the bind, and RecordFailure/
// RecordSuccess bookkeeping — so hub connection attempts can no longer be used as
// an unthrottled LDAP-bind oracle, and hub failures feed the shared lockout.
var outcome = await ManagementAuthenticator.AuthenticateAsync(httpContext);
if (outcome.User is null)
{
Context.Items[RolesKey] = bypassUser.Roles;
Context.Items[PermittedSiteIdsKey] = bypassUser.PermittedSiteIds;
_logger.LogInformation(
"DebugStreamHub connection established for {Username} (login disabled)", bypassUser.Username);
await base.OnConnectedAsync();
return;
}
// Extract Basic Auth credentials
var authHeader = httpContext.Request.Headers.Authorization.ToString();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("DebugStreamHub connection rejected: missing Basic Auth header");
_logger.LogWarning("DebugStreamHub connection rejected: authentication failed or throttled");
Context.Abort();
return;
}
var user = outcome.User;
string username, password;
try
// Role check — Deployer role required (unchanged policy).
if (!user.Roles.Contains(Roles.Deployer))
{
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader["Basic ".Length..]));
var colon = decoded.IndexOf(':');
if (colon < 0) throw new FormatException();
username = decoded[..colon];
password = decoded[(colon + 1)..];
}
catch
{
_logger.LogWarning("DebugStreamHub connection rejected: malformed Basic Auth");
Context.Abort();
return;
}
// LDAP authentication
var ldapAuth = httpContext.RequestServices.GetRequiredService<ILdapAuthService>();
var authResult = await ldapAuth.AuthenticateAsync(username, password, Context.ConnectionAborted);
if (!authResult.Succeeded)
{
_logger.LogWarning("DebugStreamHub connection rejected: LDAP auth failed for {Username}", username);
Context.Abort();
return;
}
// Role check — Deployer role required
var roleMapper = httpContext.RequestServices.GetRequiredService<RoleMapper>();
var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, Context.ConnectionAborted);
if (!mappingResult.Roles.Contains(Roles.Deployer))
{
_logger.LogWarning("DebugStreamHub connection rejected: {Username} lacks Deployer role", username);
_logger.LogWarning("DebugStreamHub connection rejected: {Username} lacks Deployer role", user.Username);
Context.Abort();
return;
}
// Persist the resolved identity on the connection so per-instance site-scope
// enforcement can be applied to SubscribeInstance calls.
Context.Items[RolesKey] = mappingResult.Roles.ToArray();
Context.Items[PermittedSiteIdsKey] = mappingResult.PermittedSiteIds.ToArray();
// enforcement can be applied to SubscribeInstance calls. PermittedSiteIds is the
// authenticator's normalized form (empty == system-wide), same as every other surface.
Context.Items[RolesKey] = user.Roles;
Context.Items[PermittedSiteIdsKey] = user.PermittedSiteIds;
_logger.LogInformation("DebugStreamHub connection established for {Username}", username);
_logger.LogInformation("DebugStreamHub connection established for {Username}", user.Username);
await base.OnConnectedAsync();
}
@@ -467,7 +467,7 @@ public class ManagementActor : ReceiveActor
SubmitSecuredWriteCommand cmd => await HandleSubmitSecuredWrite(sp, cmd, user),
ApproveSecuredWriteCommand cmd => await HandleApproveSecuredWrite(sp, cmd, user),
RejectSecuredWriteCommand cmd => await HandleRejectSecuredWrite(sp, cmd, user),
ListSecuredWritesCommand cmd => await HandleListSecuredWrites(sp, cmd),
ListSecuredWritesCommand cmd => await HandleListSecuredWrites(sp, cmd, user),
// Transport bundle operations
ExportBundleCommand cmd => await HandleExportBundle(sp, cmd, user.Username),
@@ -1247,6 +1247,11 @@ public class ManagementActor : ReceiveActor
var site = await siteRepo.GetSiteByIdentifierAsync(cmd.SiteId)
?? throw new ManagementCommandException($"Site '{cmd.SiteId}' not found.");
// Site scope (arch-review R2 N3): an LDAP-mapping-scoped Operator may only
// submit device writes to their permitted sites — same rule C2 applies to
// DeployArtifacts, on a higher-consequence path. Checked before the row exists.
EnforceSiteScope(user, site.Id);
var connections = await siteRepo.GetDataConnectionsBySiteIdAsync(site.Id);
var conn = connections.FirstOrDefault(c =>
string.Equals(c.Name, cmd.ConnectionName, StringComparison.Ordinal))
@@ -1293,6 +1298,11 @@ public class ManagementActor : ReceiveActor
throw new ManagementCommandException(
$"Secured write {cmd.Id} is '{row.Status}', not Pending; it cannot be approved.");
// Site scope (arch-review R2 N3): the approving Verifier must be permitted on
// the row's target site — checked BEFORE the TTL/self-approval/CAS chain so an
// out-of-scope verifier can neither consume the Pending transition nor relay.
await EnforceSiteScopeForIdentifier(sp, user, row.SiteId);
// Server-side TTL: a Pending write older than the configured age is transitioned
// to Expired and can never be approved — the stale setpoint is never relayed
// (arch-review S2). Checked BEFORE the self-approval/CAS so an overdue row never
@@ -1450,6 +1460,10 @@ public class ManagementActor : ReceiveActor
throw new ManagementCommandException(
$"Secured write {cmd.Id} is '{entity.Status}', not Pending; it cannot be rejected.");
// Site scope (arch-review R2 N3): the rejecting Verifier must be permitted on
// the row's target site — checked BEFORE the TTL/self-approval chain.
await EnforceSiteScopeForIdentifier(sp, user, entity.SiteId);
// Server-side TTL: an overdue Pending write expires here too, so a reject on a
// stale row deterministically reports the expiry rather than recording a
// human decision (arch-review S2).
@@ -1479,16 +1493,36 @@ public class ManagementActor : ReceiveActor
}
private static async Task<object?> HandleListSecuredWrites(
IServiceProvider sp, ListSecuredWritesCommand cmd)
IServiceProvider sp, ListSecuredWritesCommand cmd, AuthenticatedUser user)
{
// Site scoping (arch-review R2 N3): an explicit SiteId filter is scope-checked
// like every other identifier-keyed command; an UNFILTERED list from a
// site-scoped non-admin caller is constrained to their permitted sites at the
// query level (rows AND totalCount both scoped — see ISecuredWriteRepository).
IReadOnlyCollection<string>? permittedIdentifiers = null;
if (cmd.SiteId is not null)
{
await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteId);
}
else if (user.PermittedSiteIds.Length > 0
&& !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
{
var siteRepo = sp.GetRequiredService<ISiteRepository>();
var permitted = new HashSet<string>(user.PermittedSiteIds);
permittedIdentifiers = (await siteRepo.GetAllSitesAsync())
.Where(s => permitted.Contains(s.Id.ToString()))
.Select(s => s.SiteIdentifier)
.ToList();
}
var repo = sp.GetRequiredService<ISecuredWriteRepository>();
// Offset paging (arch-review P3): clamp untrusted paging inputs so a crafted
// command can neither request a negative offset nor an unbounded page.
var take = Math.Clamp(cmd.Take, 1, 500);
var skip = Math.Max(0, cmd.Skip);
var rows = await repo.QueryAsync(cmd.Status, cmd.SiteId, skip, take);
var totalCount = await repo.CountAsync(cmd.Status, cmd.SiteId);
var rows = await repo.QueryAsync(cmd.Status, cmd.SiteId, skip, take, permittedIdentifiers);
var totalCount = await repo.CountAsync(cmd.Status, cmd.SiteId, permittedIdentifiers);
// Opportunistic expiry sweep (arch-review S2): every time the list is read, walk
// the page and expire any overdue Pending row. TryExpireIfStaleAsync self-filters
@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using System.Net;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Config shape for trusted-proxy forwarded-header handling
/// (<c>ScadaBridge:Security:ForwardedHeaders</c>). Disabled by default: a deployment
/// with no reverse proxy must NOT honor X-Forwarded-For (a client could spoof its
/// throttle key). The documented Traefik topologies enable it with the docker
/// network range so LoginThrottle keys on the REAL client IP (arch-review R2 N2).
/// </summary>
public sealed class ForwardedHeadersSecurityOptions
{
/// <summary>Configuration section binding this options class.</summary>
public const string SectionName = "ScadaBridge:Security:ForwardedHeaders";
/// <summary>Whether X-Forwarded-For from trusted proxies is honored.</summary>
public bool Enabled { get; set; }
/// <summary>Exact proxy IPs allowed to supply X-Forwarded-For.</summary>
public string[] KnownProxies { get; set; } = [];
/// <summary>CIDR networks allowed to supply X-Forwarded-For (e.g. "172.16.0.0/12").</summary>
public string[] KnownNetworks { get; set; } = [];
}
/// <summary>
/// Builds the <see cref="ForwardedHeadersOptions"/> for <c>app.UseForwardedHeaders</c>.
/// Pure and fail-fast: malformed entries throw at startup rather than silently
/// trusting nothing/everything. Defaults (loopback) are CLEARED — only the
/// explicitly configured proxies/networks are trusted, and ForwardLimit=1 means
/// only the entry the trusted proxy itself appended is honored.
/// </summary>
public static class ForwardedHeadersSetup
{
/// <summary>
/// Builds the forwarded-headers options, or <see langword="null"/> when disabled.
/// </summary>
/// <param name="options">The bound <see cref="ForwardedHeadersSecurityOptions"/>.</param>
/// <returns>The configured <see cref="ForwardedHeadersOptions"/>, or <see langword="null"/> when disabled.</returns>
public static ForwardedHeadersOptions? Build(ForwardedHeadersSecurityOptions options)
{
if (!options.Enabled) return null;
var built = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
ForwardLimit = 1,
};
built.KnownProxies.Clear();
// KnownIPNetworks (System.Net.IPNetwork) is the net10 successor to the now-deprecated
// KnownNetworks; UseForwardedHeaders reads it identically.
built.KnownIPNetworks.Clear();
foreach (var proxy in options.KnownProxies)
built.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (var network in options.KnownNetworks)
{
var parts = network.Split('/');
built.KnownIPNetworks.Add(new(IPAddress.Parse(parts[0]), int.Parse(parts[1])));
}
return built;
}
}
@@ -41,6 +41,18 @@ public sealed class LoginThrottle
/// </summary>
public const int MaxTrackedKeys = 10_000;
/// <summary>Amortization cadence: a full prune sweep runs every Nth failure write
/// (plus immediately whenever the map exceeds <see cref="MaxTrackedKeys"/>), so a
/// spray does not pay an O(n) scan — and a possible full sort — on every failed
/// request (arch-review R2 N8). Lockout SEMANTICS are unaffected: IsLockedOut reads
/// window/lockout expiry directly and never depends on pruning.</summary>
internal const int PruneEveryNFailures = 64;
/// <summary>Diagnostics/test accessor: number of currently tracked keys.</summary>
internal int TrackedKeyCount => _entries.Count;
private int _failureWrites;
private readonly TimeProvider _clock;
private readonly IOptions<SecurityOptions> _options;
@@ -118,7 +130,13 @@ public sealed class LoginThrottle
return Arm(existing with { Count = existing.Count + 1 }, opts, now, lockout);
});
Prune(now);
// Amortized prune (R2 N8): every Nth failure, or immediately when over the hard
// cap — the cap is the memory-safety invariant and must never wait for the cadence.
if (Interlocked.Increment(ref _failureWrites) % PruneEveryNFailures == 0
|| _entries.Count > MaxTrackedKeys)
{
Prune(now);
}
}
/// <summary>