using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Configuration; using ZB.MOM.WW.MxGateway.Contracts; namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed class GatewayOptionsValidator : OptionsValidatorBase { private const int MinimumMaxMessageBytes = 1024; private const int MaximumMaxMessageBytes = 256 * 1024 * 1024; /// /// Validates gateway configuration options. /// /// The accumulator to record failures on. /// Gateway options to validate. protected override void Validate(ValidationBuilder builder, GatewayOptions options) { ValidateAuthentication(options.Authentication, builder); ValidateLdap(options.Ldap, builder); ValidateWorker(options.Worker, builder); ValidateSessions(options.Sessions, builder); ValidateEvents(options.Events, builder); ValidateDashboard(options.Dashboard, builder); ValidateProtocol(options.Protocol, builder); ValidateAlarms(options.Alarms, builder); ValidateTls(options.Tls, builder); } private static void ValidateAuthentication(AuthenticationOptions options, ValidationBuilder builder) { if (!Enum.IsDefined(options.Mode)) { builder.Add("MxGateway:Authentication:Mode must be a supported authentication mode."); return; } if (options.Mode == AuthenticationMode.ApiKey) { AddIfBlank( options.SqlitePath, "MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.", builder); AddIfInvalidPath( options.SqlitePath, "MxGateway:Authentication:SqlitePath must be a valid filesystem path.", builder); AddIfBlank( options.PepperSecretName, "MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.", builder); } } private static void ValidateLdap(LdapOptions options, ValidationBuilder builder) { if (!options.Enabled) { return; } AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", builder); AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", builder); AddIfBlank( options.ServiceAccountDn, "MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.", builder); AddIfBlank( options.ServiceAccountPassword, "MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.", builder); AddIfBlank( options.UserNameAttribute, "MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.", builder); AddIfBlank( options.DisplayNameAttribute, "MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.", builder); AddIfBlank( options.GroupAttribute, "MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.", builder); builder.Port(options.Port, "MxGateway:Ldap:Port"); if (options.Transport == LdapTransport.None && !options.AllowInsecure) { builder.Add("MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext)."); } } private static void ValidateWorker(WorkerOptions options, ValidationBuilder builder) { AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", builder); AddIfInvalidPath( options.ExecutablePath, "MxGateway:Worker:ExecutablePath must be a valid filesystem path.", builder); if (!string.IsNullOrWhiteSpace(options.ExecutablePath) && !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase)) { builder.Add("MxGateway:Worker:ExecutablePath must point to a .exe file."); } if (!string.IsNullOrWhiteSpace(options.WorkingDirectory)) { AddIfInvalidPath( options.WorkingDirectory, "MxGateway:Worker:WorkingDirectory must be a valid filesystem path.", builder); } if (!Enum.IsDefined(options.RequiredArchitecture)) { builder.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture."); } AddIfNotPositive( options.StartupTimeoutSeconds, "MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.", builder); AddIfNotPositive( options.StartupProbeRetryAttempts, "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.", builder); AddIfNotPositive( options.StartupProbeRetryDelayMilliseconds, "MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.", builder); AddIfNotPositive( options.PipeConnectAttemptTimeoutMilliseconds, "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.", builder); AddIfNotPositive( options.ShutdownTimeoutSeconds, "MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.", builder); AddIfNotPositive( options.HeartbeatIntervalSeconds, "MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.", builder); AddIfNotPositive( options.HeartbeatGraceSeconds, "MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.", builder); if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds) { builder.Add( "MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds."); } if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes) { builder.Add( $"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}."); } } private static void ValidateSessions(SessionOptions options, ValidationBuilder builder) { AddIfNotPositive( options.DefaultCommandTimeoutSeconds, "MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.", builder); AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", builder); AddIfNotPositive( options.MaxPendingCommandsPerSession, "MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.", builder); AddIfNotPositive( options.DefaultLeaseSeconds, "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.", builder); AddIfNotPositive( options.LeaseSweepIntervalSeconds, "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.", builder); AddIfNotPositive( options.MaxEventSubscribersPerSession, "MxGateway:Sessions:MaxEventSubscribersPerSession must be greater than zero.", builder); // NOTE: We intentionally do NOT reject !AllowMultipleEventSubscribers && // MaxEventSubscribersPerSession > 1 as a hard validation error here. The default // SessionOptions ships with AllowMultipleEventSubscribers=false and // MaxEventSubscribersPerSession=8; making those defaults a validation failure would // break every deployment that has not explicitly set the cap. The cap is simply // ignored in single-subscriber mode (AttachEventSubscriber derives effectiveCap=1), // so the only practical consequence of the apparent inconsistency is a dead config // knob, not incorrect behavior. } private static void ValidateEvents(EventOptions options, ValidationBuilder builder) { AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", builder); if (!Enum.IsDefined(options.BackpressurePolicy)) { builder.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy."); } // ReplayBufferCapacity and ReplayRetentionSeconds are bounds on the replay ring // buffer; 0 is a valid value (disables that dimension), so only negatives fail. AddIfNegative( options.ReplayBufferCapacity, "MxGateway:Events:ReplayBufferCapacity must be greater than or equal to zero.", builder); builder.RequireThat( options.ReplayRetentionSeconds >= 0, "MxGateway:Events:ReplayRetentionSeconds must be greater than or equal to zero."); } private static void ValidateDashboard(DashboardOptions options, ValidationBuilder builder) { // GroupToRole shape is validated even when the dashboard is disabled so // misconfiguration surfaces at startup; emptiness is allowed, with the // consequence that no LDAP user can sign in (login returns "no roles // mapped"). Operators who disable the dashboard or want a closed // deployment can ship without a mapping. foreach (KeyValuePair entry in options.GroupToRole) { if (string.IsNullOrWhiteSpace(entry.Key)) { builder.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank."); } if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal) && !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal)) { builder.Add( $"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'."); } } AddIfNotPositive( options.SnapshotIntervalMilliseconds, "MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.", builder); AddIfNegative( options.RecentFaultLimit, "MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.", builder); AddIfNegative( options.RecentSessionLimit, "MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.", builder); } private static readonly string[] ValidAlarmFallbackModes = ["Auto", "ForceAlarmManager", "ForceSubtag"]; private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder) { if (!options.Enabled) { return; } // When the central alarm monitor is enabled, it needs either a canonical // SubscriptionExpression or a DefaultArea to compose one from. Validating // it at startup makes the misconfiguration fail-fast at boot, in line // with every other section. if (string.IsNullOrWhiteSpace(options.SubscriptionExpression) && string.IsNullOrWhiteSpace(options.DefaultArea)) { builder.Add( "MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true."); } if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression) && !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal)) { builder.Add( @"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\\Galaxy! shape)."); } ValidateAlarmFallback(options.Fallback, builder); } private static void ValidateAlarmFallback(AlarmFallbackOptions fallback, ValidationBuilder builder) { // Validate Mode is one of the recognised values (case-insensitive). bool modeValid = Array.Exists( ValidAlarmFallbackModes, m => string.Equals(m, fallback.Mode, StringComparison.OrdinalIgnoreCase)); if (!modeValid) { builder.Add( $"MxGateway:Alarms:Fallback:Mode must be one of: {string.Join(", ", ValidAlarmFallbackModes)} (was '{fallback.Mode}')."); } // ForceSubtag requires either Galaxy Repository discovery or an explicit IncludeAttributes list. if (modeValid && string.Equals(fallback.Mode, "ForceSubtag", StringComparison.OrdinalIgnoreCase) && !fallback.Discovery.UseGalaxyRepository && fallback.Discovery.IncludeAttributes.Length == 0) { builder.Add( "MxGateway:Alarms:Fallback ForceSubtag requires Galaxy Repository discovery or a non-empty Discovery:IncludeAttributes list."); } // Floor validation: numeric thresholds must be at least 1. AddIfNotPositive( fallback.ConsecutiveFailureThreshold, "MxGateway:Alarms:Fallback:ConsecutiveFailureThreshold must be greater than zero.", builder); AddIfNotPositive( fallback.FailbackProbeIntervalSeconds, "MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds must be greater than zero.", builder); AddIfNotPositive( fallback.FailbackStableProbes, "MxGateway:Alarms:Fallback:FailbackStableProbes must be greater than zero.", builder); } private const int MinimumCertValidityYears = 1; private const int MaximumCertValidityYears = 100; private static void ValidateTls(TlsOptions options, ValidationBuilder builder) { if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears) { builder.Add( $"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}."); } // The default is non-blank, so this only catches an explicitly-blanked path. AddIfBlank( options.SelfSignedCertPath, "MxGateway:Tls:SelfSignedCertPath must not be blank.", builder); AddIfInvalidPath( options.SelfSignedCertPath, "MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.", builder); foreach (string dns in options.AdditionalDnsNames) { if (string.IsNullOrWhiteSpace(dns)) { builder.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank."); } } } private static void ValidateProtocol(ProtocolOptions options, ValidationBuilder builder) { if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion) { builder.Add( $"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}."); } if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes) { builder.Add( $"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}."); } } private static void AddIfBlank(string? value, string message, ValidationBuilder builder) { builder.RequireThat(!string.IsNullOrWhiteSpace(value), message); } private static void AddIfNotPositive(int value, string message, ValidationBuilder builder) { builder.RequireThat(value > 0, message); } private static void AddIfNegative(int value, string message, ValidationBuilder builder) { builder.RequireThat(value >= 0, message); } private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder) { if (string.IsNullOrWhiteSpace(value)) { return; } try { _ = Path.GetFullPath(value); } catch (ArgumentException) { builder.Add(message); } catch (NotSupportedException) { builder.Add(message); } catch (PathTooLongException) { builder.Add(message); } catch (IOException) { builder.Add(message); } } }