0868613890
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>
180 lines
9.0 KiB
C#
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.");
|
|
}
|
|
}
|