Files
git.stella-ops.org/src/Router/StellaOps.Gateway.WebService/Program.cs

541 lines
21 KiB
C#

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>(
GatewayOptions.SectionName,
(opts, _) => GatewayOptionsValidator.Validate(opts));
builder.Services.AddOptions<GatewayOptions>()
.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<IEffectiveClaimsStore, EffectiveClaimsStore>();
builder.Services.AddSingleton<StellaOps.Router.Gateway.Authorization.IEffectiveClaimsStore>(sp =>
sp.GetRequiredService<IEffectiveClaimsStore>());
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<StellaOps.Router.Gateway.Authorization.IEffectiveClaimsStore>(
sp => sp.GetRequiredService<IEffectiveClaimsStore>()));
builder.Services.AddSingleton<GatewayServiceStatus>();
builder.Services.AddSingleton<GatewayMetrics>();
// Load router transport plugins
var transportPluginLoader = new RouterTransportPluginLoader(
NullLoggerFactory.Instance.CreateLogger<RouterTransportPluginLoader>());
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<GatewayTransportClient>();
builder.Services.AddSingleton<ITransportClient>(sp => sp.GetRequiredService<GatewayTransportClient>());
builder.Services.AddSingleton(new GatewayRouteCatalog(bootstrapOptions.Routes));
builder.Services.AddSingleton<IOpenApiDocumentGenerator, OpenApiDocumentGenerator>();
builder.Services.AddSingleton<IRouterOpenApiDocumentCache, RouterOpenApiDocumentCache>();
builder.Services.AddHostedService<GatewayHostedService>();
builder.Services.AddHostedService<GatewayHealthMonitorService>();
builder.Services.AddSingleton<IDpopReplayCache, InMemoryDpopReplayCache>();
builder.Services.AddSingleton<IDpopProofValidator, DpopProofValidator>();
// 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<IEnumerable<StellaOpsRoute>>(
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.AssemblyInformationalVersionAttribute>(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<CorrelationIdMiddleware>();
app.UseStellaOpsCors();
app.UseAuthentication();
app.UseMiddleware<SenderConstraintMiddleware>();
// IdentityHeaderPolicyMiddleware replaces TenantMiddleware and ClaimsPropagationMiddleware
// It strips reserved identity headers and overwrites them from validated claims (security fix)
app.UseMiddleware<IdentityHeaderPolicyMiddleware>();
app.UseMiddleware<HealthCheckMiddleware>();
app.TryUseStellaRouter(routerEnabled);
// WebSocket support (before route dispatch)
app.UseWebSockets();
// Route dispatch for configured routes (static files, reverse proxy, websocket)
app.UseMiddleware<RouteDispatchMiddleware>();
if (bootstrapOptions.OpenApi.Enabled)
{
app.MapRouterOpenApi();
}
app.UseWhen(
context => !GatewayRoutes.IsSystemPath(context.Request.Path),
branch =>
{
branch.UseMiddleware<RequestLoggingMiddleware>();
branch.UseMiddleware<GlobalErrorHandlerMiddleware>();
branch.UseMiddleware<PayloadLimitsMiddleware>();
branch.UseMiddleware<EndpointResolutionMiddleware>();
branch.UseMiddleware<AuthorizationMiddleware>();
branch.UseRateLimiting();
branch.UseMiddleware<RoutingDecisionMiddleware>();
branch.UseMiddleware<RequestRoutingMiddleware>();
});
// Error page fallback (after all other middleware)
app.UseMiddleware<ErrorPageFallbackMiddleware>();
// 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<AuthenticationSchemeOptions, AllowAllAuthenticationHandler>(
AllowAllAuthenticationHandler.SchemeName,
_ => { });
return;
}
throw new InvalidOperationException("Gateway authentication requires an Authority issuer or AllowAnonymous.");
}
static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, GatewayOptions gatewayOptions)
{
builder.Services.AddOptions<RouterNodeConfig>()
.Configure<IOptions<GatewayOptions>>((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<RoutingOptions>()
.Configure<IOptions<GatewayOptions>>((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<PayloadLimits>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var routing = gateway.Value.Routing;
options.MaxRequestBytesPerCall = GatewayValueParser.ParseSizeBytes(routing.MaxRequestBodySize, options.MaxRequestBytesPerCall);
});
builder.Services.AddOptions<HealthOptions>()
.Configure<IOptions<GatewayOptions>>((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<OpenApiAggregationOptions>()
.Configure<IOptions<GatewayOptions>>((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('/');
}