using Microsoft.Extensions.Options; namespace Mbproxy.Options; public sealed class MbproxyOptions { public BcdTagListOptions BcdTags { get; init; } = new(); public IReadOnlyList Plcs { get; init; } = []; public int AdminPort { get; init; } = 8080; /// /// 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. /// public int AdminPushIntervalMs { get; init; } = 1000; public ConnectionOptions Connection { get; init; } = new(); public ResilienceOptions Resilience { get; init; } = new(); /// /// Service-wide response-cache settings. The cache is opt-in per-tag /// (); this section configures the safety /// knobs that gate / bound the cache. /// public CacheOptions Cache { get; init; } = new(); } /// /// 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. /// public sealed class CacheOptions { /// /// Gate for any greater than 60_000 ms. /// Defaults to false so accidentally-stale-for-an-hour deployments are caught /// at reload validation. Set to true to explicitly allow long TTLs. /// public bool AllowLongTtl { get; init; } = false; /// /// 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. /// public int MaxEntriesPerPlc { get; init; } = 1000; /// /// Background eviction loop tick in milliseconds. Each tick scans the cache and /// removes entries past their ExpiresAtUtc. Defaults to 5000 ms; values below /// 100 ms are clamped at 100 to avoid pathologically tight loops. /// public int EvictionIntervalMs { get; init; } = 5000; } /// /// Schema-level validation for . Business-rule validation /// (duplicate addresses, port conflicts) is delegated to . /// public sealed class MbproxyOptionsValidator : IValidateOptions { public ValidateOptionsResult Validate(string? name, MbproxyOptions options) { var errors = new List(); 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 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."); } }