From 91ea71b0b7971a77c555a57a312b5379211ab22c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 16:11:30 -0400 Subject: [PATCH] Issue #3: add gateway configuration and validation --- docs/gateway-process-design.md | 12 + .../Configuration/AuthenticationMode.cs | 7 + .../Configuration/AuthenticationOptions.cs | 12 + .../Configuration/DashboardOptions.cs | 20 ++ .../EffectiveAuthenticationConfiguration.cs | 7 + .../EffectiveDashboardConfiguration.cs | 11 + .../EffectiveEventConfiguration.cs | 5 + .../EffectiveGatewayConfiguration.cs | 9 + .../EffectiveProtocolConfiguration.cs | 3 + .../EffectiveSessionConfiguration.cs | 6 + .../EffectiveWorkerConfiguration.cs | 11 + .../Configuration/EventBackpressurePolicy.cs | 6 + .../Configuration/EventOptions.cs | 8 + .../GatewayConfigurationProvider.cs | 46 ++++ ...onfigurationServiceCollectionExtensions.cs | 19 ++ .../Configuration/GatewayOptions.cs | 18 ++ .../Configuration/GatewayOptionsValidator.cs | 210 ++++++++++++++++++ .../IGatewayConfigurationProvider.cs | 6 + .../Configuration/ProtocolOptions.cs | 8 + .../Configuration/SessionOptions.cs | 10 + .../Configuration/WorkerArchitecture.cs | 7 + .../Configuration/WorkerOptions.cs | 21 ++ src/MxGateway.Server/GatewayApplication.cs | 2 + src/MxGateway.Server/appsettings.json | 41 +++- .../Configuration/GatewayOptionsTests.cs | 119 ++++++++++ .../Gateway/GatewayApplicationTests.cs | 34 +++ 26 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 src/MxGateway.Server/Configuration/AuthenticationMode.cs create mode 100644 src/MxGateway.Server/Configuration/AuthenticationOptions.cs create mode 100644 src/MxGateway.Server/Configuration/DashboardOptions.cs create mode 100644 src/MxGateway.Server/Configuration/EffectiveAuthenticationConfiguration.cs create mode 100644 src/MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs create mode 100644 src/MxGateway.Server/Configuration/EffectiveEventConfiguration.cs create mode 100644 src/MxGateway.Server/Configuration/EffectiveGatewayConfiguration.cs create mode 100644 src/MxGateway.Server/Configuration/EffectiveProtocolConfiguration.cs create mode 100644 src/MxGateway.Server/Configuration/EffectiveSessionConfiguration.cs create mode 100644 src/MxGateway.Server/Configuration/EffectiveWorkerConfiguration.cs create mode 100644 src/MxGateway.Server/Configuration/EventBackpressurePolicy.cs create mode 100644 src/MxGateway.Server/Configuration/EventOptions.cs create mode 100644 src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs create mode 100644 src/MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs create mode 100644 src/MxGateway.Server/Configuration/GatewayOptions.cs create mode 100644 src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs create mode 100644 src/MxGateway.Server/Configuration/IGatewayConfigurationProvider.cs create mode 100644 src/MxGateway.Server/Configuration/ProtocolOptions.cs create mode 100644 src/MxGateway.Server/Configuration/SessionOptions.cs create mode 100644 src/MxGateway.Server/Configuration/WorkerArchitecture.cs create mode 100644 src/MxGateway.Server/Configuration/WorkerOptions.cs create mode 100644 src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs diff --git a/docs/gateway-process-design.md b/docs/gateway-process-design.md index d94acb9..e23c77a 100644 --- a/docs/gateway-process-design.md +++ b/docs/gateway-process-design.md @@ -710,6 +710,18 @@ Suggested configuration shape: Do not scatter connection or path constants through implementation code. +`MxGateway.Server` binds this section to `GatewayOptions` at startup and +registers validation with `ValidateOnStart()`. Startup fails before the gateway +begins serving traffic when required authentication settings are missing, +timeouts or queue sizes are not positive, dashboard settings are malformed, or +the configured worker protocol version does not match the contract version. + +The gateway exposes read-only effective settings through +`IGatewayConfigurationProvider`. This projection is for dashboard settings and +diagnostics, so it redacts secret-related fields such as +`Authentication:PepperSecretName` and does not include raw API keys or key +material. + ## Galaxy Repository Metadata Galaxy hierarchy and tag metadata can be discovered through SQL Server when diff --git a/src/MxGateway.Server/Configuration/AuthenticationMode.cs b/src/MxGateway.Server/Configuration/AuthenticationMode.cs new file mode 100644 index 0000000..6559a0d --- /dev/null +++ b/src/MxGateway.Server/Configuration/AuthenticationMode.cs @@ -0,0 +1,7 @@ +namespace MxGateway.Server.Configuration; + +public enum AuthenticationMode +{ + ApiKey, + Disabled +} diff --git a/src/MxGateway.Server/Configuration/AuthenticationOptions.cs b/src/MxGateway.Server/Configuration/AuthenticationOptions.cs new file mode 100644 index 0000000..b3a9962 --- /dev/null +++ b/src/MxGateway.Server/Configuration/AuthenticationOptions.cs @@ -0,0 +1,12 @@ +namespace MxGateway.Server.Configuration; + +public sealed class AuthenticationOptions +{ + public AuthenticationMode Mode { get; init; } = AuthenticationMode.ApiKey; + + public string SqlitePath { get; init; } = @"C:\ProgramData\MxGateway\gateway-auth.db"; + + public string PepperSecretName { get; init; } = "MxGateway:ApiKeyPepper"; + + public bool RunMigrationsOnStartup { get; init; } = true; +} diff --git a/src/MxGateway.Server/Configuration/DashboardOptions.cs b/src/MxGateway.Server/Configuration/DashboardOptions.cs new file mode 100644 index 0000000..5c19e47 --- /dev/null +++ b/src/MxGateway.Server/Configuration/DashboardOptions.cs @@ -0,0 +1,20 @@ +namespace MxGateway.Server.Configuration; + +public sealed class DashboardOptions +{ + public bool Enabled { get; init; } = true; + + public string PathBase { get; init; } = "/dashboard"; + + public bool RequireAdminScope { get; init; } = true; + + public bool AllowAnonymousLocalhost { get; init; } + + public int SnapshotIntervalMilliseconds { get; init; } = 1_000; + + public int RecentFaultLimit { get; init; } = 100; + + public int RecentSessionLimit { get; init; } = 200; + + public bool ShowTagValues { get; init; } +} diff --git a/src/MxGateway.Server/Configuration/EffectiveAuthenticationConfiguration.cs b/src/MxGateway.Server/Configuration/EffectiveAuthenticationConfiguration.cs new file mode 100644 index 0000000..9e553e2 --- /dev/null +++ b/src/MxGateway.Server/Configuration/EffectiveAuthenticationConfiguration.cs @@ -0,0 +1,7 @@ +namespace MxGateway.Server.Configuration; + +public sealed record EffectiveAuthenticationConfiguration( + string Mode, + string SqlitePath, + string PepperSecretName, + bool RunMigrationsOnStartup); diff --git a/src/MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs b/src/MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs new file mode 100644 index 0000000..ee22a92 --- /dev/null +++ b/src/MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs @@ -0,0 +1,11 @@ +namespace MxGateway.Server.Configuration; + +public sealed record EffectiveDashboardConfiguration( + bool Enabled, + string PathBase, + bool RequireAdminScope, + bool AllowAnonymousLocalhost, + int SnapshotIntervalMilliseconds, + int RecentFaultLimit, + int RecentSessionLimit, + bool ShowTagValues); diff --git a/src/MxGateway.Server/Configuration/EffectiveEventConfiguration.cs b/src/MxGateway.Server/Configuration/EffectiveEventConfiguration.cs new file mode 100644 index 0000000..8fe5938 --- /dev/null +++ b/src/MxGateway.Server/Configuration/EffectiveEventConfiguration.cs @@ -0,0 +1,5 @@ +namespace MxGateway.Server.Configuration; + +public sealed record EffectiveEventConfiguration( + int QueueCapacity, + string BackpressurePolicy); diff --git a/src/MxGateway.Server/Configuration/EffectiveGatewayConfiguration.cs b/src/MxGateway.Server/Configuration/EffectiveGatewayConfiguration.cs new file mode 100644 index 0000000..5782537 --- /dev/null +++ b/src/MxGateway.Server/Configuration/EffectiveGatewayConfiguration.cs @@ -0,0 +1,9 @@ +namespace MxGateway.Server.Configuration; + +public sealed record EffectiveGatewayConfiguration( + EffectiveAuthenticationConfiguration Authentication, + EffectiveWorkerConfiguration Worker, + EffectiveSessionConfiguration Sessions, + EffectiveEventConfiguration Events, + EffectiveDashboardConfiguration Dashboard, + EffectiveProtocolConfiguration Protocol); diff --git a/src/MxGateway.Server/Configuration/EffectiveProtocolConfiguration.cs b/src/MxGateway.Server/Configuration/EffectiveProtocolConfiguration.cs new file mode 100644 index 0000000..00b0d7e --- /dev/null +++ b/src/MxGateway.Server/Configuration/EffectiveProtocolConfiguration.cs @@ -0,0 +1,3 @@ +namespace MxGateway.Server.Configuration; + +public sealed record EffectiveProtocolConfiguration(uint WorkerProtocolVersion); diff --git a/src/MxGateway.Server/Configuration/EffectiveSessionConfiguration.cs b/src/MxGateway.Server/Configuration/EffectiveSessionConfiguration.cs new file mode 100644 index 0000000..24e9e74 --- /dev/null +++ b/src/MxGateway.Server/Configuration/EffectiveSessionConfiguration.cs @@ -0,0 +1,6 @@ +namespace MxGateway.Server.Configuration; + +public sealed record EffectiveSessionConfiguration( + int DefaultCommandTimeoutSeconds, + int MaxSessions, + bool AllowMultipleEventSubscribers); diff --git a/src/MxGateway.Server/Configuration/EffectiveWorkerConfiguration.cs b/src/MxGateway.Server/Configuration/EffectiveWorkerConfiguration.cs new file mode 100644 index 0000000..68bd6b1 --- /dev/null +++ b/src/MxGateway.Server/Configuration/EffectiveWorkerConfiguration.cs @@ -0,0 +1,11 @@ +namespace MxGateway.Server.Configuration; + +public sealed record EffectiveWorkerConfiguration( + string ExecutablePath, + string? WorkingDirectory, + string RequiredArchitecture, + int StartupTimeoutSeconds, + int ShutdownTimeoutSeconds, + int HeartbeatIntervalSeconds, + int HeartbeatGraceSeconds, + int MaxMessageBytes); diff --git a/src/MxGateway.Server/Configuration/EventBackpressurePolicy.cs b/src/MxGateway.Server/Configuration/EventBackpressurePolicy.cs new file mode 100644 index 0000000..9d341e0 --- /dev/null +++ b/src/MxGateway.Server/Configuration/EventBackpressurePolicy.cs @@ -0,0 +1,6 @@ +namespace MxGateway.Server.Configuration; + +public enum EventBackpressurePolicy +{ + FailFast +} diff --git a/src/MxGateway.Server/Configuration/EventOptions.cs b/src/MxGateway.Server/Configuration/EventOptions.cs new file mode 100644 index 0000000..b93323e --- /dev/null +++ b/src/MxGateway.Server/Configuration/EventOptions.cs @@ -0,0 +1,8 @@ +namespace MxGateway.Server.Configuration; + +public sealed class EventOptions +{ + public int QueueCapacity { get; init; } = 10_000; + + public EventBackpressurePolicy BackpressurePolicy { get; init; } = EventBackpressurePolicy.FailFast; +} diff --git a/src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs b/src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs new file mode 100644 index 0000000..69c97bf --- /dev/null +++ b/src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.Options; + +namespace MxGateway.Server.Configuration; + +public sealed class GatewayConfigurationProvider(IOptions options) : IGatewayConfigurationProvider +{ + public const string RedactedValue = "[redacted]"; + + public EffectiveGatewayConfiguration GetEffectiveConfiguration() + { + GatewayOptions value = options.Value; + + return new EffectiveGatewayConfiguration( + Authentication: new EffectiveAuthenticationConfiguration( + Mode: value.Authentication.Mode.ToString(), + SqlitePath: value.Authentication.SqlitePath, + PepperSecretName: RedactedValue, + RunMigrationsOnStartup: value.Authentication.RunMigrationsOnStartup), + Worker: new EffectiveWorkerConfiguration( + ExecutablePath: value.Worker.ExecutablePath, + WorkingDirectory: value.Worker.WorkingDirectory, + RequiredArchitecture: value.Worker.RequiredArchitecture.ToString(), + StartupTimeoutSeconds: value.Worker.StartupTimeoutSeconds, + ShutdownTimeoutSeconds: value.Worker.ShutdownTimeoutSeconds, + HeartbeatIntervalSeconds: value.Worker.HeartbeatIntervalSeconds, + HeartbeatGraceSeconds: value.Worker.HeartbeatGraceSeconds, + MaxMessageBytes: value.Worker.MaxMessageBytes), + Sessions: new EffectiveSessionConfiguration( + DefaultCommandTimeoutSeconds: value.Sessions.DefaultCommandTimeoutSeconds, + MaxSessions: value.Sessions.MaxSessions, + AllowMultipleEventSubscribers: value.Sessions.AllowMultipleEventSubscribers), + Events: new EffectiveEventConfiguration( + QueueCapacity: value.Events.QueueCapacity, + BackpressurePolicy: value.Events.BackpressurePolicy.ToString()), + Dashboard: new EffectiveDashboardConfiguration( + Enabled: value.Dashboard.Enabled, + PathBase: value.Dashboard.PathBase, + RequireAdminScope: value.Dashboard.RequireAdminScope, + AllowAnonymousLocalhost: value.Dashboard.AllowAnonymousLocalhost, + SnapshotIntervalMilliseconds: value.Dashboard.SnapshotIntervalMilliseconds, + RecentFaultLimit: value.Dashboard.RecentFaultLimit, + RecentSessionLimit: value.Dashboard.RecentSessionLimit, + ShowTagValues: value.Dashboard.ShowTagValues), + Protocol: new EffectiveProtocolConfiguration(value.Protocol.WorkerProtocolVersion)); + } +} diff --git a/src/MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs b/src/MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs new file mode 100644 index 0000000..9c23e61 --- /dev/null +++ b/src/MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Options; + +namespace MxGateway.Server.Configuration; + +public static class GatewayConfigurationServiceCollectionExtensions +{ + public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services) + { + services + .AddOptions() + .BindConfiguration(GatewayOptions.SectionName) + .ValidateOnStart(); + + services.AddSingleton, GatewayOptionsValidator>(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/MxGateway.Server/Configuration/GatewayOptions.cs b/src/MxGateway.Server/Configuration/GatewayOptions.cs new file mode 100644 index 0000000..b1b7585 --- /dev/null +++ b/src/MxGateway.Server/Configuration/GatewayOptions.cs @@ -0,0 +1,18 @@ +namespace MxGateway.Server.Configuration; + +public sealed class GatewayOptions +{ + public const string SectionName = "MxGateway"; + + public AuthenticationOptions Authentication { get; init; } = new(); + + public WorkerOptions Worker { get; init; } = new(); + + public SessionOptions Sessions { get; init; } = new(); + + public EventOptions Events { get; init; } = new(); + + public DashboardOptions Dashboard { get; init; } = new(); + + public ProtocolOptions Protocol { get; init; } = new(); +} diff --git a/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs new file mode 100644 index 0000000..c71856e --- /dev/null +++ b/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -0,0 +1,210 @@ +using Microsoft.Extensions.Options; +using MxGateway.Contracts; + +namespace MxGateway.Server.Configuration; + +public sealed class GatewayOptionsValidator : IValidateOptions +{ + private const int MinimumMaxMessageBytes = 1024; + private const int MaximumMaxMessageBytes = 256 * 1024 * 1024; + + public ValidateOptionsResult Validate(string? name, GatewayOptions options) + { + List failures = []; + + ValidateAuthentication(options.Authentication, failures); + ValidateWorker(options.Worker, failures); + ValidateSessions(options.Sessions, failures); + ValidateEvents(options.Events, failures); + ValidateDashboard(options.Dashboard, failures); + ValidateProtocol(options.Protocol, failures); + + return failures.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(failures); + } + + private static void ValidateAuthentication(AuthenticationOptions options, List failures) + { + if (!Enum.IsDefined(options.Mode)) + { + failures.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.", + failures); + AddIfInvalidPath( + options.SqlitePath, + "MxGateway:Authentication:SqlitePath must be a valid filesystem path.", + failures); + AddIfBlank( + options.PepperSecretName, + "MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.", + failures); + } + } + + private static void ValidateWorker(WorkerOptions options, List failures) + { + AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures); + AddIfInvalidPath( + options.ExecutablePath, + "MxGateway:Worker:ExecutablePath must be a valid filesystem path.", + failures); + + if (!string.IsNullOrWhiteSpace(options.ExecutablePath) + && !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase)) + { + failures.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.", + failures); + } + + if (!Enum.IsDefined(options.RequiredArchitecture)) + { + failures.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture."); + } + + AddIfNotPositive( + options.StartupTimeoutSeconds, + "MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.", + failures); + AddIfNotPositive( + options.ShutdownTimeoutSeconds, + "MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.", + failures); + AddIfNotPositive( + options.HeartbeatIntervalSeconds, + "MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.", + failures); + AddIfNotPositive( + options.HeartbeatGraceSeconds, + "MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.", + failures); + + if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds) + { + failures.Add( + "MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds."); + } + + if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes) + { + failures.Add( + $"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}."); + } + } + + private static void ValidateSessions(SessionOptions options, List failures) + { + AddIfNotPositive( + options.DefaultCommandTimeoutSeconds, + "MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.", + failures); + AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", failures); + } + + private static void ValidateEvents(EventOptions options, List failures) + { + AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", failures); + + if (!Enum.IsDefined(options.BackpressurePolicy)) + { + failures.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy."); + } + } + + private static void ValidateDashboard(DashboardOptions options, List failures) + { + if (options.Enabled) + { + AddIfBlank(options.PathBase, "MxGateway:Dashboard:PathBase is required when the dashboard is enabled.", failures); + if (!string.IsNullOrWhiteSpace(options.PathBase) && !options.PathBase.StartsWith('/')) + { + failures.Add("MxGateway:Dashboard:PathBase must start with '/'."); + } + } + + AddIfNotPositive( + options.SnapshotIntervalMilliseconds, + "MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.", + failures); + AddIfNegative( + options.RecentFaultLimit, + "MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.", + failures); + AddIfNegative( + options.RecentSessionLimit, + "MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.", + failures); + } + + private static void ValidateProtocol(ProtocolOptions options, List failures) + { + if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion) + { + failures.Add( + $"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}."); + } + } + + private static void AddIfBlank(string? value, string message, List failures) + { + if (string.IsNullOrWhiteSpace(value)) + { + failures.Add(message); + } + } + + private static void AddIfNotPositive(int value, string message, List failures) + { + if (value <= 0) + { + failures.Add(message); + } + } + + private static void AddIfNegative(int value, string message, List failures) + { + if (value < 0) + { + failures.Add(message); + } + } + + private static void AddIfInvalidPath(string? value, string message, List failures) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + try + { + _ = Path.GetFullPath(value); + } + catch (ArgumentException) + { + failures.Add(message); + } + catch (NotSupportedException) + { + failures.Add(message); + } + catch (PathTooLongException) + { + failures.Add(message); + } + } +} diff --git a/src/MxGateway.Server/Configuration/IGatewayConfigurationProvider.cs b/src/MxGateway.Server/Configuration/IGatewayConfigurationProvider.cs new file mode 100644 index 0000000..6393d19 --- /dev/null +++ b/src/MxGateway.Server/Configuration/IGatewayConfigurationProvider.cs @@ -0,0 +1,6 @@ +namespace MxGateway.Server.Configuration; + +public interface IGatewayConfigurationProvider +{ + EffectiveGatewayConfiguration GetEffectiveConfiguration(); +} diff --git a/src/MxGateway.Server/Configuration/ProtocolOptions.cs b/src/MxGateway.Server/Configuration/ProtocolOptions.cs new file mode 100644 index 0000000..4f75ec3 --- /dev/null +++ b/src/MxGateway.Server/Configuration/ProtocolOptions.cs @@ -0,0 +1,8 @@ +using MxGateway.Contracts; + +namespace MxGateway.Server.Configuration; + +public sealed class ProtocolOptions +{ + public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion; +} diff --git a/src/MxGateway.Server/Configuration/SessionOptions.cs b/src/MxGateway.Server/Configuration/SessionOptions.cs new file mode 100644 index 0000000..ac0db35 --- /dev/null +++ b/src/MxGateway.Server/Configuration/SessionOptions.cs @@ -0,0 +1,10 @@ +namespace MxGateway.Server.Configuration; + +public sealed class SessionOptions +{ + public int DefaultCommandTimeoutSeconds { get; init; } = 30; + + public int MaxSessions { get; init; } = 64; + + public bool AllowMultipleEventSubscribers { get; init; } +} diff --git a/src/MxGateway.Server/Configuration/WorkerArchitecture.cs b/src/MxGateway.Server/Configuration/WorkerArchitecture.cs new file mode 100644 index 0000000..6de6f45 --- /dev/null +++ b/src/MxGateway.Server/Configuration/WorkerArchitecture.cs @@ -0,0 +1,7 @@ +namespace MxGateway.Server.Configuration; + +public enum WorkerArchitecture +{ + X86, + X64 +} diff --git a/src/MxGateway.Server/Configuration/WorkerOptions.cs b/src/MxGateway.Server/Configuration/WorkerOptions.cs new file mode 100644 index 0000000..98ee21f --- /dev/null +++ b/src/MxGateway.Server/Configuration/WorkerOptions.cs @@ -0,0 +1,21 @@ +namespace MxGateway.Server.Configuration; + +public sealed class WorkerOptions +{ + public string ExecutablePath { get; init; } = + @"src\MxGateway.Worker\bin\x86\Release\MxGateway.Worker.exe"; + + public string? WorkingDirectory { get; init; } + + public WorkerArchitecture RequiredArchitecture { get; init; } = WorkerArchitecture.X86; + + public int StartupTimeoutSeconds { get; init; } = 30; + + public int ShutdownTimeoutSeconds { get; init; } = 10; + + public int HeartbeatIntervalSeconds { get; init; } = 5; + + public int HeartbeatGraceSeconds { get; init; } = 15; + + public int MaxMessageBytes { get; init; } = 16 * 1024 * 1024; +} diff --git a/src/MxGateway.Server/GatewayApplication.cs b/src/MxGateway.Server/GatewayApplication.cs index 648b537..317f427 100644 --- a/src/MxGateway.Server/GatewayApplication.cs +++ b/src/MxGateway.Server/GatewayApplication.cs @@ -1,4 +1,5 @@ using MxGateway.Contracts; +using MxGateway.Server.Configuration; namespace MxGateway.Server; @@ -18,6 +19,7 @@ public static class GatewayApplication { WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + builder.Services.AddGatewayConfiguration(); builder.Services.AddHealthChecks(); return builder; diff --git a/src/MxGateway.Server/appsettings.json b/src/MxGateway.Server/appsettings.json index 10f68b8..4786fe6 100644 --- a/src/MxGateway.Server/appsettings.json +++ b/src/MxGateway.Server/appsettings.json @@ -5,5 +5,44 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "MxGateway": { + "Authentication": { + "Mode": "ApiKey", + "SqlitePath": "C:\\ProgramData\\MxGateway\\gateway-auth.db", + "PepperSecretName": "MxGateway:ApiKeyPepper", + "RunMigrationsOnStartup": true + }, + "Worker": { + "ExecutablePath": "src\\MxGateway.Worker\\bin\\x86\\Release\\MxGateway.Worker.exe", + "RequiredArchitecture": "X86", + "StartupTimeoutSeconds": 30, + "ShutdownTimeoutSeconds": 10, + "HeartbeatIntervalSeconds": 5, + "HeartbeatGraceSeconds": 15, + "MaxMessageBytes": 16777216 + }, + "Sessions": { + "DefaultCommandTimeoutSeconds": 30, + "MaxSessions": 64, + "AllowMultipleEventSubscribers": false + }, + "Events": { + "QueueCapacity": 10000, + "BackpressurePolicy": "FailFast" + }, + "Dashboard": { + "Enabled": true, + "PathBase": "/dashboard", + "RequireAdminScope": true, + "AllowAnonymousLocalhost": false, + "SnapshotIntervalMilliseconds": 1000, + "RecentFaultLimit": 100, + "RecentSessionLimit": 200, + "ShowTagValues": false + }, + "Protocol": { + "WorkerProtocolVersion": 1 + } + } } diff --git a/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs b/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs new file mode 100644 index 0000000..3a47905 --- /dev/null +++ b/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs @@ -0,0 +1,119 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; + +namespace MxGateway.Tests.Configuration; + +public sealed class GatewayOptionsTests +{ + [Fact] + public void OptionsBinding_UsesDesignDefaults() + { + GatewayOptions options = BindOptions(new Dictionary()); + + Assert.Equal(AuthenticationMode.ApiKey, options.Authentication.Mode); + Assert.Equal(@"C:\ProgramData\MxGateway\gateway-auth.db", options.Authentication.SqlitePath); + Assert.Equal("MxGateway:ApiKeyPepper", options.Authentication.PepperSecretName); + Assert.True(options.Authentication.RunMigrationsOnStartup); + + Assert.Equal(@"src\MxGateway.Worker\bin\x86\Release\MxGateway.Worker.exe", options.Worker.ExecutablePath); + Assert.Equal(WorkerArchitecture.X86, options.Worker.RequiredArchitecture); + Assert.Equal(30, options.Worker.StartupTimeoutSeconds); + Assert.Equal(10, options.Worker.ShutdownTimeoutSeconds); + Assert.Equal(5, options.Worker.HeartbeatIntervalSeconds); + Assert.Equal(15, options.Worker.HeartbeatGraceSeconds); + Assert.Equal(16 * 1024 * 1024, options.Worker.MaxMessageBytes); + + Assert.Equal(30, options.Sessions.DefaultCommandTimeoutSeconds); + Assert.Equal(64, options.Sessions.MaxSessions); + Assert.False(options.Sessions.AllowMultipleEventSubscribers); + + Assert.Equal(10_000, options.Events.QueueCapacity); + Assert.Equal(EventBackpressurePolicy.FailFast, options.Events.BackpressurePolicy); + + Assert.True(options.Dashboard.Enabled); + Assert.Equal("/dashboard", options.Dashboard.PathBase); + Assert.True(options.Dashboard.RequireAdminScope); + Assert.False(options.Dashboard.AllowAnonymousLocalhost); + Assert.Equal(1_000, options.Dashboard.SnapshotIntervalMilliseconds); + Assert.Equal(100, options.Dashboard.RecentFaultLimit); + Assert.Equal(200, options.Dashboard.RecentSessionLimit); + Assert.False(options.Dashboard.ShowTagValues); + + Assert.Equal(1u, options.Protocol.WorkerProtocolVersion); + } + + [Fact] + public void OptionsBinding_AppliesConfigurationOverrides() + { + GatewayOptions options = BindOptions( + new Dictionary + { + ["MxGateway:Authentication:Mode"] = "Disabled", + ["MxGateway:Worker:ExecutablePath"] = @"C:\Gateway\MxGateway.Worker.exe", + ["MxGateway:Sessions:MaxSessions"] = "12", + ["MxGateway:Events:QueueCapacity"] = "256", + ["MxGateway:Dashboard:Enabled"] = "false" + }); + + Assert.Equal(AuthenticationMode.Disabled, options.Authentication.Mode); + Assert.Equal(@"C:\Gateway\MxGateway.Worker.exe", options.Worker.ExecutablePath); + Assert.Equal(12, options.Sessions.MaxSessions); + Assert.Equal(256, options.Events.QueueCapacity); + Assert.False(options.Dashboard.Enabled); + } + + [Theory] + [InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")] + [InlineData("MxGateway:Events:QueueCapacity", "0", "MxGateway:Events:QueueCapacity must be greater than zero.")] + [InlineData("MxGateway:Authentication:PepperSecretName", "", "MxGateway:Authentication:PepperSecretName is required")] + [InlineData("MxGateway:Dashboard:PathBase", "dashboard", "MxGateway:Dashboard:PathBase must start with '/'.")] + public void Validation_InvalidConfiguration_FailsClearly(string key, string value, string expectedFailure) + { + OptionsValidationException exception = Assert.Throws(() => + _ = BindOptions(new Dictionary { [key] = value })); + + Assert.Contains(exception.Failures, failure => failure.Contains(expectedFailure, StringComparison.Ordinal)); + } + + [Fact] + public void EffectiveConfiguration_RedactsPepperSecretName() + { + using ServiceProvider services = BuildServices( + new Dictionary + { + ["MxGateway:Authentication:PepperSecretName"] = "RawPepperSecretName" + }); + + IGatewayConfigurationProvider provider = services.GetRequiredService(); + + EffectiveGatewayConfiguration configuration = provider.GetEffectiveConfiguration(); + + Assert.Equal(GatewayConfigurationProvider.RedactedValue, configuration.Authentication.PepperSecretName); + Assert.DoesNotContain( + "RawPepperSecretName", + System.Text.Json.JsonSerializer.Serialize(configuration), + StringComparison.Ordinal); + } + + private static GatewayOptions BindOptions(IReadOnlyDictionary configurationValues) + { + using ServiceProvider services = BuildServices(configurationValues); + + return services.GetRequiredService>().Value; + } + + private static ServiceProvider BuildServices(IReadOnlyDictionary configurationValues) + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configurationValues) + .Build(); + + ServiceCollection services = new(); + services.AddSingleton(configuration); + services.AddGatewayConfiguration(); + + return services.BuildServiceProvider(validateScopes: true); + } +} diff --git a/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs index 006798f..b5e30d6 100644 --- a/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs +++ b/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; using MxGateway.Server; namespace MxGateway.Tests.Gateway; @@ -19,4 +20,37 @@ public sealed class GatewayApplicationTests Assert.Equal("LiveHealth", endpoint.Metadata.GetMetadata()?.EndpointName); } + + [Theory] + [InlineData( + "MxGateway:Worker:ExecutablePath", + "worker.dll", + "MxGateway:Worker:ExecutablePath must point to a .exe file.")] + [InlineData( + "MxGateway:Events:QueueCapacity", + "0", + "MxGateway:Events:QueueCapacity must be greater than zero.")] + [InlineData( + "MxGateway:Authentication:PepperSecretName", + "", + "MxGateway:Authentication:PepperSecretName is required")] + [InlineData( + "MxGateway:Dashboard:PathBase", + "dashboard", + "MxGateway:Dashboard:PathBase must start with '/'.")] + public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup( + string key, + string value, + string expectedFailure) + { + await using WebApplication app = GatewayApplication.Build( + [$"--{key}={value}", "--urls=http://127.0.0.1:0"]); + + OptionsValidationException exception = await Assert.ThrowsAsync( + () => app.StartAsync()); + + Assert.Contains( + exception.Failures, + failure => failure.Contains(expectedFailure, StringComparison.Ordinal)); + } } -- 2.52.0