Files
wwtools/mbproxy/src/Mbproxy/Configuration/ReloadValidator.cs
T
Joseph Doherty 0868613890 mbproxy: add keepalive / connection monitoring
The DL205/DL260 ECOM emits no TCP keepalives, so an idle backend socket
can be silently dropped by a middlebox (switch, firewall, NAT) after
2-5 minutes. Enable OS SO_KEEPALIVE on backend and accepted upstream
sockets, and drive a periodic synthetic FC03 heartbeat on each idle
backend socket so a dead path is detected before a real client request
hits it. Controlled by Connection.Keepalive (ON by default).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:40:54 -04:00

180 lines
9.0 KiB
C#

using Mbproxy.Bcd;
using Mbproxy.Options;
namespace Mbproxy.Configuration;
/// <summary>
/// Validates an incoming <see cref="MbproxyOptions"/> snapshot before any state mutation
/// is attempted. All cross-PLC checks (uniqueness, port collisions) live here.
/// Per-PLC tag-list well-formedness is delegated to <see cref="BcdTagMapBuilder.Build"/>.
///
/// <para>Usage:</para>
/// <code>
/// if (!ReloadValidator.Validate(next, out var errors))
/// // log errors and abort reload
/// </code>
/// </summary>
internal static class ReloadValidator
{
/// <summary>
/// Validates <paramref name="next"/>. Returns <c>true</c> when valid.
///
/// <para>Checks performed (in order):</para>
/// <list type="number">
/// <item>All PLC names are non-empty and unique (ordinal comparison).</item>
/// <item>All <c>ListenPort</c> values are in [1, 65535] and unique.</item>
/// <item><c>AdminPort</c> is in [1, 65535] and does not collide with any <c>ListenPort</c>.</item>
/// <item>For each PLC, <see cref="BcdTagMapBuilder.Build"/> reports no errors.</item>
/// </list>
/// </summary>
public static bool Validate(MbproxyOptions next, out IReadOnlyList<string> errors)
{
var errs = new List<string>();
// ── 1. PLC name uniqueness ────────────────────────────────────────────
var seenNames = new HashSet<string>(StringComparer.Ordinal);
for (int i = 0; i < next.Plcs.Count; i++)
{
var plc = next.Plcs[i];
if (string.IsNullOrWhiteSpace(plc.Name))
{
errs.Add($"Plcs[{i}]: Name must be non-empty.");
}
else if (!seenNames.Add(plc.Name))
{
errs.Add($"Plcs[{i}]: Duplicate PLC name '{plc.Name}'.");
}
}
// ── 2. ListenPort uniqueness and range ────────────────────────────────
var seenPorts = new Dictionary<int, string>(next.Plcs.Count); // port → PLC name
foreach (var plc in next.Plcs)
{
if (plc.ListenPort is < 1 or > 65535)
{
errs.Add($"Plc '{plc.Name}': ListenPort {plc.ListenPort} is out of range [1, 65535].");
}
else if (!seenPorts.TryAdd(plc.ListenPort, plc.Name))
{
errs.Add($"Plc '{plc.Name}': Duplicate ListenPort {plc.ListenPort} " +
$"(already used by '{seenPorts[plc.ListenPort]}').");
}
}
// ── 3. AdminPort range and collision ─────────────────────────────────
int adminPort = next.AdminPort;
if (adminPort is < 1 or > 65535)
{
errs.Add($"AdminPort {adminPort} is out of range [1, 65535].");
}
else if (seenPorts.TryGetValue(adminPort, out string? clashPlc))
{
errs.Add($"AdminPort {adminPort} collides with ListenPort of PLC '{clashPlc}'.");
}
// ── 4. Per-PLC tag-map build ──────────────────────────────────────────
// BcdTagMapBuilder.Build is the single source of truth for tag-list
// well-formedness; we must not duplicate its validation logic here.
// Also re-check the RESOLVED per-tag CacheTtlMs against AllowLongTtl. The raw-
// input check at section 5 covers explicit per-tag and per-PLC-default values,
// but defensively re-validating the post-fold values catches any future fold
// logic that produces a value above the gate.
bool allowLongTtlForResolved = next.Cache.AllowLongTtl;
foreach (var plc in next.Plcs)
{
var result = BcdTagMapBuilder.Build(next.BcdTags, plc.BcdTags, plc.DefaultCacheTtlMs);
foreach (var err in result.Errors)
errs.Add($"Plc '{plc.Name}': BCD tag map error ({err.Kind}): {err.Message}");
if (!allowLongTtlForResolved)
{
foreach (var tag in result.Map.All)
{
if (tag.CacheTtlMs > 60_000)
errs.Add(
$"Plc '{plc.Name}': resolved CacheTtlMs for Address {tag.Address} is " +
$"{tag.CacheTtlMs} ms (exceeds 60_000) without Cache.AllowLongTtl=true.");
}
}
}
// ── 5. Cache TTL bounds ───────────────────────────────────────────────
// The MbproxyOptionsValidator catches these at schema time too, but ReloadValidator
// is the gate that the hot-reload path consults directly so re-checking here keeps
// both paths internally consistent (and the validator runs against tag-map-resolved
// BcdTag.CacheTtlMs values too).
bool allowLongTtl = next.Cache.AllowLongTtl;
foreach (var tag in next.BcdTags.Global)
{
CheckTtl(errs, $"BcdTags.Global Address {tag.Address}", tag.CacheTtlMs, allowLongTtl);
}
foreach (var plc in next.Plcs)
{
if (plc.DefaultCacheTtlMs > 60_000 && !allowLongTtl)
errs.Add(
$"Plc '{plc.Name}': DefaultCacheTtlMs={plc.DefaultCacheTtlMs} exceeds 60_000 ms " +
"without Cache.AllowLongTtl=true.");
else if (plc.DefaultCacheTtlMs < 0)
errs.Add($"Plc '{plc.Name}': DefaultCacheTtlMs must be >= 0; got {plc.DefaultCacheTtlMs}.");
if (plc.BcdTags?.Add is { } addList)
{
foreach (var tag in addList)
CheckTtl(errs, $"Plc '{plc.Name}' BcdTags.Add Address {tag.Address}",
tag.CacheTtlMs, allowLongTtl);
}
}
if (next.Cache.MaxEntriesPerPlc < 0)
errs.Add($"Cache.MaxEntriesPerPlc must be >= 0; got {next.Cache.MaxEntriesPerPlc}.");
if (next.Cache.EvictionIntervalMs < 0)
errs.Add($"Cache.EvictionIntervalMs must be >= 0; got {next.Cache.EvictionIntervalMs}.");
// Connection timeouts must be > 0. A reload that sets any of these to 0 or
// negative would break the runtime; reject the reload as a whole.
if (next.Connection.BackendConnectTimeoutMs <= 0)
errs.Add(
$"Connection.BackendConnectTimeoutMs must be > 0; got {next.Connection.BackendConnectTimeoutMs}.");
if (next.Connection.BackendRequestTimeoutMs <= 0)
errs.Add(
$"Connection.BackendRequestTimeoutMs must be > 0; got {next.Connection.BackendRequestTimeoutMs}.");
if (next.Connection.GracefulShutdownTimeoutMs <= 0)
errs.Add(
$"Connection.GracefulShutdownTimeoutMs must be > 0; got {next.Connection.GracefulShutdownTimeoutMs}.");
// ── 6. Keepalive section ──────────────────────────────────────────────
// Schema bounds are also checked in MbproxyOptionsValidator; re-checking here keeps
// the hot-reload gate self-contained. The cross-field rule (heartbeat interval must
// sit above the request timeout, or it would fire continuously) lives only here.
var ka = next.Connection.Keepalive;
if (ka.TcpIdleTimeMs <= 0)
errs.Add($"Connection.Keepalive.TcpIdleTimeMs must be > 0; got {ka.TcpIdleTimeMs}.");
if (ka.TcpProbeIntervalMs <= 0)
errs.Add($"Connection.Keepalive.TcpProbeIntervalMs must be > 0; got {ka.TcpProbeIntervalMs}.");
if (ka.TcpProbeCount <= 0)
errs.Add($"Connection.Keepalive.TcpProbeCount must be > 0; got {ka.TcpProbeCount}.");
if (ka.BackendHeartbeatProbeAddress is < 0 or > 65535)
errs.Add(
$"Connection.Keepalive.BackendHeartbeatProbeAddress must be in [0, 65535]; " +
$"got {ka.BackendHeartbeatProbeAddress}.");
if (ka.BackendHeartbeatIdleMs <= next.Connection.BackendRequestTimeoutMs)
errs.Add(
$"Connection.Keepalive.BackendHeartbeatIdleMs ({ka.BackendHeartbeatIdleMs}) must be greater " +
$"than Connection.BackendRequestTimeoutMs ({next.Connection.BackendRequestTimeoutMs}); " +
"a heartbeat interval at or below the request timeout would fire continuously.");
errors = errs;
return errs.Count == 0;
}
private static void CheckTtl(List<string> errs, string context, int? ttl, bool allowLongTtl)
{
if (ttl is null) return;
int v = ttl.Value;
if (v < 0)
errs.Add($"{context}: CacheTtlMs must be >= 0; got {v}.");
else if (v > 60_000 && !allowLongTtl)
errs.Add(
$"{context}: CacheTtlMs={v} exceeds 60_000 ms without Cache.AllowLongTtl=true.");
}
}