using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Configuration; using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Server.Workers; namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed class GatewayOptionsValidator : OptionsValidatorBase { private const int MinimumMaxMessageBytes = 1024; private const int MaximumMaxMessageBytes = 256 * 1024 * 1024; // Whether the host is running in the Production environment. Drives the production-only // hard-stops (dashboard login disabled, plaintext LDAP transport) that must abort startup // rather than merely warn. Non-production hosts keep the permissive dev posture. private readonly bool _isProduction; /// /// Initializes a new instance of the class for the /// dependency-injection path, deriving the production posture from the host environment. /// /// The host environment. public GatewayOptionsValidator(IHostEnvironment environment) { ArgumentNullException.ThrowIfNull(environment); _isProduction = environment.IsProduction(); } /// /// Initializes a new instance of the class for unit /// tests and non-DI callers. Defaults to a non-production posture so the production-only /// hard-stops do not fire; pass to exercise them. /// /// Whether to treat the host as running in Production. internal GatewayOptionsValidator(bool isProduction = false) { _isProduction = isProduction; } /// protected override void Validate(ValidationBuilder builder, GatewayOptions options) { ValidateAuthentication(options.Authentication, builder); ValidateLdap(options.Ldap, builder, _isProduction); ValidateWorker(options.Worker, builder); ValidateSessions(options.Sessions, builder); ValidateEvents(options.Events, builder); ValidateDashboard(options.Dashboard, builder, _isProduction); ValidateProtocol(options.Protocol, builder); ValidateFrameSizeHeadroom(options.Worker, options.Protocol, builder); ValidateAlarms(options.Alarms, builder); ValidateTls(options.Tls, builder); ValidateSecurity(options.Security, builder); } private static void ValidateSecurity(SecurityOptions options, ValidationBuilder builder) { // Cache/coalesce windows may be 0 (disables that mechanism); negatives are invalid. AddIfNegative( options.ApiKeyVerificationCacheSeconds, "MxGateway:Security:ApiKeyVerificationCacheSeconds must be greater than or equal to zero (0 disables the verification cache).", builder); AddIfNegative( options.ApiKeyLastUsedCoalesceSeconds, "MxGateway:Security:ApiKeyLastUsedCoalesceSeconds must be greater than or equal to zero (0 forwards every last_used write).", builder); // Rate-limit knobs must be positive: a zero permit/window/limit is a misconfiguration that // would either reject every request or divide by zero rather than express an intent. AddIfNotPositive( options.LoginRateLimitPermitLimit, "MxGateway:Security:LoginRateLimitPermitLimit must be greater than zero.", builder); AddIfNotPositive( options.LoginRateLimitWindowSeconds, "MxGateway:Security:LoginRateLimitWindowSeconds must be greater than zero.", builder); AddIfNotPositive( options.ApiKeyFailureLimit, "MxGateway:Security:ApiKeyFailureLimit must be greater than zero.", builder); AddIfNotPositive( options.ApiKeyFailureWindowSeconds, "MxGateway:Security:ApiKeyFailureWindowSeconds must be greater than zero.", builder); AddIfNotPositive( options.ApiKeyFailureTrackedPeers, "MxGateway:Security:ApiKeyFailureTrackedPeers must be greater than zero.", 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); AddIfNotRooted( options.SqlitePath, "MxGateway:Authentication:SqlitePath must be an absolute (rooted) path so the credential store never lands in the launch working directory.", builder); AddIfBlank( options.PepperSecretName, "MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.", builder); } } private static void ValidateLdap(LdapOptions options, ValidationBuilder builder, bool isProduction) { 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)."); } // Production hard-stop: plaintext LDAP binds send the operator's password in the clear. // The permissive dev default (Transport=None against the shared GLAuth instance) is // acceptable off-production but must never ship to a Production host. Deployed hosts must // set Transport=Ldaps/StartTls; see docs/GatewayConfiguration.md and glauth.md. if (isProduction && options.Transport == LdapTransport.None) { builder.Add( "MxGateway:Ldap:Transport must not be None (plaintext) in the Production environment; use Ldaps or StartTls."); } } 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); AddIfNegative( options.DetachGraceSeconds, "MxGateway:Sessions:DetachGraceSeconds must be zero or greater (0 disables detach-grace retention).", builder); AddIfNegative( options.FaultedGraceSeconds, "MxGateway:Sessions:FaultedGraceSeconds must be zero or greater (0 reaps a faulted session on the next sweep).", builder); AddIfNegative( options.WorkerReadyWaitTimeoutMs, "MxGateway:Sessions:WorkerReadyWaitTimeoutMs must be greater than or equal to 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."); builder.RequireThat( options.MaxSparseArrayLength >= 1 && options.MaxSparseArrayLength <= Array.MaxLength, $"MxGateway:Events:MaxSparseArrayLength must be between 1 and {Array.MaxLength}."); } private static void ValidateDashboard(DashboardOptions options, ValidationBuilder builder, bool isProduction) { // Production hard-stop: DisableLogin swaps in an auto-login handler that authenticates // EVERY request (remote included) as AutoLoginUser holding both roles, turning the whole // dashboard — API-key CRUD and worker Kill included — into an unauthenticated admin // surface on a 0.0.0.0-bound port. It is a dev/test-only convenience; abort startup if it // is set in Production. Non-production hosts keep the existing runtime warning. if (isProduction && options.DisableLogin) { builder.Add( "MxGateway:Dashboard:DisableLogin must not be true in the Production environment; it disables all dashboard authentication."); } // 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); AddIfNotRooted( options.SelfSignedCertPath, "MxGateway:Tls:SelfSignedCertPath must be an absolute (rooted) path so the generated private key never lands in the launch working directory.", 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}."); } } // The worker-frame (pipe) maximum must stay above the public gRPC cap by the envelope-overhead // reserve, otherwise a maximally-sized accepted gRPC payload does not fit one worker frame once // wrapped in a WorkerEnvelope and the outbound write faults the whole session. Fail fast // at startup rather than mid-traffic. Only checked when both knobs are themselves in range so the // message is not doubled up with the individual range errors. private static void ValidateFrameSizeHeadroom( WorkerOptions worker, ProtocolOptions protocol, ValidationBuilder builder) { bool workerInRange = worker.MaxMessageBytes is >= MinimumMaxMessageBytes and <= MaximumMaxMessageBytes; bool grpcInRange = protocol.MaxGrpcMessageBytes is >= MinimumMaxMessageBytes and <= MaximumMaxMessageBytes; if (!workerInRange || !grpcInRange) { return; } long requiredWorkerMax = (long)protocol.MaxGrpcMessageBytes + WorkerFrameProtocolOptions.EnvelopeOverheadReserveBytes; if (worker.MaxMessageBytes < requiredWorkerMax) { builder.Add( $"MxGateway:Worker:MaxMessageBytes ({worker.MaxMessageBytes}) must be at least " + $"MxGateway:Protocol:MaxGrpcMessageBytes ({protocol.MaxGrpcMessageBytes}) plus the " + $"{WorkerFrameProtocolOptions.EnvelopeOverheadReserveBytes}-byte worker-frame envelope reserve " + $"(>= {requiredWorkerMax})."); } } 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 AddIfNotRooted(string? value, string message, ValidationBuilder builder) { // Security-sensitive paths (the auth DB, the self-signed private key) must be absolute: // a non-rooted value silently resolves against the launch working directory, so the store // moves with the CWD and can leak into the source tree. Reject rather than auto-root — // silent relocation of a credential store is worse than a boot error. Blank is handled by // AddIfBlank; an empty value is not treated as non-rooted here. if (string.IsNullOrWhiteSpace(value)) { return; } if (!IsRootedForAnyPlatform(value)) { builder.Add(message); } } /// /// Determines whether is an absolute path for any platform, /// not just the host running the validator. This matters on the macOS dev box, where the /// production appsettings.json ships Windows-absolute paths (C:\ProgramData\...) /// that reports as non-rooted on Unix. The intent of the /// rooting check is to reject bare filenames that resolve against the launch working directory, /// so a valid Windows drive-qualified or UNC path must pass regardless of the current OS. /// private static bool IsRootedForAnyPlatform(string value) { // Rooted on the current OS (Unix "/...", or a Windows drive/UNC path when on Windows). if (Path.IsPathRooted(value)) { return true; } // Windows drive-qualified path ("C:\..." or "C:/...") checked on a non-Windows host. if (value.Length >= 3 && char.IsLetter(value[0]) && value[1] == ':' && (value[2] == '\\' || value[2] == '/')) { return true; } // Windows UNC path ("\\server\share") checked on a non-Windows host. return value.StartsWith(@"\\", StringComparison.Ordinal); } 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); } } }