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;
}
}