b222362ce0
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>
163 lines
7.8 KiB
C#
163 lines
7.8 KiB
C#
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 1–60000 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.");
|
||
}
|
||
}
|