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); if (options.AllowMultipleEventSubscribers) { builder.Add( "MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented."); } } 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."); } } 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 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)."); } } 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); } } }