using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Auth.Abstractions; using StellaOps.Auth.Security.Dpop; using StellaOps.Auth.ServerIntegration; using StellaOps.Configuration; using StellaOps.Gateway.WebService.Authorization; using StellaOps.Gateway.WebService.Configuration; using StellaOps.Gateway.WebService.Middleware; using StellaOps.Gateway.WebService.Routing; using StellaOps.Gateway.WebService.Security; using StellaOps.Gateway.WebService.Services; using StellaOps.Router.AspNet; using StellaOps.Router.Common.Abstractions; using StellaOps.Router.Common.Models; using StellaOps.Router.Common.Plugins; using StellaOps.Router.Gateway; using StellaOps.Router.Gateway.Configuration; using StellaOps.Router.Gateway.DependencyInjection; using StellaOps.Router.Gateway.Middleware; using StellaOps.Router.Gateway.OpenApi; using StellaOps.Router.Gateway.RateLimit; using StellaOps.Router.Gateway.Routing; using System.Net; using System.Net.Sockets; using System.Runtime.Loader; using System.Security.Cryptography.X509Certificates; var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddStellaOpsDefaults(options => { options.BasePath = builder.Environment.ContentRootPath; options.EnvironmentPrefix = "GATEWAY_"; }); var bootstrapOptions = builder.Configuration.BindOptions( GatewayOptions.SectionName, (opts, _) => GatewayOptionsValidator.Validate(opts)); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(GatewayOptions.SectionName)) .PostConfigure(GatewayOptionsValidator.Validate) .ValidateOnStart(); builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddRouterGatewayCore(); builder.Services.AddRouterRateLimiting(builder.Configuration); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); var authorityClaimsUrl = ResolveAuthorityClaimsUrl(bootstrapOptions.Auth.Authority); StellaOps.Router.Gateway.Authorization.AuthorizationServiceCollectionExtensions.AddAuthorityIntegration( builder.Services, options => { options.Enabled = !string.IsNullOrWhiteSpace(authorityClaimsUrl); options.AuthorityUrl = authorityClaimsUrl ?? string.Empty; options.RefreshInterval = TimeSpan.FromSeconds(30); options.WaitForAuthorityOnStartup = false; options.StartupTimeout = TimeSpan.FromSeconds(10); options.UseAuthorityPushNotifications = false; }); builder.Services.Replace(ServiceDescriptor.Singleton( sp => sp.GetRequiredService())); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Load router transport plugins var transportPluginLoader = new RouterTransportPluginLoader( NullLoggerFactory.Instance.CreateLogger()); foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies() .Where(assembly => assembly.GetName().Name?.StartsWith("StellaOps.Router.Transport.", StringComparison.OrdinalIgnoreCase) == true && AssemblyLoadContext.GetLoadContext(assembly) == AssemblyLoadContext.Default)) { transportPluginLoader.LoadFromAssembly(assembly); } var pluginsPath = builder.Configuration["Gateway:TransportPlugins:Directory"]; if (string.IsNullOrWhiteSpace(pluginsPath)) { pluginsPath = Path.Combine(AppContext.BaseDirectory, "plugins", "router", "transports"); } var transportSearchPattern = builder.Configuration["Gateway:TransportPlugins:SearchPattern"]; if (string.IsNullOrWhiteSpace(transportSearchPattern)) { transportSearchPattern = "StellaOps.Router.Transport.*.dll"; } transportPluginLoader.LoadFromDirectory(pluginsPath, transportSearchPattern); RegisterGatewayTransportIfEnabled("tcp", bootstrapOptions.Transports.Tcp.Enabled, "Gateway:Transports:Tcp"); RegisterGatewayTransportIfEnabled("tls", bootstrapOptions.Transports.Tls.Enabled, "Gateway:Transports:Tls"); RegisterGatewayTransportIfEnabled("messaging", bootstrapOptions.Transports.Messaging.Enabled, "Gateway:Transports:Messaging"); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(new GatewayRouteCatalog(bootstrapOptions.Routes)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Identity header policy options builder.Services.AddSingleton(new IdentityHeaderPolicyOptions { EnableLegacyHeaders = bootstrapOptions.Auth.EnableLegacyHeaders, AllowScopeHeaderOverride = bootstrapOptions.Auth.AllowScopeHeader, EmitIdentityEnvelope = bootstrapOptions.Auth.EmitIdentityEnvelope, IdentityEnvelopeSigningKey = bootstrapOptions.Auth.IdentityEnvelopeSigningKey, IdentityEnvelopeIssuer = bootstrapOptions.Auth.IdentityEnvelopeIssuer, IdentityEnvelopeTtl = TimeSpan.FromSeconds(Math.Max(1, bootstrapOptions.Auth.IdentityEnvelopeTtlSeconds)), JwtPassthroughPrefixes = bootstrapOptions.Routes .Where(r => r.PreserveAuthHeaders) .Select(r => r.Path) .ToList(), ApprovedAuthPassthroughPrefixes = [.. bootstrapOptions.Auth.ApprovedAuthPassthroughPrefixes], EnableTenantOverride = bootstrapOptions.Auth.EnableTenantOverride }); // Route table: resolver + error routes + HTTP client for reverse proxy builder.Services.AddSingleton(new StellaOpsRouteResolver(bootstrapOptions.Routes)); builder.Services.AddSingleton>( bootstrapOptions.Routes.Where(r => r.Type == StellaOpsRouteType.NotFoundPage || r.Type == StellaOpsRouteType.ServerErrorPage).ToList()); builder.Services.AddHttpClient("RouteDispatch") .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false, ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator }); ConfigureAuthentication(builder, bootstrapOptions); ConfigureGatewayOptionsMapping(builder, bootstrapOptions); // Stella Router integration var routerEnabled = builder.Services.AddRouterMicroservice( builder.Configuration, serviceName: "gateway", version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0", routerOptionsSection: "Router"); builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration); if (ShouldApplyStellaOpsLocalBinding()) { builder.TryAddStellaOpsLocalBinding("router"); } else { ConfigureContainerFrontdoorBindings(builder); } var app = builder.Build(); app.LogStellaOpsLocalHostname("router"); // Force browser traffic onto HTTPS so auth (PKCE/DPoP/WebCrypto) always runs in a secure context. app.Use(async (context, next) => { var isWebSocketUpgrade = context.WebSockets.IsWebSocketRequest || string.Equals(context.Request.Headers.Upgrade, "websocket", StringComparison.OrdinalIgnoreCase); if (!context.Request.IsHttps && context.Request.Host.HasValue && !GatewayRoutes.IsSystemPath(context.Request.Path) && !isWebSocketUpgrade) { var host = context.Request.Host.Host; var redirect = $"https://{host}{context.Request.PathBase}{context.Request.Path}{context.Request.QueryString}"; context.Response.Redirect(redirect, permanent: false); return; } await next().ConfigureAwait(false); }); app.UseMiddleware(); app.UseStellaOpsCors(); app.UseAuthentication(); app.UseMiddleware(); // IdentityHeaderPolicyMiddleware replaces TenantMiddleware and ClaimsPropagationMiddleware // It strips reserved identity headers and overwrites them from validated claims (security fix) app.UseMiddleware(); app.UseMiddleware(); app.TryUseStellaRouter(routerEnabled); // WebSocket support (before route dispatch) app.UseWebSockets(); // Route dispatch for configured routes (static files, reverse proxy, websocket) app.UseMiddleware(); if (bootstrapOptions.OpenApi.Enabled) { app.MapRouterOpenApi(); } app.UseWhen( context => !GatewayRoutes.IsSystemPath(context.Request.Path), branch => { branch.UseMiddleware(); branch.UseMiddleware(); branch.UseMiddleware(); branch.UseMiddleware(); branch.UseMiddleware(); branch.UseRateLimiting(); branch.UseMiddleware(); branch.UseMiddleware(); }); // Error page fallback (after all other middleware) app.UseMiddleware(); // Refresh Router endpoint cache app.TryRefreshStellaRouterEndpoints(routerEnabled); await app.RunAsync(); void RegisterGatewayTransportIfEnabled(string transportName, bool enabled, string configurationSection) { if (!enabled) { return; } var plugin = transportPluginLoader.GetPlugin(transportName); if (plugin is null) { throw new InvalidOperationException( $"Gateway transport plugin '{transportName}' is not available. " + $"Provide a plugin assembly in '{pluginsPath}' or add the transport plugin dependency."); } plugin.Register(new RouterTransportRegistrationContext( builder.Services, builder.Configuration, RouterTransportMode.Server) { ConfigurationSection = configurationSection }); } static void ConfigureAuthentication(WebApplicationBuilder builder, GatewayOptions options) { var authOptions = options.Auth; if (!string.IsNullOrWhiteSpace(authOptions.Authority.Issuer)) { builder.Services.AddStellaOpsResourceServerAuthentication( builder.Configuration, configurationSection: null, configure: resourceOptions => { resourceOptions.Authority = authOptions.Authority.Issuer; resourceOptions.RequireHttpsMetadata = authOptions.Authority.RequireHttpsMetadata; resourceOptions.MetadataAddress = authOptions.Authority.MetadataAddress; resourceOptions.Audiences.Clear(); foreach (var audience in authOptions.Authority.Audiences) { resourceOptions.Audiences.Add(audience); } }); // Configure the OIDC metadata HTTP client to accept self-signed certificates // (Authority uses a dev cert in Docker) if (!authOptions.Authority.RequireHttpsMetadata) { // Explicitly configure the named metadata client used by StellaOpsAuthorityConfigurationManager. // ConfigureHttpClientDefaults may not apply to named clients in all .NET versions. builder.Services.AddHttpClient("StellaOps.Auth.ServerIntegration.Metadata") .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator }); builder.Services.ConfigureHttpClientDefaults(clientBuilder => { clientBuilder.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator }); }); } if (authOptions.Authority.RequiredScopes.Count > 0) { builder.Services.AddAuthorization(config => { config.AddPolicy("gateway.default", policy => { policy.RequireAuthenticatedUser(); policy.Requirements.Add(new StellaOpsScopeRequirement(authOptions.Authority.RequiredScopes)); policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme); }); }); } return; } if (authOptions.AllowAnonymous) { builder.Services.AddAuthentication(authConfig => { authConfig.DefaultAuthenticateScheme = AllowAllAuthenticationHandler.SchemeName; authConfig.DefaultChallengeScheme = AllowAllAuthenticationHandler.SchemeName; }).AddScheme( AllowAllAuthenticationHandler.SchemeName, _ => { }); return; } throw new InvalidOperationException("Gateway authentication requires an Authority issuer or AllowAnonymous."); } static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, GatewayOptions gatewayOptions) { builder.Services.AddOptions() .Configure>((options, gateway) => { options.Region = gateway.Value.Node.Region; options.NodeId = gateway.Value.Node.NodeId; options.Environment = gateway.Value.Node.Environment; options.NeighborRegions = gateway.Value.Node.NeighborRegions; }); builder.Services.AddOptions() .Configure>((options, gateway) => { var routing = gateway.Value.Routing; options.RoutingTimeoutMs = (int)GatewayValueParser.ParseDuration(routing.DefaultTimeout, TimeSpan.FromSeconds(30)).TotalMilliseconds; options.GlobalTimeoutCapMs = (int)GatewayValueParser.ParseDuration(routing.GlobalTimeoutCap, TimeSpan.FromSeconds(120)).TotalMilliseconds; options.PreferLocalRegion = routing.PreferLocalRegion; options.AllowDegradedInstances = routing.AllowDegradedInstances; options.StrictVersionMatching = routing.StrictVersionMatching; }); builder.Services.AddOptions() .Configure>((options, gateway) => { var routing = gateway.Value.Routing; options.MaxRequestBytesPerCall = GatewayValueParser.ParseSizeBytes(routing.MaxRequestBodySize, options.MaxRequestBytesPerCall); }); builder.Services.AddOptions() .Configure>((options, gateway) => { var health = gateway.Value.Health; options.StaleThreshold = GatewayValueParser.ParseDuration(health.StaleThreshold, options.StaleThreshold); options.DegradedThreshold = GatewayValueParser.ParseDuration(health.DegradedThreshold, options.DegradedThreshold); options.CheckInterval = GatewayValueParser.ParseDuration(health.CheckInterval, options.CheckInterval); GatewayHealthThresholdPolicy.ApplyMinimums(options, gateway.Value.Transports.Messaging); }); builder.Services.AddOptions() .Configure>((options, gateway) => { var openApi = gateway.Value.OpenApi; options.Enabled = openApi.Enabled; options.CacheTtlSeconds = openApi.CacheTtlSeconds; options.Title = openApi.Title; options.Description = openApi.Description; options.Version = openApi.Version; options.ServerUrl = openApi.ServerUrl; options.TokenUrl = openApi.TokenUrl; }); } static bool ShouldApplyStellaOpsLocalBinding() { var runningInContainer = string.Equals( Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), "true", StringComparison.OrdinalIgnoreCase); if (!runningInContainer) { return true; } // Compose-published container runs already define the frontdoor port contract. // Respect explicit container port settings instead of replacing them with 80/443. var explicitUrls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS"); var explicitHttpPorts = Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS"); var explicitHttpsPorts = Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORTS"); return string.IsNullOrWhiteSpace(explicitUrls) && string.IsNullOrWhiteSpace(explicitHttpPorts) && string.IsNullOrWhiteSpace(explicitHttpsPorts); } static void ConfigureContainerFrontdoorBindings(WebApplicationBuilder builder) { var currentUrls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls( builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey), Environment.GetEnvironmentVariable("ASPNETCORE_URLS"), Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS"), Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORTS")); builder.WebHost.ConfigureKestrel((context, kestrel) => { var defaultCert = LoadDefaultCertificate(context.Configuration); foreach (var uri in currentUrls) { var address = ResolveListenAddress(uri.Host); if (string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) { kestrel.Listen(address, uri.Port, listenOptions => { if (defaultCert is not null) { listenOptions.UseHttps(defaultCert); } else { listenOptions.UseHttps(); } }); continue; } kestrel.Listen(address, uri.Port); } if (defaultCert is not null && IsPortAvailable(443, IPAddress.Any)) { kestrel.ListenAnyIP(443, listenOptions => listenOptions.UseHttps(defaultCert)); } }); } static X509Certificate2? LoadDefaultCertificate(IConfiguration configuration) { var certPath = configuration["Kestrel:Certificates:Default:Path"]; var certPass = configuration["Kestrel:Certificates:Default:Password"]; if (string.IsNullOrWhiteSpace(certPath) || !File.Exists(certPath)) { return null; } return X509CertificateLoader.LoadPkcs12FromFile(certPath, certPass); } static IPAddress ResolveListenAddress(string host) { if (string.IsNullOrWhiteSpace(host) || string.Equals(host, "*", StringComparison.OrdinalIgnoreCase) || string.Equals(host, "+", StringComparison.OrdinalIgnoreCase) || string.Equals(host, "0.0.0.0", StringComparison.OrdinalIgnoreCase)) { return IPAddress.Any; } if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) { return IPAddress.Loopback; } return IPAddress.Parse(host); } static bool IsPortAvailable(int port, IPAddress address) { try { using var listener = new TcpListener(address, port); listener.Start(); listener.Stop(); return true; } catch { return false; } } static string? ResolveAuthorityClaimsUrl(GatewayAuthorityOptions authorityOptions) { if (!string.IsNullOrWhiteSpace(authorityOptions.ClaimsOverridesUrl)) { return authorityOptions.ClaimsOverridesUrl.TrimEnd('/'); } var candidate = authorityOptions.Issuer; if (string.IsNullOrWhiteSpace(candidate)) { candidate = authorityOptions.MetadataAddress; } if (string.IsNullOrWhiteSpace(candidate)) { return null; } if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) { return candidate.TrimEnd('/'); } // Authority runs HTTP on the internal compose network by default. var builder = new UriBuilder(uri) { Path = string.Empty, Query = string.Empty, Fragment = string.Empty }; if (string.Equals(builder.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) { builder.Scheme = Uri.UriSchemeHttp; builder.Port = builder.Port == 443 ? 80 : builder.Port; } return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/'); }