b86c6bb47f
Resolve all CommentChecker findings across the gateway server, worker, tests, and .NET client (314 -> 0 real issues): add missing <returns>/<summary>/<param> on public and test members, convert Stream/interface overrides to <inheritdoc/>, and remove internal task/issue tracking IDs (SEC-*, IPC-*, WRK-*, GWC-*, TST-*, Client.Dotnet-*) from shipped code documentation while preserving the design rationale prose. Shipped comments should not carry internal bookkeeping, and complete XML docs keep the analyzer/TreatWarningsAsErrors gate and generated API docs clean. The 6 remaining flags are heuristic false positives (MD5, UTC-4, capacity-1, near-1601) left intact so real documentation is not corrupted. Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
591 lines
26 KiB
C#
591 lines
26 KiB
C#
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<GatewayOptions>
|
|
{
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="GatewayOptionsValidator"/> class for the
|
|
/// dependency-injection path, deriving the production posture from the host environment.
|
|
/// </summary>
|
|
/// <param name="environment">The host environment.</param>
|
|
public GatewayOptionsValidator(IHostEnvironment environment)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(environment);
|
|
_isProduction = environment.IsProduction();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="GatewayOptionsValidator"/> class for unit
|
|
/// tests and non-DI callers. Defaults to a non-production posture so the production-only
|
|
/// hard-stops do not fire; pass <see langword="true"/> to exercise them.
|
|
/// </summary>
|
|
/// <param name="isProduction">Whether to treat the host as running in Production.</param>
|
|
internal GatewayOptionsValidator(bool isProduction = false)
|
|
{
|
|
_isProduction = isProduction;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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<string, string> 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 \\<host>\Galaxy!<area> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines whether <paramref name="value"/> is an absolute path for <em>any</em> platform,
|
|
/// not just the host running the validator. This matters on the macOS dev box, where the
|
|
/// production <c>appsettings.json</c> ships Windows-absolute paths (<c>C:\ProgramData\...</c>)
|
|
/// that <see cref="Path.IsPathRooted(string)"/> 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|