Files
wwtools/mbproxy/src/Mbproxy/Options/MbproxyOptions.cs
T
Joseph Doherty b222362ce0 mbproxy: remediate the 2026-05-16 code-review findings
Fixes every finding from the codereviews/2026-05-16 multi-agent review
(2 Critical, 20 Major, 38 Minor) and adds that review to the repo.

Highlights: dashboard XSS escape; response cache invalidated on the
write request (not just the response); ReloadValidator now runs at
startup so port collisions / duplicate names / malformed Resilience
profiles fail fast; AdminPort 0 genuinely disables the admin endpoint;
PlcListener accept-loop faults propagate to the supervisor's faulted
path; reconciler Restart builds before removing; Resilience pipelines
are restart-only from a frozen snapshot; multiplexer connect-race leak,
watchdog party-list snapshot, backend-response and FC16 framing
validation; frontend reconnect retry and util.js load guard; plus the
log-event/doc drift sweep and test-port hygiene.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:08:06 -04:00

163 lines
7.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Extensions.Options;
namespace Mbproxy.Options;
public sealed class MbproxyOptions
{
public BcdTagListOptions BcdTags { get; init; } = new();
public IReadOnlyList<PlcOptions> Plcs { get; init; } = [];
public int AdminPort { get; init; } = 8080;
/// <summary>
/// Server-push cadence (milliseconds) for the admin dashboard's SignalR feed.
/// Every interval the admin endpoint builds a status snapshot and pushes it to
/// connected dashboard / detail-page clients. Must be in the range 160000 ms
/// (a value past a minute makes the "live" feed non-live). Defaults to 1000.
/// </summary>
public int AdminPushIntervalMs { get; init; } = 1000;
public ConnectionOptions Connection { get; init; } = new();
public ResilienceOptions Resilience { get; init; } = new();
/// <summary>
/// Service-wide response-cache settings. The cache is opt-in per-tag
/// (<see cref="BcdTagOptions.CacheTtlMs"/>); this section configures the safety
/// knobs that gate / bound the cache.
/// </summary>
public CacheOptions Cache { get; init; } = new();
}
/// <summary>
/// Service-wide response-cache knobs. The cache is OFF by default for every tag;
/// this section governs the limits when an operator opts a tag in.
/// </summary>
public sealed class CacheOptions
{
/// <summary>
/// Gate for any <see cref="BcdTagOptions.CacheTtlMs"/> greater than 60_000 ms.
/// Defaults to <c>false</c> so accidentally-stale-for-an-hour deployments are caught
/// at reload validation. Set to <c>true</c> to explicitly allow long TTLs.
/// </summary>
public bool AllowLongTtl { get; init; } = false;
/// <summary>
/// LRU cap on the number of entries per-PLC. Past this cap, the next insert evicts
/// the least-recently-used entry. Defaults to 1000 — comfortable for a 54-PLC fleet
/// with short TTLs.
/// </summary>
public int MaxEntriesPerPlc { get; init; } = 1000;
/// <summary>
/// Background eviction loop tick in milliseconds. Each tick scans the cache and
/// removes entries past their <c>ExpiresAtUtc</c>. Defaults to 5000 ms; values below
/// 100 ms are clamped at 100 to avoid pathologically tight loops.
/// </summary>
public int EvictionIntervalMs { get; init; } = 5000;
}
/// <summary>
/// Schema-level validation for <see cref="MbproxyOptions"/>. Business-rule validation
/// (duplicate addresses, port conflicts) is delegated to <see cref="Configuration.ReloadValidator"/>.
/// </summary>
public sealed class MbproxyOptionsValidator : IValidateOptions<MbproxyOptions>
{
public ValidateOptionsResult Validate(string? name, MbproxyOptions options)
{
var errors = new List<string>();
bool allowLongTtl = options.Cache.AllowLongTtl;
foreach (var tag in options.BcdTags.Global)
{
if (tag.Width != 16 && tag.Width != 32)
errors.Add($"BcdTags.Global: Address {tag.Address} has invalid Width {tag.Width}; must be 16 or 32.");
ValidateCacheTtl(errors, $"BcdTags.Global Address {tag.Address}", tag.CacheTtlMs, allowLongTtl);
}
for (int i = 0; i < options.Plcs.Count; i++)
{
var plc = options.Plcs[i];
// Per-PLC default TTL bounds.
if (plc.DefaultCacheTtlMs < 0)
errors.Add($"Plcs[{i}] ({plc.Name}): DefaultCacheTtlMs must be >= 0.");
else if (plc.DefaultCacheTtlMs > 60_000 && !allowLongTtl)
errors.Add(
$"Plcs[{i}] ({plc.Name}): DefaultCacheTtlMs={plc.DefaultCacheTtlMs} exceeds the 60_000 ms safety cap; " +
$"set Cache.AllowLongTtl=true to opt in.");
if (plc.BcdTags is { } overrides)
{
foreach (var tag in overrides.Add)
{
if (tag.Width != 16 && tag.Width != 32)
errors.Add($"Plcs[{i}] ({plc.Name}): BcdTags.Add Address {tag.Address} has invalid Width {tag.Width}; must be 16 or 32.");
ValidateCacheTtl(errors, $"Plcs[{i}] ({plc.Name}) BcdTags.Add Address {tag.Address}",
tag.CacheTtlMs, allowLongTtl);
}
}
}
// Cache section ranges. MaxEntriesPerPlc has a hard ceiling because the cache's
// LRU eviction is an O(n) scan under a lock — a fat-fingered seven-figure value
// would stall the backend reader on every cache-miss store.
if (options.Cache.MaxEntriesPerPlc < 0)
errors.Add($"Cache.MaxEntriesPerPlc must be >= 0; got {options.Cache.MaxEntriesPerPlc}.");
else if (options.Cache.MaxEntriesPerPlc > 100_000)
errors.Add(
$"Cache.MaxEntriesPerPlc must be <= 100000; got {options.Cache.MaxEntriesPerPlc}.");
if (options.Cache.EvictionIntervalMs < 0)
errors.Add($"Cache.EvictionIntervalMs must be >= 0; got {options.Cache.EvictionIntervalMs}.");
// Connection timeouts must be strictly positive. A 0 or negative value produces
// a CancelAfter(0) that fires immediately and breaks every backend connect/request.
if (options.Connection.BackendConnectTimeoutMs <= 0)
errors.Add(
$"Connection.BackendConnectTimeoutMs must be > 0; got {options.Connection.BackendConnectTimeoutMs}.");
if (options.Connection.BackendRequestTimeoutMs <= 0)
errors.Add(
$"Connection.BackendRequestTimeoutMs must be > 0; got {options.Connection.BackendRequestTimeoutMs}.");
if (options.Connection.GracefulShutdownTimeoutMs <= 0)
errors.Add(
$"Connection.GracefulShutdownTimeoutMs must be > 0; got {options.Connection.GracefulShutdownTimeoutMs}.");
// AdminPushIntervalMs has a soft upper bound: a value past a minute makes the
// dashboard's "live" feed effectively non-live, which is almost always a typo
// (e.g. a seconds value pasted as milliseconds) rather than an intent.
if (options.AdminPushIntervalMs <= 0 || options.AdminPushIntervalMs > 60_000)
errors.Add(
$"AdminPushIntervalMs must be between 1 and 60000 ms; got {options.AdminPushIntervalMs}.");
// Keepalive section ranges. Cross-field rules (heartbeat interval vs request
// timeout) are enforced in ReloadValidator.
var ka = options.Connection.Keepalive;
if (ka.TcpIdleTimeMs <= 0)
errors.Add($"Connection.Keepalive.TcpIdleTimeMs must be > 0; got {ka.TcpIdleTimeMs}.");
if (ka.TcpProbeIntervalMs <= 0)
errors.Add($"Connection.Keepalive.TcpProbeIntervalMs must be > 0; got {ka.TcpProbeIntervalMs}.");
if (ka.TcpProbeCount <= 0)
errors.Add($"Connection.Keepalive.TcpProbeCount must be > 0; got {ka.TcpProbeCount}.");
if (ka.BackendHeartbeatIdleMs <= 0)
errors.Add($"Connection.Keepalive.BackendHeartbeatIdleMs must be > 0; got {ka.BackendHeartbeatIdleMs}.");
if (ka.BackendHeartbeatProbeAddress is < 0 or > 65535)
errors.Add(
$"Connection.Keepalive.BackendHeartbeatProbeAddress must be in [0, 65535]; " +
$"got {ka.BackendHeartbeatProbeAddress}.");
return errors.Count > 0
? ValidateOptionsResult.Fail(errors)
: ValidateOptionsResult.Success;
}
private static void ValidateCacheTtl(List<string> errors, string context, int? ttlMs, bool allowLongTtl)
{
if (ttlMs is null) return;
int value = ttlMs.Value;
if (value < 0)
errors.Add($"{context}: CacheTtlMs must be >= 0; got {value}.");
else if (value > 60_000 && !allowLongTtl)
errors.Add(
$"{context}: CacheTtlMs={value} exceeds the 60_000 ms safety cap; " +
$"set Cache.AllowLongTtl=true to opt in.");
}
}