using Microsoft.Extensions.Options; namespace DodoSSH.Api.Setup; /// Binds and validates strongly-typed options. /// /// Every section uses ValidateOnStart. A self-hosted server that boots half-configured and /// fails later, per-request, is far harder to diagnose than one that refuses to start and says /// which setting is wrong. /// internal static class Configuration { internal static IServiceCollection AddDodoOptions(this IServiceCollection services) { services.AddOptions() .BindConfiguration(ServerOptions.SectionName) .ValidateDataAnnotations() .ValidateOnStart(); services.AddOptions() .BindConfiguration(DatabaseOptions.SectionName) .ValidateDataAnnotations() .ValidateOnStart(); services.AddOptions() .BindConfiguration(OidcOptions.SectionName) .ValidateDataAnnotations() .ValidateOnStart(); services.AddOptions() .BindConfiguration(SyncOptions.SectionName) .ValidateDataAnnotations() .Validate( options => options.MaxItemPayloadBytes <= options.MaxPayloadBytes, "Sync:MaxItemPayloadBytes must not exceed Sync:MaxPayloadBytes.") .Validate( options => options.DefaultPullLimit <= options.MaxPullLimit, "Sync:DefaultPullLimit must not exceed Sync:MaxPullLimit.") .ValidateOnStart(); services.AddOptions() .BindConfiguration(EventsOptions.SectionName) .ValidateDataAnnotations() .Validate( options => options.HeartbeatInterval > TimeSpan.Zero, "Events:HeartbeatInterval must be greater than zero.") .Validate( options => options.AccessRefreshInterval > TimeSpan.Zero, "Events:AccessRefreshInterval must be greater than zero.") .Validate( options => options.MaxConnectionDuration > options.AccessRefreshInterval, "Events:MaxConnectionDuration must exceed Events:AccessRefreshInterval; a connection " + "that never lives long enough to re-read its own access has none of the bound that " + "setting exists to provide.") .ValidateOnStart(); services.AddRelayOptions(); return services; } /// The relay's options, which carry more rules than any other section. See ADR 0004. private static void AddRelayOptions(this IServiceCollection services) => services.AddOptions() .BindConfiguration(RelayOptions.SectionName) .ValidateDataAnnotations() .Validate( options => !options.Enabled || !string.IsNullOrWhiteSpace(options.WebSocketUrl), "Relay:WebSocketUrl is required when Relay:Enabled is true.") .Validate( options => options.IdleTimeout < options.MaxSessionDuration, "Relay:IdleTimeout must be shorter than Relay:MaxSessionDuration.") .Validate( options => options.TicketLifetime > TimeSpan.Zero && options.TicketLifetime <= TimeSpan.FromSeconds(30), "Relay:TicketLifetime must be positive and at most 30 seconds. A ticket is a bearer " + "credential in a header; ADR 0004 bounds how long one may be worth stealing.") .Validate( options => options.ConnectTimeout > TimeSpan.Zero && options.DrainTimeout >= TimeSpan.Zero, "Relay:ConnectTimeout must be positive and Relay:DrainTimeout must not be negative.") .Validate( options => Features.Relay.RelayAddressPolicy.AreNetworks(options.AllowedNetworks) && Features.Relay.RelayAddressPolicy.AreNetworks(options.DeniedNetworks), "Relay:AllowedNetworks and Relay:DeniedNetworks must hold CIDR networks such as " + "10.20.0.0/16, with no host bits set. An entry this server cannot read would be a gap " + "the operator believes is closed.") .Validate( options => !options.Enabled || (Uri.TryCreate(options.WebSocketUrl, UriKind.Absolute, out var url) && (string.Equals(url.Scheme, Uri.UriSchemeWs, StringComparison.Ordinal) || string.Equals(url.Scheme, Uri.UriSchemeWss, StringComparison.Ordinal))), "Relay:WebSocketUrl must be an absolute ws:// or wss:// URL.") .ValidateOnStart(); /// /// Warns about configuration that is individually valid but dangerous in combination. /// /// /// These cannot be hard failures — each is legitimate in some deployment — but silently /// accepting them produces subtly broken security properties that nobody notices. /// internal static WebApplication WarnOnRiskyConfiguration(this WebApplication app) { var logger = app.Services.GetRequiredService().CreateLogger("DodoSSH.Startup"); var oidc = app.Services.GetRequiredService>().Value; if (!oidc.RequireHttpsMetadata && !app.Environment.IsDevelopment()) { StartupLog.InsecureMetadataOutsideDevelopment(logger); } if (oidc.AllowEmailLinking) { StartupLog.EmailLinkingEnabled(logger); } return app; } }